Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
36 changes: 35 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ def my_function():
| `enabled` | bool | `True` | Enable L1 in-memory cache |
| `max_size_mb` | int | `100` | Maximum L1 cache size in MB |
| `swr_enabled` | bool | `True` | Enable stale-while-revalidate (SWR) |
| `swr_threshold_ratio` | float | `0.5` | Refresh at X% of TTL (0.1-1.0) |
| `swr_threshold_ratio` | float | `0.5` | Refresh at X% of TTL, in `(0.0, 1.0]` |
| `invalidation_enabled` | bool | `True` | Enable invalidation event broadcasts |
| `namespace_index` | bool | `True` | Enable fast namespace-based invalidation |

Expand All @@ -237,6 +237,40 @@ def my_function():
- **Expiry**: Hard deadline when entry is deleted from cache
- **Namespace**: Logical grouping for bulk invalidation (see [L1 Invalidation Guide](features/l1-invalidation.md))

### L1-Only Mode (`backend=None`)

With `backend=None` the decorator caches raw Python objects in process memory (no
serialization — tuples, sets, and frozensets keep their types). `L1CacheConfig` is
honored as follows:

- **`max_size_mb`** bounds the cache by *estimated bytes*, not entry count. Sizes of
raw objects are estimated best-effort (builtin containers are walked recursively;
other objects are counted via `sys.getsizeof`). A single value larger than the whole
budget is returned to the caller but never cached.
- **SWR requires a `ttl`.** With `swr_enabled=True` and a `ttl` set, a cache hit past
`ttl * swr_threshold_ratio` (±10% jitter) serves the cached value immediately and
refreshes it in the background — via `asyncio.create_task` for `async def` functions,
or a daemon thread for sync functions. A successful refresh restarts both the
freshness clock and the TTL. With `ttl=None` entries never go stale, so no refresh
is ever scheduled — they are stored with a one-year (31,536,000 s) sentinel
expiry rather than truly indefinitely, and can still be evicted earlier under
byte pressure.
- **Refresh failures are non-fatal**: the stale value keeps being served until hard
expiry, and the next qualifying hit retries the refresh.

```python notest
import asyncio
from cachekit import cache
from cachekit.config import L1CacheConfig

@cache(ttl=60, backend=None, l1=L1CacheConfig(swr_enabled=True, swr_threshold_ratio=0.5))
async def load_dashboard():
return await fetch_expensive_data() # illustrative - not defined

# After ~30s (50% of TTL, ±10% jitter), the next call returns the cached value
# instantly and schedules a background refresh — callers never block on revalidation.
```

### Intent Presets

Use intent presets to configure L1 and other features for different use cases:
Expand Down
15 changes: 13 additions & 2 deletions src/cachekit/config/nested.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,15 @@ class L1CacheConfig:

Attributes:
enabled: Enable L1 in-memory cache (default: True)
max_size_mb: Maximum L1 cache size in megabytes (default: 100)
max_size_mb: Maximum L1 cache size in megabytes (default: 100). In L1-only
mode (backend=None) this is a best-effort byte bound on raw object sizes.
swr_enabled: Enable stale-while-revalidate background refresh (default: True).
Requires a ttl; in L1-only mode async functions refresh via an asyncio
task and sync functions via a daemon thread.
swr_threshold_ratio: Fraction of TTL after which a hit triggers a background
refresh, in (0.0, 1.0] (default: 0.5)
invalidation_enabled: Enable invalidation event broadcasts (default: True)
namespace_index: Enable fast namespace-based invalidation (default: True)

Examples:
Create with defaults:
Expand Down Expand Up @@ -57,10 +65,13 @@ def validate(self) -> None:
"""Validate L1 cache configuration.

Raises:
ConfigurationError: If max_size_mb < 1
ConfigurationError: If max_size_mb < 1 or swr_threshold_ratio is
outside (0.0, 1.0]
"""
if self.max_size_mb < 1:
raise ConfigurationError(f"L1 max_size_mb must be >= 1, got {self.max_size_mb}")
if not (0.0 < self.swr_threshold_ratio <= 1.0):
raise ConfigurationError(f"L1 swr_threshold_ratio must be in (0.0, 1.0], got {self.swr_threshold_ratio}")
Comment thread
27Bslash6 marked this conversation as resolved.


@dataclass(frozen=True)
Expand Down
156 changes: 153 additions & 3 deletions src/cachekit/decorators/wrapper.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import copy
import functools
import inspect
import logging
Expand Down Expand Up @@ -36,6 +37,11 @@

_logger = logging.getLogger(__name__)

# Cap on concurrent L1-only SWR background refreshes per wrapped function.
# Bounds resource usage when many distinct keys go stale together; at capacity
# the refresh is skipped (stale keeps being served) and a later hit retries.
_L1_SWR_MAX_CONCURRENT_REFRESHES = 32


def _ttl_refresh_done_callback(task: asyncio.Task, cache_key: str) -> None:
"""Callback for background TTL refresh tasks to handle errors.
Expand Down Expand Up @@ -489,7 +495,28 @@ def create_cache_wrapper(

# L1-only mode: use ObjectCache for raw Python object storage (no serialization).
# This preserves types (tuples, sets, frozensets) that MessagePack would degrade.
_object_cache: ObjectCache | None = ObjectCache(max_entries=256) if _l1_only_mode else None
# L1CacheConfig is honored here (#207): max_size_mb bounds bytes (best-effort
# object-graph estimate, not entry count) and swr_enabled/swr_threshold_ratio
# drive background refresh via get_with_swr.
from ..config.nested import L1CacheConfig

_l1_config: L1CacheConfig = config.l1 if config is not None else L1CacheConfig()
# l1_enabled already merges the decorator param with config.l1.enabled (see
# config handling above) — with it False in L1-only mode there is no cache
# at all and the wrappers call the function directly.
_object_cache: ObjectCache | None = (
ObjectCache(
max_entries=None,
max_size_bytes=_l1_config.max_size_mb * 1024 * 1024,
swr_threshold_ratio=_l1_config.swr_threshold_ratio,
)
if _l1_only_mode and l1_enabled
else None
)

# SWR needs a TTL: freshness is measured against ttl * swr_threshold_ratio.
# With ttl=None entries never go stale, so there is nothing to revalidate.
_l1_swr_active = _object_cache is not None and _l1_config.swr_enabled and ttl is not None and ttl > 0

# Create per-function statistics tracker with lazy session ID generation
# Session ID format: "{process_uuid}:{module}.{function_name}"
Expand All @@ -510,6 +537,81 @@ def create_cache_wrapper(
# Pass l1_enabled for rate limit classification header
_stats = _FunctionStats(function_identifier=function_identifier, l1_enabled=l1_enabled)

# L1-only SWR: strong refs to in-flight refresh tasks. asyncio only keeps weak
# refs to tasks, so a fire-and-forget refresh could be GC'd mid-flight otherwise.
_l1_swr_tasks: set[asyncio.Task[None]] = set()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Per-key suppression alone doesn't bound refresh concurrency: a workload
# crossing the SWR threshold on many distinct keys at once would spawn one
# task/thread per key. This semaphore caps in-flight refreshes per wrapped
# function; at capacity the refresh is skipped (stale keeps being served)
# and a later qualifying hit retries.
_l1_swr_slots = threading.BoundedSemaphore(_L1_SWR_MAX_CONCURRENT_REFRESHES)

def _l1_swr_acquire(
cache_key: str, version: int, call_args: tuple[Any, ...], call_kwargs: dict[str, Any]
) -> tuple[Any, Any] | None:
"""Reserve a refresh slot and snapshot the live arguments.

The cache key was computed from the arguments as they were at call
time; the refresh runs later, so it must not see mutations the caller
makes after receiving the stale value (it would store the new state
under the old key). Returns deep-copied (args, kwargs), or None when at
capacity or the arguments can't be copied — in both cases this exact
refresh (version) is cancelled so a later call retries, and the caller
must not schedule a refresh.
"""
assert _object_cache is not None # noqa: S101 - only called when scheduling a refresh
if not _l1_swr_slots.acquire(blocking=False):
_object_cache.cancel_refresh(cache_key, version)
return None
try:
return copy.deepcopy((call_args, call_kwargs))
except Exception as exc:
_l1_swr_slots.release()
_object_cache.cancel_refresh(cache_key, version)
_logger.debug("L1-only SWR refresh skipped for %s: arguments not deep-copyable: %s", cache_key, exc)
return None

def _l1_swr_task_done(task: asyncio.Task[None], cache_key: str) -> None:
_l1_swr_tasks.discard(task)
_ttl_refresh_done_callback(task, cache_key)

async def _l1_swr_refresh_async(
cache_key: str, version: int, call_args: tuple[Any, ...], call_kwargs: dict[str, Any]
) -> None:
"""Background SWR refresh for async functions in L1-only mode.

Only ever scheduled with a slot held via _l1_swr_acquire; releases it.
"""
assert _object_cache is not None and ttl is not None # noqa: S101 - _l1_swr_active guarantees both
try:
try:
result = await func(*call_args, **call_kwargs)
except BaseException:
_object_cache.cancel_refresh(cache_key, version) # let a later call retry
raise # logged (at debug) by _l1_swr_task_done
_object_cache.complete_refresh(cache_key, version, result, ttl=ttl)
finally:
_l1_swr_slots.release()

def _l1_swr_refresh_sync(cache_key: str, version: int, call_args: tuple[Any, ...], call_kwargs: dict[str, Any]) -> None:
"""Background SWR refresh for sync functions in L1-only mode (runs on a daemon thread).

Only ever scheduled with a slot held via _l1_swr_acquire; releases it.
"""
assert _object_cache is not None and ttl is not None # noqa: S101 - _l1_swr_active guarantees both
try:
try:
result = func(*call_args, **call_kwargs)
except Exception as exc:
_object_cache.cancel_refresh(cache_key, version) # let a later call retry
_logger.debug("L1-only SWR background refresh failed for %s: %s", cache_key, exc)
return
_object_cache.complete_refresh(cache_key, version, result, ttl=ttl)
finally:
_l1_swr_slots.release()

# 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:
Expand Down Expand Up @@ -583,10 +685,39 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912

# L1-ONLY MODE: Store raw Python objects (no serialization).
# Preserves types (tuples, sets, frozensets) that MessagePack would degrade.
if _l1_only_mode and _object_cache is None:
# L1 disabled in L1-only mode -> no cache anywhere; call through
try:
return func(*args, **kwargs)
finally:
features.clear_correlation_id()
reset_current_function_stats(token)
if _l1_only_mode and _object_cache:
found, cached_value = _object_cache.get(cache_key)
if _l1_swr_active and ttl is not None:
found, cached_value, needs_refresh, version = _object_cache.get_with_swr(cache_key, ttl)
else:
found, cached_value = _object_cache.get(cache_key)
needs_refresh, version = False, 0
if found:
_stats.record_l1_hit()
if needs_refresh:
# SWR: serve the stale value now, refresh on a daemon thread
# (sync functions have no event loop to schedule a task on)
snapshot = _l1_swr_acquire(cache_key, version, args, kwargs)
if snapshot is not None:
refresh_args, refresh_kwargs = snapshot
try:
threading.Thread(
target=_l1_swr_refresh_sync,
args=(cache_key, version, refresh_args, refresh_kwargs),
name=f"cachekit-swr-{func.__name__}",
daemon=True,
).start()
except RuntimeError:
# Thread couldn't start (resource pressure) — release
# the slot and this exact refresh so a later call retries
_l1_swr_slots.release()
_object_cache.cancel_refresh(cache_key, version)
features.clear_correlation_id()
reset_current_function_stats(token)
return cached_value
Expand Down Expand Up @@ -908,10 +1039,29 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:

# L1-ONLY MODE: Store raw Python objects (no serialization).
# Preserves types (tuples, sets, frozensets) that MessagePack would degrade.
if _l1_only_mode and _object_cache is None:
# L1 disabled in L1-only mode -> no cache anywhere; call through
# (outer finally clears correlation ID and resets stats context)
return await func(*args, **kwargs)
if _l1_only_mode and _object_cache:
found, cached_value = _object_cache.get(cache_key)
if _l1_swr_active and ttl is not None:
found, cached_value, needs_refresh, version = _object_cache.get_with_swr(cache_key, ttl)
else:
found, cached_value = _object_cache.get(cache_key)
needs_refresh, version = False, 0
if found:
_stats.record_l1_hit()
if needs_refresh:
# SWR: serve the stale value now, refresh in the background
# without blocking the caller
snapshot = _l1_swr_acquire(cache_key, version, args, kwargs)
if snapshot is not None:
refresh_args, refresh_kwargs = snapshot
refresh_task = asyncio.create_task(
_l1_swr_refresh_async(cache_key, version, refresh_args, refresh_kwargs)
)
_l1_swr_tasks.add(refresh_task)
refresh_task.add_done_callback(functools.partial(_l1_swr_task_done, cache_key=cache_key))
features.clear_correlation_id()
return cached_value

Expand Down
Loading
Loading