diff --git a/src/cachekit/decorators/intent.py b/src/cachekit/decorators/intent.py index ff7581c..7f04851 100644 --- a/src/cachekit/decorators/intent.py +++ b/src/cachekit/decorators/intent.py @@ -1,7 +1,6 @@ -"""Intent-based intelligent cache decorator interface. +"""Intent-based cache decorator interface. -Provides the new @cache decorator with automatic optimization and -intent-based variants (@cache.minimal, @cache.production, @cache.secure, @cache.dev, @cache.test). +Provides the @cache decorator with intent-based variants (@cache.minimal, @cache.production, @cache.secure, @cache.dev, @cache.test). """ from __future__ import annotations @@ -16,18 +15,22 @@ F = TypeVar("F", bound=Callable[..., Any]) -def _apply_cache_logic(func: Callable[..., Any], decorator_config: DecoratorConfig) -> Callable[..., Any]: +def _apply_cache_logic( + func: Callable[..., Any], decorator_config: DecoratorConfig, _l1_only_mode: bool = False +) -> Callable[..., Any]: """Apply resolved configuration using the wrapper factory. Args: func: Function to wrap decorator_config: DecoratorConfig instance with all settings + _l1_only_mode: If True, backend=None was explicitly passed (L1-only mode). + This prevents the wrapper from trying to get a backend from the provider. Returns: Wrapped function """ # Use the wrapper factory with DecoratorConfig - return create_cache_wrapper(func, config=decorator_config) + return create_cache_wrapper(func, config=decorator_config, _l1_only_mode=_l1_only_mode) def cache( @@ -101,7 +104,11 @@ def cache( def decorator(f: F) -> F: # Resolve backend at decorator application time - # Only if backend is explicitly provided in manual_overrides + # Track if backend=None was explicitly passed (L1-only mode) + # This is a sentinel problem: we need to distinguish between: + # 1. User passed @cache(backend=None) explicitly -> L1-only mode + # 2. User didn't pass backend at all -> should try provider + _explicit_l1_only = "backend" in manual_overrides and manual_overrides.get("backend") is None backend = manual_overrides.pop("backend", None) # Backward compatibility: map flattened l1_enabled to nested l1.enabled @@ -150,8 +157,11 @@ def decorator(f: F) -> F: # No intent specified - use default DecoratorConfig with overrides resolved_config = DecoratorConfig(backend=backend, **manual_overrides) - # Delegate to wrapper factory - return _apply_cache_logic(f, resolved_config) # type: ignore[return-value] + # Delegate to wrapper factory with L1-only mode flag + # Note: _explicit_l1_only is ONLY set when backend=None was explicitly passed + # via manual_overrides. DecoratorConfig.backend defaults to None, but that + # should NOT trigger L1-only mode - it should fall back to the provider. + return _apply_cache_logic(f, resolved_config, _l1_only_mode=_explicit_l1_only) # type: ignore[return-value] # Handle both @cache and @cache() syntax if func is None: diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index 2c1c4b3..9ce9de5 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -4,6 +4,7 @@ import functools import inspect import logging +import os import threading import time from typing import TYPE_CHECKING, Any, Callable, NamedTuple, TypeVar, Union @@ -291,6 +292,8 @@ def create_cache_wrapper( collect_stats: bool = True, enable_tracing: bool = True, enable_structured_logging: bool = True, + # L1-only mode flag + _l1_only_mode: bool = False, **kwargs: Any, ) -> F: """Create cache wrapper for a function with specified configuration. @@ -475,6 +478,19 @@ def create_cache_wrapper( # Pass l1_enabled for rate limit classification header _stats = _FunctionStats(function_identifier=function_identifier, l1_enabled=l1_enabled) + # L1-only mode: debug log if backend would have been available + # Helps developers understand that Redis config is being intentionally ignored + if _l1_only_mode: + redis_url = os.environ.get("REDIS_URL") or os.environ.get("CACHEKIT_REDIS_URL") + if redis_url: + # Truncate URL to avoid logging credentials + safe_url = redis_url.split("@")[-1] if "@" in redis_url else redis_url[:30] + _logger.debug( + "L1-only mode: %s using in-memory cache only (backend=None explicit), ignoring available Redis at %s", + function_identifier, + safe_url, + ) + @functools.wraps(func) def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912 # Bypass check (5-10μs savings) @@ -496,7 +512,7 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912 # Create tracing span for cache operation span_attributes = { - "cache.system": "redis", + "cache.system": "l1_memory" if _l1_only_mode else "redis", "cache.operation": "get", "cache.namespace": namespace or "default", "cache.serializer": serializer, @@ -504,20 +520,73 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912 "function.async": False, } - with features.create_span("redis_cache", span_attributes) as span: + # Key generation - needed for both L1-only and L1+L2 modes + try: + if fast_mode: + # Minimal key generation - no string formatting overhead + from ..hash_utils import cache_key_hash + + cache_namespace = namespace or "default" + args_kwargs_str = str(args) + str(kwargs) + cache_key = cache_namespace + ":" + func_hash + ":" + cache_key_hash(args_kwargs_str) + else: + cache_key = operation_handler.get_cache_key(func, args, kwargs, namespace, integrity_checking) + except Exception as e: + # Key generation failed - execute function without caching + features.handle_cache_error( + error=e, + operation="key_generation", + cache_key="", + namespace=namespace or "default", + duration_ms=0.0, + ) + reset_current_function_stats(token) + return func(*args, **kwargs) + + # L1-ONLY MODE: Skip backend initialization entirely + # This is the fix for the sentinel problem: when backend=None is explicitly passed, + # we should NOT try to get a backend from the provider + if _l1_only_mode: + # L1-only mode: Check L1 cache, execute function on miss, store in L1 + if _l1_cache and cache_key: + l1_found, l1_bytes = _l1_cache.get(cache_key) + if l1_found and l1_bytes: + # L1 cache hit + try: + # Pass cache_key for AAD verification (required for encryption) + l1_value = operation_handler.serialization_handler.deserialize_data(l1_bytes, cache_key=cache_key) + _stats.record_l1_hit() + reset_current_function_stats(token) + return l1_value + except Exception: + # L1 deserialization failed - invalidate and continue + _l1_cache.invalidate(cache_key) + + # L1 cache miss - execute function and store in L1 + _stats.record_miss() try: - # Fast path key generation (30-50μs savings) - if fast_mode: - # Minimal key generation - no string formatting overhead - from ..hash_utils import cache_key_hash + result = func(*args, **kwargs) + # Serialize and store in L1 + try: + # Pass cache_key for AAD binding (required for encryption) + serialized_bytes = operation_handler.serialization_handler.serialize_data( + result, args, kwargs, cache_key=cache_key + ) + if _l1_cache and cache_key and serialized_bytes: + _l1_cache.put(cache_key, serialized_bytes, redis_ttl=ttl) + except Exception as e: + # Serialization/storage failed but function succeeded - log and return result + logger().debug(f"L1-only mode: serialization/storage failed for {cache_key}: {e}") + return result + finally: + features.clear_correlation_id() + reset_current_function_stats(token) - cache_namespace = namespace or "default" - args_kwargs_str = str(args) + str(kwargs) - cache_key = cache_namespace + ":" + func_hash + ":" + cache_key_hash(args_kwargs_str) - else: - cache_key = operation_handler.get_cache_key(func, args, kwargs, namespace, integrity_checking) - lock_key = f"{cache_key}:lock" + # L1+L2 MODE: Original behavior with backend initialization + lock_key = f"{cache_key}:lock" + with features.create_span("redis_cache", span_attributes) as span: + try: # Add cache key to span attributes if span: features.set_span_attributes(span, {"cache.key": cache_key}) @@ -713,7 +782,7 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912 # Also store in L1 cache for fast subsequent access (using serialized bytes) if _l1_cache and cache_key and serialized_bytes: - _l1_cache.put(cache_key, serialized_bytes, redis_ttl=ttl or ttl) + _l1_cache.put(cache_key, serialized_bytes, redis_ttl=ttl) # Record successful cache set set_duration_ms = (time.time() - start_time) * 1000 @@ -786,14 +855,6 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: token = set_current_function_stats(_stats) try: - # Guard clause: Circuit breaker check - fail fast if circuit is open - # This prevents cascading failures - if not features.should_allow_request(): - # Circuit breaker fail-fast: raise exception immediately - raise BackendError( # noqa: F823 # pyright: ignore[reportUnboundVariable] - "Circuit breaker OPEN - failing fast", error_type=BackendErrorType.TRANSIENT - ) - # Get cache key early for consistent usage - note this may fail for complex types cache_key = None func_start_time: float | None = None # Initialize for exception handlers @@ -821,6 +882,49 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: ) return await func(*args, **kwargs) + # L1-ONLY MODE: Skip backend initialization entirely + # This is the fix for the sentinel problem: when backend=None is explicitly passed, + # we should NOT try to get a backend from the provider + if _l1_only_mode: + # L1-only mode: Check L1 cache, execute function on miss, store in L1 + if _l1_cache and cache_key: + l1_found, l1_bytes = _l1_cache.get(cache_key) + if l1_found and l1_bytes: + # L1 cache hit + try: + # Pass cache_key for AAD verification (required for encryption) + l1_value = operation_handler.serialization_handler.deserialize_data(l1_bytes, cache_key=cache_key) + _stats.record_l1_hit() + return l1_value + except Exception: + # L1 deserialization failed - invalidate and continue + _l1_cache.invalidate(cache_key) + + # L1 cache miss - execute function and store in L1 + _stats.record_miss() + result = await func(*args, **kwargs) + # Serialize and store in L1 + try: + # Pass cache_key for AAD binding (required for encryption) + serialized_bytes = operation_handler.serialization_handler.serialize_data( + result, args, kwargs, cache_key=cache_key + ) + if _l1_cache and cache_key and serialized_bytes: + _l1_cache.put(cache_key, serialized_bytes, redis_ttl=ttl) + except Exception as e: + # Serialization/storage failed but function succeeded - log and return result + logger().debug(f"L1-only mode: serialization/storage failed for {cache_key}: {e}") + return result + + # L1+L2 MODE: Original behavior with backend initialization + # Guard clause: Circuit breaker check - fail fast if circuit is open + # This prevents cascading failures + if not features.should_allow_request(): + # Circuit breaker fail-fast: raise exception immediately + raise BackendError( # noqa: F823 # pyright: ignore[reportUnboundVariable] + "Circuit breaker OPEN - failing fast", error_type=BackendErrorType.TRANSIENT + ) + # Guard clause: L1 cache check first - early return eliminates network latency if _l1_cache and cache_key: l1_found, l1_bytes = _l1_cache.get(cache_key) @@ -908,7 +1012,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: if _l1_cache and cache_key and cached_data: # cached_data is already serialized bytes from Redis cached_bytes = cached_data.encode("utf-8") if isinstance(cached_data, str) else cached_data - _l1_cache.put(cache_key, cached_bytes, redis_ttl=ttl or ttl) + _l1_cache.put(cache_key, cached_bytes, redis_ttl=ttl) # Handle TTL refresh if configured and threshold met 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: cached_bytes = ( cached_data.encode("utf-8") if isinstance(cached_data, str) else cached_data ) - _l1_cache.put(cache_key, cached_bytes, redis_ttl=ttl or ttl) + _l1_cache.put(cache_key, cached_bytes, redis_ttl=ttl) return result except Exception as e: @@ -992,7 +1096,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: cached_bytes = ( cached_data.encode("utf-8") if isinstance(cached_data, str) else cached_data ) - _l1_cache.put(cache_key, cached_bytes, redis_ttl=ttl or ttl) + _l1_cache.put(cache_key, cached_bytes, redis_ttl=ttl) return result except Exception: @@ -1017,7 +1121,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: await operation_handler.cache_handler.set_async( # type: ignore[attr-defined] cache_key, serialized_data, - ttl=ttl or ttl, + ttl=ttl, ) # Also store in L1 cache for fast subsequent access (using serialized bytes) @@ -1025,7 +1129,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: serialized_bytes = ( serialized_data.encode("utf-8") if isinstance(serialized_data, str) else serialized_data ) - _l1_cache.put(cache_key, serialized_bytes, redis_ttl=ttl or ttl) + _l1_cache.put(cache_key, serialized_bytes, redis_ttl=ttl) # Record successful cache set set_duration_ms = (time.perf_counter() - start_time) * 1000 @@ -1094,7 +1198,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: await operation_handler.cache_handler.set_async( # type: ignore[attr-defined] cache_key, serialized_data, - ttl=ttl or ttl, + ttl=ttl, ) # Also store in L1 cache for fast subsequent access (using serialized bytes) @@ -1102,7 +1206,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: serialized_bytes = ( serialized_data.encode("utf-8") if isinstance(serialized_data, str) else serialized_data ) - _l1_cache.put(cache_key, serialized_bytes, redis_ttl=ttl or ttl) + _l1_cache.put(cache_key, serialized_bytes, redis_ttl=ttl) # Record successful cache set set_duration_ms = (time.perf_counter() - start_time) * 1000 @@ -1144,11 +1248,15 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: def invalidate_cache(*args: Any, **kwargs: Any) -> None: nonlocal _backend - if _backend is None: + + # L1-ONLY MODE: Skip backend lookup entirely + # This fixes the sentinel problem: when backend=None is explicitly passed, + # we should NOT try to get a backend from the provider + if not _l1_only_mode and _backend is None: try: _backend = get_backend_provider().get_backend() except Exception as e: - # If backend creation fails, can't invalidate + # If backend creation fails, can't invalidate L2 _logger.debug("Failed to get backend for invalidation: %s", e) # Clear both L2 (backend) and L1 cache @@ -1158,18 +1266,22 @@ def invalidate_cache(*args: Any, **kwargs: Any) -> None: if _l1_cache and cache_key: _l1_cache.invalidate(cache_key) - # Clear L2 cache via invalidator - if _backend: + # Clear L2 cache via invalidator (skip in L1-only mode) + if _backend and not _l1_only_mode: invalidator.set_backend(_backend) invalidator.invalidate_cache(func, args, kwargs, namespace) async def ainvalidate_cache(*args: Any, **kwargs: Any) -> None: nonlocal _backend - if _backend is None: + + # L1-ONLY MODE: Skip backend lookup entirely + # This fixes the sentinel problem: when backend=None is explicitly passed, + # we should NOT try to get a backend from the provider + if not _l1_only_mode and _backend is None: try: _backend = get_backend_provider().get_backend() except Exception as e: - # If backend creation fails, can't invalidate + # If backend creation fails, can't invalidate L2 _logger.debug("Failed to get backend for async invalidation: %s", e) # Clear both L2 (backend) and L1 cache @@ -1179,8 +1291,8 @@ async def ainvalidate_cache(*args: Any, **kwargs: Any) -> None: if _l1_cache and cache_key: _l1_cache.invalidate(cache_key) - # Clear L2 cache via invalidator - if _backend: + # Clear L2 cache via invalidator (skip in L1-only mode) + if _backend and not _l1_only_mode: invalidator.set_backend(_backend) await invalidator.invalidate_cache_async(func, args, kwargs, namespace) diff --git a/tests/unit/test_l1_only_mode.py b/tests/unit/test_l1_only_mode.py new file mode 100644 index 0000000..08db76f --- /dev/null +++ b/tests/unit/test_l1_only_mode.py @@ -0,0 +1,562 @@ +""" +Test for L1-only mode bug: @cache(backend=None) should NOT attempt Redis connection. + +Bug: When backend=None is explicitly passed, the decorator should use L1-only mode +(no Redis). However, the wrapper tries to get a backend from the provider on first +use, causing Redis connection attempts that fail without Redis running. + +Root cause: Sentinel problem - can't distinguish between: +1. User passed @cache(backend=None) explicitly -> should be L1-only +2. User didn't pass backend at all -> should try provider + +This test reproduces the bug from docs/getting-started.md doctest failure. +""" + +from __future__ import annotations + +import time +from unittest.mock import MagicMock, patch + + +class TestL1OnlyModeBug: + """Tests for L1-only mode (backend=None) behavior. + + These tests mock the backend provider to simulate Redis being unavailable, + ensuring the tests work regardless of whether Redis is running locally. + """ + + def test_explicit_backend_none_should_not_call_backend_provider(self): + """ + BUG REPRODUCTION: @cache(backend=None) should NOT call get_backend_provider(). + + When backend=None is explicitly passed, the decorator should: + 1. Use L1 (in-memory) cache ONLY + 2. Never call get_backend_provider().get_backend() + 3. Work without Redis running + + This test fails because the wrapper ignores explicit backend=None and + tries to get a backend from the provider. + """ + from cachekit.decorators import cache + + # Mock the backend provider to detect if it's called + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_provider.return_value.get_backend.side_effect = RuntimeError("Should not be called!") + + call_count = 0 + + @cache(backend=None) + def compute_result() -> int: + nonlocal call_count + call_count += 1 + return 42 + + # First call - should cache in L1, NOT call backend provider + result1 = compute_result() + assert result1 == 42 + assert call_count == 1 + + # Second call - should hit L1 cache + result2 = compute_result() + assert result2 == 42 + # If L1-only mode works, call_count should still be 1 + assert call_count == 1, f"L1 cache miss - function called {call_count} times (expected 1)" + + # Backend provider should NEVER have been called + mock_provider.return_value.get_backend.assert_not_called() + + def test_l1_only_mode_performance(self): + """ + L1-only mode should provide significant speedup on cache hit. + + This reproduces the doctest failure from docs/getting-started.md: + - First call: cache miss, function executes (~10ms sleep) + - Second call: L1 cache hit, should be much faster + + Without the fix, the second call triggers Redis connection attempt, + which fails and falls back to re-executing the function. + """ + from cachekit.decorators import cache + + # Mock backend provider to fail (simulating no Redis) + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_provider.return_value.get_backend.side_effect = ConnectionError("Redis unavailable") + + @cache(backend=None) + def slow_function() -> int: + time.sleep(0.01) # 10ms delay + return 42 + + # First call - cache miss + start1 = time.perf_counter() + result1 = slow_function() + duration1 = time.perf_counter() - start1 + + # Second call - should be L1 cache hit + start2 = time.perf_counter() + result2 = slow_function() + duration2 = time.perf_counter() - start2 + + assert result1 == 42 + assert result2 == 42 + + # L1 cache hit should be at least 10x faster (sub-millisecond vs 10ms) + # The doctest assertion was: assert duration2 < duration1 / 2 + assert duration2 < duration1 / 2, ( + f"L1 cache not working: second call ({duration2 * 1000:.2f}ms) " + f"should be much faster than first ({duration1 * 1000:.2f}ms)" + ) + + def test_config_minimal_with_backend_none(self): + """ + Test L1-only mode with DecoratorConfig preset AND backend=None. + + NOTE: L1-only mode requires backend=None at the decorator level, not in config. + This is because DecoratorConfig.backend defaults to None, and we can't + distinguish "explicit None" from "default None" in the config. + + Correct usage for L1-only with presets: + @cache(backend=None, config=DecoratorConfig.minimal()) + """ + from cachekit.config import DecoratorConfig + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_provider.return_value.get_backend.side_effect = RuntimeError("Should not be called!") + + call_count = 0 + + # L1-only mode: backend=None passed directly to @cache, config for preset settings + @cache(backend=None, config=DecoratorConfig.minimal()) + def minimal_func() -> str: + nonlocal call_count + call_count += 1 + return "cached" + + result1 = minimal_func() + result2 = minimal_func() + + assert result1 == "cached" + assert result2 == "cached" + assert call_count == 1, f"L1 cache miss - function called {call_count} times" + + def test_explicit_backend_none_vs_default_behavior(self): + """ + Verify the semantic difference between explicit backend=None and no backend specified. + + - @cache(backend=None) -> L1-only mode, no provider lookup + - @cache() -> should attempt to get backend from provider (may fail without Redis) + + This test documents the expected behavior distinction. + """ + from cachekit.config import DecoratorConfig + + # Explicit backend=None in config + config_explicit = DecoratorConfig(backend=None, ttl=60) + # The config stores the backend + assert config_explicit.backend is None + + # This test just documents that we CAN configure backend=None + # The fix should make the wrapper respect this and NOT call get_backend_provider() + + def test_async_l1_only_mode(self): + """ + Async functions should also respect backend=None for L1-only mode. + """ + import asyncio + + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_provider.return_value.get_backend.side_effect = RuntimeError("Should not be called!") + + call_count = 0 + + @cache(backend=None) + async def async_compute() -> int: + nonlocal call_count + call_count += 1 + return 123 + + async def run_test(): + result1 = await async_compute() + result2 = await async_compute() + return result1, result2 + + result1, result2 = asyncio.run(run_test()) + assert result1 == 123 + assert result2 == 123 + assert call_count == 1, f"Async L1 cache miss - function called {call_count} times" + + def test_intent_presets_with_backend_none(self): + """ + Intent-based presets (@cache.minimal, @cache.production, etc.) should respect backend=None. + + This tests the edge case where backend=None is passed to intent presets like: + - @cache.minimal(backend=None) + - @cache.production(backend=None) + - @cache.secure(master_key="...", backend=None) + """ + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_provider.return_value.get_backend.side_effect = RuntimeError("Should not be called!") + + # Test @cache.minimal(backend=None) + minimal_call_count = 0 + + @cache.minimal(backend=None) + def minimal_func() -> str: + nonlocal minimal_call_count + minimal_call_count += 1 + return "minimal" + + assert minimal_func() == "minimal" + assert minimal_func() == "minimal" + assert minimal_call_count == 1, f"@cache.minimal L1 miss - called {minimal_call_count} times" + + # Test @cache.production(backend=None) + production_call_count = 0 + + @cache.production(backend=None) + def production_func() -> str: + nonlocal production_call_count + production_call_count += 1 + return "production" + + assert production_func() == "production" + assert production_func() == "production" + assert production_call_count == 1, f"@cache.production L1 miss - called {production_call_count} times" + + # Test @cache.secure(master_key="...", backend=None) + secure_call_count = 0 + + @cache.secure(master_key="a" * 64, backend=None) + def secure_func() -> str: + nonlocal secure_call_count + secure_call_count += 1 + return "secure" + + assert secure_func() == "secure" + assert secure_func() == "secure" + assert secure_call_count == 1, f"@cache.secure L1 miss - called {secure_call_count} times" + + # Backend provider should NEVER have been called for any preset + mock_provider.return_value.get_backend.assert_not_called() + + +class TestL1OnlyModeInvalidation: + """Tests for L1-only invalidation - should NOT attempt backend lookup.""" + + def test_invalidate_cache_should_not_call_backend_provider(self): + """ + BUG REPRODUCTION: invalidate_cache() in L1-only mode should NOT call get_backend_provider(). + + When backend=None is explicitly passed, invalidate_cache() should: + 1. Only invalidate the L1 cache + 2. Never call get_backend_provider().get_backend() + 3. Work without Redis running + """ + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_provider.return_value.get_backend.side_effect = RuntimeError("Should not be called!") + + call_count = 0 + + @cache(backend=None) + def cached_func(x: int) -> int: + nonlocal call_count + call_count += 1 + return x * 2 + + # Cache a value + result = cached_func(5) + assert result == 10 + assert call_count == 1 + + # invalidate_cache should NOT call backend provider + cached_func.invalidate_cache(5) + + # After invalidation, next call should re-execute function + result2 = cached_func(5) + assert result2 == 10 + assert call_count == 2, "Function should have been called again after invalidation" + + # Backend provider should NEVER have been called + mock_provider.return_value.get_backend.assert_not_called() + + def test_ainvalidate_cache_should_not_call_backend_provider(self): + """ + BUG REPRODUCTION: ainvalidate_cache() in L1-only mode should NOT call get_backend_provider(). + + Async version of invalidate_cache() should also respect L1-only mode. + """ + import asyncio + + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_provider.return_value.get_backend.side_effect = RuntimeError("Should not be called!") + + call_count = 0 + + @cache(backend=None) + async def async_cached_func(x: int) -> int: + nonlocal call_count + call_count += 1 + return x * 3 + + async def run_test(): + nonlocal call_count + + # Cache a value + result = await async_cached_func(7) + assert result == 21 + assert call_count == 1 + + # ainvalidate_cache should NOT call backend provider + await async_cached_func.ainvalidate_cache(7) + + # After invalidation, next call should re-execute function + result2 = await async_cached_func(7) + assert result2 == 21 + assert call_count == 2, "Function should have been called again after invalidation" + + asyncio.run(run_test()) + + # Backend provider should NEVER have been called + mock_provider.return_value.get_backend.assert_not_called() + + def test_invalidate_cache_actually_clears_l1(self): + """Verify invalidate_cache() actually clears the L1 cache in L1-only mode.""" + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_provider.return_value.get_backend.side_effect = RuntimeError("Should not be called!") + + call_count = 0 + return_values = [100, 200] # Different values for each call + + @cache(backend=None) + def changing_func(x: int) -> int: + nonlocal call_count + result = return_values[call_count] + call_count += 1 + return result + + # First call returns 100 + result1 = changing_func(1) + assert result1 == 100 + + # Second call should hit cache and return 100 + result2 = changing_func(1) + assert result2 == 100 + assert call_count == 1, "Should have hit L1 cache" + + # Invalidate the cache + changing_func.invalidate_cache(1) + + # Third call should re-execute and return 200 + result3 = changing_func(1) + assert result3 == 200 + assert call_count == 2, "Should have re-executed after invalidation" + + +class TestDefaultBackendBehavior: + """ + CRITICAL: Tests that @cache() WITHOUT backend=None DOES attempt provider lookup. + + This is the regression test for the bug where we accidentally made ALL decorators + L1-only by checking `config.backend is None` (which is the default). + """ + + def test_default_cache_should_call_backend_provider(self): + """ + @cache() without backend=None SHOULD call get_backend_provider(). + + This is the INVERSE of L1-only mode - verifies we didn't break default behavior. + """ + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + # Make provider return a mock backend + mock_backend = MagicMock() + mock_provider.return_value.get_backend.return_value = mock_backend + + @cache(ttl=60) # NO backend=None - should use provider + def default_func() -> str: + return "result" + + # Call the function - this should trigger provider lookup + default_func() + + # Backend provider SHOULD have been called + mock_provider.return_value.get_backend.assert_called() + + def test_cache_minimal_without_backend_none_should_call_provider(self): + """ + @cache.minimal() without backend=None SHOULD call get_backend_provider(). + """ + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_backend = MagicMock() + mock_provider.return_value.get_backend.return_value = mock_backend + + @cache.minimal(ttl=60) # NO backend=None + def minimal_func() -> str: + return "result" + + minimal_func() + + # Backend provider SHOULD have been called + mock_provider.return_value.get_backend.assert_called() + + def test_decorator_config_default_backend_should_call_provider(self): + """ + DecoratorConfig() with default backend SHOULD call get_backend_provider(). + + This specifically tests that DecoratorConfig.backend defaulting to None + does NOT trigger L1-only mode (the bug we fixed). + """ + from cachekit.config import DecoratorConfig + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_backend = MagicMock() + mock_provider.return_value.get_backend.return_value = mock_backend + + # DecoratorConfig() has backend=None by DEFAULT - should NOT be L1-only + @cache(config=DecoratorConfig(ttl=60)) + def config_func() -> str: + return "result" + + config_func() + + # Backend provider SHOULD have been called (default != explicit None) + mock_provider.return_value.get_backend.assert_called() + + def test_explicit_backend_instance_should_be_used(self): + """ + @cache(backend=explicit_backend) should use that backend, not provider. + """ + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_provider.return_value.get_backend.side_effect = RuntimeError("Should not be called!") + + # Create an explicit mock backend + explicit_backend = MagicMock() + explicit_backend.get.return_value = None # Cache miss + + @cache(backend=explicit_backend, ttl=60) + def explicit_func() -> str: + return "result" + + explicit_func() + + # Provider should NOT be called - explicit backend provided + mock_provider.return_value.get_backend.assert_not_called() + + def test_dev_and_test_presets_without_backend_none(self): + """ + @cache.dev() and @cache.test() without backend=None SHOULD call provider. + + Completes coverage for all intent presets. + """ + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_backend = MagicMock() + mock_provider.return_value.get_backend.return_value = mock_backend + + @cache.dev(ttl=60) + def dev_func() -> str: + return "dev" + + @cache.test(ttl=60) + def test_func() -> str: + return "test" + + dev_func() + test_func() + + # Both should have triggered provider lookup + assert mock_provider.return_value.get_backend.call_count >= 2 + + def test_dev_and_test_presets_with_backend_none(self): + """ + @cache.dev(backend=None) and @cache.test(backend=None) should be L1-only. + + Completes L1-only coverage for all intent presets. + """ + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + mock_provider.return_value.get_backend.side_effect = RuntimeError("Should not be called!") + + dev_count = 0 + + @cache.dev(backend=None) + def dev_func() -> str: + nonlocal dev_count + dev_count += 1 + return "dev" + + test_count = 0 + + @cache.test(backend=None) + def test_func() -> str: + nonlocal test_count + test_count += 1 + return "test" + + # Execute twice each - should hit L1 cache + dev_func() + dev_func() + test_func() + test_func() + + assert dev_count == 1, f"@cache.dev L1 miss - called {dev_count} times" + assert test_count == 1, f"@cache.test L1 miss - called {test_count} times" + + # Provider should NEVER be called + mock_provider.return_value.get_backend.assert_not_called() + + +class TestL1OnlyModeNoRedisWarnings: + """ + Verify that L1-only mode doesn't produce Redis connection warnings. + + The original bug manifests as: + WARNING cachekit.decorators.orchestrator:provider.py:45 + Cache operation 'client_creation' failed for key '...': + Transient Redis error: Error 111 connecting to localhost:6379. Connection refused. + """ + + def test_no_redis_warnings_on_l1_only(self, caplog): + """ + L1-only mode should not log Redis connection errors. + """ + import logging + + from cachekit.decorators import cache + + with patch("cachekit.decorators.wrapper.get_backend_provider") as mock_provider: + # Make provider raise Redis error if called + mock_provider.return_value.get_backend.side_effect = ConnectionError("Transient Redis error: Connection refused") + + with caplog.at_level(logging.WARNING): + + @cache(backend=None) + def cached_func() -> str: + return "value" + + # Execute multiple times + for _ in range(3): + cached_func() + + # No Redis-related warnings should appear + redis_warnings = [r for r in caplog.records if "Redis" in r.message or "Connection refused" in r.message] + assert len(redis_warnings) == 0, f"Unexpected Redis warnings: {[r.message for r in redis_warnings]}" diff --git a/uv.lock b/uv.lock index 2979a52..9b5e32d 100644 --- a/uv.lock +++ b/uv.lock @@ -235,7 +235,7 @@ filecache = [ [[package]] name = "cachekit" -version = "0.1.0" +version = "0.0.0" source = { editable = "." } dependencies = [ { name = "blake3" },