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
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Rules and behavior:
- **CachekitIO only** — other backends have no read-side freshness signal and raise `ConfigurationError` if `stale_ttl` is set.
- Concurrent stale hits trigger at most one revalidation: per-process dedup plus (async functions) a non-blocking distributed lease on the backend's lock. Contested = serve stale, don't wait.
- A failed background recompute is silent: the entry keeps serving stale until its hard eviction bound, after which the next call takes the ordinary synchronous miss path.
- The background recompute runs **outside the request context** — don't rely on request-scoped state (contextvars, open sessions) inside functions that enable SWR.
- The background recompute runs with a **snapshot of the caller's `contextvars`** (contextvar-based tenant extraction works), but outside the request otherwise — don't rely on other request-scoped resources (open sessions, connections) inside functions that enable SWR.
- Stale values are never written to the L1 in-memory cache, and stale reads still count as cache **hits** for metered-misses billing.

### File Backend Environment Variables
Expand Down
11 changes: 0 additions & 11 deletions src/cachekit/backends/cachekitio/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -511,17 +511,6 @@ async def get_async(self, key: str) -> bytes | None:
return None
raise

async def get_with_freshness_async(self, key: str) -> tuple[bytes, bool] | None:
"""Retrieve value plus its SWR freshness (async). See :meth:`get_with_freshness`."""
try:
response = await self._request_async("GET", key)
return response.content, self._is_stale(response)
except BackendError as exc:
if exc.original_exception and isinstance(exc.original_exception, httpx.HTTPStatusError):
if exc.original_exception.response.status_code == 404:
return None
raise

async def set_async(self, key: str, value: bytes, ttl: int | None = None, stale_ttl: int | None = None) -> None:
"""Store value in cache (async).

Expand Down
101 changes: 37 additions & 64 deletions src/cachekit/cache_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,31 @@ def get_cache_key(
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "_bypass_cache"}
return self.key_generator.generate_key(func, args, filtered_kwargs, namespace, integrity_checking)

def _handle_l2_read_error(self, e: SerializationError, cache_key: str) -> None:
"""Shared decrypt/integrity failure tail for sync L2 reads (LAB-108/#159).

Routes through the single policy point (raises DecryptionAuthenticationError
when fail-closed — poisoned entry retained as evidence), else best-effort
evicts the poisoned entry and notifies, so the caller treats it as a miss.
"""
handle_decrypt_failure(e, tier="l2", cache_key=cache_key, fail_closed=self.serialization_handler.encryption_fail_closed)
try:
if self._cache_handler is not None:
self._cache_handler.delete(cache_key)
except Exception as del_err: # best-effort eviction; never mask the miss/recompute
get_logger().warning(f"Failed to evict poisoned L2 entry {redact_cache_key(cache_key)}: {del_err}")
self._notify_deserialize_error(e, cache_key)

async def _handle_l2_read_error_async(self, e: SerializationError, cache_key: str) -> None:
"""Async twin of :meth:`_handle_l2_read_error` (delete_async eviction)."""
handle_decrypt_failure(e, tier="l2", cache_key=cache_key, fail_closed=self.serialization_handler.encryption_fail_closed)
try:
if self._cache_handler is not None:
await self._cache_handler.delete_async(cache_key)
except Exception as del_err: # best-effort eviction; never mask the miss/recompute
get_logger().warning(f"Failed to evict poisoned L2 entry {redact_cache_key(cache_key)}: {del_err}")
self._notify_deserialize_error(e, cache_key)

def get_cached_value(self, cache_key: str, refresh_ttl: Optional[int] = None) -> Optional[Any]:
"""Get value from cache if it exists.

Expand Down Expand Up @@ -1272,29 +1297,13 @@ def get_cached_value(self, cache_key: str, refresh_ttl: Optional[int] = None) ->
return (True, deserialized)
return None
except SerializationError as e:
# Single policy point (cachekit-py#170): records the metric and raises when
# fail-closed — in that case the poisoned entry is deliberately RETAINED as
# evidence for the operator (eviction only happens on the fail-open return).
handle_decrypt_failure(
e, tier="l2", cache_key=cache_key, fail_closed=self.serialization_handler.encryption_fail_closed
)
# Fail open: best-effort evict the poisoned entry so subsequent reads don't
# re-pay full decompress+verify only to fail again; the caller recomputes
# and re-stores the value (#159).
try:
if self._cache_handler is not None:
self._cache_handler.delete(cache_key)
except Exception as del_err: # best-effort eviction; never mask the miss/recompute
get_logger().warning(f"Failed to evict poisoned L2 entry {redact_cache_key(cache_key)}: {del_err}")
self._notify_deserialize_error(e, cache_key)
self._handle_l2_read_error(e, cache_key) # raises when fail-closed (LAB-108)
return None
except Exception as e:
get_logger().warning(f"Backend operation failed for get on {cache_key}: {e}")
return None

def get_cached_value_with_freshness(
self, cache_key: str, refresh_ttl: Optional[int] = None
) -> Optional[tuple[tuple[bool, Any], bool]]:
def get_cached_value_with_freshness(self, cache_key: str) -> Optional[tuple[tuple[bool, Any], bool]]:
"""SWR variant of :meth:`get_cached_value` (LAB-381): also reports staleness.

Returns ``((True, value), is_stale)`` on a hit, None on miss/error. The mmap
Expand All @@ -1315,26 +1324,13 @@ def get_cached_value_with_freshness(
deserialized = self.serialization_handler.deserialize_data(cached_data, cache_key)
return ((True, deserialized), is_stale)
except SerializationError as e:
# Single policy point (cachekit-py#170): records the metric and raises when
# fail-closed — in that case the poisoned entry is deliberately RETAINED as
# evidence for the operator (eviction only happens on the fail-open return).
handle_decrypt_failure(
e, tier="l2", cache_key=cache_key, fail_closed=self.serialization_handler.encryption_fail_closed
)
try:
if self._cache_handler is not None:
self._cache_handler.delete(cache_key)
except Exception as del_err: # best-effort eviction; never mask the miss/recompute
get_logger().warning(f"Failed to evict poisoned L2 entry {redact_cache_key(cache_key)}: {del_err}")
self._notify_deserialize_error(e, cache_key)
self._handle_l2_read_error(e, cache_key) # raises when fail-closed (LAB-108)
return None
except Exception as e:
get_logger().warning(f"Backend operation failed for get on {cache_key}: {e}")
get_logger().warning(f"Backend operation failed for get on {redact_cache_key(cache_key)}: {e}")
return None

async def get_cached_value_with_freshness_async(
self, cache_key: str, refresh_ttl: Optional[int] = None
) -> Optional[tuple[tuple[bool, Any, bytes], bool]]:
async def get_cached_value_with_freshness_async(self, cache_key: str) -> Optional[tuple[tuple[bool, Any, bytes], bool]]:
"""Async SWR variant (LAB-381): staleness + the raw envelope for L1 backfill.

Returns ``((True, value, raw_bytes), is_stale)`` on a hit — the 3-tuple
Expand All @@ -1354,19 +1350,10 @@ async def get_cached_value_with_freshness_async(
deserialized = self.serialization_handler.deserialize_data(cached_data, cache_key)
return ((True, deserialized, cached_data), is_stale)
except SerializationError as e:
# Single policy point (cachekit-py#170) — see get_cached_value_async.
handle_decrypt_failure(
e, tier="l2", cache_key=cache_key, fail_closed=self.serialization_handler.encryption_fail_closed
)
try:
if self._cache_handler is not None:
await self._cache_handler.delete_async(cache_key)
except Exception as del_err: # best-effort eviction; never mask the miss/recompute
get_logger().warning(f"Failed to evict poisoned L2 entry {redact_cache_key(cache_key)}: {del_err}")
self._notify_deserialize_error(e, cache_key)
await self._handle_l2_read_error_async(e, cache_key) # raises when fail-closed (LAB-108)
return None
except Exception as e:
get_logger().warning(f"Backend operation failed for get on {cache_key}: {e}")
get_logger().warning(f"Backend operation failed for get on {redact_cache_key(cache_key)}: {e}")
return None

async def get_cached_value_async(self, cache_key: str, refresh_ttl: Optional[int] = None) -> Optional[Any]:
Expand Down Expand Up @@ -1400,21 +1387,7 @@ async def get_cached_value_async(self, cache_key: str, refresh_ttl: Optional[int
return (True, deserialized, cached_data)
return None
except SerializationError as e:
# Single policy point (cachekit-py#170): records the metric and raises when
# fail-closed — in that case the poisoned entry is deliberately RETAINED as
# evidence for the operator (eviction only happens on the fail-open return).
handle_decrypt_failure(
e, tier="l2", cache_key=cache_key, fail_closed=self.serialization_handler.encryption_fail_closed
)
# Fail open: best-effort evict the poisoned entry so subsequent reads don't
# re-pay full decompress+verify only to fail again; the caller recomputes
# and re-stores the value (#159).
try:
if self._cache_handler is not None:
await self._cache_handler.delete_async(cache_key)
except Exception as del_err: # best-effort eviction; never mask the miss/recompute
get_logger().warning(f"Failed to evict poisoned L2 entry {redact_cache_key(cache_key)}: {del_err}")
self._notify_deserialize_error(e, cache_key)
await self._handle_l2_read_error_async(e, cache_key) # raises when fail-closed (LAB-108)
return None
except Exception as e:
get_logger().warning(f"Backend operation failed for get on {cache_key}: {e}")
Expand Down Expand Up @@ -1832,10 +1805,10 @@ def get_with_freshness(self, key: str) -> Optional[tuple[bytes, bool]]:
try:
return self._with_backpressure_and_timeout(self.backend.get_with_freshness, key)
except BackendError as e:
get_logger().error(f"Backend error getting key {key}: {e}")
get_logger().error(f"Backend error getting key {redact_cache_key(key)}: {e}")
return None
except Exception as e:
get_logger().error(f"Unexpected error getting key {key}: {e}")
get_logger().error(f"Unexpected error getting key {redact_cache_key(key)}: {e}")
return None

async def get_with_freshness_async(self, key: str) -> Optional[tuple[bytes, bool]]:
Expand All @@ -1846,10 +1819,10 @@ async def get_with_freshness_async(self, key: str) -> Optional[tuple[bytes, bool
try:
return await self._with_backpressure_and_timeout_async(self.backend.get_with_freshness, key)
except BackendError as e:
get_logger().error(f"Backend error getting key {key}: {e}")
get_logger().error(f"Backend error getting key {redact_cache_key(key)}: {e}")
return None
except Exception as e:
get_logger().error(f"Unexpected error getting key {key}: {e}")
get_logger().error(f"Unexpected error getting key {redact_cache_key(key)}: {e}")
return None

def set(
Expand Down
4 changes: 3 additions & 1 deletion src/cachekit/decorators/intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ def cache(
``ttl`` and an SWR-capable backend (CachekitIO); ``ttl + stale_ttl``
is capped at 2,592,000 s (30 days). ``@cache.io`` defaults it to
``ttl`` — pass ``stale_ttl=0`` to opt out. The background recompute
runs outside the request context (no request-scoped state).
sees a snapshot of the caller's ``contextvars`` (so contextvar-based
tenant extraction works), but no other request-scoped resources —
open sessions/connections from the request must not be relied on.

Returns:
Decorated function with intelligent caching
Expand Down
Loading
Loading