Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/features/l1-invalidation.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,13 @@ Things to know:

**Backend failures.** Invalidation never raises on a backend failure: `invalidate_cache()` and `ainvalidate_cache()` return `None` whether or not the L2 deletes succeed. (The interop prefix guard is not a backend failure and still raises its `ConfigurationError`; see [Interop Mode](interop-mode.md).) When a no-args invalidation deletes this process's keys one by one — on a backend without a key registry, or after a failed drain — and some of those deletes fail, cachekit logs one ERROR `Failed to delete N L2 key(s)` per call, with the count, rather than one line per key. Each of those keys stays tracked in this process, so this process's next no-args `invalidate_cache()` retries it. Until then, the entries are still served from L2.

**`str` subclass namespaces.** A `str` subclass namespace is used as its plain `str` value everywhere, so a `StrEnum` or `(str, Enum)` member `USERS = "users"` is the namespace `users`. Earlier releases rendered a `(str, Enum)` member as `NS.USERS` in some places: in metrics labels on every Python version, and in custom `key=` keys, key registry set names and log-message prefixes on Python 3.11 and later. Plain `str` and `StrEnum` namespaces are unaffected. For a `(str, Enum)` namespace, upgrading changes the following.

- **Custom `key=` entries move (Python 3.11+).** They move from `NS.USERS:k` to `users:k`, and the old entries are no longer served, so each distinct key is recomputed once. On a function that takes parameters, one no-args `invalidate_cache()` from an upgraded process deletes every old entry still tracked in the old registry set; run it once per tenant, since it reaches only the tenant set in `tenant_context`. On a zero-parameter function, a no-args `invalidate_cache()` deletes only the new `users:k` entry and never drains a registry set, so it does not erase the old one. Old entries that are not tracked, because their set expired seven days after its last write or because the backend does not track keys, and every zero-parameter function's old entry, retire only by TTL, and never if none was set. To erase them, run the `scan_iter` + `unlink` script from [Upgrading to 0.20.0](../backends/README.md#upgrading-to-0200) with `pattern = "t:*:NS.USERS:*"` on the tenant-scoped Redis backend (env auto-detection or `RedisBackendProvider`), or `pattern = "NS.USERS:*"` on a plain `RedisBackend`. A `redis-cli SCAN NS.USERS:*` against the tenant-scoped backend matches nothing, because its keys carry the `t:{tenant}:` prefix.
- **The key registry set is renamed (Python 3.11+).** It moves from `ck:reg:NS.USERS:…` to `ck:reg:users:…`. Auto-mode keys do not move. On a function that takes parameters, a no-args `invalidate_cache()` drains both sets, so entries tracked before the upgrade are still invalidated. If the old set's drain fails, cachekit logs a WARNING `Legacy key registry drain failed` and still applies the new set's drain; the old set's entries stay in it for the next no-args `invalidate_cache()`. A zero-parameter function has a single auto-mode key, which its no-args `invalidate_cache()` deletes directly.
- **Rolling deploys and rollbacks leave stale entries (Python 3.11+).** During a rollout, a no-args `invalidate_cache()` from a process on the earlier release misses entries the upgraded processes serve. On a function that takes parameters, it drains only the old set, so it misses every entry tracked in the new one. On a zero-parameter function, it deletes only the key the earlier release derives: in auto mode that is the shared key, so nothing is missed, but with `key=` it deletes `NS.USERS:k` and leaves `users:k`. Missed entries stay stale after the rollout completes, until their TTL (never, if none was set) or until an upgraded process invalidates them. Once the rollout completes, run one no-args `invalidate_cache()` per tenant from an upgraded process for each affected function. A rollback leaves the same gap in the other direction for entries tracked in the new set, which lives seven days after its last write. For `key=` functions, the earlier release also serves `NS.USERS:k` entries again, which this release's exact-args and zero-parameter invalidations never deleted. After a rollback, run one no-args `invalidate_cache()` per tenant from a one-off script on this release, then erase the old `key=` entries with the `scan_iter` + `unlink` script above.
- **Observability names change.** The `namespace` metrics label reads `users` instead of `NS.USERS` on every Python version, 3.10 included, so update dashboards and alerts that match on it. On Python 3.11 and later, the log-message prefix `[NS.USERS]` becomes `[users]`.

**Tenant scope:** with the tenant-scoped Redis backend (env auto-detection, or `RedisBackendProvider(...).get_shared_backend()`), each tenant's entries live under its own `t:{tenant}:` prefix. `invalidate_cache()` — with or without arguments — deletes only the L2 entries of the tenant set in `tenant_context` for the calling context (`default` when none is set); other tenants' entries stay cached and tracked. L1 is not tenant-scoped: within a process, all tenants share one L1 entry per cache key, so a tenant can be served the value another tenant cached, and `invalidate_cache()` evicts that entry for every tenant. Disable L1 on functions whose results differ by tenant: `@cache(..., l1_enabled=False)`, or with a preset `@cache.production(..., l1_enabled=False)`, which keeps the preset's other L1 settings.

---
Expand Down
39 changes: 33 additions & 6 deletions src/cachekit/decorators/wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,11 +583,20 @@ def create_cache_wrapper(

func_hash = function_hash(f"{func.__module__}.{func.__qualname__}")

# Rebind a str namespace to its exact str value before any use (LAB-6197). A str
# subclass renders through its own __format__/__eq__/startswith: a (str, Enum) member
# formats as "NS.USERS" on Python 3.11+ but "users" on 3.10, which split registry ids and
# key= keys across versions and could slip a crafted value past the "ck" check below.
# _legacy_namespace keeps the pre-fix f-string rendering for the registry drain below.
_legacy_namespace: str | None = None
if isinstance(namespace, str):
_legacy_namespace = f"{namespace}"
namespace = str.__str__(namespace)

# INTEROP MODE (interop/v1, protocol spec/interop-mode.md): validate loudly at
# decoration time. These checks also cover direct create_cache_wrapper callers
# that bypass DecoratorConfig validation. Runs before any other use of namespace
# and rebinds both segments to the exact str values it checked, so a str subclass
# (e.g. a (str, Enum) member) cannot render differently in a key.
# that bypass DecoratorConfig validation. Rebinds interop to the exact str value it
# checked; namespace is already exact from the block above.
_interop_sig: inspect.Signature | None = None
if interop is not None:
try:
Expand Down Expand Up @@ -616,9 +625,19 @@ def create_cache_wrapper(
# a key written under it could take the ck:reg: shape and overwrite a tracking set.
if namespace == "ck" or (namespace or "").startswith("ck:"):
raise ConfigurationError("namespace 'ck' (and 'ck:*') is reserved for cachekit's key registry")
_registry_id = (
f"ck:reg:{namespace if namespace is not None else ''}:"
f"{blake3_hash(f'{func.__module__}.{func.__qualname__}', digest_size=8)}"
_registry_hash = blake3_hash(f"{func.__module__}.{func.__qualname__}", digest_size=8)
_registry_id = f"ck:reg:{namespace if namespace is not None else ''}:{_registry_hash}"
# Pre-fix releases named the set with the namespace's f-string rendering. Auto-mode keys
# did not move, so entries tracked under the old name are still served; the no-args drain
# empties that set too, or they would outlive invalidate_cache() (LAB-5288 precedent).
# Interop is skipped: 0.20.0 shipped the registry with interop's exact-str rebind, so no
# release wrote a non-exact interop set. This drains the set name 0.20.x wrote: remove
# it only in a major release whose notes declare upgrades from 0.20.x unsupported (the
# rule get_legacy_cache_key follows).
_legacy_registry_id = (
f"ck:reg:{_legacy_namespace}:{_registry_hash}"
if interop is None and _legacy_namespace is not None and _legacy_namespace != namespace
else None
)

# ENCRYPTION + L1-ONLY (LAB-4665, protocol spec/intent-presets.md § L1 Posture rule 3:
Expand Down Expand Up @@ -2336,6 +2355,14 @@ def _drain_all() -> None:
scope = _l2_scope()
mine = {entry for entry in snap if entry[0] == scope}
deleted = _backend.drain_tracked(_registry_id, {key for _, key in mine}) # type: ignore[union-attr]
if _legacy_registry_id is not None:
# Its own try: the primary drain already deleted keys that other wrappers
# may hold in the shared L1, so its result must still be applied below.
# A failed legacy drain leaves its members in the old set for the next one.
try:
deleted |= _backend.drain_tracked(_legacy_registry_id, ()) # type: ignore[union-attr]
except Exception as e:
Comment thread
27Bslash6 marked this conversation as resolved.
_logger.warning("Legacy key registry drain failed: %s", redact_error_for_log(e))
# Trim BEFORE evicting: _put_l1 puts then records, so a concurrent write can
# never leave an L1 entry whose key is no longer in _cached_keys.
trim = mine - watch
Expand Down
168 changes: 168 additions & 0 deletions tests/unit/test_key_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
import contextvars
import logging
import os
import sys
import threading
import time
from enum import Enum
from pathlib import Path
from typing import Any, Optional

Expand Down Expand Up @@ -797,3 +799,169 @@ def __exit__(self, *exc: object) -> None:
l1._lock = real_lock
assert acquisitions == 3 # 1 000-key batches: a large drain never holds every get/put off at once
assert l1.get("k2499") == (False, None)


class _NS(str, Enum):
USERS = "users"


class _StrEnumNS(str, Enum):
"""enum.StrEnum's rendering (3.11+), spelled out so the test also runs on 3.10."""

USERS = "users"
__str__ = str.__str__
__format__ = str.__format__


class _FormatsAs(str):
"""A str whose __format__ lies: f-strings render ``rendered``, "".join the real value."""

rendered = "ck:reg:users"

def __format__(self, spec: str) -> str:
return self.rendered


class _LegacyFormat(_FormatsAs):
rendered = "legacy"


class _HidesCk(str):
"""A str that claims not to be "ck" and not to start with it."""

def __eq__(self, other: object) -> bool:
return False

__hash__ = str.__hash__

def startswith(self, *args: Any, **kwargs: Any) -> bool: # type: ignore[override]
return False


@pytest.mark.unit
class TestNamespaceExactStr:
"""A str-subclass namespace keys and names its registry set by its underlying str (LAB-6197)."""

def test_str_enum_registry_id_uses_value(self) -> None:
backend = TrackingBackend()

@cache(backend=backend, ttl=60, namespace=_NS.USERS, l1_enabled=False)
def f(x: int) -> int:
return x

f(1)
(rid,) = _registry_ids(backend)
assert rid.startswith("ck:reg:users:")

def test_str_enum_custom_key_uses_value(self) -> None:
backend = TrackingBackend()

@cache(backend=backend, ttl=60, namespace=_NS.USERS, key=lambda *a, **kw: "k", l1_enabled=False)
def f(x: int) -> int:
return x

f(1)
assert set(backend.store) == {"users:k"}
(rid,) = _registry_ids(backend)
assert rid.startswith("ck:reg:users:")

def test_format_override_cannot_forge_registry_shape(self) -> None:
backend = TrackingBackend()

@cache(backend=backend, ttl=60, namespace=_FormatsAs("x"), key=lambda *a, **kw: "k", l1_enabled=False)
def f(x: int) -> int:
return x

f(1)
assert set(backend.store) == {"x:k"}
(rid,) = _registry_ids(backend)
assert rid.startswith("ck:reg:x:")

@pytest.mark.parametrize("value", ["ck", "ck:reg"])
def test_eq_and_startswith_override_cannot_bypass_ck_reservation(self, value: str) -> None:
ns = _HidesCk(value)
assert not ns == "ck" and not ns.startswith("ck:") # the overrides the old check trusted
with pytest.raises(ConfigurationError, match="reserved"):

@cache(backend=TrackingBackend(), ttl=60, namespace=ns)
def f(x: int) -> int:
return x

def test_drain_also_empties_pre_fix_registry_set(self) -> None:
"""Entries tracked under the pre-fix f-string registry id go on a no-args drain."""
backend = TrackingBackend()

@cache(backend=backend, ttl=60, namespace=_LegacyFormat("x"), l1_enabled=False)
def f(x: int) -> int:
return x

f(1)
(rid,) = _registry_ids(backend)
legacy_rid = "ck:reg:legacy:" + rid.rsplit(":", 1)[1]
backend.store["pre-upgrade-key"] = b"x" # written and tracked by pre-fix code
backend.sets[legacy_rid] = {"pre-upgrade-key"}

f.invalidate_cache()
assert backend.store == {}
assert [r for r, _ in backend.drain_calls] == [rid, legacy_rid]

def test_legacy_drain_failure_still_applies_primary_drain(self, caplog: pytest.LogCaptureFixture) -> None:
"""A failed legacy drain must not discard what the primary drain deleted: another
wrapper's shared-L1 copy of a drained key is still evicted, and the old set is kept."""

class LegacyFails(TrackingBackend):
def drain_tracked(self, registry_id: str, local_keys: Any) -> set[str]:
if registry_id.startswith("ck:reg:legacy:"):
raise BackendError("legacy drain failed")
return super().drain_tracked(registry_id, local_keys)

backend = LegacyFails()
calls: list[int] = []

def f(x: int) -> int:
calls.append(x)
return x

ns = _LegacyFormat("legacy_fail")
writer = cache(backend=backend, ttl=60, namespace=ns)(f)
writer(1) # this wrapper's L1 now holds the entry
(rid,) = _registry_ids(backend)
legacy_rid = "ck:reg:legacy:" + rid.rsplit(":", 1)[1]
backend.sets[legacy_rid] = {"pre-upgrade-key"}

fresh = cache(backend=backend, ttl=60, namespace=ns)(f) # knows no keys itself
with caplog.at_level(logging.WARNING):
fresh.invalidate_cache()

assert "Legacy key registry drain failed" in caplog.text
assert "invalidating local keys only" not in caplog.text
assert legacy_rid in backend.sets # retried by the next drain
writer(1)
assert calls == [1, 1] # the shared L1 copy was evicted, so it recomputed

@pytest.mark.parametrize(
("namespace", "interop", "legacy"),
[
("users", None, None),
(_StrEnumNS.USERS, None, None),
(None, None, None),
# f"{member}" is "_NS.USERS" only from 3.11; on 3.10 the set name never moved.
(_NS.USERS, None, "_NS.USERS" if sys.version_info >= (3, 11) else None),
# 0.20.0 shipped the registry with interop's exact-str rebind: no pre-fix set exists.
(_NS.USERS, "get_user", None),
],
)
def test_no_args_drain_ids(self, namespace: Optional[str], interop: Optional[str], legacy: Optional[str]) -> None:
"""Only a namespace whose set name actually moved gets a second drain."""
backend = TrackingBackend()

@cache(backend=backend, ttl=60, namespace=namespace, interop=interop, l1_enabled=False)
def f(x: int) -> int:
return x

f(1)
(rid,) = _registry_ids(backend)
assert rid.startswith(f"ck:reg:{'users' if namespace is not None else ''}:")
f.invalidate_cache()
expected = [rid] if legacy is None else [rid, f"ck:reg:{legacy}:{rid.rsplit(':', 1)[1]}"]
assert [r for r, _ in backend.drain_calls] == expected
Loading