From 963adbe440c579cb53853c1785deb3d4a25c85de Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 26 Sep 2026 00:58:46 +1000 Subject: [PATCH 1/5] fix(invalidation): invalidate_cache(args) also deletes the pre-0.20.0 key (LAB-5288) Before 0.20.0 every generated key ended in serializer code s. A non-default serializer now writes and invalidates a different key, so after an upgrade single-key invalidation returned normally while the pre-upgrade copy survived to its TTL, or indefinitely at ttl=None. CacheInvalidator now also deletes the default-serializer key for the same call whenever it differs from the current one; each delete is independent and logs its own redacted failure. --- docs/api-reference.md | 4 +- docs/serializers/README.md | 43 ++++-- src/cachekit/cache_handler.py | 79 +++++++---- tests/unit/test_error_path_key_redaction.py | 24 ++++ tests/unit/test_key_serializer_suffix.py | 146 ++++++++++++++++++++ 5 files changed, 250 insertions(+), 46 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index af5af87e..b001b7f1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -566,9 +566,9 @@ def get_data(): See [Changing Serializers](serializers/README.md#changing-serializers-separate-keyspaces) for the code table, the cold-cache warning, and the data-retention caveat on orphaned entries. -Upgrading from v0.18 or earlier? Keys for non-default serializers change identity without any +Upgrading from v0.19 or earlier? Keys for non-default serializers change identity without any change on your side — see -[Breaking change in v0.19.0](serializers/README.md#breaking-change-in-v0190-the-key-carries-the-real-serializer). +[Breaking change in v0.20.0](serializers/README.md#breaking-change-in-v0200-the-key-carries-the-real-serializer). **Best Practice**: Use namespace versioning for zero-downtime migrations: diff --git a/docs/serializers/README.md b/docs/serializers/README.md index 7c73a3e4..da6508b5 100644 --- a/docs/serializers/README.md +++ b/docs/serializers/README.md @@ -41,11 +41,11 @@ For caching Pydantic models, see [Caching Pydantic Models](pydantic.md). ## Migration Guide -### Breaking change in v0.19.0: the key carries the real serializer +### Breaking change in v0.20.0: the key carries the real serializer -Before v0.19.0 the serializer half of the key suffix was a constant: **every key ended `:1s` -whatever serializer was configured** (`:0s` with `integrity_checking=False`). From v0.19.0 the -code reflects the serializer in use — the "Before v0.19.0" column in the table below — so keys +Before v0.20.0 the serializer half of the key suffix was a constant: **every key ended `:1s` +whatever serializer was configured** (`:0s` with `integrity_checking=False`). From v0.20.0 the +code reflects the serializer in use — the "Before v0.20.0" column in the table below — so keys change identity on upgrade, with no change on your side, for: - `serializer="auto"` / `"pythonic"`, `"orjson"`, `"arrow"` → now `:1a`, `:1o`, `:1w`; @@ -60,10 +60,20 @@ pre-upgrade entries are never read again. Two decorators over one function that in serializer used to evict each other on every call through the `Serializer mismatch` path; they now coexist. -**Before you deploy:** pre-upgrade entries are unreachable from the upgraded code, so -`invalidate_cache()` cannot delete them. If you cache personal data, or rely on invalidation -reaching entries written before the upgrade, follow the retention warning -[below](#changing-serializers-separate-keyspaces) — **after the last v0.18 replica is +**What invalidation still reaches.** Upgraded code never *reads* a pre-upgrade entry, but +single-key invalidation — `fn.invalidate_cache(*args)` / `await fn.ainvalidate_cache(*args)` +on a decorator with a generated key — deletes both the current key and the pre-v0.20.0 +`:{integrity_flag}s` key for the same arguments. An erasure through the SDK therefore removes +the pre-upgrade copy too, including one an old replica wrote during a rolling deploy. The +cost: a default-serializer decorator over the same function, namespace and arguments loses +that entry and recomputes once. + +**What still needs a backend flush.** The SDK cannot reach a pre-upgrade entry whose +arguments you never invalidate, and no-argument `invalidate_cache()` / `cache_clear()` only +deletes the keys the current process wrote — in a freshly deployed process that is none of the +old ones. So if you cache personal data under `ttl=None`, or otherwise need every pre-upgrade +entry gone rather than aging out, follow the flush procedure in the retention warning +[below](#changing-serializers-separate-keyspaces) — **after the last v0.19 replica is retired**, not at the start of a rolling deploy, or replicas still on the old release keep writing `:1s` entries behind your flush. A `namespace=` bump gives an explicit cut-over but orphans the old keyspace rather than deleting it; the retention step still applies. @@ -74,7 +84,7 @@ The serializer is part of the cache key. The key's trailing metadata suffix is `{integrity_flag}{serializer_code}` — `1`/`0` for integrity checking, then one character for the serializer: -| Configured as | Code | Suffix | Before v0.19.0 | +| Configured as | Code | Suffix | Before v0.20.0 | | :--- | :---: | :--- | :--- | | `serializer="std"` / `"default"` / `"standard"` (the default) | `s` | `:1s` | `:1s` | | `serializer="auto"` / `"pythonic"` | `a` | `:1a` | `:1s` | @@ -134,12 +144,17 @@ def get_data(): > On a hot path, roll it out behind your usual warm-up or stampede controls. > [!WARNING] -> **Orphaned entries are a data-retention question, not just a hit-rate one.** Once the key -> changes, `invalidate_cache()` computes the *new* key and can no longer reach the old copy -> — a deletion for erasure, consent withdrawal or permission revocation will report success -> while the previous entry survives until its TTL expires, or indefinitely if no TTL is set. +> **Orphaned entries are a data-retention question, not just a hit-rate one.** When you +> change a function's serializer, `invalidate_cache()` computes the *new* key and cannot +> reach the old copy — a deletion for erasure, consent withdrawal or permission revocation +> will report success while the previous entry survives until its TTL expires, or +> indefinitely if no TTL is set. The one old key it does reach is the default serializer's +> `:{integrity_flag}s` key, kept for the v0.20.0 upgrade (see +> [above](#breaking-change-in-v0200-the-key-carries-the-real-serializer)); a move from one +> non-default serializer to another, say `"auto"` to `"arrow"`, is not covered. > If you cache personal data, **flush the affected namespace** when you change a serializer -> or upgrade across a release that re-keys it, rather than relying on expiry. The SDK has no +> rather than relying on expiry, and after upgrading to v0.20.0 for any entries that +> single-key invalidation will not reach. The SDK has no > bulk delete — `cache_clear()` only knows the keys the current process wrote — so flush on > the backend: on Redis, `SCAN` for the key prefix (`ns::*`) and `UNLINK` the > matches; the File backend stores one file per hashed key in `cache_dir`, so the only flush diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index 5f4de1bc..e0472b41 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -1771,6 +1771,47 @@ def set_backend(self, backend: BaseBackend): """ self._backend = backend + # Pre-0.20.0 releases never passed the serializer to generate_key, so every generated + # key ended in the default code `s` whatever the serializer was (LAB-4351). A + # deployment upgraded from one still holds those entries — and old replicas keep + # writing them during a rolling deploy — at a key the current code never computes. + # Invalidating only the current key would let an erasure return normally while the + # pre-upgrade copy survives to its TTL, or forever at ttl=None. So invalidation also + # deletes the legacy key. Over-deleting costs a `default`-serializer decorator on the + # same function and arguments one recompute; under-deleting leaks retained data. + # Remove only in a major release whose notes declare upgrades from below 0.20.0 + # unsupported: ttl=None entries never age out, so no TTL clock can retire this. + _LEGACY_SERIALIZER_TYPE = "default" + + def _invalidation_keys( + self, + func: Callable[..., Any], + args: tuple[Any, ...], + kwargs: dict[str, Any], + namespace: str | None, + ) -> list[str]: + """The current key, plus the pre-0.20.0 key when the serializer code differs.""" + cache_key = self.key_generator.generate_key( + func, args, kwargs, namespace, self.integrity_checking, serializer_type=self.serializer_type + ) + legacy_key = self.key_generator.generate_key( + func, args, kwargs, namespace, self.integrity_checking, serializer_type=self._LEGACY_SERIALIZER_TYPE + ) + return [cache_key] if legacy_key == cache_key else [cache_key, legacy_key] + + @staticmethod + def _delete(backend: BaseBackend, cache_key: str) -> None: + """Delete one key; a failure is logged, never raised, so later deletes still run.""" + try: + backend.delete(cache_key) + get_logger().cache_invalidated(cache_key, "Backend") + except BackendError as e: + get_logger().error( + f"Backend operation failed for invalidation on {redact_cache_key(cache_key)}: {redact_error_for_log(e)}" + ) + except Exception as e: + get_logger().error(f"Unexpected error invalidating {redact_cache_key(cache_key)}: {redact_error_for_log(e)}") + def invalidate_cache( self, func: Callable[..., Any], @@ -1778,7 +1819,7 @@ def invalidate_cache( kwargs: dict[str, Any], namespace: str | None, ) -> None: - """Invalidate cache entry. + """Invalidate cache entry, including its pre-0.20.0 key (see _LEGACY_SERIALIZER_TYPE). Args: func: Cached function @@ -1791,19 +1832,8 @@ def invalidate_cache( """ if self._backend is None: raise RuntimeError("Backend must be set before calling invalidate_cache") - cache_key = self.key_generator.generate_key( - func, args, kwargs, namespace, self.integrity_checking, serializer_type=self.serializer_type - ) - - try: - self._backend.delete(cache_key) - get_logger().cache_invalidated(cache_key, "Backend") - except BackendError as e: - get_logger().error( - f"Backend operation failed for invalidation on {redact_cache_key(cache_key)}: {redact_error_for_log(e)}" - ) - except Exception as e: - get_logger().error(f"Unexpected error invalidating {redact_cache_key(cache_key)}: {redact_error_for_log(e)}") + for cache_key in self._invalidation_keys(func, args, kwargs, namespace): + self._delete(self._backend, cache_key) async def invalidate_cache_async( self, @@ -1812,7 +1842,7 @@ async def invalidate_cache_async( kwargs: dict[str, Any], namespace: str | None, ) -> None: - """Invalidate cache entry (async version). + """Invalidate cache entry (async version), including its pre-0.20.0 key. Args: func: Cached function @@ -1825,21 +1855,10 @@ async def invalidate_cache_async( """ if self._backend is None: raise RuntimeError("Backend must be set before calling invalidate_cache_async") - cache_key = self.key_generator.generate_key( - func, args, kwargs, namespace, self.integrity_checking, serializer_type=self.serializer_type - ) - - try: - # Note: BaseBackend methods are sync (not async) - # We call sync method from async context (will be wrapped in executor by caller if needed) - self._backend.delete(cache_key) - get_logger().cache_invalidated(cache_key, "Backend") - except BackendError as e: - get_logger().error( - f"Backend operation failed for invalidation on {redact_cache_key(cache_key)}: {redact_error_for_log(e)}" - ) - except Exception as e: - get_logger().error(f"Unexpected error invalidating {redact_cache_key(cache_key)}: {redact_error_for_log(e)}") + # Note: BaseBackend methods are sync (not async) + # We call sync method from async context (will be wrapped in executor by caller if needed) + for cache_key in self._invalidation_keys(func, args, kwargs, namespace): + self._delete(self._backend, cache_key) @runtime_checkable diff --git a/tests/unit/test_error_path_key_redaction.py b/tests/unit/test_error_path_key_redaction.py index 384601af..204d1087 100644 --- a/tests/unit/test_error_path_key_redaction.py +++ b/tests/unit/test_error_path_key_redaction.py @@ -250,6 +250,30 @@ def cached_func(user: str) -> str: assert len(backend.received_keys) == 1 _assert_redacted(caplog, backend.received_keys[0]) + async def test_legacy_key_failure_logs_redacted_at_error(self, caplog: pytest.LogCaptureFixture) -> None: + """A non-default serializer also deletes the pre-0.20.0 key (LAB-5288); both failures log redacted at ERROR.""" + error = ValueError(f"illegal input: {TENANT_KEY}") + + def cached_func(user: str) -> str: + return user + + for invoke in ("sync", "async"): + backend = _FailingBackend(error) + invalidator = CacheInvalidator(key_generator=CacheKeyGenerator(), backend=backend, serializer_type="auto") + caplog.clear() + with caplog.at_level(logging.ERROR): + if invoke == "sync": + invalidator.invalidate_cache(cached_func, ("alice",), {}, namespace="tenant-42-secret") + else: + await invalidator.invalidate_cache_async(cached_func, ("alice",), {}, namespace="tenant-42-secret") + + current_key, legacy_key = backend.received_keys + assert legacy_key.endswith(":1s") and not current_key.endswith(":1s") + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(error_records) == 2, invoke + _assert_redacted(caplog, current_key) + _assert_redacted(caplog, legacy_key) + class TestKeyCarryingBackendErrorRedaction: """A BackendError that carries the raw key must not leak it through ``{e}``. diff --git a/tests/unit/test_key_serializer_suffix.py b/tests/unit/test_key_serializer_suffix.py index 033b11cd..da4dcc57 100644 --- a/tests/unit/test_key_serializer_suffix.py +++ b/tests/unit/test_key_serializer_suffix.py @@ -299,3 +299,149 @@ def fb(x: int) -> dict: assert len(backend.store) == 2, f"two serializer instances shared a key: {list(backend.store)}" assert calls == 2, f"expected 2 misses then hits, got {calls} calls — the two decorators are evicting each other" + + +def _pre_020_key(current_key: str) -> str: + """The key a pre-0.20.0 release wrote for the same call: same key, serializer code ``s``. + + Derived from the key the write path produced, not from ``generate_key``, so the test + pins the legacy key's shape independently of the code under test. + """ + head, suffix = current_key.rsplit(":", 1) + return f"{head}:{suffix[0]}s" + + +def _non_default_serializers() -> list[Any]: + from cachekit.serializers.standard_serializer import StandardSerializer + + return [pytest.param("auto", id="auto"), pytest.param(StandardSerializer(), id="instance")] + + +class _FailOnKeysBackend(_RecordingBackend): + """Recording backend whose delete raises for chosen keys, after recording the attempt.""" + + def __init__(self) -> None: + super().__init__() + self.fail_on: set[str] = set() + + def delete(self, key: str) -> bool: + if key in self.fail_on: + self.deleted.append(key) + from cachekit.backends.errors import BackendError, BackendErrorType + + raise BackendError("delete refused", error_type=BackendErrorType.TRANSIENT) + return super().delete(key) + + +@pytest.mark.unit +class TestInvalidationReachesPre020Keys: + """`invalidate_cache(args)` also deletes the pre-0.20.0 `:{ic}s` entry (LAB-5288). + + Before LAB-4351 every generated key ended in `s`. After upgrading, a non-default + serializer writes and invalidates a new key, so an erasure that deletes only the new + key returns normally while the pre-upgrade copy survives to its TTL. + """ + + @pytest.mark.parametrize("serializer", _non_default_serializers()) + def test_sync_invalidate_deletes_current_and_legacy_key(self, serializer: Any): + backend = _RecordingBackend() + calls = 0 + + @cache(backend=backend, ttl=None, namespace="lab5288-sync", serializer=serializer) + def fn(x: int) -> dict: + nonlocal calls + calls += 1 + return {"v": x} + + fn(1) + (current_key,) = backend.store + legacy_key = _pre_020_key(current_key) + assert legacy_key != current_key + backend.store[legacy_key] = b"pre-0.20.0 plaintext copy" + + fn.invalidate_cache(1) + + assert legacy_key not in backend.store, "pre-0.20.0 entry survived invalidation" + assert current_key not in backend.store + fn(1) + assert calls == 2 + + @pytest.mark.parametrize("serializer", _non_default_serializers()) + async def test_async_invalidate_deletes_current_and_legacy_key(self, serializer: Any): + backend = _RecordingBackend() + calls = 0 + + @cache(backend=backend, ttl=None, namespace="lab5288-async", serializer=serializer) + async def fn(x: int) -> dict: + nonlocal calls + calls += 1 + return {"v": x} + + await fn(1) + (current_key,) = backend.store + legacy_key = _pre_020_key(current_key) + assert legacy_key != current_key + backend.store[legacy_key] = b"pre-0.20.0 plaintext copy" + + await fn.ainvalidate_cache(1) + + assert legacy_key not in backend.store, "pre-0.20.0 entry survived invalidation" + assert current_key not in backend.store + await fn(1) + assert calls == 2 + + def test_default_serializer_issues_exactly_one_delete(self): + """Code `s` already is the legacy key: no second round-trip.""" + backend = _RecordingBackend() + + @cache(backend=backend, ttl=60, namespace="lab5288-default", serializer="default") + def fn(x: int) -> int: + return x + + fn(1) + (current_key,) = backend.store + assert _suffix(current_key) == "1s" + backend.deleted.clear() + + fn.invalidate_cache(1) + + assert backend.deleted == [current_key] + + async def test_default_serializer_issues_exactly_one_delete_async(self): + backend = _RecordingBackend() + + @cache(backend=backend, ttl=60, namespace="lab5288-default-async", serializer="default") + async def fn(x: int) -> int: + return x + + await fn(1) + (current_key,) = backend.store + backend.deleted.clear() + + await fn.ainvalidate_cache(1) + + assert backend.deleted == [current_key] + + @pytest.mark.parametrize("failing", ["legacy", "current"]) + async def test_one_failed_delete_does_not_skip_the_other(self, failing: str): + """Each delete is independent: a failure on one key still attempts the other, sync and async.""" + + def cached(x: int) -> int: + return x + + for invoke in ("sync", "async"): + backend = _FailOnKeysBackend() + invalidator = CacheInvalidator(CacheKeyGenerator(), backend, serializer_type="auto") + current_key = CacheKeyGenerator().generate_key(cached, (1,), {}, "lab5288-fail", True, serializer_type="auto") + legacy_key = _pre_020_key(current_key) + backend.store = {current_key: b"new", legacy_key: b"old"} + backend.fail_on = {legacy_key if failing == "legacy" else current_key} + + if invoke == "sync": + invalidator.invalidate_cache(cached, (1,), {}, "lab5288-fail") + else: + await invalidator.invalidate_cache_async(cached, (1,), {}, "lab5288-fail") + + assert backend.deleted == [current_key, legacy_key], invoke + survivor = legacy_key if failing == "legacy" else current_key + assert list(backend.store) == [survivor], invoke From 235ea9da96643238249caeb534c499f3b2a9e064 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 26 Sep 2026 01:14:01 +1000 Subject: [PATCH 2/5] fix(invalidation): panel follow-ups on legacy-key delete (LAB-5288) Docs: state the rolling-deploy reverse direction (erasure on an old replica misses the new key) and that moving to the default serializer is also uncovered. Tests: failure-isolation tests drive a real decorator (sync and async) instead of a key the test built itself. --- docs/serializers/README.md | 10 +++- tests/unit/test_key_serializer_suffix.py | 68 ++++++++++++++---------- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/docs/serializers/README.md b/docs/serializers/README.md index da6508b5..862c17d7 100644 --- a/docs/serializers/README.md +++ b/docs/serializers/README.md @@ -68,6 +68,11 @@ the pre-upgrade copy too, including one an old replica wrote during a rolling de cost: a default-serializer decorator over the same function, namespace and arguments loses that entry and recomputes once. +The reverse direction is not covered. An erasure served by a v0.19 replica during the rollout, +or by any replica after a rollback to v0.19, deletes only the `:1s` key, so a copy that a +v0.20.0 replica wrote under the new key survives. Re-issue any erasure made during the +rollout once the last v0.19 replica is retired, or cover it with the flush below. + **What still needs a backend flush.** The SDK cannot reach a pre-upgrade entry whose arguments you never invalidate, and no-argument `invalidate_cache()` / `cache_clear()` only deletes the keys the current process wrote — in a freshly deployed process that is none of the @@ -150,8 +155,9 @@ def get_data(): > will report success while the previous entry survives until its TTL expires, or > indefinitely if no TTL is set. The one old key it does reach is the default serializer's > `:{integrity_flag}s` key, kept for the v0.20.0 upgrade (see -> [above](#breaking-change-in-v0200-the-key-carries-the-real-serializer)); a move from one -> non-default serializer to another, say `"auto"` to `"arrow"`, is not covered. +> [above](#breaking-change-in-v0200-the-key-carries-the-real-serializer)). Any move *away* +> from a non-default serializer is not covered — to another one (`"auto"` to `"arrow"`) or +> to the default (`"auto"` to `"default"`, or to `@cache.secure`, which requires it). > If you cache personal data, **flush the affected namespace** when you change a serializer > rather than relying on expiry, and after upgrading to v0.20.0 for any entries that > single-key invalidation will not reach. The SDK has no diff --git a/tests/unit/test_key_serializer_suffix.py b/tests/unit/test_key_serializer_suffix.py index da4dcc57..afa107e8 100644 --- a/tests/unit/test_key_serializer_suffix.py +++ b/tests/unit/test_key_serializer_suffix.py @@ -19,8 +19,10 @@ import pytest from cachekit import cache +from cachekit.backends.errors import BackendError, BackendErrorType from cachekit.cache_handler import CacheInvalidator, CacheOperationHandler, CacheSerializationHandler from cachekit.key_generator import CacheKeyGenerator +from cachekit.serializers.standard_serializer import StandardSerializer class _RecordingBackend: @@ -311,10 +313,7 @@ def _pre_020_key(current_key: str) -> str: return f"{head}:{suffix[0]}s" -def _non_default_serializers() -> list[Any]: - from cachekit.serializers.standard_serializer import StandardSerializer - - return [pytest.param("auto", id="auto"), pytest.param(StandardSerializer(), id="instance")] +NON_DEFAULT_SERIALIZERS = [pytest.param("auto", id="auto"), pytest.param(StandardSerializer(), id="instance")] class _FailOnKeysBackend(_RecordingBackend): @@ -327,8 +326,6 @@ def __init__(self) -> None: def delete(self, key: str) -> bool: if key in self.fail_on: self.deleted.append(key) - from cachekit.backends.errors import BackendError, BackendErrorType - raise BackendError("delete refused", error_type=BackendErrorType.TRANSIENT) return super().delete(key) @@ -342,7 +339,7 @@ class TestInvalidationReachesPre020Keys: key returns normally while the pre-upgrade copy survives to its TTL. """ - @pytest.mark.parametrize("serializer", _non_default_serializers()) + @pytest.mark.parametrize("serializer", NON_DEFAULT_SERIALIZERS) def test_sync_invalidate_deletes_current_and_legacy_key(self, serializer: Any): backend = _RecordingBackend() calls = 0 @@ -366,7 +363,7 @@ def fn(x: int) -> dict: fn(1) assert calls == 2 - @pytest.mark.parametrize("serializer", _non_default_serializers()) + @pytest.mark.parametrize("serializer", NON_DEFAULT_SERIALIZERS) async def test_async_invalidate_deletes_current_and_legacy_key(self, serializer: Any): backend = _RecordingBackend() calls = 0 @@ -423,25 +420,42 @@ async def fn(x: int) -> int: assert backend.deleted == [current_key] @pytest.mark.parametrize("failing", ["legacy", "current"]) - async def test_one_failed_delete_does_not_skip_the_other(self, failing: str): - """Each delete is independent: a failure on one key still attempts the other, sync and async.""" + def test_one_failed_delete_does_not_skip_the_other(self, failing: str): + """Each delete is independent: a failure on one key still attempts the other.""" + backend = _FailOnKeysBackend() - def cached(x: int) -> int: + @cache(backend=backend, ttl=None, namespace="lab5288-fail", serializer="auto") + def fn(x: int) -> int: return x - for invoke in ("sync", "async"): - backend = _FailOnKeysBackend() - invalidator = CacheInvalidator(CacheKeyGenerator(), backend, serializer_type="auto") - current_key = CacheKeyGenerator().generate_key(cached, (1,), {}, "lab5288-fail", True, serializer_type="auto") - legacy_key = _pre_020_key(current_key) - backend.store = {current_key: b"new", legacy_key: b"old"} - backend.fail_on = {legacy_key if failing == "legacy" else current_key} - - if invoke == "sync": - invalidator.invalidate_cache(cached, (1,), {}, "lab5288-fail") - else: - await invalidator.invalidate_cache_async(cached, (1,), {}, "lab5288-fail") - - assert backend.deleted == [current_key, legacy_key], invoke - survivor = legacy_key if failing == "legacy" else current_key - assert list(backend.store) == [survivor], invoke + fn(1) + (current_key,) = backend.store + legacy_key = _pre_020_key(current_key) + backend.store[legacy_key] = b"old" + backend.fail_on = {legacy_key if failing == "legacy" else current_key} + backend.deleted.clear() + + fn.invalidate_cache(1) + + assert backend.deleted == [current_key, legacy_key] + assert list(backend.store) == [legacy_key if failing == "legacy" else current_key] + + @pytest.mark.parametrize("failing", ["legacy", "current"]) + async def test_one_failed_delete_does_not_skip_the_other_async(self, failing: str): + backend = _FailOnKeysBackend() + + @cache(backend=backend, ttl=None, namespace="lab5288-fail-async", serializer="auto") + async def fn(x: int) -> int: + return x + + await fn(1) + (current_key,) = backend.store + legacy_key = _pre_020_key(current_key) + backend.store[legacy_key] = b"old" + backend.fail_on = {legacy_key if failing == "legacy" else current_key} + backend.deleted.clear() + + await fn.ainvalidate_cache(1) + + assert backend.deleted == [current_key, legacy_key] + assert list(backend.store) == [legacy_key if failing == "legacy" else current_key] From 4399274c81c23d29da15887e3a0708fe9351b6ea Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 26 Sep 2026 03:08:59 +1000 Subject: [PATCH 3/5] fix(invalidation): skip the duplicate legacy key when codes match; README column ref (LAB-5288) generate_key reads serializer_type only through serializer_code, so equal codes give a byte-identical key; compare the codes first instead of hashing the arguments twice for the default serializer. A parametrised test over every code, alias and a custom identity pins the result to both keys generated in full and de-duplicated, so the shortcut cannot drop the legacy key. README: the new suffix is in the "Suffix" column, not "Before v0.20.0". --- docs/serializers/README.md | 2 +- src/cachekit/cache_handler.py | 7 ++++- tests/unit/test_key_serializer_suffix.py | 40 ++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/docs/serializers/README.md b/docs/serializers/README.md index 862c17d7..a1a37c02 100644 --- a/docs/serializers/README.md +++ b/docs/serializers/README.md @@ -45,7 +45,7 @@ For caching Pydantic models, see [Caching Pydantic Models](pydantic.md). Before v0.20.0 the serializer half of the key suffix was a constant: **every key ended `:1s` whatever serializer was configured** (`:0s` with `integrity_checking=False`). From v0.20.0 the -code reflects the serializer in use — the "Before v0.20.0" column in the table below — so keys +code reflects the serializer in use — the "Suffix" column in the table below — so keys change identity on upgrade, with no change on your side, for: - `serializer="auto"` / `"pythonic"`, `"orjson"`, `"arrow"` → now `:1a`, `:1o`, `:1w`; diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index e0472b41..df3875f4 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -1794,10 +1794,15 @@ def _invalidation_keys( cache_key = self.key_generator.generate_key( func, args, kwargs, namespace, self.integrity_checking, serializer_type=self.serializer_type ) + # generate_key reads serializer_type only through serializer_code, so equal codes mean + # a byte-identical key: skip hashing the arguments a second time. + serializer_code = self.key_generator.serializer_code + if serializer_code(self.serializer_type) == serializer_code(self._LEGACY_SERIALIZER_TYPE): + return [cache_key] legacy_key = self.key_generator.generate_key( func, args, kwargs, namespace, self.integrity_checking, serializer_type=self._LEGACY_SERIALIZER_TYPE ) - return [cache_key] if legacy_key == cache_key else [cache_key, legacy_key] + return [cache_key, legacy_key] @staticmethod def _delete(backend: BaseBackend, cache_key: str) -> None: diff --git a/tests/unit/test_key_serializer_suffix.py b/tests/unit/test_key_serializer_suffix.py index afa107e8..b0c3431c 100644 --- a/tests/unit/test_key_serializer_suffix.py +++ b/tests/unit/test_key_serializer_suffix.py @@ -459,3 +459,43 @@ async def fn(x: int) -> int: assert backend.deleted == [current_key, legacy_key] assert list(backend.store) == [legacy_key if failing == "legacy" else current_key] + + @pytest.mark.parametrize("namespace", ["ns", "n" * 300], ids=["short", "hashed-long-key"]) + @pytest.mark.parametrize("integrity_checking", [True, False], ids=["ic1", "ic0"]) + @pytest.mark.parametrize( + "serializer_type", + [ + *CacheKeyGenerator.SERIALIZER_CODES, + *CacheKeyGenerator.SERIALIZER_NAME_ALIASES, + f"{CacheKeyGenerator.CUSTOM_SERIALIZER_PREFIX}my.Serializer", + ], + ) + def test_code_shortcut_never_drops_the_legacy_key( + self, serializer_type: str, integrity_checking: bool, namespace: str, monkeypatch: pytest.MonkeyPatch + ): + """Skipping the second generate_key on equal codes must return exactly the distinct keys. + + The reference is both keys generated in full and de-duplicated: if the code shortcut + ever disagreed with generate_key's own canonicalisation, the legacy key would be dropped. + """ + generator = CacheKeyGenerator() + + def fn(x: int) -> int: + return x + + current = generator.generate_key(fn, (1,), {}, namespace, integrity_checking, serializer_type=serializer_type) + legacy = generator.generate_key(fn, (1,), {}, namespace, integrity_checking, serializer_type="default") + expected = list(dict.fromkeys([current, legacy])) + + calls: list[str] = [] + real_generate_key = generator.generate_key + + def counting_generate_key(*args: Any, **kwargs: Any) -> str: + calls.append(kwargs["serializer_type"]) + return real_generate_key(*args, **kwargs) + + monkeypatch.setattr(generator, "generate_key", counting_generate_key) + invalidator = CacheInvalidator(generator, integrity_checking=integrity_checking, serializer_type=serializer_type) + + assert invalidator._invalidation_keys(fn, (1,), {}, namespace) == expected + assert len(calls) == len(expected), f"arguments hashed {len(calls)}x for {len(expected)} distinct key(s)" From b12b7f6aa23bfa855239cad46406aacb83ef84f7 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sat, 26 Sep 2026 03:54:25 +1000 Subject: [PATCH 4/5] docs(serializers): rollback and flush notes use :{integrity_flag}s, not :1s (LAB-5288) With integrity_checking=False a v0.19 replica deletes and writes :0s, so the hard-coded :1s understated what the rollback and flush guidance covers. --- docs/serializers/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/serializers/README.md b/docs/serializers/README.md index a1a37c02..3eb3122f 100644 --- a/docs/serializers/README.md +++ b/docs/serializers/README.md @@ -69,9 +69,9 @@ cost: a default-serializer decorator over the same function, namespace and argum that entry and recomputes once. The reverse direction is not covered. An erasure served by a v0.19 replica during the rollout, -or by any replica after a rollback to v0.19, deletes only the `:1s` key, so a copy that a -v0.20.0 replica wrote under the new key survives. Re-issue any erasure made during the -rollout once the last v0.19 replica is retired, or cover it with the flush below. +or by any replica after a rollback to v0.19, deletes only the `:{integrity_flag}s` key, so a +copy that a v0.20.0 replica wrote under the new key survives. Re-issue any erasure made during +the rollout once the last v0.19 replica is retired, or cover it with the flush below. **What still needs a backend flush.** The SDK cannot reach a pre-upgrade entry whose arguments you never invalidate, and no-argument `invalidate_cache()` / `cache_clear()` only @@ -80,7 +80,7 @@ old ones. So if you cache personal data under `ttl=None`, or otherwise need ever entry gone rather than aging out, follow the flush procedure in the retention warning [below](#changing-serializers-separate-keyspaces) — **after the last v0.19 replica is retired**, not at the start of a rolling deploy, or replicas still on the old release keep -writing `:1s` entries behind your flush. A `namespace=` bump gives an explicit cut-over but +writing `:{integrity_flag}s` entries behind your flush. A `namespace=` bump gives an explicit cut-over but orphans the old keyspace rather than deleting it; the retention step still applies. ### Changing Serializers: Separate Keyspaces From 72668650e3ec3d59c1a4e002903dc8e3304b6842 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Tue, 29 Sep 2026 22:22:28 +1000 Subject: [PATCH 5/5] fix(invalidation): apply panel findings on the pre-0.20.0 twin delete (LAB-5288) Docs: @cache.secure also allows orjson and arrow; the no-args caveat applies only to functions that take parameters. Code: one _generated_key helper owns the _bypass_cache filter for both the current and legacy key; one _generated_key_mode flag drives both key resolution and the twin; the equal-code shortcut is gone and the wrapper dedupes by full key. Tests: interop has no twin; the shortcut test is removed with the shortcut. --- docs/serializers/README.md | 6 +-- src/cachekit/cache_handler.py | 37 +++++++++--------- src/cachekit/decorators/wrapper.py | 27 +++++++------ tests/unit/test_key_serializer_suffix.py | 50 +++--------------------- 4 files changed, 40 insertions(+), 80 deletions(-) diff --git a/docs/serializers/README.md b/docs/serializers/README.md index cfda6f97..00b48150 100644 --- a/docs/serializers/README.md +++ b/docs/serializers/README.md @@ -74,8 +74,8 @@ copy that a v0.20.0 replica wrote under the new key survives. Re-issue any erasu the rollout once the last v0.19 replica is retired, or cover it with the flush below. **What still needs a backend flush.** The SDK cannot reach a pre-upgrade entry whose -arguments you never invalidate. No-argument `invalidate_cache()` / `cache_clear()` does not -reach them either: it deletes the keys this process tracked plus, on the tenant-scoped Redis +arguments you never invalidate. On a function that takes parameters, no-argument +`invalidate_cache()` / `cache_clear()` does not reach them either: it deletes the keys this process tracked plus, on the tenant-scoped Redis backend, the keys in the server-side key registry, and releases before v0.20.0 recorded their keys in neither. So if you cache personal data under `ttl=None`, or otherwise need every pre-upgrade entry gone rather than aging out, follow the flush procedure in the retention warning @@ -158,7 +158,7 @@ def get_data(): > `:{integrity_flag}s` key, kept for the v0.20.0 upgrade (see > [above](#breaking-change-in-v0200-the-key-carries-the-real-serializer)). Any move *away* > from a non-default serializer is not covered — to another one (`"auto"` to `"arrow"`) or -> to the default (`"auto"` to `"default"`, or to `@cache.secure`, which requires it). +> to the default (`"auto"` to `"default"`, or to `@cache.secure` on its default serializer). > If you cache personal data, **flush the affected namespace** when you change a serializer > rather than relying on expiry, and after upgrading to v0.20.0 for any entries that > single-key invalidation will not reach. The SDK has no diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index 5dbb4ecf..b15cd30e 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -1434,14 +1434,8 @@ def get_cache_key( >>> key1 == key2 # _bypass_cache doesn't affect key True """ - 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, - serializer_type=self.serialization_handler.serializer_key_name, + return self._generated_key( + func, args, kwargs, namespace, integrity_checking, self.serialization_handler.serializer_key_name ) # Pre-0.20.0 releases never passed the serializer to generate_key, so every generated @@ -1454,8 +1448,6 @@ def get_cache_key( # decorator on the same function and arguments one recompute; under-deleting leaks # retained data. Remove only in a major release whose notes declare upgrades from below # 0.20.0 unsupported: ttl=None entries never age out, so no TTL clock can retire this. - _LEGACY_SERIALIZER_TYPE = "default" - def get_legacy_cache_key( self, func: Callable[..., Any], @@ -1463,8 +1455,8 @@ def get_legacy_cache_key( kwargs: dict[str, Any], namespace: str | None, integrity_checking: bool = True, - ) -> str | None: - """The key a pre-0.20.0 release wrote for this call, or None when it is get_cache_key's. + ) -> str: + """The key a pre-0.20.0 release wrote for this call; equal to get_cache_key's on the default serializer. Examples: >>> from cachekit.key_generator import CacheKeyGenerator @@ -1473,17 +1465,24 @@ def get_legacy_cache_key( >>> auto.get_cache_key(my_func, (1,), {}, None)[-3:], auto.get_legacy_cache_key(my_func, (1,), {}, None)[-3:] (':1a', ':1s') >>> std = CacheOperationHandler(CacheSerializationHandler(), CacheKeyGenerator()) - >>> std.get_legacy_cache_key(my_func, (1,), {}, None) is None + >>> std.get_legacy_cache_key(my_func, (1,), {}, None) == std.get_cache_key(my_func, (1,), {}, None) True """ - # generate_key reads serializer_type only through serializer_code, so equal codes mean - # a byte-identical key: skip hashing the arguments a second time. - serializer_code = self.key_generator.serializer_code - if serializer_code(self.serialization_handler.serializer_key_name) == serializer_code(self._LEGACY_SERIALIZER_TYPE): - return None + return self._generated_key(func, args, kwargs, namespace, integrity_checking, "default") + + def _generated_key( + self, + func: Callable[..., Any], + args: tuple[Any, ...], + kwargs: dict[str, Any], + namespace: str | None, + integrity_checking: bool, + serializer_type: str, + ) -> str: + """generate_key minus reserved kwargs: one filter, so a legacy key hashes the same arguments.""" 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, serializer_type=self._LEGACY_SERIALIZER_TYPE + func, args, filtered_kwargs, namespace, integrity_checking, serializer_type=serializer_type ) def _handle_l2_read_error(self, e: SerializationError, cache_key: str) -> None: diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index 4cbb500e..ff7638db 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -1132,8 +1132,16 @@ def _interop_cache_key(call_args: tuple[Any, ...], call_kwargs: dict[str, Any]) flat = bind_flat_args(_interop_sig, call_args, call_kwargs) return generate_interop_key(namespace, interop, flat) + # The generated key is the only one carrying a serializer code, so it alone has a + # pre-0.20.0 twin. One flag, read by both functions below, so the twin can never be + # computed for a key the write path did not generate. + _generated_key_mode = interop is None and custom_key_func is None and not fast_mode + def _resolve_cache_key(call_args: tuple[Any, ...], call_kwargs: dict[str, Any]) -> str: """Single key derivation shared by the read/write and invalidate paths (LAB-4387).""" + # Standard key generation with type-aware handling + if _generated_key_mode: + return operation_handler.get_cache_key(func, call_args, call_kwargs, namespace, integrity_checking) # Interop mode takes priority (mutually exclusive with key= and fast_mode) if interop is not None: return _interop_cache_key(call_args, call_kwargs) @@ -1143,25 +1151,18 @@ def _resolve_cache_key(call_args: tuple[Any, ...], call_kwargs: dict[str, Any]) if not isinstance(custom_key, str): raise TypeError(f"key function must return str, got {type(custom_key).__name__}") return f"{namespace or 'default'}:{custom_key}" - if fast_mode: - # Minimal key generation - no string formatting overhead (10-50μs savings) - from ..hash_utils import cache_key_hash + # fast_mode: minimal key generation - no string formatting overhead (10-50μs savings) + from ..hash_utils import cache_key_hash - return (namespace or "default") + ":" + func_hash + ":" + cache_key_hash(str(call_args) + str(call_kwargs)) - # Standard key generation with type-aware handling - return operation_handler.get_cache_key(func, call_args, call_kwargs, namespace, integrity_checking) + return (namespace or "default") + ":" + func_hash + ":" + cache_key_hash(str(call_args) + str(call_kwargs)) def _resolve_invalidation_keys(call_args: tuple[Any, ...], call_kwargs: dict[str, Any]) -> list[str]: - """The key _resolve_cache_key derives, plus its pre-0.20.0 twin on the generated-key path. - - Only a generated key carries a serializer code, so interop, key= and fast_mode keys have - no twin; neither does L1-only mode, whose in-memory cache cannot outlive an upgrade. - """ + """The key _resolve_cache_key derives, plus its pre-0.20.0 twin on the generated-key path.""" cache_key = _resolve_cache_key(call_args, call_kwargs) - if interop is not None or custom_key_func is not None or fast_mode or _l1_only_mode: + if not _generated_key_mode: return [cache_key] legacy_key = operation_handler.get_legacy_cache_key(func, call_args, call_kwargs, namespace, integrity_checking) - return [cache_key] if legacy_key is None else [cache_key, legacy_key] + return [cache_key] if legacy_key == cache_key else [cache_key, legacy_key] # Track the cache keys this process wrote or read for this function (for no-args # invalidation). Key normalization (hashing of long keys) makes prefix matching diff --git a/tests/unit/test_key_serializer_suffix.py b/tests/unit/test_key_serializer_suffix.py index 78384301..b027cac4 100644 --- a/tests/unit/test_key_serializer_suffix.py +++ b/tests/unit/test_key_serializer_suffix.py @@ -14,7 +14,6 @@ from __future__ import annotations -from types import SimpleNamespace from typing import Any import pytest @@ -449,50 +448,7 @@ async def fn(x: int) -> int: assert backend.deleted == [current_key, legacy_key] assert list(backend.store) == [legacy_key if failing == "legacy" else current_key] - @pytest.mark.parametrize("namespace", ["ns", "n" * 300], ids=["short", "hashed-long-key"]) - @pytest.mark.parametrize("integrity_checking", [True, False], ids=["ic1", "ic0"]) - @pytest.mark.parametrize( - "serializer_type", - [ - *CacheKeyGenerator.SERIALIZER_CODES, - *CacheKeyGenerator.SERIALIZER_NAME_ALIASES, - f"{CacheKeyGenerator.CUSTOM_SERIALIZER_PREFIX}my.Serializer", - ], - ) - def test_code_shortcut_never_drops_the_legacy_key( - self, serializer_type: str, integrity_checking: bool, namespace: str, monkeypatch: pytest.MonkeyPatch - ): - """Skipping the second generate_key on equal codes must return exactly the distinct keys. - - The reference is both keys generated in full and de-duplicated: if the code shortcut - ever disagreed with generate_key's own canonicalisation, the legacy key would be dropped. - """ - generator = CacheKeyGenerator() - - def fn(x: int) -> int: - return x - - current = generator.generate_key(fn, (1,), {}, namespace, integrity_checking, serializer_type=serializer_type) - legacy = generator.generate_key(fn, (1,), {}, namespace, integrity_checking, serializer_type="default") - expected = list(dict.fromkeys([current, legacy])) - - calls: list[str] = [] - real_generate_key = generator.generate_key - - def counting_generate_key(*args: Any, **kwargs: Any) -> str: - calls.append(kwargs["serializer_type"]) - return real_generate_key(*args, **kwargs) - - monkeypatch.setattr(generator, "generate_key", counting_generate_key) - # Any identity, alias or custom, reaches the key only through serializer_key_name. - serialization = SimpleNamespace(serializer_key_name=serializer_type) - handler = CacheOperationHandler(serialization, generator) # type: ignore[arg-type] - - legacy_key = handler.get_legacy_cache_key(fn, (1,), {}, namespace, integrity_checking) - assert [current, *([legacy_key] if legacy_key else [])] == expected - assert len(calls) == len(expected) - 1, f"arguments hashed {len(calls)}x for the legacy key alone" - - @pytest.mark.parametrize("mode", ["key=", "fast_mode"]) + @pytest.mark.parametrize("mode", ["key=", "fast_mode", "interop"]) def test_non_generated_keys_have_no_legacy_twin(self, mode: str): """Only a generated key carries a serializer code; other key modes issue one delete.""" from cachekit.decorators.wrapper import create_cache_wrapper @@ -505,6 +461,10 @@ def fn(x: int) -> int: # key= is read only from DecoratorConfig (the @cache path); fast_mode is internal-only. if mode == "key=": wrapped = cache(backend=backend, l1_enabled=False, namespace="lab5288-mode", serializer="auto", key=str)(fn) + elif mode == "interop": + # Interop requires a cross-SDK serializer, so the default one: its generated key would + # still differ from the interop key, so only the mode flag stops a second delete. + wrapped = cache(backend=backend, l1_enabled=False, namespace="lab5288-mode", interop="lab5288_op")(fn) else: wrapped = create_cache_wrapper( fn, backend=backend, l1_enabled=False, namespace="lab5288-mode", serializer="auto", fast_mode=True