@@ -50,6 +50,12 @@ static pthread_mutex_t g_interp_state_mutex = PTHREAD_MUTEX_INITIALIZER;
5050/* Flag: ASGI subsystem is initialized (not per-interpreter) */
5151static bool g_asgi_initialized = false;
5252
53+ /* ASGI-specific Erlang atoms for scope map keys */
54+ ERL_NIF_TERM ATOM_ASGI_PATH ;
55+ ERL_NIF_TERM ATOM_ASGI_HEADERS ;
56+ ERL_NIF_TERM ATOM_ASGI_CLIENT ;
57+ ERL_NIF_TERM ATOM_ASGI_QUERY_STRING ;
58+
5359/**
5460 * @brief Initialize a single interpreter state
5561 */
@@ -426,6 +432,197 @@ void cleanup_all_asgi_interp_states(void) {
426432 pthread_mutex_unlock (& g_interp_state_mutex );
427433}
428434
435+ /* ============================================================================
436+ * Thread-Local Scope Template Cache
437+ * ============================================================================
438+ * For repeated requests to the same path, most scope values are identical.
439+ * Cache scope templates and clone them for subsequent requests, updating
440+ * only the dynamic fields (client, headers, query_string).
441+ */
442+
443+ typedef struct {
444+ uint64_t path_hash ; /* FNV-1a hash of path */
445+ size_t path_len ; /* Length of path for collision check */
446+ PyObject * scope_template ; /* Pre-built scope with static fields */
447+ PyInterpreterState * interp ; /* Interpreter that owns scope_template */
448+ } scope_cache_entry_t ;
449+
450+ typedef struct {
451+ scope_cache_entry_t entries [SCOPE_CACHE_SIZE ];
452+ bool initialized ;
453+ } scope_cache_t ;
454+
455+ static __thread scope_cache_t * tl_scope_cache = NULL ;
456+
457+ /**
458+ * @brief FNV-1a hash for path strings
459+ */
460+ static inline uint64_t hash_path (const unsigned char * path , size_t len ) {
461+ uint64_t hash = 14695981039346656037ULL ;
462+ for (size_t i = 0 ; i < len ; i ++ ) {
463+ hash ^= (uint64_t )path [i ];
464+ hash *= 1099511628211ULL ;
465+ }
466+ return hash ;
467+ }
468+
469+ /**
470+ * @brief Initialize thread-local scope cache
471+ */
472+ static int asgi_init_scope_cache (void ) {
473+ if (tl_scope_cache != NULL && tl_scope_cache -> initialized ) {
474+ return 0 ;
475+ }
476+
477+ tl_scope_cache = enif_alloc (sizeof (scope_cache_t ));
478+ if (tl_scope_cache == NULL ) {
479+ return -1 ;
480+ }
481+
482+ memset (tl_scope_cache , 0 , sizeof (scope_cache_t ));
483+ tl_scope_cache -> initialized = true;
484+ return 0 ;
485+ }
486+
487+ /**
488+ * @brief Clean up thread-local scope cache
489+ */
490+ static void asgi_cleanup_scope_cache (void ) {
491+ if (tl_scope_cache == NULL ) {
492+ return ;
493+ }
494+
495+ for (int i = 0 ; i < SCOPE_CACHE_SIZE ; i ++ ) {
496+ Py_XDECREF (tl_scope_cache -> entries [i ].scope_template );
497+ }
498+
499+ enif_free (tl_scope_cache );
500+ tl_scope_cache = NULL ;
501+ }
502+
503+ /**
504+ * @brief Update dynamic fields in a cloned scope
505+ *
506+ * Updates client, headers, and query_string which vary per request.
507+ */
508+ static int update_dynamic_scope_fields (ErlNifEnv * env , PyObject * scope ,
509+ ERL_NIF_TERM scope_map ) {
510+ ERL_NIF_TERM value ;
511+ asgi_interp_state_t * state = get_asgi_interp_state ();
512+ if (!state ) return -1 ;
513+
514+ /* Update client - use Erlang atom for map lookup, Python key for dict */
515+ if (enif_get_map_value (env , scope_map , ATOM_ASGI_CLIENT , & value )) {
516+ PyObject * py_client = term_to_py (env , value );
517+ if (py_client == NULL ) return -1 ;
518+ if (PyDict_SetItem (scope , state -> key_client , py_client ) < 0 ) {
519+ Py_DECREF (py_client );
520+ return -1 ;
521+ }
522+ Py_DECREF (py_client );
523+ }
524+
525+ /* Update headers - use Erlang atom for map lookup */
526+ if (enif_get_map_value (env , scope_map , ATOM_ASGI_HEADERS , & value )) {
527+ unsigned int headers_len ;
528+ if (enif_get_list_length (env , value , & headers_len )) {
529+ PyObject * py_headers = PyList_New (headers_len );
530+ if (py_headers == NULL ) return -1 ;
531+
532+ ERL_NIF_TERM head , tail = value ;
533+ for (unsigned int idx = 0 ; idx < headers_len ; idx ++ ) {
534+ if (!enif_get_list_cell (env , tail , & head , & tail )) {
535+ Py_DECREF (py_headers );
536+ return -1 ;
537+ }
538+
539+ ERL_NIF_TERM hname_term , hvalue_term ;
540+ int harity ;
541+ const ERL_NIF_TERM * htuple ;
542+ ERL_NIF_TERM hhead , htail ;
543+
544+ if (enif_get_tuple (env , head , & harity , & htuple ) && harity == 2 ) {
545+ hname_term = htuple [0 ];
546+ hvalue_term = htuple [1 ];
547+ } else if (enif_get_list_cell (env , head , & hhead , & htail )) {
548+ hname_term = hhead ;
549+ if (!enif_get_list_cell (env , htail , & hvalue_term , & htail )) {
550+ Py_DECREF (py_headers );
551+ return -1 ;
552+ }
553+ } else {
554+ Py_DECREF (py_headers );
555+ return -1 ;
556+ }
557+
558+ ErlNifBinary name_bin , value_bin ;
559+ if (!enif_inspect_binary (env , hname_term , & name_bin ) ||
560+ !enif_inspect_binary (env , hvalue_term , & value_bin )) {
561+ Py_DECREF (py_headers );
562+ return -1 ;
563+ }
564+
565+ PyObject * py_name = get_cached_header_name (state , name_bin .data , name_bin .size );
566+ PyObject * py_hvalue = PyBytes_FromStringAndSize ((char * )value_bin .data , value_bin .size );
567+
568+ if (py_name == NULL || py_hvalue == NULL ) {
569+ Py_XDECREF (py_name );
570+ Py_XDECREF (py_hvalue );
571+ Py_DECREF (py_headers );
572+ return -1 ;
573+ }
574+
575+ PyObject * header_tuple = PyTuple_Pack (2 , py_name , py_hvalue );
576+ Py_DECREF (py_name );
577+ Py_DECREF (py_hvalue );
578+
579+ if (header_tuple == NULL ) {
580+ Py_DECREF (py_headers );
581+ return -1 ;
582+ }
583+
584+ PyList_SET_ITEM (py_headers , idx , header_tuple );
585+ }
586+
587+ if (PyDict_SetItem (scope , state -> key_headers , py_headers ) < 0 ) {
588+ Py_DECREF (py_headers );
589+ return -1 ;
590+ }
591+ Py_DECREF (py_headers );
592+ }
593+ }
594+
595+ /* Update query_string - use Erlang atom for map lookup */
596+ if (enif_get_map_value (env , scope_map , ATOM_ASGI_QUERY_STRING , & value )) {
597+ ErlNifBinary qs_bin ;
598+ PyObject * py_qs ;
599+ if (enif_inspect_binary (env , value , & qs_bin )) {
600+ if (qs_bin .size == 0 ) {
601+ Py_INCREF (state -> empty_bytes );
602+ py_qs = state -> empty_bytes ;
603+ } else {
604+ py_qs = PyBytes_FromStringAndSize ((char * )qs_bin .data , qs_bin .size );
605+ }
606+ if (py_qs == NULL ) return -1 ;
607+ if (PyDict_SetItem (scope , state -> key_query_string , py_qs ) < 0 ) {
608+ Py_DECREF (py_qs );
609+ return -1 ;
610+ }
611+ Py_DECREF (py_qs );
612+ }
613+ }
614+
615+ return 0 ;
616+ }
617+
618+ /**
619+ * @brief Get scope from cache or create new one
620+ *
621+ * For paths that are in the cache, clones the template and updates
622+ * dynamic fields. For cache misses, builds full scope and caches template.
623+ */
624+ static PyObject * get_cached_scope (ErlNifEnv * env , ERL_NIF_TERM scope_map );
625+
429626/* ============================================================================
430627 * Thread-Local Response Pool
431628 * ============================================================================ */
@@ -1421,6 +1618,106 @@ static PyObject *asgi_scope_from_map(ErlNifEnv *env, ERL_NIF_TERM scope_map) {
14211618 return scope ;
14221619}
14231620
1621+ /* ============================================================================
1622+ * Scope Template Caching
1623+ * ============================================================================ */
1624+
1625+ /**
1626+ * @brief Get scope from cache or create new one
1627+ *
1628+ * For paths that are in the cache, clones the template and updates
1629+ * dynamic fields. For cache misses, builds full scope and caches template.
1630+ */
1631+ static PyObject * get_cached_scope (ErlNifEnv * env , ERL_NIF_TERM scope_map ) {
1632+ /* Initialize cache on first use */
1633+ if (tl_scope_cache == NULL || !tl_scope_cache -> initialized ) {
1634+ if (asgi_init_scope_cache () < 0 ) {
1635+ /* Fallback to uncached */
1636+ return asgi_scope_from_map (env , scope_map );
1637+ }
1638+ }
1639+
1640+ asgi_interp_state_t * state = get_asgi_interp_state ();
1641+ if (!state ) {
1642+ return asgi_scope_from_map (env , scope_map );
1643+ }
1644+
1645+ /* Get current interpreter for subinterpreter/free-threading safety */
1646+ PyInterpreterState * current_interp = PyInterpreterState_Get ();
1647+
1648+ /* Extract path for cache lookup - use Erlang atom */
1649+ ERL_NIF_TERM path_term ;
1650+ if (!enif_get_map_value (env , scope_map , ATOM_ASGI_PATH , & path_term )) {
1651+ return asgi_scope_from_map (env , scope_map );
1652+ }
1653+
1654+ ErlNifBinary path_bin ;
1655+ if (!enif_inspect_binary (env , path_term , & path_bin )) {
1656+ return asgi_scope_from_map (env , scope_map );
1657+ }
1658+
1659+ uint64_t path_hash = hash_path (path_bin .data , path_bin .size );
1660+ int idx = path_hash % SCOPE_CACHE_SIZE ;
1661+
1662+ scope_cache_entry_t * entry = & tl_scope_cache -> entries [idx ];
1663+
1664+ /* Cache hit check: hash matches, path length matches, AND same interpreter
1665+ * The interpreter check is critical for subinterpreter/free-threading safety:
1666+ * PyObjects from different interpreters cannot be shared. */
1667+ if (entry -> path_hash == path_hash &&
1668+ entry -> path_len == path_bin .size &&
1669+ entry -> interp == current_interp &&
1670+ entry -> scope_template != NULL ) {
1671+ /* Cache hit - clone template and update dynamic fields */
1672+ PyObject * scope = PyDict_Copy (entry -> scope_template );
1673+ if (scope == NULL ) {
1674+ return asgi_scope_from_map (env , scope_map );
1675+ }
1676+
1677+ if (update_dynamic_scope_fields (env , scope , scope_map ) < 0 ) {
1678+ Py_DECREF (scope );
1679+ return asgi_scope_from_map (env , scope_map );
1680+ }
1681+
1682+ return scope ;
1683+ }
1684+
1685+ /* Cache miss or interpreter mismatch - build full scope */
1686+ PyObject * scope = asgi_scope_from_map (env , scope_map );
1687+ if (scope == NULL ) {
1688+ return NULL ;
1689+ }
1690+
1691+ /* Create template by copying scope and removing dynamic fields */
1692+ PyObject * template = PyDict_Copy (scope );
1693+ if (template != NULL ) {
1694+ /* Remove dynamic fields from template */
1695+ PyDict_DelItem (template , state -> key_client );
1696+ PyDict_DelItem (template , state -> key_headers );
1697+ PyDict_DelItem (template , state -> key_query_string );
1698+ PyErr_Clear (); /* DelItem may fail if key doesn't exist */
1699+
1700+ /* If replacing entry from different interpreter, release old reference
1701+ * Note: In free-threading mode, we might need the other interpreter's GIL
1702+ * to safely decref, but since we're using thread-local storage, each thread
1703+ * should only ever see entries from its own interpreter transitions. */
1704+ if (entry -> scope_template != NULL && entry -> interp != current_interp ) {
1705+ /* Different interpreter - can't safely decref, just overwrite
1706+ * This may leak in edge cases but is safe */
1707+ entry -> scope_template = NULL ;
1708+ }
1709+
1710+ /* Update cache with current interpreter tracking */
1711+ Py_XDECREF (entry -> scope_template );
1712+ entry -> path_hash = path_hash ;
1713+ entry -> path_len = path_bin .size ;
1714+ entry -> scope_template = template ;
1715+ entry -> interp = current_interp ;
1716+ }
1717+
1718+ return scope ;
1719+ }
1720+
14241721/* ============================================================================
14251722 * Direct Response Extraction
14261723 * ============================================================================ */
@@ -1532,7 +1829,8 @@ static ERL_NIF_TERM nif_asgi_build_scope(ErlNifEnv *env, int argc, const ERL_NIF
15321829
15331830 PyGILState_STATE gstate = PyGILState_Ensure ();
15341831
1535- PyObject * scope = asgi_scope_from_map (env , argv [0 ]);
1832+ /* Use cached scope for better performance with repeated paths */
1833+ PyObject * scope = get_cached_scope (env , argv [0 ]);
15361834 if (scope == NULL ) {
15371835 ERL_NIF_TERM error = make_py_error (env );
15381836 PyGILState_Release (gstate );
@@ -1613,8 +1911,8 @@ static ERL_NIF_TERM nif_asgi_run(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar
16131911 goto cleanup ;
16141912 }
16151913
1616- /* Build optimized scope dict from Erlang map */
1617- PyObject * scope = asgi_scope_from_map (env , argv [3 ]);
1914+ /* Build optimized scope dict from Erlang map (with caching) */
1915+ PyObject * scope = get_cached_scope (env , argv [3 ]);
16181916 if (scope == NULL ) {
16191917 Py_DECREF (asgi_app );
16201918 result = make_py_error (env );
0 commit comments