Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 38 additions & 16 deletions docs/backends/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,43 +170,65 @@ def explicit_backend():

### 2. Module-Level Default Backend (Middle Priority)

```python notest
```python
import tempfile

from cachekit import cache
from cachekit.config.decorator import set_default_backend
from cachekit.backends.file import FileBackend, FileBackendConfig

# Set once at application startup
file_backend = FileBackend(FileBackendConfig(cache_dir="/var/cache/myapp"))
file_backend = FileBackend(FileBackendConfig(cache_dir=tempfile.mkdtemp()))
set_default_backend(file_backend)

# All decorators now use file backend — no backend= needed
@cache.minimal(ttl=300)
def fast_lookup():
return data()
def fast_lookup(x: int) -> int:
return x * 2

@cache.production(ttl=600)
def critical_function():
return data()
def critical_function(x: int) -> int:
return x * 3

assert fast_lookup(2) == 4 and critical_function(2) == 6

set_default_backend(None) # clear the default
```

Call `set_default_backend(None)` to clear the default. Works with any backend (Redis, File, CachekitIO, custom).

**Call order does not matter for backend selection.** A decorator applied
Comment thread
27Bslash6 marked this conversation as resolved.
Outdated
without `backend=` pins the default when it is first seen — at decoration if
already set, otherwise at first call — so the usual layout (business modules
imported at the top of the file, `set_default_backend()` in `main()`) works.
Later `set_default_backend()` calls do not re-point already-pinned functions.
Exception: `stale_ttl` and `@cache.io`'s default stale window validate SWR
capability at decoration, so set a CachekitIO default *before* importing modules
that use them.

### 3. Environment Variable Auto-Detection (Lowest Priority)

```bash
# Primary: CACHEKIT_REDIS_URL
CACHEKIT_REDIS_URL=redis://prod.example.com:6379
If no explicit backend and no module-level default, `DefaultBackendProvider`
picks a backend from exactly one environment selector, in this order:

# Fallback: REDIS_URL
REDIS_URL=redis://localhost:6379
```
| Priority | Environment variable | Backend |
|----------|-----------------------------|--------------------|
| 1 | `CACHEKIT_API_KEY` | `CachekitIOBackend` (SaaS) |
| 2 | `CACHEKIT_REDIS_URL` | `RedisBackend` |
| 3 | `CACHEKIT_MEMCACHED_SERVERS`| `MemcachedBackend` |
| 4 | `CACHEKIT_FILE_CACHE_DIR` | `FileBackend` |
| 5 | `REDIS_URL`, or nothing set | `RedisBackend` (localhost fallback) |

If no explicit backend and no module-level default, cachekit creates a RedisBackend from environment variables.
Setting more than one of the four `CACHEKIT_*` selectors is ambiguous and raises
`ConfigurationError` at first call. The decorator catches it, logs a WARNING on
the `cachekit.decorators.orchestrator` logger, and runs the function uncached.
`REDIS_URL` is a 12-factor fallback and never counts as a conflict.

**Resolution order**:
1. Check for explicit `backend` parameter in `@cache(backend=...)`
2. Check for module-level default via `set_default_backend()`
3. Create RedisBackend from environment variables (CACHEKIT_REDIS_URL > REDIS_URL)
1. Explicit `backend` parameter in `@cache(backend=...)`
2. Module-level default via `set_default_backend()` (checked at decoration, and
again at first call if still unset)
3. Environment auto-detection per the table above

## Performance Considerations

Expand Down
6 changes: 5 additions & 1 deletion src/cachekit/decorators/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,11 @@ def decorator(f: F) -> F:
backend = manual_overrides.pop("backend", None)

# Tier 2 resolution: if no explicit backend and not L1-only mode,
# check module-level default set via set_default_backend()
# check module-level default set via set_default_backend(). Kept here
# (not only lazily) because decoration-time validation — the interop
# backend guard and stale_ttl/SWR capability (LAB-557) — needs the
# backend when it is already known. If the default is set LATER, the
# wrapper re-consults it at first call (_resolve_lazy_backend, LAB-4457).
if backend is None and not _explicit_l1_only:
from ..config.decorator import get_default_backend

Expand Down
24 changes: 19 additions & 5 deletions src/cachekit/decorators/wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@
if TYPE_CHECKING:
from ..serializers.base import SerializerProtocol


def _resolve_lazy_backend() -> Any:
"""Backend for a decorator that was applied without ``backend=``.

Consulted at FIRST CALL, not at decoration, so ``set_default_backend()``
takes effect regardless of whether it ran before or after the module holding
the decorated function was imported (LAB-4457).
"""
from ..config.decorator import get_default_backend

default = get_default_backend()
return default if default is not None else get_backend_provider().get_backend()
Comment thread
27Bslash6 marked this conversation as resolved.
Outdated


F = TypeVar("F", bound=Callable[..., Any])

_logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -1263,7 +1277,7 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912

nonlocal _backend
if _backend is None:
_backend = get_backend_provider().get_backend()
_backend = _resolve_lazy_backend()

# Setup cache handler strategy on first use
handler = StandardCacheHandler(
Expand Down Expand Up @@ -1672,7 +1686,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
if interop is not None:
if _backend is None:
try:
_backend = get_backend_provider().get_backend()
_backend = _resolve_lazy_backend()
except Exception as e:
# If Redis connection fails, execute function without caching - RETURN EARLY
# This prevents the decorator from breaking the application
Expand Down Expand Up @@ -1746,7 +1760,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
# Initialize backend only when needed (lazy init for performance)
if _backend is None:
try:
_backend = get_backend_provider().get_backend()
_backend = _resolve_lazy_backend()
except Exception as e:
# If Redis connection fails, execute function without caching - RETURN EARLY
# This prevents the decorator from breaking the application
Expand Down Expand Up @@ -2078,7 +2092,7 @@ def invalidate_cache(*args: Any, **kwargs: Any) -> None:
# 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()
_backend = _resolve_lazy_backend()
except Exception as e:
# If backend creation fails, can't invalidate L2
_logger.debug("Failed to get backend for invalidation: %s", redact_error_for_log(e))
Expand Down Expand Up @@ -2138,7 +2152,7 @@ async def ainvalidate_cache(*args: Any, **kwargs: Any) -> None:
# 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()
_backend = _resolve_lazy_backend()
except Exception as e:
# If backend creation fails, can't invalidate L2
_logger.debug("Failed to get backend for async invalidation: %s", redact_error_for_log(e))
Expand Down
19 changes: 19 additions & 0 deletions tests/integration/test_file_backend_decorator_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,25 @@ def compute(x: int) -> int:
finally:
set_default_backend(original)

def test_set_default_backend_after_decoration(self, tmp_path: Path) -> None:
"""set_default_backend() called AFTER decoration still takes effect at first call (LAB-4457)."""
original = get_default_backend()
try:
set_default_backend(None)

@cache.minimal(ttl=300) # decorate FIRST: default is None here
def compute(x: int) -> int:
return x * 5

chosen = _make_file_backend(tmp_path, subdir="chosen")
set_default_backend(chosen) # configure SECOND

assert compute(2) == 10
Comment thread
27Bslash6 marked this conversation as resolved.
# Proof the explicit choice was used, not the env-detected fallback.
assert any((tmp_path / "chosen").iterdir())
finally:
set_default_backend(original)

def test_explicit_backend_overrides_default(self, tmp_path: Path) -> None:
"""Explicit backend= kwarg takes precedence over set_default_backend()."""
default_backend = _make_file_backend(tmp_path, subdir="default_cache")
Expand Down
Loading