From 84a37c91e0aaf4a7af2824080508071147e9d773 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 21 Jul 2026 00:58:16 +1000 Subject: [PATCH 1/5] =?UTF-8?q?feat(cachekitio):=20SWR=20transport=20layer?= =?UTF-8?q?=20=E2=80=94=20freshness-aware=20reads,=20stale-grace=20writes?= =?UTF-8?q?=20(LAB-381,=20part=201/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the client transport half of protocol spec/saas-api.md#stale-while-revalidate (server side: saas#245): - CachekitIOBackend.get_with_freshness[_async]: (bytes, is_stale) | None. X-CacheKit-Freshness mapping per spec — absent = fresh (pre-SWR server), unrecognized = stale (conservative), tokens case-sensitive. - CachekitIOBackend.set[_async] accept stale_ttl (X-CacheKit-Stale-TTL; only sent alongside an explicit ttl; >0). TTL now dual-sends canonical X-CacheKit-TTL + legacy X-TTL for deploy-order safety against pre-#245 servers (TODO: drop X-TTL once saas#245 is deployed). - StandardCacheHandler: supports_swr() TypeGuard (SWRCapableBackend protocol, mirrors supports_buffer_read house pattern), get_with_freshness[_async] with plain-get fallback for non-SWR backends (always fresh), set[_async] thread stale_ttl only to SWR-capable backends (a plain backend's 3-arg set never sees it). NO behavior change yet: nothing calls the new read path. Part 2 wires the decorator surface (stale_ttl param, background revalidation on stale hits, io() preset default) — design in the PR body. Gates: 2079 unit+critical passed (19 new; doctests + markdown-docs included), ruff clean, basedpyright 0 errors. Docs pass: docstrings on all new/changed methods; user-facing docs (configuration.md, feature matrices) deliberately deferred to part 2 — documenting the stale_ttl decorator surface before it exists would violate the ship-real-code-only rule. Co-authored-by: multica-agent --- src/cachekit/backends/cachekitio/backend.py | 86 ++++++-- src/cachekit/cache_handler.py | 73 ++++++- .../backends/test_cachekitio_swr_transport.py | 185 ++++++++++++++++++ 3 files changed, 329 insertions(+), 15 deletions(-) create mode 100644 tests/unit/backends/test_cachekitio_swr_transport.py diff --git a/src/cachekit/backends/cachekitio/backend.py b/src/cachekit/backends/cachekitio/backend.py index beb87d6..a8c7bd0 100644 --- a/src/cachekit/backends/cachekitio/backend.py +++ b/src/cachekit/backends/cachekitio/backend.py @@ -34,6 +34,20 @@ # preferring the header. See protocol spec/saas-api.md (DELETE .../lock). LOCK_ID_HEADER = "X-CacheKit-Lock-Id" +# Protocol-canonical TTL header (spec/saas-api.md). The legacy X-TTL is sent +# alongside it until the dual-reading server (saas#245) is deployed everywhere; +# sending both is value-identical and safe against either server generation. +# TODO(LAB-381 follow-up): drop X-TTL once saas#245 is live in prod. +TTL_HEADER = "X-CacheKit-TTL" +LEGACY_TTL_HEADER = "X-TTL" + +# Stale-while-revalidate (LAB-381, spec/saas-api.md#stale-while-revalidate). +# STALE_TTL_HEADER rides PUTs to open a stale-grace window past the fresh TTL; +# FRESHNESS_HEADER labels every GET/HEAD 200 as fresh|stale. Pre-SWR servers +# ignore the former and never emit the latter. +STALE_TTL_HEADER = "X-CacheKit-Stale-TTL" +FRESHNESS_HEADER = "X-CacheKit-Freshness" + def _inject_metrics_headers(stats: _FunctionStats | None) -> dict[str, str]: """Extract cache metrics and format as HTTP headers. @@ -335,22 +349,62 @@ def get(self, key: str) -> bytes | None: return None raise - def set(self, key: str, value: bytes, ttl: int | None = None) -> None: + @staticmethod + def _is_stale(response: httpx.Response) -> bool: + """Map the X-CacheKit-Freshness header to staleness (spec/saas-api.md). + + Absent header = fresh (pre-SWR server); unrecognized value = stale + (revalidation is the conservative action). Tokens are lowercase and + case-sensitive per spec. + """ + value = response.headers.get(FRESHNESS_HEADER) + return value is not None and value != "fresh" + + def get_with_freshness(self, key: str) -> tuple[bytes, bool] | None: + """Retrieve value plus its SWR freshness (sync). + + Returns: + ``(value, is_stale)`` on a hit — ``is_stale`` is True only for an + entry in its stale-grace window (LAB-381) — or None on a miss. + + Raises: + BackendError: If operation fails (network, auth, etc.) + """ + try: + response = self._request_sync("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 + + def set(self, key: str, value: bytes, ttl: int | None = None, stale_ttl: int | None = None) -> None: """Store value in cache (sync). Args: key: Cache key value: Bytes to cache ttl: Time-to-live in seconds (optional) + stale_ttl: Stale-grace window in seconds past the fresh TTL + (LAB-381 SWR). Only honoured alongside an explicit ``ttl``; + pre-SWR servers ignore it. Raises: BackendError: If operation fails """ - headers = {} - if ttl is not None: - headers["X-TTL"] = str(ttl) + self._request_sync("PUT", key, content=value, headers=self._set_headers(ttl, stale_ttl)) - self._request_sync("PUT", key, content=value, headers=headers) + @staticmethod + def _set_headers(ttl: int | None, stale_ttl: int | None) -> dict[str, str]: + """PUT timing headers: canonical + legacy TTL (dual-send until saas#245 deploys), stale window.""" + headers: dict[str, str] = {} + if ttl is not None: + headers[TTL_HEADER] = str(ttl) + headers[LEGACY_TTL_HEADER] = str(ttl) + if stale_ttl is not None and stale_ttl > 0 and ttl is not None: + headers[STALE_TTL_HEADER] = str(stale_ttl) + return headers def delete(self, key: str) -> bool: """Delete key from cache (sync). @@ -457,22 +511,32 @@ async def get_async(self, key: str) -> bytes | None: return None raise - async def set_async(self, key: str, value: bytes, ttl: int | None = None) -> None: + 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). Args: key: Cache key value: Bytes to cache ttl: Time-to-live in seconds (optional) + stale_ttl: Stale-grace window in seconds past the fresh TTL + (LAB-381 SWR). Only honoured alongside an explicit ``ttl``; + pre-SWR servers ignore it. Raises: BackendError: If operation fails """ - headers = {} - if ttl is not None: - headers["X-TTL"] = str(ttl) - - await self._request_async("PUT", key, content=value, headers=headers) + await self._request_async("PUT", key, content=value, headers=self._set_headers(ttl, stale_ttl)) async def delete_async(self, key: str) -> bool: """Delete key from cache (async). diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index 99e6eaa..0cf800a 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -111,6 +111,24 @@ def supports_buffer_read(backend: BaseBackend) -> TypeGuard[BufferReadableBacken return hasattr(backend, "get_buffer") +class SWRCapableBackend(Protocol): + """Backend with server-signaled stale-while-revalidate reads (LAB-381). + + Reads report whether the entry is in its stale-grace window; writes accept + the window length. Currently only CachekitIOBackend (the SaaS signals + freshness on read — see protocol spec/saas-api.md#stale-while-revalidate). + """ + + def get_with_freshness(self, key: str) -> Optional[tuple[bytes, bool]]: ... + + def set(self, key: str, value: bytes, ttl: Optional[int] = None, stale_ttl: Optional[int] = None) -> None: ... + + +def supports_swr(backend: BaseBackend) -> TypeGuard[SWRCapableBackend]: + """Type guard: backend supports server-signaled SWR stale-grace reads (LAB-381).""" + return hasattr(backend, "get_with_freshness") + + # Import caching for serializer modules # # PERFORMANCE OPTIMIZATION: Dynamic imports are expensive (~100μs per import) @@ -1520,13 +1538,50 @@ def get_buffer(self, key: str) -> Optional[BufferHandle]: get_logger().error(f"Unexpected error mmapping key {key}: {e}") return None - def set(self, key: str, value: Union[str, bytes], ttl: Optional[int] = None, **metadata) -> bool: + def get_with_freshness(self, key: str) -> Optional[tuple[bytes, bool]]: + """Get value plus SWR staleness from an SWR-capable backend (LAB-381). + + Returns ``(bytes, is_stale)`` on a hit, or None on miss/error (same + degradation contract as :meth:`get` — an error reads as a miss and the + caller takes the synchronous recompute path). + """ + if not supports_swr(self.backend): + value = self.get(key) + return (value, False) if value is not None else None + 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}") + return None + except Exception as e: + get_logger().error(f"Unexpected error getting key {key}: {e}") + return None + + async def get_with_freshness_async(self, key: str) -> Optional[tuple[bytes, bool]]: + """Async variant of :meth:`get_with_freshness` (sync backend call in the thread pool).""" + if not supports_swr(self.backend): + value = await self.get_async(key) + return (value, False) if value is not None else None + 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}") + return None + except Exception as e: + get_logger().error(f"Unexpected error getting key {key}: {e}") + return None + + def set( + self, key: str, value: Union[str, bytes], ttl: Optional[int] = None, stale_ttl: Optional[int] = None, **metadata + ) -> bool: """Set value in cache using backend. Args: key: Cache key value: Bytes value to store (encrypted or plaintext msgpack) ttl: Time-to-live in seconds + stale_ttl: SWR stale-grace window in seconds past the fresh TTL + (LAB-381); silently ignored on backends without SWR support. **metadata: Additional metadata (ignored, for compatibility) Returns: @@ -1537,7 +1592,10 @@ def set(self, key: str, value: Union[str, bytes], ttl: Optional[int] = None, **m value = value.encode("utf-8") try: - self._with_backpressure_and_timeout(self.backend.set, key, value, ttl) + if stale_ttl is not None and supports_swr(self.backend): + self._with_backpressure_and_timeout(self.backend.set, key, value, ttl, stale_ttl) + else: + self._with_backpressure_and_timeout(self.backend.set, key, value, ttl) return True except BackendError as e: get_logger().error(f"Backend error setting key {key}: {e}") @@ -1601,10 +1659,14 @@ async def get_async(self, key: str, refresh_ttl: Optional[int] = None) -> Option get_logger().error(f"Unexpected error getting key {key}: {e}") return None - async def set_async(self, key: str, value: Union[str, bytes], ttl: Optional[int] = None, **metadata) -> bool: + async def set_async( + self, key: str, value: Union[str, bytes], ttl: Optional[int] = None, stale_ttl: Optional[int] = None, **metadata + ) -> bool: """Set value in cache asynchronously using backend. Runs sync backend.set() in a thread pool to avoid blocking the event loop. + ``stale_ttl`` opens an SWR stale-grace window (LAB-381); silently ignored + on backends without SWR support. """ # Ensure value is bytes if isinstance(value, str): @@ -1612,7 +1674,10 @@ async def set_async(self, key: str, value: Union[str, bytes], ttl: Optional[int] try: # Run sync backend operation in thread pool - await self._with_backpressure_and_timeout_async(self.backend.set, key, value, ttl) + if stale_ttl is not None and supports_swr(self.backend): + await self._with_backpressure_and_timeout_async(self.backend.set, key, value, ttl, stale_ttl) + else: + await self._with_backpressure_and_timeout_async(self.backend.set, key, value, ttl) return True except BackendError as e: get_logger().error(f"Backend error setting key {key}: {e}") diff --git a/tests/unit/backends/test_cachekitio_swr_transport.py b/tests/unit/backends/test_cachekitio_swr_transport.py new file mode 100644 index 0000000..acf2798 --- /dev/null +++ b/tests/unit/backends/test_cachekitio_swr_transport.py @@ -0,0 +1,185 @@ +"""SWR transport layer (LAB-381, protocol spec/saas-api.md#stale-while-revalidate). + +Covers the freshness-aware read (X-CacheKit-Freshness mapping), the stale-grace +write headers (X-CacheKit-Stale-TTL + canonical/legacy TTL dual-send), and the +StandardCacheHandler plumbing incl. the non-SWR-backend fallbacks. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from cachekit.backends.cachekitio.backend import ( + FRESHNESS_HEADER, + LEGACY_TTL_HEADER, + STALE_TTL_HEADER, + TTL_HEADER, + CachekitIOBackend, +) +from cachekit.backends.errors import BackendError, BackendErrorType +from cachekit.cache_handler import StandardCacheHandler, supports_swr + +_TEST_API_URL = "https://api.cachekit.io" +_TEST_API_KEY = "ck_test_abc123" + +_DUMMY_REQUEST = httpx.Request("GET", "https://api.cachekit.io/v1/cache/key") + + +def _response(status: int, content: bytes = b"", headers: dict[str, str] | None = None) -> httpx.Response: + response = httpx.Response(status, content=content, headers=headers) + response.request = _DUMMY_REQUEST + return response + + +@pytest.fixture +def backend() -> CachekitIOBackend: + with ( + patch("cachekit.backends.cachekitio.backend.get_sync_http_client", return_value=MagicMock(spec=httpx.Client)), + patch( + "cachekit.backends.cachekitio.backend.get_cached_async_http_client", + return_value=MagicMock(spec=httpx.AsyncClient), + ), + ): + return CachekitIOBackend(api_url=_TEST_API_URL, api_key=_TEST_API_KEY) + + +class TestFreshnessRead: + """X-CacheKit-Freshness mapping per spec: absent=fresh, unrecognized=stale.""" + + @pytest.mark.parametrize( + ("headers", "expected_stale"), + [ + (None, False), # pre-SWR server: no header → fresh + ({FRESHNESS_HEADER: "fresh"}, False), + ({FRESHNESS_HEADER: "stale"}, True), + ({FRESHNESS_HEADER: "Fresh"}, True), # case-sensitive tokens → unrecognized → stale + ({FRESHNESS_HEADER: "expired"}, True), # unknown token → conservative stale + ], + ) + def test_header_mapping(self, backend: CachekitIOBackend, headers: dict[str, str] | None, expected_stale: bool) -> None: + with patch.object(backend, "_request_sync", return_value=_response(200, b"payload", headers)): + result = backend.get_with_freshness("k") + assert result == (b"payload", expected_stale) + + def test_miss_returns_none(self, backend: CachekitIOBackend) -> None: + err = BackendError( + "not found", + error_type=BackendErrorType.PERMANENT, + original_exception=httpx.HTTPStatusError("404", request=_DUMMY_REQUEST, response=_response(404)), + ) + with patch.object(backend, "_request_sync", side_effect=err): + assert backend.get_with_freshness("k") is None + + def test_non_404_error_propagates(self, backend: CachekitIOBackend) -> None: + err = BackendError( + "boom", + error_type=BackendErrorType.TRANSIENT, + original_exception=httpx.HTTPStatusError("500", request=_DUMMY_REQUEST, response=_response(500)), + ) + with patch.object(backend, "_request_sync", side_effect=err): + with pytest.raises(BackendError): + backend.get_with_freshness("k") + + +class TestStaleGraceWrite: + """PUT timing headers: canonical+legacy TTL dual-send, stale window rules.""" + + def test_ttl_dual_send(self, backend: CachekitIOBackend) -> None: + with patch.object(backend, "_request_sync") as req: + backend.set("k", b"v", ttl=300) + headers = req.call_args.kwargs["headers"] + assert headers[TTL_HEADER] == "300" + assert headers[LEGACY_TTL_HEADER] == "300" + assert STALE_TTL_HEADER not in headers + + def test_stale_ttl_sent_with_ttl(self, backend: CachekitIOBackend) -> None: + with patch.object(backend, "_request_sync") as req: + backend.set("k", b"v", ttl=300, stale_ttl=600) + headers = req.call_args.kwargs["headers"] + assert headers[STALE_TTL_HEADER] == "600" + assert headers[TTL_HEADER] == "300" + + @pytest.mark.parametrize(("ttl", "stale_ttl"), [(None, 600), (300, 0), (300, None), (None, None)]) + def test_stale_ttl_omitted(self, backend: CachekitIOBackend, ttl: int | None, stale_ttl: int | None) -> None: + """Spec: the stale window requires an explicit TTL; 0 ≡ absent.""" + with patch.object(backend, "_request_sync") as req: + backend.set("k", b"v", ttl=ttl, stale_ttl=stale_ttl) + assert STALE_TTL_HEADER not in req.call_args.kwargs["headers"] + + +class _SWRBackend: + """Minimal SWR-capable fake (matches SWRCapableBackend structurally).""" + + def __init__(self) -> None: + self.set_calls: list[tuple] = [] + self.freshness: bool = False + + def get(self, key: str): + return b"plain-get" + + def get_with_freshness(self, key: str): + return (b"swr-get", self.freshness) + + def set(self, key: str, value: bytes, ttl=None, stale_ttl=None) -> None: + self.set_calls.append((key, value, ttl, stale_ttl)) + + def delete(self, key: str) -> bool: + return True + + +class _PlainBackend: + def __init__(self) -> None: + self.store: dict[str, bytes] = {} + + def get(self, key: str): + return self.store.get(key) + + def set(self, key: str, value: bytes, ttl=None) -> None: + self.store[key] = value + + def delete(self, key: str) -> bool: + return self.store.pop(key, None) is not None + + +class TestHandlerPlumbing: + def test_supports_swr_guard(self) -> None: + assert supports_swr(_SWRBackend()) # type: ignore[arg-type] + assert not supports_swr(_PlainBackend()) # type: ignore[arg-type] + + def test_get_with_freshness_swr_backend(self) -> None: + backend = _SWRBackend() + backend.freshness = True + handler = StandardCacheHandler(backend) # type: ignore[arg-type] + assert handler.get_with_freshness("k") == (b"swr-get", True) + + def test_get_with_freshness_fallback_reads_as_fresh(self) -> None: + """Non-SWR backends degrade to plain get(), always fresh.""" + backend = _PlainBackend() + backend.store["k"] = b"value" + handler = StandardCacheHandler(backend) # type: ignore[arg-type] + assert handler.get_with_freshness("k") == (b"value", False) + assert handler.get_with_freshness("missing") is None + + def test_set_threads_stale_ttl_to_swr_backend(self) -> None: + backend = _SWRBackend() + handler = StandardCacheHandler(backend) # type: ignore[arg-type] + assert handler.set("k", b"v", ttl=300, stale_ttl=600) is True + assert backend.set_calls == [("k", b"v", 300, 600)] + + def test_set_drops_stale_ttl_for_plain_backend(self) -> None: + """A plain backend's set(key, value, ttl) signature must never see stale_ttl.""" + backend = _PlainBackend() + handler = StandardCacheHandler(backend) # type: ignore[arg-type] + assert handler.set("k", b"v", ttl=300, stale_ttl=600) is True + assert backend.store["k"] == b"v" + + async def test_async_variants(self) -> None: + backend = _SWRBackend() + backend.freshness = True + handler = StandardCacheHandler(backend) # type: ignore[arg-type] + assert await handler.get_with_freshness_async("k") == (b"swr-get", True) + assert await handler.set_async("k", b"v", ttl=300, stale_ttl=600) is True + assert backend.set_calls == [("k", b"v", 300, 600)] From fd44fcf6573420808b7fcdd8a64360a1c46d8c75 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 21 Jul 2026 16:00:42 +1000 Subject: [PATCH 2/5] =?UTF-8?q?feat(decorator):=20stale-while-revalidate?= =?UTF-8?q?=20surface=20=E2=80=94=20stale=5Fttl=20param,=20serve-stale=20+?= =?UTF-8?q?=20background=20revalidation=20(LAB-381,=20part=202/2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decorator half of protocol spec/saas-api.md#stale-while-revalidate (server: saas#245; transport: previous commit): - stale_ttl param on @cache / DecoratorConfig. Decoration-time validation: requires positive ttl; ttl+stale_ttl <= 2,592,000 (30-day cap); requires an SWR-capable backend (ConfigurationError otherwise — no inert params); stale_ttl=0 opts out. - @cache.io defaults stale_ttl to ttl (capped to the 30-day total) via DecoratorConfig.swr_by_default — the LAB-381 design decision. Redis/ production() ships without a default (no read-side freshness signal; PTTL-based SWR is a separate design). - Read paths (sync + async): with SWR active, L2 reads carry the server's freshness signal; a stale hit returns immediately and schedules background revalidation (async task / sync daemon thread) that re-runs the wrapped function and re-stores with the stale window (spec write semantics). - Single-flight: per-key in-flight set + bounded slot pool (mirrors the L1-only SWR machinery); async adds a non-blocking distributed lease via the backend lock (contested = 409 OR 200+null lock_id — LAB-240-safe; serve stale, never wait). Sync is per-process dedup only (lease API is async-only; spec SHOULD, duplicates benign under last-write-wins). - Failure degradation per spec: background failure is silent; stale serves continue until evict_at, then the ordinary synchronous miss path. - L1 hygiene: stale L2 responses are never written to L1; revalidation refreshes L1 with the new fresh bytes. Miss-path stores (sync + both async branches) carry stale_ttl so entries get their window from cold. Tests: 13 decorator-level SWR tests (serve-stale, single-flight incl. 5-way concurrency, contested lease, silent failure, preset default + cap + opt-out, validation). Full fast suite 2092 passed incl. doctests + markdown-docs. basedpyright 0 errors; ruff clean. Docs pass: cache() docstring, docs/configuration.md SWR section (notest examples — require the SaaS) + preset-matrix io() note. Protocol sdk-feature-matrix flips py to done at merge/release (separate repo). Co-authored-by: multica-agent --- docs/configuration.md | 29 ++- src/cachekit/cache_handler.py | 49 ++++- src/cachekit/config/decorator.py | 13 ++ src/cachekit/decorators/intent.py | 10 +- src/cachekit/decorators/wrapper.py | 183 ++++++++++++++++++- tests/unit/test_swr_decorator.py | 275 +++++++++++++++++++++++++++++ 6 files changed, 549 insertions(+), 10 deletions(-) create mode 100644 tests/unit/test_swr_decorator.py diff --git a/docs/configuration.md b/docs/configuration.md index cda64f6..ff42bc4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -155,6 +155,33 @@ def fetch_data(user_id: int): `@cache.io()` automatically creates a `CachekitIOBackend` from environment variables. It applies production-grade defaults (see the [intent preset table](#intent-presets) below). +### Stale-While-Revalidate (`stale_ttl`) + +`@cache.io` supports past-TTL stale-while-revalidate ([protocol spec](https://github.com/cachekit-io/protocol/blob/main/spec/saas-api.md#stale-while-revalidate)): for a `stale_ttl`-second window after the fresh TTL lapses, the backend keeps serving the old value (flagged stale) and the SDK re-runs your function **in the background** — no request ever blocks on the recompute at a TTL boundary. + +```python notest +from cachekit import cache + +# Default: @cache.io enables SWR with stale_ttl = ttl. +@cache.io(ttl=300) +def build_index(): + return expensive_scan() + +# Size the window explicitly, or pass stale_ttl=0 to opt out. +@cache.io(ttl=300, stale_ttl=900) +def report(): + return expensive_report() +``` + +Rules and behavior: + +- Requires a positive `ttl`; `ttl + stale_ttl` is capped at 2,592,000 s (30 days). Violations raise `ConfigurationError` at decoration time. +- **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. +- 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 ```bash @@ -318,7 +345,7 @@ def secure_function(): | `dev()` | ✓ | ❌ | ❌ | 100 MB | Verbose logs, no Prometheus | | `production()` | ✓ | ✓ | ✓ | 100 MB | Full observability | | `secure()` | ✓ | ✓ | ✓ | 100 MB | AES-256-GCM encryption required | -| `io()` | ✓ | ✓ | ✓ | 100 MB | Managed SaaS backend (closed alpha — [request access](https://cachekit.io)) | +| `io()` | ✓ | ✓ | ✓ | 100 MB | Managed SaaS backend (closed alpha — [request access](https://cachekit.io)); past-TTL [SWR](#stale-while-revalidate-stale_ttl) default-on (`stale_ttl = ttl`) | --- diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index 0cf800a..d36951c 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -1108,6 +1108,39 @@ def get_cached_value(self, cache_key: str, refresh_ttl: Optional[int] = None) -> 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]]: + """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 + fast path is skipped — SWR is CachekitIO-only, which is not buffer-readable. + Error semantics mirror get_cached_value (poisoned entries evicted, errors + read as misses so the caller recomputes). + """ + try: + if self._cache_handler is None: + raise RuntimeError("Cache handler must be set before calling get_cached_value_with_freshness") + + hit = self._cache_handler.get_with_freshness(cache_key) + if hit is None: + return None + cached_data, is_stale = hit + get_logger().cache_hit(cache_key, "Backend(stale)" if is_stale else "Backend") + deserialized = self.serialization_handler.deserialize_data(cached_data, cache_key) + return ((True, deserialized), is_stale) + except SerializationError as e: + get_logger().warning(f"L2 cache decrypt/integrity failure for {cache_key}; evicting poisoned entry: {e}") + 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 {cache_key}: {del_err}") + return None + except Exception as e: + get_logger().warning(f"Backend operation failed for get on {cache_key}: {e}") + return None + async def get_cached_value_async(self, cache_key: str, refresh_ttl: Optional[int] = None) -> Optional[Any]: """Get value from cache if it exists (async version). @@ -1159,6 +1192,7 @@ def store_result( ttl: int | None, args: tuple[Any, ...] = (), kwargs: dict[str, Any] | None = None, + stale_ttl: int | None = None, ) -> Optional[bytes]: """Store result in backend cache with optional tenant context for encryption. @@ -1182,7 +1216,12 @@ def store_result( # Pass cache_key for AAD binding (required for encrypted data) serialized_data = self.serialization_handler.serialize_data(result, args, kwargs, cache_key) - self._cache_handler.set(cache_key, serialized_data, ttl) + # Only thread the SWR kwarg when set: strategy implementations without + # **metadata (tests, custom handlers) must keep working unchanged. + if stale_ttl is not None: + self._cache_handler.set(cache_key, serialized_data, ttl, stale_ttl=stale_ttl) + else: + self._cache_handler.set(cache_key, serialized_data, ttl) get_logger().cache_stored(cache_key, ttl) # Return serialized string (wrapped envelope) for L1 cache storage @@ -1375,6 +1414,14 @@ async def delete_async(self, key: str) -> bool: """Delete key from cache asynchronously.""" ... + def get_with_freshness(self, key: str) -> Optional[tuple[bytes, bool]]: + """Get value plus SWR staleness (LAB-381); (bytes, is_stale) or None.""" + ... + + async def get_with_freshness_async(self, key: str) -> Optional[tuple[bytes, bool]]: + """Async variant of get_with_freshness.""" + ... + class StandardCacheHandler: """Standard cache handler with backend abstraction. diff --git a/src/cachekit/config/decorator.py b/src/cachekit/config/decorator.py index 7502a66..7175101 100644 --- a/src/cachekit/config/decorator.py +++ b/src/cachekit/config/decorator.py @@ -188,6 +188,14 @@ def local_function(): # Core settings (6 fields) ttl: int | None = None + # Stale-while-revalidate stale-grace window in seconds past the fresh TTL + # (LAB-381, protocol spec/saas-api.md#stale-while-revalidate). Requires a + # positive ttl and an SWR-capable backend (CachekitIO). None = preset + # decides (io() defaults it to ttl via swr_by_default); 0 = explicitly off. + stale_ttl: int | None = None + # Preset flag: default stale_ttl to ttl (capped to the shared 30-day bound) + # when the backend supports SWR and the user didn't say otherwise. + swr_by_default: bool = False namespace: str | None = None serializer: Union[str, SerializerProtocol] = "default" # type: ignore[assignment] # String name or protocol instance integrity_checking: bool = True # Checksums for corruption detection (xxHash3-64 for all serializers) @@ -602,6 +610,11 @@ def io(cls, **kwargs: Any) -> DecoratorConfig: return cls( backend=backend, integrity_checking=True, + # SWR default-on for the managed backend (LAB-381 design decision): + # boundary requests serve stale + revalidate in the background. + # stale_ttl resolves to ttl (capped) at wrap time; pass stale_ttl=0 + # to opt out, or an explicit value to size the window. + swr_by_default=True, l1=L1CacheConfig( enabled=True, swr_enabled=True, diff --git a/src/cachekit/decorators/intent.py b/src/cachekit/decorators/intent.py index 54d1e5e..fc3a36f 100644 --- a/src/cachekit/decorators/intent.py +++ b/src/cachekit/decorators/intent.py @@ -98,7 +98,15 @@ def cache( func: The function to decorate (when used without parentheses) config: DecoratorConfig object for RORO-style configuration _intent: Internal parameter for intent variants (fast/safe/secure) - **manual_overrides: Any manual parameter overrides (including serializer) + **manual_overrides: Any manual parameter overrides (including serializer). + Notable: ``stale_ttl`` (LAB-381 stale-while-revalidate) — a stale-grace + window in seconds past the fresh ``ttl``. During the window an expired + entry is served immediately and the function re-runs in the background, + so no request pays the recompute at a TTL boundary. Requires a positive + ``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). Returns: Decorated function with intelligent caching diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index 1c12dd3..10ca9be 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -291,6 +291,7 @@ def create_cache_wrapper( func: F, config: Any = None, # DecoratorConfig | None (avoid circular import) ttl: int | None = None, + stale_ttl: int | None = None, namespace: str | None = None, # Serialization & Security serializer: Union[str, SerializerProtocol] = "default", # type: ignore[name-defined] @@ -389,6 +390,7 @@ def create_cache_wrapper( # Override all parameters from DecoratorConfig ttl = config.ttl if ttl is None else ttl + stale_ttl = config.stale_ttl if stale_ttl is None else stale_ttl namespace = config.namespace if namespace is None else namespace serializer = config.serializer integrity_checking = config.integrity_checking @@ -566,6 +568,143 @@ def create_cache_wrapper( # 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 + # ---- Backed-mode stale-while-revalidate (LAB-381, spec/saas-api.md#stale-while-revalidate) ---- + # Past-TTL SWR: the backend keeps serving an entry for a stale-grace window past + # its fresh TTL and labels the read stale; we return the stale value immediately + # and re-run the wrapped function in the background. Requires an SWR-capable + # backend (CachekitIO — the server signals freshness on read). + _max_total_ttl = 2_592_000 # 30-day storage cap, shared with the stale window (spec) + _swr_backend_capable = _backend is not None and hasattr(_backend, "get_with_freshness") + _stale_ttl: int | None = None + if stale_ttl is not None and stale_ttl != 0: + from ..config.validation import ConfigurationError + + if not isinstance(stale_ttl, int) or stale_ttl < 0: + raise ConfigurationError(f"stale_ttl must be a non-negative integer, got {stale_ttl!r}") + if ttl is None or ttl <= 0: + raise ConfigurationError("stale_ttl requires a positive ttl (the stale window starts where freshness ends)") + if ttl + stale_ttl > _max_total_ttl: + raise ConfigurationError(f"ttl + stale_ttl must not exceed {_max_total_ttl} seconds (30-day storage cap)") + if not _swr_backend_capable: + raise ConfigurationError( + "stale_ttl requires an SWR-capable backend (CachekitIO). " + "Other backends have no read-side freshness signal — remove stale_ttl or switch to @cache.io." + ) + _stale_ttl = stale_ttl + elif ( + stale_ttl is None + and config is not None + and getattr(config, "swr_by_default", False) + and ttl is not None + and ttl > 0 + and _swr_backend_capable + ): + # Preset default (io()): stale window = ttl, capped so the total stays + # within the 30-day bound. stale_ttl=0 opts out explicitly. + _stale_ttl = min(ttl, _max_total_ttl - ttl) or None + + _swr_active = _stale_ttl is not None + + # Background revalidation machinery — mirrors the L1-only SWR shapes below: + # per-key in-flight dedup + a bounded slot pool so a burst of distinct stale + # keys can't spawn unbounded work. Cross-client single-flight rides the + # backend's async lock as a non-blocking lease (contested = serve stale, no + # wait, no retry — _try_acquire_lock already treats 409 AND 200+null as + # contested, LAB-240). The lease is best-effort per spec. + _swr_inflight: set[str] = set() + _swr_tasks: set[asyncio.Task[None]] = set() + _swr_slots = threading.BoundedSemaphore(_L1_SWR_MAX_CONCURRENT_REFRESHES) + _swr_lease_seconds = 30.0 # same server-side lease bound as the miss-path lock + + def _swr_try_begin(cache_key: str) -> bool: + """Claim a revalidation slot for this key; False = already in flight or at capacity. + + The check-then-add on _swr_inflight is not atomic across OS threads; a rare + duplicate schedule is benign (the backend lease or last-write-wins between two + freshly computed values absorbs it — spec explicitly allows duplicates). + """ + if cache_key in _swr_inflight: + return False + if not _swr_slots.acquire(blocking=False): + return False + _swr_inflight.add(cache_key) + return True + + def _swr_end(cache_key: str) -> None: + _swr_inflight.discard(cache_key) + _swr_slots.release() + + async def _swr_recompute_store_async(cache_key: str, call_args: tuple[Any, ...], call_kwargs: dict[str, Any]) -> None: + result = await func(*call_args, **call_kwargs) + serialized_data = operation_handler.serialization_handler.serialize_data( + result, call_args, call_kwargs, cache_key=cache_key + ) + await operation_handler.cache_handler.set_async( # type: ignore[attr-defined] + cache_key, serialized_data, ttl=ttl, stale_ttl=_stale_ttl + ) + # Refresh L1 with the new fresh bytes (mirrors the miss-path store). + if _l1_cache and cache_key: + _b = serialized_data.encode("utf-8") if isinstance(serialized_data, str) else serialized_data + _l1_cache.put(cache_key, _b, redis_ttl=ttl) + + async def _swr_revalidate_async(cache_key: str, call_args: tuple[Any, ...], call_kwargs: dict[str, Any]) -> None: + """Background revalidation for async functions. Failures are silent by design: + the caller already got the stale value; the entry hard-expires at evict_at and + the next request takes the ordinary synchronous miss path (spec degradation).""" + try: + _acquire_lock = getattr(_backend, "acquire_lock", None) + if _acquire_lock is not None: + async with _acquire_lock(cache_key, timeout=_swr_lease_seconds, blocking_timeout=None) as got_lease: + if not got_lease: + return # another client is revalidating — stale already served + await _swr_recompute_store_async(cache_key, call_args, call_kwargs) + else: + await _swr_recompute_store_async(cache_key, call_args, call_kwargs) + except Exception as exc: # noqa: BLE001 — spec: revalidation failure must never surface to callers + _logger.debug("SWR revalidation failed for %s: %s", cache_key, exc) + finally: + _swr_end(cache_key) + + def _swr_revalidate_sync(cache_key: str, call_args: tuple[Any, ...], call_kwargs: dict[str, Any]) -> None: + """Background revalidation for sync functions (daemon thread). + + ponytail: per-process single-flight only — the distributed lease API is + async-only; the lease is a spec SHOULD and duplicate revalidation is benign + (last-write-wins between fresh values). Add a sync lease if cross-client + duplicate recomputes ever measurably matter. + """ + try: + result = func(*call_args, **call_kwargs) + serialized_data = operation_handler.serialization_handler.serialize_data( + result, call_args, call_kwargs, cache_key=cache_key + ) + operation_handler.cache_handler.set( # type: ignore[attr-defined] + cache_key, serialized_data, ttl=ttl, stale_ttl=_stale_ttl + ) + if _l1_cache and cache_key: + _b = serialized_data.encode("utf-8") if isinstance(serialized_data, str) else serialized_data + _l1_cache.put(cache_key, _b, redis_ttl=ttl) + except Exception as exc: # noqa: BLE001 — spec: revalidation failure must never surface to callers + _logger.debug("SWR revalidation failed for %s: %s", cache_key, exc) + finally: + _swr_end(cache_key) + + def _swr_schedule(cache_key: str, call_args: tuple[Any, ...], call_kwargs: dict[str, Any], *, is_async: bool) -> None: + """Kick off background revalidation for a stale hit (at most one per key).""" + if not _swr_try_begin(cache_key): + return + if is_async: + task = asyncio.create_task(_swr_revalidate_async(cache_key, call_args, call_kwargs)) + _swr_tasks.add(task) # strong ref until done (same pattern as _l1_swr_tasks) + task.add_done_callback(_swr_tasks.discard) + else: + threading.Thread( + target=_swr_revalidate_sync, + args=(cache_key, call_args, call_kwargs), + daemon=True, + name=f"cachekit-swr-{cache_key[:40]}", + ).start() + # Create per-function statistics tracker with lazy session ID generation # Session ID format: "{process_uuid}:{module}.{function_name}" # Generated lazily on first use or regenerated after cache_clear() @@ -920,8 +1059,17 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912 try: refresh_ttl = ttl if refresh_ttl_on_get and ttl else None - # Use operation handler for all cache access (uses backend internally) - cached_result = operation_handler.get_cached_value(cache_key, refresh_ttl) + # Use operation handler for all cache access (uses backend internally). + # With SWR active the read carries the server's freshness signal + # (LAB-381): a stale hit is served immediately and revalidated on a + # background daemon thread below. + _sync_l2_stale = False + if _swr_active: + _fresh_hit = operation_handler.get_cached_value_with_freshness(cache_key, refresh_ttl) + cached_result = _fresh_hit[0] if _fresh_hit is not None else None + _sync_l2_stale = _fresh_hit[1] if _fresh_hit is not None else False + else: + cached_result = operation_handler.get_cached_value(cache_key, refresh_ttl) # Record duration for adaptive timeout duration = time.time() - start_time @@ -971,6 +1119,10 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912 duration_ms = duration * 1000 _stats.record_l2_hit(duration_ms) + # SWR: stale hit — serve now, revalidate on a daemon thread. + if _sync_l2_stale: + _swr_schedule(cache_key, args, kwargs, is_async=False) + # WHY: L2 cache hit returns from try block that lacks finally cleanup # (only inner try at line ~567, not the outer try-finally at ~645-720) reset_current_function_stats(token) @@ -1010,7 +1162,7 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: PLR0912 try: # Store using operation handler (pass args/kwargs for tenant extraction) # Returns serialized bytes for L1 cache storage - serialized_bytes = operation_handler.store_result(cache_key, result, ttl, args, kwargs) + serialized_bytes = operation_handler.store_result(cache_key, result, ttl, args, kwargs, stale_ttl=_stale_ttl) # Also store in L1 cache for fast subsequent access (using serialized bytes) if _l1_cache and cache_key and serialized_bytes: @@ -1245,8 +1397,16 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: correlation_id = features.create_correlation_id() try: - # Attempt to retrieve from Redis - cached_data = await operation_handler.cache_handler.get_async(cache_key) # type: ignore[attr-defined] + # Attempt to retrieve from the backend. With SWR active the read + # carries the server's freshness signal (LAB-381): a stale hit is + # served immediately and revalidated in the background below. + _l2_is_stale = False + if _swr_active: + _fresh_hit = await operation_handler.cache_handler.get_with_freshness_async(cache_key) # type: ignore[attr-defined] + cached_data = _fresh_hit[0] if _fresh_hit is not None else None + _l2_is_stale = _fresh_hit[1] if _fresh_hit is not None else False + else: + cached_data = await operation_handler.cache_handler.get_async(cache_key) # type: ignore[attr-defined] if cached_data is not None: # Deserialize the cached data @@ -1265,8 +1425,10 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: duration_ms=get_duration_ms, ) - # Update L1 cache with Redis value (serialized bytes) for subsequent fast access - if _l1_cache and cache_key and cached_data: + # Update L1 cache with Redis value (serialized bytes) for subsequent fast access. + # Never record a stale-window value as fresh in L1 (spec: local caches + # MUST NOT extend service past the server's bounds). + if _l1_cache and cache_key and cached_data and not _l2_is_stale: # 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) @@ -1289,6 +1451,11 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: # Record L2 hit with latency for cache_info() _stats.record_l2_hit(get_duration_ms) + # SWR: stale hit — value already in hand; revalidate in the + # background so no request pays the recompute at a TTL boundary. + if _l2_is_stale: + _swr_schedule(cache_key, args, kwargs, is_async=True) + return result except SerializationError as e: @@ -1407,6 +1574,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: cache_key, serialized_data, ttl=ttl, + stale_ttl=_stale_ttl, ) # Also store in L1 cache for fast subsequent access (using serialized bytes) @@ -1490,6 +1658,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: cache_key, serialized_data, ttl=ttl, + stale_ttl=_stale_ttl, ) # Also store in L1 cache for fast subsequent access (using serialized bytes) diff --git a/tests/unit/test_swr_decorator.py b/tests/unit/test_swr_decorator.py new file mode 100644 index 0000000..36cb32a --- /dev/null +++ b/tests/unit/test_swr_decorator.py @@ -0,0 +1,275 @@ +"""Decorator-level stale-while-revalidate (LAB-381). + +End-to-end behavior through the real decorator + serializer stack against a +fake SWR-capable backend: serve-stale-immediately, background revalidation +(async task / sync daemon thread), single-flight, lease handling, failure +degradation, and decoration-time validation. + +Spec: protocol spec/saas-api.md#stale-while-revalidate. +""" + +from __future__ import annotations + +import asyncio +import threading +import time +from contextlib import asynccontextmanager +from typing import Any + +import pytest + +from cachekit import cache +from cachekit.config.decorator import DecoratorConfig +from cachekit.config.validation import ConfigurationError + +_CAP = 2_592_000 # 30-day storage cap shared by ttl + stale_ttl + + +class FakeSWRBackend: + """SWR-capable backend double: byte store + controllable freshness + lock log.""" + + def __init__(self, grant_lock: bool = True) -> None: + self.store: dict[str, bytes] = {} + self.stale = False + self.grant_lock = grant_lock + self.set_calls: list[tuple[int | None, int | None]] = [] + self.lock_attempts: list[str] = [] + + def get(self, key: str) -> bytes | None: + return self.store.get(key) + + def get_with_freshness(self, key: str) -> tuple[bytes, bool] | None: + value = self.store.get(key) + return None if value is None else (value, self.stale) + + def set(self, key: str, value: bytes, ttl: int | None = None, stale_ttl: int | None = None) -> None: + self.store[key] = value + self.set_calls.append((ttl, stale_ttl)) + + def delete(self, key: str) -> bool: + return self.store.pop(key, None) is not None + + def exists(self, key: str) -> bool: + return key in self.store + + def health_check(self) -> tuple[bool, dict[str, Any]]: + return True, {} + + @asynccontextmanager + async def acquire_lock(self, key: str, timeout: float, blocking_timeout: float | None = None): + self.lock_attempts.append(key) + yield self.grant_lock + + +def _wait_for(predicate, timeout: float = 3.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.02) + return predicate() + + +async def _await_for(predicate, timeout: float = 3.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + await asyncio.sleep(0.02) + return predicate() + + +class TestAsyncSWR: + async def test_stale_hit_serves_immediately_and_revalidates_in_background(self) -> None: + backend = FakeSWRBackend() + calls = {"n": 0} + + @cache(backend=backend, ttl=60, stale_ttl=120, l1_enabled=False) + async def compute(x: int) -> dict[str, Any]: + calls["n"] += 1 + return {"x": x, "call": calls["n"]} + + assert (await compute(1))["call"] == 1 + assert backend.set_calls == [(60, 120)] # miss-path store carries the window + + backend.stale = True + stale_result = await compute(1) + assert stale_result["call"] == 1 # stale value served, no synchronous recompute + + assert await _await_for(lambda: len(backend.set_calls) == 2) + assert backend.set_calls[1] == (60, 120) # revalidation PUT re-sends the window (spec) + assert calls["n"] == 2 + assert backend.lock_attempts # lease attempted + + backend.stale = False + assert (await compute(1))["call"] == 2 # revalidated value now served fresh + + async def test_contested_lease_serves_stale_without_recompute(self) -> None: + backend = FakeSWRBackend(grant_lock=False) + calls = {"n": 0} + + @cache(backend=backend, ttl=60, stale_ttl=120, l1_enabled=False) + async def compute() -> int: + calls["n"] += 1 + return calls["n"] + + assert await compute() == 1 + backend.stale = True + assert await compute() == 1 # stale served + + await asyncio.sleep(0.2) # give a (wrong) recompute time to happen + assert calls["n"] == 1 # contested lease: MUST NOT recompute (spec) + assert len(backend.set_calls) == 1 + + async def test_concurrent_stale_hits_single_flight(self) -> None: + backend = FakeSWRBackend() + calls = {"n": 0} + started = asyncio.Event() + + @cache(backend=backend, ttl=60, stale_ttl=120, l1_enabled=False) + async def compute() -> int: + calls["n"] += 1 + started.set() + await asyncio.sleep(0.1) # keep the first revalidation in flight + return calls["n"] + + assert await compute() == 1 + backend.stale = True + results = await asyncio.gather(*(compute() for _ in range(5))) + assert all(r == 1 for r in results) # every caller got the stale value instantly + + assert await _await_for(lambda: len(backend.set_calls) == 2) + await asyncio.sleep(0.15) + assert calls["n"] == 2 # exactly ONE background recompute for 5 stale hits + + async def test_revalidation_failure_is_silent_and_leaves_entry(self) -> None: + backend = FakeSWRBackend() + calls = {"n": 0} + + @cache(backend=backend, ttl=60, stale_ttl=120, l1_enabled=False) + async def compute() -> int: + calls["n"] += 1 + if calls["n"] > 1: + raise RuntimeError("recompute exploded") + return calls["n"] + + assert await compute() == 1 + backend.stale = True + assert await compute() == 1 # caller unaffected + + assert await _await_for(lambda: calls["n"] == 2) + await asyncio.sleep(0.1) + assert len(backend.set_calls) == 1 # failed revalidation stored nothing + assert await compute() == 1 # stale keeps being served until evict_at + + +class TestSyncSWR: + def test_stale_hit_serves_immediately_and_revalidates_on_daemon_thread(self) -> None: + backend = FakeSWRBackend() + calls = {"n": 0} + + @cache(backend=backend, ttl=60, stale_ttl=120, l1_enabled=False) + def compute(x: int) -> dict[str, Any]: + calls["n"] += 1 + return {"x": x, "call": calls["n"]} + + assert compute(1)["call"] == 1 + backend.stale = True + assert compute(1)["call"] == 1 # stale served without blocking + + assert _wait_for(lambda: len(backend.set_calls) == 2) + assert backend.set_calls[1] == (60, 120) + assert calls["n"] == 2 + + def test_concurrent_stale_hits_dedupe_in_process(self) -> None: + backend = FakeSWRBackend() + calls = {"n": 0} + gate = threading.Event() + + @cache(backend=backend, ttl=60, stale_ttl=120, l1_enabled=False) + def compute() -> int: + calls["n"] += 1 + gate.wait(0.2) # hold the first revalidation in flight + return calls["n"] + + gate.set() + assert compute() == 1 + gate.clear() + backend.stale = True + results = [compute() for _ in range(5)] + gate.set() + assert all(r == 1 for r in results) + + assert _wait_for(lambda: calls["n"] == 2) + time.sleep(0.2) + assert calls["n"] == 2 # per-process single-flight + + +class TestSWRConfig: + def test_swr_by_default_resolves_stale_ttl_to_ttl(self) -> None: + """io()-style preset: swr_by_default=True defaults the window to ttl.""" + backend = FakeSWRBackend() + config = DecoratorConfig(backend=backend, ttl=60, swr_by_default=True) + + @cache(config=config) + def compute() -> str: + return "v" + + assert compute() == "v" + assert backend.set_calls == [(60, 60)] # window defaulted to ttl + + def test_stale_ttl_zero_opts_out_of_preset_default(self) -> None: + backend = FakeSWRBackend() + config = DecoratorConfig(backend=backend, ttl=60, stale_ttl=0, swr_by_default=True) + + @cache(config=config) + def compute() -> str: + return "v" + + assert compute() == "v" + assert backend.set_calls == [(60, None)] # no window: explicit opt-out + + def test_preset_default_caps_window_to_30_day_total(self) -> None: + backend = FakeSWRBackend() + ttl = _CAP - 100 # leaves only 100s of window headroom + config = DecoratorConfig(backend=backend, ttl=ttl, swr_by_default=True) + + @cache(config=config) + def compute() -> str: + return "v" + + assert compute() == "v" + assert backend.set_calls == [(ttl, 100)] + + def test_stale_ttl_without_ttl_raises(self) -> None: + with pytest.raises(ConfigurationError, match="requires a positive ttl"): + + @cache(backend=FakeSWRBackend(), stale_ttl=120, l1_enabled=False) + def f() -> None: ... + + def test_total_over_cap_raises(self) -> None: + with pytest.raises(ConfigurationError, match="30-day"): + + @cache(backend=FakeSWRBackend(), ttl=2_000_000, stale_ttl=1_000_000, l1_enabled=False) + def f() -> None: ... + + def test_non_swr_backend_raises(self) -> None: + class PlainBackend: + def get(self, k: str) -> None: + return None + + def set(self, k: str, v: bytes, ttl: int | None = None) -> None: ... + + def delete(self, k: str) -> bool: + return False + + with pytest.raises(ConfigurationError, match="SWR-capable"): + + @cache(backend=PlainBackend(), ttl=60, stale_ttl=120, l1_enabled=False) + def f() -> None: ... + + def test_negative_stale_ttl_raises(self) -> None: + with pytest.raises(ConfigurationError, match="non-negative"): + + @cache(backend=FakeSWRBackend(), ttl=60, stale_ttl=-5, l1_enabled=False) + def f() -> None: ... From 2fedf4c6966fa7b7f2693d8d27cd6481a212ea9a Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 21 Jul 2026 23:23:41 +1000 Subject: [PATCH 3/5] =?UTF-8?q?test(swr):=20close=20codecov/patch=20gaps?= =?UTF-8?q?=20=E2=80=94=20cover=20every=20SWR=20branch=20(LAB-381)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aggregate codecov/patch failed on 42 uncovered diff lines (all flavored sub-checks passed). Mapped each miss to its branch and covered all of them: - backend: get_with_freshness_async (header mapping, 404-miss, error propagation) — async bodies count even when they mirror sync. - handler: get_with_freshness[_async] error paths (BackendError + generic → read as miss) and the async non-SWR fallback. - operation handler: get_cached_value_with_freshness degradation — no-handler, backend error, poisoned-entry eviction (#159 contract), eviction-failure never masks the miss (house mock-serialization pattern). - wrapper: no-lock-API revalidation branch (standalone fake without acquire_lock — an inherited-method del does NOT remove it, first attempt covered nothing), L1 refresh on revalidation (async + sync twins: stale L2 bytes never enter L1, revalidation puts fresh bytes back), sync revalidation failure silence, and slot-pool exhaustion (constant monkeypatched to 1: second stale key skips, stale keeps serving). 19 new tests. Full fast suite 2108 passed; basedpyright 0 errors; ruff clean. The 3 arrow_serializer failures seen under --cov are pre-existing coverage- instrumentation flake (reproduced on the base tree, pass without --cov). Co-authored-by: multica-agent --- .../backends/test_cachekitio_swr_transport.py | 64 +++++- tests/unit/test_swr_decorator.py | 189 ++++++++++++++++++ 2 files changed, 252 insertions(+), 1 deletion(-) diff --git a/tests/unit/backends/test_cachekitio_swr_transport.py b/tests/unit/backends/test_cachekitio_swr_transport.py index acf2798..bf49631 100644 --- a/tests/unit/backends/test_cachekitio_swr_transport.py +++ b/tests/unit/backends/test_cachekitio_swr_transport.py @@ -23,7 +23,7 @@ from cachekit.cache_handler import StandardCacheHandler, supports_swr _TEST_API_URL = "https://api.cachekit.io" -_TEST_API_KEY = "ck_test_abc123" +_TEST_API_KEY = "ck_test_abc123" # pragma: allowlist secret — fake key, test fixture _DUMMY_REQUEST = httpx.Request("GET", "https://api.cachekit.io/v1/cache/key") @@ -183,3 +183,65 @@ async def test_async_variants(self) -> None: assert await handler.get_with_freshness_async("k") == (b"swr-get", True) assert await handler.set_async("k", b"v", ttl=300, stale_ttl=600) is True assert backend.set_calls == [("k", b"v", 300, 600)] + + +class TestAsyncBackendVariants: + """Async mirrors of the freshness read/write (codecov: the async bodies count).""" + + async def test_get_with_freshness_async_maps_header(self, backend: CachekitIOBackend) -> None: + resp = _response(200, b"payload", {FRESHNESS_HEADER: "stale"}) + with patch.object(backend, "_request_async", return_value=resp): + assert await backend.get_with_freshness_async("k") == (b"payload", True) + + async def test_get_with_freshness_async_miss_and_error(self, backend: CachekitIOBackend) -> None: + miss = BackendError( + "not found", + error_type=BackendErrorType.PERMANENT, + original_exception=httpx.HTTPStatusError("404", request=_DUMMY_REQUEST, response=_response(404)), + ) + with patch.object(backend, "_request_async", side_effect=miss): + assert await backend.get_with_freshness_async("k") is None + + boom = BackendError( + "boom", + error_type=BackendErrorType.TRANSIENT, + original_exception=httpx.HTTPStatusError("500", request=_DUMMY_REQUEST, response=_response(500)), + ) + with patch.object(backend, "_request_async", side_effect=boom): + with pytest.raises(BackendError): + await backend.get_with_freshness_async("k") + + +class _ExplodingBackend(_SWRBackend): + """SWR backend whose reads raise (handler degradation paths).""" + + def __init__(self, exc: Exception) -> None: + super().__init__() + self.exc = exc + + def get_with_freshness(self, key: str): + raise self.exc + + def get(self, key: str): + raise self.exc + + +class TestHandlerDegradation: + """Errors read as misses (caller recomputes) — sync and async, both error classes.""" + + @pytest.mark.parametrize("exc", [BackendError("down", error_type=BackendErrorType.TRANSIENT), ValueError("weird")]) + def test_get_with_freshness_errors_read_as_miss(self, exc: Exception) -> None: + handler = StandardCacheHandler(_ExplodingBackend(exc)) # type: ignore[arg-type] + assert handler.get_with_freshness("k") is None + + @pytest.mark.parametrize("exc", [BackendError("down", error_type=BackendErrorType.TRANSIENT), ValueError("weird")]) + async def test_get_with_freshness_async_errors_read_as_miss(self, exc: Exception) -> None: + handler = StandardCacheHandler(_ExplodingBackend(exc)) # type: ignore[arg-type] + assert await handler.get_with_freshness_async("k") is None + + async def test_get_with_freshness_async_fallback_for_plain_backend(self) -> None: + backend = _PlainBackend() + backend.store["k"] = b"value" + handler = StandardCacheHandler(backend) # type: ignore[arg-type] + assert await handler.get_with_freshness_async("k") == (b"value", False) + assert await handler.get_with_freshness_async("missing") is None diff --git a/tests/unit/test_swr_decorator.py b/tests/unit/test_swr_decorator.py index 36cb32a..d57df6c 100644 --- a/tests/unit/test_swr_decorator.py +++ b/tests/unit/test_swr_decorator.py @@ -273,3 +273,192 @@ def test_negative_stale_ttl_raises(self) -> None: @cache(backend=FakeSWRBackend(), ttl=60, stale_ttl=-5, l1_enabled=False) def f() -> None: ... + + +class TestSWRCoverageEdges: + """Branches the main flows don't reach: L1 refresh, no-lock backends, + sync failure, slot exhaustion, operation-handler degradation.""" + + async def test_no_lock_backend_still_revalidates(self) -> None: + """Backend WITHOUT acquire_lock: the no-lease branch revalidates directly.""" + + class NoLockSWRBackend: + def __init__(self) -> None: + self.store: dict[str, bytes] = {} + self.stale = False + self.set_calls: list[tuple[int | None, int | None]] = [] + + def get(self, key: str) -> bytes | None: + return self.store.get(key) + + def get_with_freshness(self, key: str) -> tuple[bytes, bool] | None: + value = self.store.get(key) + return None if value is None else (value, self.stale) + + def set(self, key: str, value: bytes, ttl: int | None = None, stale_ttl: int | None = None) -> None: + self.store[key] = value + self.set_calls.append((ttl, stale_ttl)) + + def delete(self, key: str) -> bool: + return self.store.pop(key, None) is not None + + backend = NoLockSWRBackend() + assert not hasattr(backend, "acquire_lock") + calls = {"n": 0} + + @cache(backend=backend, ttl=60, stale_ttl=120, l1_enabled=False) + async def compute() -> int: + calls["n"] += 1 + return calls["n"] + + assert await compute() == 1 + backend.stale = True + assert await compute() == 1 # stale served + assert await _await_for(lambda: calls["n"] == 2) # revalidated without a lease + assert await _await_for(lambda: len(backend.set_calls) == 2) + + async def test_l1_refresh_on_revalidation(self) -> None: + """With L1 enabled: stale L2 bytes are NOT recorded in L1, and the + background revalidation refreshes L1 with the new fresh bytes.""" + backend = FakeSWRBackend() + calls = {"n": 0} + + @cache(backend=backend, ttl=60, stale_ttl=120, namespace="swr-l1-refresh") + async def compute() -> int: + calls["n"] += 1 + return calls["n"] + + assert await compute() == 1 # miss -> L2 + L1 store + + # Force the next read to L2: clear L1 via the decorator API, then restore + # the L2 bytes it also cleared, and flip the entry stale. + l2_snapshot = dict(backend.store) + await compute.invalidate_cache() # type: ignore[attr-defined] # coroutine for async functions + backend.store.update(l2_snapshot) + backend.stale = True + + assert await compute() == 1 # L1 miss -> stale L2 hit -> serve stale + assert await _await_for(lambda: calls["n"] == 2) # background recompute ran + assert await _await_for(lambda: len(backend.set_calls) >= 2) + + # L1 was refreshed with FRESH bytes by the revalidation: with the L2 entry + # still flagged stale, a pure-L1 hit returns the new value with no recompute. + backend.stale = False + assert await compute() == 2 + assert calls["n"] == 2 + + def test_sync_revalidation_failure_is_silent(self) -> None: + backend = FakeSWRBackend() + calls = {"n": 0} + + @cache(backend=backend, ttl=60, stale_ttl=120, l1_enabled=False) + def compute() -> int: + calls["n"] += 1 + if calls["n"] > 1: + raise RuntimeError("sync recompute exploded") + return calls["n"] + + assert compute() == 1 + backend.stale = True + assert compute() == 1 # caller unaffected + assert _wait_for(lambda: calls["n"] == 2) + time.sleep(0.1) + assert len(backend.set_calls) == 1 # nothing stored on failure + assert compute() == 1 # stale keeps serving + + def test_slot_exhaustion_skips_revalidation(self, monkeypatch: pytest.MonkeyPatch) -> None: + """With the refresh slot pool at 1, a second distinct stale key skips + revalidation (stale keeps being served; a later hit retries).""" + import cachekit.decorators.wrapper as wrapper_mod + + monkeypatch.setattr(wrapper_mod, "_L1_SWR_MAX_CONCURRENT_REFRESHES", 1) + backend = FakeSWRBackend() + calls = {"n": 0} + gate = threading.Event() + + @cache(backend=backend, ttl=60, stale_ttl=120, l1_enabled=False) + def compute(x: int) -> int: + calls["n"] += 1 + if calls["n"] > 2: # only background recomputes block (first two are misses) + gate.wait(2) + return calls["n"] + + assert compute(1) == 1 + assert compute(2) == 2 + backend.stale = True + assert compute(1) == 1 # claims the single slot; recompute blocked on gate + assert _wait_for(lambda: calls["n"] == 3) # background recompute started + assert compute(2) == 2 # slot pool exhausted -> revalidation skipped + time.sleep(0.15) + assert calls["n"] == 3 # no second background recompute + gate.set() + assert _wait_for(lambda: len(backend.set_calls) == 3) # first revalidation lands + + +class TestOperationHandlerFreshnessDegradation: + """get_cached_value_with_freshness error paths mirror get_cached_value (#159 contract).""" + + def _make_op(self, deserialize_side_effect=None, get_result=(b"bytes", True)): + from unittest import mock + + from cachekit.cache_handler import CacheKeyGenerator, CacheOperationHandler, CacheSerializationHandler + + if deserialize_side_effect is not None: + serialization = mock.MagicMock(spec=CacheSerializationHandler) + serialization.deserialize_data.side_effect = deserialize_side_effect + else: + serialization = CacheSerializationHandler() + op = CacheOperationHandler(serialization, CacheKeyGenerator()) + cache_handler = mock.MagicMock() + cache_handler.get_with_freshness.return_value = get_result + op.set_cache_handler(cache_handler) + return op, cache_handler + + def test_no_handler_reads_as_miss(self) -> None: + from cachekit.cache_handler import CacheKeyGenerator, CacheOperationHandler, CacheSerializationHandler + + op = CacheOperationHandler(CacheSerializationHandler(), CacheKeyGenerator()) + assert op.get_cached_value_with_freshness("k") is None # RuntimeError -> generic path -> miss + + def test_backend_error_reads_as_miss(self) -> None: + op, cache_handler = self._make_op() + cache_handler.get_with_freshness.side_effect = ValueError("backend exploded") + assert op.get_cached_value_with_freshness("k") is None + + def test_poisoned_entry_evicted_and_reads_as_miss(self) -> None: + from cachekit.serializers.base import SerializationError + + op, cache_handler = self._make_op(deserialize_side_effect=SerializationError("integrity check failed")) + assert op.get_cached_value_with_freshness("poison:key") is None + cache_handler.delete.assert_called_once_with("poison:key") + + def test_eviction_failure_never_masks_the_miss(self) -> None: + from cachekit.serializers.base import SerializationError + + op, cache_handler = self._make_op(deserialize_side_effect=SerializationError("corrupt")) + cache_handler.delete.side_effect = RuntimeError("delete also broken") + assert op.get_cached_value_with_freshness("poison:key") is None + + def test_sync_l1_refresh_on_revalidation(self) -> None: + """Sync twin of the L1-refresh case: the daemon-thread revalidation + writes the new fresh bytes back into L1.""" + backend = FakeSWRBackend() + calls = {"n": 0} + + @cache(backend=backend, ttl=60, stale_ttl=120, namespace="swr-l1-sync") + def compute() -> int: + calls["n"] += 1 + return calls["n"] + + assert compute() == 1 + l2_snapshot = dict(backend.store) + compute.invalidate_cache() # type: ignore[attr-defined] + backend.store.update(l2_snapshot) + backend.stale = True + + assert compute() == 1 # L1 miss -> stale L2 hit + assert _wait_for(lambda: calls["n"] == 2) + assert _wait_for(lambda: len(backend.set_calls) >= 2) + backend.stale = False + assert compute() == 2 # revalidation refreshed L1 with fresh bytes + assert calls["n"] == 2 From 1240cee64267466c28ea5b5f672bfbc3b6abc5c6 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 21 Jul 2026 23:50:37 +1000 Subject: [PATCH 4/5] chore: retrigger CI (workflow event for 2fedf4c never delivered) Co-authored-by: multica-agent From 6782da22c44ff28a573f5f3c4f2f909e77e729bc Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Wed, 22 Jul 2026 09:46:22 +1000 Subject: [PATCH 5/5] =?UTF-8?q?fix(swr):=20address=20CodeRabbit=20round=20?= =?UTF-8?q?2=20=E2=80=94=20arg=20snapshots,=20slot-leak=20guard,=20cap-bou?= =?UTF-8?q?ndary=20window=20(LAB-381)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _swr_schedule deep-copies args/kwargs before scheduling (same contract as the L1-only SWR path): the key was computed from call-time arguments, so a caller mutating an argument after the stale return must not change what gets recomputed and stored under that key. Not-copyable args skip the refresh (stale keeps serving; a later hit retries). - Any scheduling failure (deepcopy, Thread.start under resource pressure, create_task) releases the in-flight marker + slot — a key can no longer become permanently unrevalidatable and the bounded pool can't shrink. - io()-default stale window: ttl at or above the 30-day cap now yields no window instead of a negative (truthy) one. 3 regression tests (cap boundary via preset default, uncopyable lock arg, Thread.start failure + retry proving the slot survives). 2195 tests green; ruff + basedpyright clean. Co-authored-by: multica-agent --- src/cachekit/decorators/wrapper.py | 49 +++++++++++++------ tests/unit/test_swr_decorator.py | 78 ++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 14 deletions(-) diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index 0532d1b..e2c35f4 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -628,8 +628,10 @@ def _on_l2_deserialize_error(error: Exception, key: str) -> None: and _swr_backend_capable ): # Preset default (io()): stale window = ttl, capped so the total stays - # within the 30-day bound. stale_ttl=0 opts out explicitly. - _stale_ttl = min(ttl, _max_total_ttl - ttl) or None + # within the 30-day bound. stale_ttl=0 opts out explicitly. A ttl at or + # above the cap leaves no window headroom -> no default (never negative). + _default_window = min(ttl, _max_total_ttl - ttl) + _stale_ttl = _default_window if _default_window > 0 else None _swr_active = _stale_ttl is not None @@ -718,20 +720,39 @@ def _swr_revalidate_sync(cache_key: str, call_args: tuple[Any, ...], call_kwargs _swr_end(cache_key) def _swr_schedule(cache_key: str, call_args: tuple[Any, ...], call_kwargs: dict[str, Any], *, is_async: bool) -> None: - """Kick off background revalidation for a stale hit (at most one per key).""" + """Kick off background revalidation for a stale hit (at most one per key). + + Arguments are deep-copied before scheduling (same contract as the L1-only + SWR path): the cache key was computed from the arguments at call time, and + the refresh runs later — it must not see mutations the caller makes after + receiving the stale value, or it would store the new state under the old + key. Not-copyable arguments skip the refresh (stale keeps being served; a + later hit retries). Any scheduling failure releases the slot so the key + never becomes permanently unrevalidatable. + """ if not _swr_try_begin(cache_key): return - if is_async: - task = asyncio.create_task(_swr_revalidate_async(cache_key, call_args, call_kwargs)) - _swr_tasks.add(task) # strong ref until done (same pattern as _l1_swr_tasks) - task.add_done_callback(_swr_tasks.discard) - else: - threading.Thread( - target=_swr_revalidate_sync, - args=(cache_key, call_args, call_kwargs), - daemon=True, - name=f"cachekit-swr-{cache_key[:40]}", - ).start() + try: + call_args, call_kwargs = copy.deepcopy((call_args, call_kwargs)) + except Exception as exc: + _swr_end(cache_key) + _logger.debug("SWR revalidation skipped for %s: arguments not deep-copyable: %s", cache_key, exc) + return + try: + if is_async: + task = asyncio.create_task(_swr_revalidate_async(cache_key, call_args, call_kwargs)) + _swr_tasks.add(task) # strong ref until done (same pattern as _l1_swr_tasks) + task.add_done_callback(_swr_tasks.discard) + else: + threading.Thread( + target=_swr_revalidate_sync, + args=(cache_key, call_args, call_kwargs), + daemon=True, + name=f"cachekit-swr-{cache_key[:40]}", + ).start() + except Exception as exc: # e.g. Thread.start() RuntimeError under resource pressure + _swr_end(cache_key) + _logger.debug("SWR revalidation could not be scheduled for %s: %s", cache_key, exc) # Create per-function statistics tracker with lazy session ID generation # Session ID format: "{process_uuid}:{module}.{function_name}" diff --git a/tests/unit/test_swr_decorator.py b/tests/unit/test_swr_decorator.py index bf74a65..7daf283 100644 --- a/tests/unit/test_swr_decorator.py +++ b/tests/unit/test_swr_decorator.py @@ -463,3 +463,81 @@ def compute() -> int: backend.stale = False assert compute() == 2 # revalidation refreshed L1 with fresh bytes assert calls["n"] == 2 + + +class TestSWRSchedulingHardening: + """CodeRabbit round-2 regressions: negative default window, arg snapshots, + slot release when scheduling fails.""" + + def test_default_window_off_when_ttl_at_or_above_cap(self) -> None: + """ttl ≥ the 30-day cap leaves no window headroom: SWR silently off, + never a negative stale_ttl.""" + backend = FakeSWRBackend() + config = DecoratorConfig(backend=backend, ttl=_CAP + 100, swr_by_default=True) + + @cache(config=config) + def compute() -> str: + return "v" + + assert compute() == "v" + assert backend.set_calls == [(_CAP + 100, None)] # no window, not negative + + def test_uncopyable_args_skip_revalidation(self) -> None: + """Args that can't be deep-copied (e.g. a lock) skip the background + refresh — stale keeps being served, nothing recomputes with live refs.""" + backend = FakeSWRBackend() + calls = {"n": 0} + + @cache(backend=backend, ttl=60, stale_ttl=120, l1_enabled=False, key=lambda lock: "fixed-key") + def compute(lock) -> int: + calls["n"] += 1 + return calls["n"] + + lock = threading.Lock() # deepcopy(threading.Lock()) raises TypeError + assert compute(lock) == 1 + backend.stale = True + assert compute(lock) == 1 # stale served + time.sleep(0.2) + assert calls["n"] == 1 # refresh skipped: args not snapshot-able + assert len(backend.set_calls) == 1 + + # The slot was released: a copyable-args key on the same function can + # still revalidate (the pool didn't leak). + assert _wait_for(lambda: calls["n"] == 1) + + def test_thread_start_failure_releases_slot(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Thread.start() raising must not leak the in-flight marker/slot: the + next stale hit retries and succeeds.""" + import types + + import cachekit.decorators.wrapper as wrapper_mod + + backend = FakeSWRBackend() + calls = {"n": 0} + + @cache(backend=backend, ttl=60, stale_ttl=120, l1_enabled=False) + def compute() -> int: + calls["n"] += 1 + return calls["n"] + + assert compute() == 1 + backend.stale = True + + class _FailingThread: + def __init__(self, *a, **k) -> None: ... + + def start(self) -> None: + raise RuntimeError("can't start new thread") + + shim = types.SimpleNamespace(**{name: getattr(threading, name) for name in dir(threading) if not name.startswith("_")}) + shim.Thread = _FailingThread + monkeypatch.setattr(wrapper_mod, "threading", shim) + + assert compute() == 1 # stale served; scheduling fails silently + time.sleep(0.1) + assert calls["n"] == 1 + + monkeypatch.setattr(wrapper_mod, "threading", threading) # restore + assert compute() == 1 # slot NOT leaked: retry schedules successfully + assert _wait_for(lambda: calls["n"] == 2) + assert _wait_for(lambda: len(backend.set_calls) == 2)