Skip to content

Commit 0898986

Browse files
authored
fix: L1-only mode (backend=None) should not attempt Redis connection
* fix: L1-only mode (backend=None) should not attempt Redis connection When using @cache(backend=None) for L1-only caching, the decorator was still attempting to connect to Redis on every operation. Root cause: Sentinel problem - couldn't distinguish explicit backend=None from unspecified backend. Changes: - Track explicit backend=None intent via _l1_only_mode flag - Skip backend provider lookup in L1-only mode - Fix invalidate_cache() to honor L1-only mode - Add DEBUG log when L1-only mode ignores available Redis - Clean up ttl or ttl copy-paste cruft Closes #3 * fix: only trigger L1-only mode for explicit backend=None at decorator level Remove buggy check that treated DecoratorConfig.backend defaulting to None as L1-only mode. Now only @cache(backend=None) triggers L1-only. For L1-only with presets, use: @cache(backend=None, config=...) * test: add regression tests for default backend behavior Adds TestDefaultBackendBehavior class with 6 tests verifying that: - @cache() without backend=None DOES call get_backend_provider() - @cache.minimal/dev/test() presets behave correctly - DecoratorConfig default (backend=None) != explicit backend=None - Explicit backend instances bypass provider lookup These regression tests protect against the bug where checking `config.backend is None` would treat ALL decorators as L1-only (since DecoratorConfig.backend defaults to None).
1 parent 6c40241 commit 0898986

File tree

4 files changed

+729
-45
lines changed

4 files changed

+729
-45
lines changed

src/cachekit/decorators/intent.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
"""Intent-based intelligent cache decorator interface.
1+
"""Intent-based cache decorator interface.
22
3-
Provides the new @cache decorator with automatic optimization and
4-
intent-based variants (@cache.minimal, @cache.production, @cache.secure, @cache.dev, @cache.test).
3+
Provides the @cache decorator with intent-based variants (@cache.minimal, @cache.production, @cache.secure, @cache.dev, @cache.test).
54
"""
65

76
from __future__ import annotations
@@ -16,18 +15,22 @@
1615
F = TypeVar("F", bound=Callable[..., Any])
1716

1817

19-
def _apply_cache_logic(func: Callable[..., Any], decorator_config: DecoratorConfig) -> Callable[..., Any]:
18+
def _apply_cache_logic(
19+
func: Callable[..., Any], decorator_config: DecoratorConfig, _l1_only_mode: bool = False
20+
) -> Callable[..., Any]:
2021
"""Apply resolved configuration using the wrapper factory.
2122
2223
Args:
2324
func: Function to wrap
2425
decorator_config: DecoratorConfig instance with all settings
26+
_l1_only_mode: If True, backend=None was explicitly passed (L1-only mode).
27+
This prevents the wrapper from trying to get a backend from the provider.
2528
2629
Returns:
2730
Wrapped function
2831
"""
2932
# Use the wrapper factory with DecoratorConfig
30-
return create_cache_wrapper(func, config=decorator_config)
33+
return create_cache_wrapper(func, config=decorator_config, _l1_only_mode=_l1_only_mode)
3134

3235

3336
def cache(
@@ -101,7 +104,11 @@ def cache(
101104

102105
def decorator(f: F) -> F:
103106
# Resolve backend at decorator application time
104-
# Only if backend is explicitly provided in manual_overrides
107+
# Track if backend=None was explicitly passed (L1-only mode)
108+
# This is a sentinel problem: we need to distinguish between:
109+
# 1. User passed @cache(backend=None) explicitly -> L1-only mode
110+
# 2. User didn't pass backend at all -> should try provider
111+
_explicit_l1_only = "backend" in manual_overrides and manual_overrides.get("backend") is None
105112
backend = manual_overrides.pop("backend", None)
106113

107114
# Backward compatibility: map flattened l1_enabled to nested l1.enabled
@@ -150,8 +157,11 @@ def decorator(f: F) -> F:
150157
# No intent specified - use default DecoratorConfig with overrides
151158
resolved_config = DecoratorConfig(backend=backend, **manual_overrides)
152159

153-
# Delegate to wrapper factory
154-
return _apply_cache_logic(f, resolved_config) # type: ignore[return-value]
160+
# Delegate to wrapper factory with L1-only mode flag
161+
# Note: _explicit_l1_only is ONLY set when backend=None was explicitly passed
162+
# via manual_overrides. DecoratorConfig.backend defaults to None, but that
163+
# should NOT trigger L1-only mode - it should fall back to the provider.
164+
return _apply_cache_logic(f, resolved_config, _l1_only_mode=_explicit_l1_only) # type: ignore[return-value]
155165

156166
# Handle both @cache and @cache() syntax
157167
if func is None:

src/cachekit/decorators/wrapper.py

Lines changed: 148 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import functools
55
import inspect
66
import logging
7+
import os
78
import threading
89
import time
910
from 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

Comments
 (0)