44import functools
55import inspect
66import logging
7+ import os
78import threading
89import time
910from typing import TYPE_CHECKING , Any , Callable , NamedTuple , TypeVar , Union
@@ -291,6 +292,8 @@ def create_cache_wrapper(
291292 collect_stats : bool = True ,
292293 enable_tracing : bool = True ,
293294 enable_structured_logging : bool = True ,
295+ # L1-only mode flag
296+ _l1_only_mode : bool = False ,
294297 ** kwargs : Any ,
295298) -> F :
296299 """Create cache wrapper for a function with specified configuration.
@@ -475,6 +478,19 @@ def create_cache_wrapper(
475478 # Pass l1_enabled for rate limit classification header
476479 _stats = _FunctionStats (function_identifier = function_identifier , l1_enabled = l1_enabled )
477480
481+ # L1-only mode: debug log if backend would have been available
482+ # Helps developers understand that Redis config is being intentionally ignored
483+ if _l1_only_mode :
484+ redis_url = os .environ .get ("REDIS_URL" ) or os .environ .get ("CACHEKIT_REDIS_URL" )
485+ if redis_url :
486+ # Truncate URL to avoid logging credentials
487+ safe_url = redis_url .split ("@" )[- 1 ] if "@" in redis_url else redis_url [:30 ]
488+ _logger .debug (
489+ "L1-only mode: %s using in-memory cache only (backend=None explicit), ignoring available Redis at %s" ,
490+ function_identifier ,
491+ safe_url ,
492+ )
493+
478494 @functools .wraps (func )
479495 def sync_wrapper (* args : Any , ** kwargs : Any ) -> Any : # noqa: PLR0912
480496 # Bypass check (5-10μs savings)
@@ -496,28 +512,81 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912
496512
497513 # Create tracing span for cache operation
498514 span_attributes = {
499- "cache.system" : "redis" ,
515+ "cache.system" : "l1_memory" if _l1_only_mode else " redis" ,
500516 "cache.operation" : "get" ,
501517 "cache.namespace" : namespace or "default" ,
502518 "cache.serializer" : serializer ,
503519 "function.name" : func .__name__ ,
504520 "function.async" : False ,
505521 }
506522
507- with features .create_span ("redis_cache" , span_attributes ) as span :
523+ # Key generation - needed for both L1-only and L1+L2 modes
524+ try :
525+ if fast_mode :
526+ # Minimal key generation - no string formatting overhead
527+ from ..hash_utils import cache_key_hash
528+
529+ cache_namespace = namespace or "default"
530+ args_kwargs_str = str (args ) + str (kwargs )
531+ cache_key = cache_namespace + ":" + func_hash + ":" + cache_key_hash (args_kwargs_str )
532+ else :
533+ cache_key = operation_handler .get_cache_key (func , args , kwargs , namespace , integrity_checking )
534+ except Exception as e :
535+ # Key generation failed - execute function without caching
536+ features .handle_cache_error (
537+ error = e ,
538+ operation = "key_generation" ,
539+ cache_key = "<generation_failed>" ,
540+ namespace = namespace or "default" ,
541+ duration_ms = 0.0 ,
542+ )
543+ reset_current_function_stats (token )
544+ return func (* args , ** kwargs )
545+
546+ # L1-ONLY MODE: Skip backend initialization entirely
547+ # This is the fix for the sentinel problem: when backend=None is explicitly passed,
548+ # we should NOT try to get a backend from the provider
549+ if _l1_only_mode :
550+ # L1-only mode: Check L1 cache, execute function on miss, store in L1
551+ if _l1_cache and cache_key :
552+ l1_found , l1_bytes = _l1_cache .get (cache_key )
553+ if l1_found and l1_bytes :
554+ # L1 cache hit
555+ try :
556+ # Pass cache_key for AAD verification (required for encryption)
557+ l1_value = operation_handler .serialization_handler .deserialize_data (l1_bytes , cache_key = cache_key )
558+ _stats .record_l1_hit ()
559+ reset_current_function_stats (token )
560+ return l1_value
561+ except Exception :
562+ # L1 deserialization failed - invalidate and continue
563+ _l1_cache .invalidate (cache_key )
564+
565+ # L1 cache miss - execute function and store in L1
566+ _stats .record_miss ()
508567 try :
509- # Fast path key generation (30-50μs savings)
510- if fast_mode :
511- # Minimal key generation - no string formatting overhead
512- from ..hash_utils import cache_key_hash
568+ result = func (* args , ** kwargs )
569+ # Serialize and store in L1
570+ try :
571+ # Pass cache_key for AAD binding (required for encryption)
572+ serialized_bytes = operation_handler .serialization_handler .serialize_data (
573+ result , args , kwargs , cache_key = cache_key
574+ )
575+ if _l1_cache and cache_key and serialized_bytes :
576+ _l1_cache .put (cache_key , serialized_bytes , redis_ttl = ttl )
577+ except Exception as e :
578+ # Serialization/storage failed but function succeeded - log and return result
579+ logger ().debug (f"L1-only mode: serialization/storage failed for { cache_key } : { e } " )
580+ return result
581+ finally :
582+ features .clear_correlation_id ()
583+ reset_current_function_stats (token )
513584
514- cache_namespace = namespace or "default"
515- args_kwargs_str = str (args ) + str (kwargs )
516- cache_key = cache_namespace + ":" + func_hash + ":" + cache_key_hash (args_kwargs_str )
517- else :
518- cache_key = operation_handler .get_cache_key (func , args , kwargs , namespace , integrity_checking )
519- lock_key = f"{ cache_key } :lock"
585+ # L1+L2 MODE: Original behavior with backend initialization
586+ lock_key = f"{ cache_key } :lock"
520587
588+ with features .create_span ("redis_cache" , span_attributes ) as span :
589+ try :
521590 # Add cache key to span attributes
522591 if span :
523592 features .set_span_attributes (span , {"cache.key" : cache_key })
@@ -713,7 +782,7 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912
713782
714783 # Also store in L1 cache for fast subsequent access (using serialized bytes)
715784 if _l1_cache and cache_key and serialized_bytes :
716- _l1_cache .put (cache_key , serialized_bytes , redis_ttl = ttl or ttl )
785+ _l1_cache .put (cache_key , serialized_bytes , redis_ttl = ttl )
717786
718787 # Record successful cache set
719788 set_duration_ms = (time .time () - start_time ) * 1000
@@ -786,14 +855,6 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
786855 token = set_current_function_stats (_stats )
787856
788857 try :
789- # Guard clause: Circuit breaker check - fail fast if circuit is open
790- # This prevents cascading failures
791- if not features .should_allow_request ():
792- # Circuit breaker fail-fast: raise exception immediately
793- raise BackendError ( # noqa: F823 # pyright: ignore[reportUnboundVariable]
794- "Circuit breaker OPEN - failing fast" , error_type = BackendErrorType .TRANSIENT
795- )
796-
797858 # Get cache key early for consistent usage - note this may fail for complex types
798859 cache_key = None
799860 func_start_time : float | None = None # Initialize for exception handlers
@@ -821,6 +882,49 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
821882 )
822883 return await func (* args , ** kwargs )
823884
885+ # L1-ONLY MODE: Skip backend initialization entirely
886+ # This is the fix for the sentinel problem: when backend=None is explicitly passed,
887+ # we should NOT try to get a backend from the provider
888+ if _l1_only_mode :
889+ # L1-only mode: Check L1 cache, execute function on miss, store in L1
890+ if _l1_cache and cache_key :
891+ l1_found , l1_bytes = _l1_cache .get (cache_key )
892+ if l1_found and l1_bytes :
893+ # L1 cache hit
894+ try :
895+ # Pass cache_key for AAD verification (required for encryption)
896+ l1_value = operation_handler .serialization_handler .deserialize_data (l1_bytes , cache_key = cache_key )
897+ _stats .record_l1_hit ()
898+ return l1_value
899+ except Exception :
900+ # L1 deserialization failed - invalidate and continue
901+ _l1_cache .invalidate (cache_key )
902+
903+ # L1 cache miss - execute function and store in L1
904+ _stats .record_miss ()
905+ result = await func (* args , ** kwargs )
906+ # Serialize and store in L1
907+ try :
908+ # Pass cache_key for AAD binding (required for encryption)
909+ serialized_bytes = operation_handler .serialization_handler .serialize_data (
910+ result , args , kwargs , cache_key = cache_key
911+ )
912+ if _l1_cache and cache_key and serialized_bytes :
913+ _l1_cache .put (cache_key , serialized_bytes , redis_ttl = ttl )
914+ except Exception as e :
915+ # Serialization/storage failed but function succeeded - log and return result
916+ logger ().debug (f"L1-only mode: serialization/storage failed for { cache_key } : { e } " )
917+ return result
918+
919+ # L1+L2 MODE: Original behavior with backend initialization
920+ # Guard clause: Circuit breaker check - fail fast if circuit is open
921+ # This prevents cascading failures
922+ if not features .should_allow_request ():
923+ # Circuit breaker fail-fast: raise exception immediately
924+ raise BackendError ( # noqa: F823 # pyright: ignore[reportUnboundVariable]
925+ "Circuit breaker OPEN - failing fast" , error_type = BackendErrorType .TRANSIENT
926+ )
927+
824928 # Guard clause: L1 cache check first - early return eliminates network latency
825929 if _l1_cache and cache_key :
826930 l1_found , l1_bytes = _l1_cache .get (cache_key )
@@ -908,7 +1012,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
9081012 if _l1_cache and cache_key and cached_data :
9091013 # cached_data is already serialized bytes from Redis
9101014 cached_bytes = cached_data .encode ("utf-8" ) if isinstance (cached_data , str ) else cached_data
911- _l1_cache .put (cache_key , cached_bytes , redis_ttl = ttl or ttl )
1015+ _l1_cache .put (cache_key , cached_bytes , redis_ttl = ttl )
9121016
9131017 # Handle TTL refresh if configured and threshold met
9141018 if refresh_ttl_on_get and ttl and hasattr (_backend , "get_ttl" ) and hasattr (_backend , "refresh_ttl" ):
@@ -969,7 +1073,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
9691073 cached_bytes = (
9701074 cached_data .encode ("utf-8" ) if isinstance (cached_data , str ) else cached_data
9711075 )
972- _l1_cache .put (cache_key , cached_bytes , redis_ttl = ttl or ttl )
1076+ _l1_cache .put (cache_key , cached_bytes , redis_ttl = ttl )
9731077
9741078 return result
9751079 except Exception as e :
@@ -992,7 +1096,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
9921096 cached_bytes = (
9931097 cached_data .encode ("utf-8" ) if isinstance (cached_data , str ) else cached_data
9941098 )
995- _l1_cache .put (cache_key , cached_bytes , redis_ttl = ttl or ttl )
1099+ _l1_cache .put (cache_key , cached_bytes , redis_ttl = ttl )
9961100
9971101 return result
9981102 except Exception :
@@ -1017,15 +1121,15 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
10171121 await operation_handler .cache_handler .set_async ( # type: ignore[attr-defined]
10181122 cache_key ,
10191123 serialized_data ,
1020- ttl = ttl or ttl ,
1124+ ttl = ttl ,
10211125 )
10221126
10231127 # Also store in L1 cache for fast subsequent access (using serialized bytes)
10241128 if _l1_cache and cache_key :
10251129 serialized_bytes = (
10261130 serialized_data .encode ("utf-8" ) if isinstance (serialized_data , str ) else serialized_data
10271131 )
1028- _l1_cache .put (cache_key , serialized_bytes , redis_ttl = ttl or ttl )
1132+ _l1_cache .put (cache_key , serialized_bytes , redis_ttl = ttl )
10291133
10301134 # Record successful cache set
10311135 set_duration_ms = (time .perf_counter () - start_time ) * 1000
@@ -1094,15 +1198,15 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
10941198 await operation_handler .cache_handler .set_async ( # type: ignore[attr-defined]
10951199 cache_key ,
10961200 serialized_data ,
1097- ttl = ttl or ttl ,
1201+ ttl = ttl ,
10981202 )
10991203
11001204 # Also store in L1 cache for fast subsequent access (using serialized bytes)
11011205 if _l1_cache and cache_key :
11021206 serialized_bytes = (
11031207 serialized_data .encode ("utf-8" ) if isinstance (serialized_data , str ) else serialized_data
11041208 )
1105- _l1_cache .put (cache_key , serialized_bytes , redis_ttl = ttl or ttl )
1209+ _l1_cache .put (cache_key , serialized_bytes , redis_ttl = ttl )
11061210
11071211 # Record successful cache set
11081212 set_duration_ms = (time .perf_counter () - start_time ) * 1000
@@ -1144,11 +1248,15 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
11441248
11451249 def invalidate_cache (* args : Any , ** kwargs : Any ) -> None :
11461250 nonlocal _backend
1147- if _backend is None :
1251+
1252+ # L1-ONLY MODE: Skip backend lookup entirely
1253+ # This fixes the sentinel problem: when backend=None is explicitly passed,
1254+ # we should NOT try to get a backend from the provider
1255+ if not _l1_only_mode and _backend is None :
11481256 try :
11491257 _backend = get_backend_provider ().get_backend ()
11501258 except Exception as e :
1151- # If backend creation fails, can't invalidate
1259+ # If backend creation fails, can't invalidate L2
11521260 _logger .debug ("Failed to get backend for invalidation: %s" , e )
11531261
11541262 # Clear both L2 (backend) and L1 cache
@@ -1158,18 +1266,22 @@ def invalidate_cache(*args: Any, **kwargs: Any) -> None:
11581266 if _l1_cache and cache_key :
11591267 _l1_cache .invalidate (cache_key )
11601268
1161- # Clear L2 cache via invalidator
1162- if _backend :
1269+ # Clear L2 cache via invalidator (skip in L1-only mode)
1270+ if _backend and not _l1_only_mode :
11631271 invalidator .set_backend (_backend )
11641272 invalidator .invalidate_cache (func , args , kwargs , namespace )
11651273
11661274 async def ainvalidate_cache (* args : Any , ** kwargs : Any ) -> None :
11671275 nonlocal _backend
1168- if _backend is None :
1276+
1277+ # L1-ONLY MODE: Skip backend lookup entirely
1278+ # This fixes the sentinel problem: when backend=None is explicitly passed,
1279+ # we should NOT try to get a backend from the provider
1280+ if not _l1_only_mode and _backend is None :
11691281 try :
11701282 _backend = get_backend_provider ().get_backend ()
11711283 except Exception as e :
1172- # If backend creation fails, can't invalidate
1284+ # If backend creation fails, can't invalidate L2
11731285 _logger .debug ("Failed to get backend for async invalidation: %s" , e )
11741286
11751287 # Clear both L2 (backend) and L1 cache
@@ -1179,8 +1291,8 @@ async def ainvalidate_cache(*args: Any, **kwargs: Any) -> None:
11791291 if _l1_cache and cache_key :
11801292 _l1_cache .invalidate (cache_key )
11811293
1182- # Clear L2 cache via invalidator
1183- if _backend :
1294+ # Clear L2 cache via invalidator (skip in L1-only mode)
1295+ if _backend and not _l1_only_mode :
11841296 invalidator .set_backend (_backend )
11851297 await invalidator .invalidate_cache_async (func , args , kwargs , namespace )
11861298
0 commit comments