From e3d539d036ed74befb57978a15e08e256fdce046 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 29 Oct 2026 16:54:51 +1100 Subject: [PATCH 1/8] perf(l1): keep verified MessagePack in L1 for plaintext default-serializer caches An L1 hit on a non-encrypted backed cache re-ran ByteStorage.retrieve on bytes this process had already written or verified. L1 now holds the MessagePack a successful retrieve returned, in place of the envelope, when it is no longer and L1 admits the envelope; the hit decodes it without the LZ4 decompress and checksum. Admission is still judged on the envelope, so L1 never holds fewer entries. Encrypted caches keep their ciphertext envelope in L1. --- .secrets.baseline | 4 +- docs/data-flow-architecture.md | 11 +- docs/features/prometheus-metrics.md | 5 + docs/performance.md | 4 +- src/cachekit/cache_handler.py | 63 +++ src/cachekit/decorators/wrapper.py | 7 + src/cachekit/l1_cache.py | 14 +- .../serializers/standard_serializer.py | 68 +++ tests/unit/test_async_get_record_labels.py | 15 +- tests/unit/test_l1_verified_msgpack.py | 403 ++++++++++++++++++ tests/unit/test_l2_hit_l1_backfill.py | 19 +- tests/unit/test_single_flight.py | 51 +++ 12 files changed, 642 insertions(+), 22 deletions(-) create mode 100644 tests/unit/test_l1_verified_msgpack.py diff --git a/.secrets.baseline b/.secrets.baseline index 03f09022..bc0d34cf 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -215,7 +215,7 @@ "filename": "src/cachekit/cache_handler.py", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 647 + "line_number": 660 } ], "src/cachekit/serializers/interop_serializer.py": [ @@ -2020,5 +2020,5 @@ } ] }, - "generated_at": "2026-10-19T08:46:47Z" + "generated_at": "2026-10-29T05:54:44Z" } diff --git a/docs/data-flow-architecture.md b/docs/data-flow-architecture.md index 052137b2..650e1243 100644 --- a/docs/data-flow-architecture.md +++ b/docs/data-flow-architecture.md @@ -31,7 +31,7 @@ cachekit uses a hybrid Python-Rust architecture to provide production caching wi > **Architecture Principles:** > - **L1 + L2 Caching**: In-memory bytes cache (L1) + pluggable backend storage (L2) for optimal performance > - **Backend Abstraction**: Protocol-based L2 backend layer (Redis, CachekitIO, File, Memcached, custom) -> - **Bytes-Only Storage**: with a backend configured, L1 stores serialized bytes (encrypted or plaintext), not Python objects; L1-only mode (`backend=None`) stores the object itself +> - **Bytes-Only Storage**: with a backend configured, L1 stores serialized bytes (encrypted or plaintext), not Python objects; L1-only mode (`backend=None`) stores the object itself. A plaintext entry of the default serializer with integrity checking on is kept as its verified MessagePack when that is no longer than its envelope, so its hit skips the LZ4 decompress and checksum; encrypted caches keep the ciphertext > - **Security First**: Decompression bomb protection, checksum validation, size limits, encrypted-at-rest > - **Performance Optimized**: Thread affinity, connection pooling, pre-cached serializers > - **Flexible Configuration**: L1+L2, L1-only, L2-only, or observability-only modes @@ -307,6 +307,12 @@ cachekit uses a hybrid Python-Rust architecture to provide production caching wi │ │ │ Note: with a backend, L1 stores bytes (encrypted or plaintext), not Python │ │ objects. L1-only mode (backend=None) stores the object itself. │ +│ │ +│ Before the put, the wrapper (_put_l1) swaps an envelope L1 admits for its │ +│ verified MessagePack when the cache is plaintext, the serializer is the │ +│ default one with integrity on, and the MessagePack is no longer │ +│ (CacheSerializationHandler.l1_entry). Admission is judged on the envelope, │ +│ so L1 never holds fewer entries. Encrypted caches keep the ciphertext. │ └──────────────────────────────────┬──────────────────────────────────────────┘ │ ▼ @@ -480,9 +486,10 @@ L1-only mode (`backend=None`) skips this layer: it stores the Python object itse - **After refactor**: Stores bytes (encrypted or plaintext) → `len(bytes)` for size estimation - **Performance improvement**: O(1) size calculation vs recursive traversal - **Security improvement**: Encrypted bytes at rest (decrypt-at-read-time only) +- **Verified MessagePack**: a plaintext entry of the default serializer with integrity checking on is kept as the MessagePack a successful `ByteStorage.retrieve()` returned, in place of its envelope, when that is no longer and L1 admits the envelope. Its hit decodes the MessagePack without decompressing or checksumming it again. Compressible values are usually longer as MessagePack, so they keep the envelope; small and incompressible ones switch. Bytes from L2 never take this path: L1 marks the entry by its type (`VerifiedMsgpack`), which no backend returns **Hierarchical Read Path:** -1. Check L1 cache (bytes) → if hit, deserialize and return +1. Check L1 cache (bytes) → if hit, deserialize (verified MessagePack skips the envelope's decompress and checksum) and return 2. If L1 miss, check L2 backend (bytes) → if hit, deserialize, populate L1 with bytes, return 3. If L2 miss, execute function → serialize to bytes → store in L2 and L1 → return diff --git a/docs/features/prometheus-metrics.md b/docs/features/prometheus-metrics.md index e1e867b0..d31e2a45 100644 --- a/docs/features/prometheus-metrics.md +++ b/docs/features/prometheus-metrics.md @@ -149,6 +149,11 @@ What each `cache_operation_duration_ms` series times, measured with a monotonic | `operation="get"`, `serializer="rust"` | The L2 read of a hit | | `operation="set"` | Serialization plus the L2 write: not the miss read, the function, a lock wait, the L1 put or key tracking | +`cache_operation_size_bytes` records the length of the bytes the operation read or wrote. For an L1 `get`, that is the L1 entry: +on a plaintext cache with the default serializer and integrity checking on, an entry whose MessagePack is no longer than its +envelope is held as that MessagePack, so its L1 `get` records a smaller size than its L2 `get`. Earlier releases recorded the +envelope's length for both. + > **Changed durations.** Earlier releases recorded every L1 hit as a constant 0.001 ms, and timed > `set` from before the L2 read, so each `set` sample also held the miss round trip and the > decorated function's own run time, plus the lock wait and a second read on the async locked path. diff --git a/docs/performance.md b/docs/performance.md index a6df0624..eabb3258 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -12,7 +12,7 @@ > **Key numbers** (mean of five run medians; GitHub-hosted `ubuntu-latest` runner, AMD EPYC 7763, CPython 3.14.7, 2026-10-23; see [GitHub-Hosted Runner](#github-hosted-runner)): > - **Decorator + L1 hit, `@cache(backend=None)`, 100-user nested dict (23.5KB as plain MessagePack)**: 0.906 ± 0.007μs (about 10,900 instructions per call on CPython 3.14, see [Instruction Budgets](#instruction-budgets)) > - **Same hit, 10K-row DataFrame**: 0.91 ± 0.01μs. An L1-only hit never serializes, so payload size barely matters -> - **Decorator + L1 hit with an L2 backend configured, same dict**: 310 ± 20μs. With a backend, L1 holds bytes and every hit deserializes them +> - **Decorator + L1 hit with an L2 backend configured, same dict**: 310 ± 20μs. With a backend, L1 holds bytes and every hit deserializes them. This dict compresses, so L1 holds its envelope; see [Why an L1-Only Hit Costs the Same for Any Payload](#why-an-l1-only-hit-costs-the-same-for-any-payload) for the values L1 holds as MessagePack > - **Redis L2 hit**, L1 disabled, same dict, Redis in a container on the same runner: 551 ± 18μs > - **Raw L1 byte-cache lookup** (`L1Cache.get`, no decorator): 354–362ns on a shared x86_64 Linux host (see [Measured Figures](#measured-figures)) @@ -73,6 +73,8 @@ With `backend=None`, cachekit keeps the returned object itself in memory and han With an L2 backend configured, L1 holds the serialized bytes instead, and a hit deserializes them, so that hit costs more as the payload grows. For the 100-user dict it takes 310 ± 20μs on the same runner, about 340 times an L1-only hit of the same dict. Nearly all of the difference is deserializing 23.5KB of MessagePack back into about 400 nested dicts. It is still about half of a Redis L2 hit there (551 ± 18μs), which deserializes the same bytes after the round trip. +For a plaintext cache with the default serializer and integrity checking on, L1 holds an entry as its verified MessagePack instead of its envelope when the MessagePack is no longer, which is true of small values and incompressible ones. Such a hit decodes the MessagePack without the envelope's LZ4 decompress and xxHash3-64 checksum. The 100-user dict compresses to a 3,171-byte envelope from 23.5KB of MessagePack, so L1 keeps its envelope and the figures above are unchanged. Encrypted caches always keep the ciphertext envelope. + ### Decorator + L1 Hit (Hot Path) An L1-only hit takes 0.906μs for the 100-user dict and 0.91μs for the DataFrame on the hosted EPYC 7763 ([GitHub-Hosted Runner](#github-hosted-runner)). What it runs: diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index 561a3941..93d640ad 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -64,6 +64,7 @@ KeyringConfigurationError, require_cross_sdk_serializer, ) +from cachekit.serializers.standard_serializer import StandardSerializer from cachekit.serializers.wrapper import SerializationWrapper if TYPE_CHECKING: @@ -75,6 +76,18 @@ # it chooses its decoder by inspecting the bytes. CROSS_SDK_SERIALIZER_NAMES = ("default", "std", "standard", "orjson", "arrow") + +class VerifiedMsgpack(bytes): + """An L1 entry that holds the MessagePack a successful ``ByteStorage.retrieve()`` returned, in place of + its envelope (:meth:`CacheSerializationHandler.l1_entry`). It decodes without another retrieve(). + + The type is the mark, not the bytes: no backend returns this type, so bytes from L2 never take + the unverified decode, whatever they hold. + """ + + __slots__ = () + + # Uncompressed Arrow IPC alignment: the body follows the File header, the CK frame # prefix and ArrowSerializer's 8-byte xxHash3-64 checksum; 64 is Arrow's recommended alignment. _ARROW_IPC_ALIGN = 64 @@ -895,6 +908,16 @@ def __init__( else: self._base_serializer = _get_cached_serializer_instance(serializer_name, enable_integrity_checking) self._custom_serializer = not isinstance(serializer_name, str) + # Whether L1 may hold this handler's entries as VerifiedMsgpack (l1_entry): plaintext CK-frame + # entries of the built-in StandardSerializer with integrity on. Encrypted handlers keep ciphertext in + # L1 (protocol PRE-10, PRE-11). + self._l1_msgpack = ( + not encryption + and not interop_mode + and not self._custom_serializer + and type(self._base_serializer) is StandardSerializer + and self._base_serializer.enable_integrity_checking + ) # Cache EncryptionWrapper instances per tenant to prevent # 360K key copies/hour at 100 req/sec. Uses thread-safe LRU cache (maxsize=256) @@ -1281,6 +1304,39 @@ def write_serialized_to(self, sink: BinaryIO, data: Any) -> None: budget = get_settings().max_value_size - len(prefix) serializer.serialize_to_sink(data, sink, max_bytes=budget) + def l1_entry(self, envelope: bytes) -> bytes: + """What L1 holds for ``envelope``, an entry this handler wrote or decoded: its MessagePack as + :class:`VerifiedMsgpack` when retrieve() verifies it and it is no longer than the envelope, else the + envelope itself. + + Never longer than ``envelope``, so an L1 that admits the envelope admits the result and holds + no fewer entries. Call it only for an envelope L1 admits: a result shorter than the envelope + could pass an admission check the envelope fails. Encrypted handlers, and every handler other + than the built-in StandardSerializer with integrity on, get the envelope back unchanged. + + Examples: + >>> handler = CacheSerializationHandler() + >>> envelope = handler.serialize_data("hello", cache_key="greeting") + >>> entry = handler.l1_entry(envelope) + >>> type(entry).__name__, len(entry) <= len(envelope) + ('VerifiedMsgpack', True) + >>> handler.deserialize_data(entry, cache_key="greeting") + 'hello' + >>> compressible = handler.serialize_data("ab" * 600, cache_key="k") + >>> handler.l1_entry(compressible) is compressible # its MessagePack is longer + True + """ + if not self._l1_msgpack: + return envelope + try: + payload, metadata, serializer_name = SerializationWrapper.unwrap_metadata(envelope) + except (AttributeError, KeyError, TypeError, ValueError): + return envelope # its own read reports the corrupt frame + if serializer_name != self._serializer_string_name or metadata.encrypted or not metadata.compressed: + return envelope # a config-drift entry: an encrypted one keeps its ciphertext in L1 + msgpack_data = self._base_serializer.verified_msgpack(payload, len(envelope)) # type: ignore[union-attr] + return envelope if msgpack_data is None else VerifiedMsgpack(msgpack_data) + def deserialize_data( self, data: str | bytes | memoryview, @@ -1359,6 +1415,13 @@ def deserialize_data( >>> result["flag"] is False True """ + if type(data) is VerifiedMsgpack: + # Only L1 holds this type (l1_entry), so only a handler that writes it decodes it. Another + # handler sharing the namespace and key refuses it as it would the plaintext envelope. + if not self._l1_msgpack: + error = SuspiciousCacheEntryError if self.encryption else SerializationError + raise error("L1 entry holds plaintext MessagePack another handler verified; this handler does not read it") + return self._base_serializer.deserialize_verified(data) # type: ignore[union-attr] if self.interop_mode: return self._deserialize_interop(data, cache_key) diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index d8056f64..1a944d3c 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -1252,6 +1252,11 @@ def _put_l1(cache_key: str, serialized_data: Any, l1_ttl: int | None, *, twin: s """The only L1 write in this wrapper: put the serialized bytes (str payloads encoded), then record the key, and its pre-0.20.0 twin if it has one (_twin_key), in _cached_keys. + An envelope L1 admits is put in the form l1_entry gives it: its verified MessagePack when that + is no longer, so a hit skips retrieve(). Admission is judged on the envelope, as before: judged on + a shorter form, an entry L1 used to refuse would get in and evict others. So L1 never holds fewer + entries than it would hold envelopes. + Recording after the put makes "in L1 => in _cached_keys" hold by construction, so a whole-function invalidation that trims _cached_keys before evicting L1 cannot miss an entry. The key is recorded even when there is nothing to put (L1 disabled, a streamed @@ -1262,6 +1267,8 @@ def _put_l1(cache_key: str, serialized_data: Any, l1_ttl: int | None, *, twin: s """ if _l1_cache and serialized_data: _b = serialized_data.encode("utf-8") if isinstance(serialized_data, str) else serialized_data + if type(_b) is bytes and _l1_cache.admits(len(_b)): + _b = serialization_handler.l1_entry(_b) _l1_cache.put(cache_key, _b, redis_ttl=l1_ttl) _record(cache_key, twin) diff --git a/src/cachekit/l1_cache.py b/src/cachekit/l1_cache.py index 5ed1f139..260a0599 100644 --- a/src/cachekit/l1_cache.py +++ b/src/cachekit/l1_cache.py @@ -164,6 +164,15 @@ def _estimate_size(self, value: bytes) -> int: """ return len(value) + def admits(self, size: int) -> bool: + """Whether ``put`` stores a value of ``size`` bytes: at most an eighth of max_memory_bytes (_MAX_ENTRY_SHARE). + + Examples: + >>> L1Cache(max_memory_mb=1).admits(131_072), L1Cache(max_memory_mb=1).admits(131_073) + (True, False) + """ + return size <= self.max_memory_bytes * _MAX_ENTRY_SHARE + def get(self, key: str) -> tuple[bool, Optional[bytes]]: """Get value from L1 cache if present and not expired. @@ -278,14 +287,13 @@ def put( # would also push L1 permanently over its limit (a multi-GB DataFrame envelope is a direct # OOM vector). We only decline to mirror the value in L1. If a smaller entry for this key # was cached, drop it so L1 stops serving the stale value. - max_entry_bytes = self.max_memory_bytes * _MAX_ENTRY_SHARE - if size > max_entry_bytes: + if not self.admits(size): self._on_current_state(_L1State.remove, key) logger.debug( "Skipping L1 cache for key %s - value %d bytes exceeds L1 per-entry limit %d bytes (not kept in L1)", redact_key_for_log(key), size, - max_entry_bytes, + self.max_memory_bytes * _MAX_ENTRY_SHARE, ) return diff --git a/src/cachekit/serializers/standard_serializer.py b/src/cachekit/serializers/standard_serializer.py index be354370..f62e441e 100644 --- a/src/cachekit/serializers/standard_serializer.py +++ b/src/cachekit/serializers/standard_serializer.py @@ -46,6 +46,10 @@ # lz4_flex's get_maximum_output_size for that budget: the most compressed bytes any envelope within it carries. _ENVELOPE_PROBE_MAX_COMPRESSED = 20 + _ENVELOPE_PROBE_MAX_SIZE * 110 // 100 _CROSS_CONFIG_ERROR = "Cache entry was written with integrity checking on but this reader has integrity checking disabled" +# Length-field width of each bin marker, the payload slot's header in the current envelope layout. +_BIN_LENGTH_WIDTH = {0xC4: 1, 0xC5: 2, 0xC6: 4} +# Bytes after the payload slot that hold the checksum (eight uint8, at most 17 bytes) and original_size (at most 9). +_ENVELOPE_TAIL_PREFIX = 32 # Error message constants for unsupported types (Task 2) NUMPY_ERROR_MESSAGE = ( @@ -392,6 +396,44 @@ def deserialize(self, data: bytes | memoryview, metadata: SerializationMetadata except PAYLOAD_DECODE_ERRORS as e: raise SerializationError(f"Failed to deserialize MessagePack data: {e}") from e + def verified_msgpack(self, envelope: bytes | memoryview, limit: int) -> bytes | None: + """The MessagePack ``envelope`` holds, if it is at most ``limit`` bytes and ``retrieve()`` verifies it; else None. + + The size the envelope declares is read first, so an envelope whose payload would exceed ``limit`` + is never decompressed. Any retrieve() failure, and any layout other than the current bin + layout, gives None: the caller keeps the envelope, whose own read reports the fault. + + Examples: + >>> serializer = StandardSerializer() + >>> envelope, _ = serializer.serialize("x" * 8) + >>> serializer.verified_msgpack(envelope, len(envelope)) + b'\\xa8xxxxxxxx' + >>> serializer.verified_msgpack(envelope, 8) is None # the payload is 9 bytes + True + """ + size = _declared_size(envelope) + if size is None or size > limit: + return None + try: + msgpack_data, _ = self._byte_storage.retrieve(envelope) + except Exception: # noqa: BLE001 - not verified, whatever the cause: the envelope is kept + return None + return msgpack_data if len(msgpack_data) <= limit else None + + def deserialize_verified(self, msgpack_data: bytes) -> Any: + """Decode MessagePack that :meth:`verified_msgpack` returned, without another ``retrieve()``. + + Examples: + >>> serializer = StandardSerializer() + >>> envelope, _ = serializer.serialize({"test": 123}) + >>> serializer.deserialize_verified(serializer.verified_msgpack(envelope, len(envelope))) + {'test': 123} + """ + try: + return unpackb_bounded(msgpack_data, **self._msgpack_unpack_opts) + except PAYLOAD_DECODE_ERRORS as e: + raise SerializationError(f"Failed to deserialize MessagePack data: {e}") from e + def _is_verified_envelope(self, data: bytes | memoryview, value: Any, markers: frozenset[int]) -> bool: """True only when ``data``, already decoded to ``value``, is a ByteStorage envelope whose payload marker is in ``markers`` and that passes ``retrieve()``.""" @@ -413,6 +455,32 @@ def _is_verified_envelope(self, data: bytes | memoryview, value: Any, markers: f return True +def _declared_size(envelope: bytes | memoryview) -> int | None: + """The original_size a bin-layout ByteStorage envelope declares, read without decompressing; None for + any other layout. Unverified: retrieve() checks it. + + Examples: + >>> envelope, _ = StandardSerializer().serialize("x" * 1000) + >>> _declared_size(envelope) + 1003 + >>> _declared_size(b"\\x93") is None + True + """ + width = _BIN_LENGTH_WIDTH.get(envelope[1]) if len(envelope) > 1 and envelope[0] == 0x94 else None + if width is None: + return None + start = 2 + width + end = start + int.from_bytes(envelope[2:start], "big") + tail = msgpack.Unpacker() + tail.feed(envelope[end : end + _ENVELOPE_TAIL_PREFIX]) + try: + next(tail) # the checksum + size = next(tail) + except (StopIteration, ValueError): # msgpack's decode errors are ValueErrors + return None + return size if type(size) is int else None + + # Default instance for convenience standard_serializer = StandardSerializer() diff --git a/tests/unit/test_async_get_record_labels.py b/tests/unit/test_async_get_record_labels.py index fb61d0e0..a7c53e0e 100644 --- a/tests/unit/test_async_get_record_labels.py +++ b/tests/unit/test_async_get_record_labels.py @@ -15,7 +15,7 @@ import pytest from cachekit import cache -from cachekit.l1_cache import get_l1_cache_manager +from cachekit.l1_cache import get_l1_cache, get_l1_cache_manager from cachekit.reliability.async_metrics import AsyncMetricsCollector @@ -80,9 +80,12 @@ async def compute() -> dict[str, int]: assert len(recorded) == 1 # one hit, one record assert (recorded[0]["operation"], recorded[0].get("serializer")) == ("get", tier) # what the sync hit sites pass - # Exact served size, not just positive: both hit sites record the raw serialized - # envelope's length — the L1 backfill stores the same bytes L2 returned, so the - # single L2 store value is the ground truth for either tier. A `> 0` assertion - # would pass on a wrong constant (e.g. size_bytes=1); this pins the real value. - expected_size = len(next(iter(backend.store.values()))) + # Exact served size, not just positive: each hit site records the length of the bytes it read, + # the L2 envelope for an L2 hit and the L1 entry for an L1 hit. This one's L1 entry is its + # verified MessagePack, shorter than the envelope (CacheSerializationHandler.l1_entry). A `> 0` + # assertion would pass on a wrong constant (e.g. size_bytes=1); this pins the real value. + ((key, envelope),) = backend.store.items() + l1_entry = get_l1_cache("async-get-labels").peek(key) + assert l1_entry is not None and len(l1_entry) < len(envelope) + expected_size = len(envelope) if tier == "rust" else len(l1_entry) assert recorded[0].get("size_bytes") == expected_size diff --git a/tests/unit/test_l1_verified_msgpack.py b/tests/unit/test_l1_verified_msgpack.py new file mode 100644 index 00000000..6ca22015 --- /dev/null +++ b/tests/unit/test_l1_verified_msgpack.py @@ -0,0 +1,403 @@ +"""A non-encrypted backed cache keeps verified MessagePack in L1 where it is no longer than the envelope. + +An L1 hit on such an entry decodes without ``ByteStorage.retrieve()``. Three rules keep that from costing +L1 hits or security, and each is pinned here: + +- Admission is judged on the envelope, as before; the shorter form only replaces an envelope L1 admits. + So L1 never holds fewer entries than it would hold envelopes (the replay property below). +- MessagePack enters L1 only from a ``retrieve()`` that verified it; a corrupt envelope stays an envelope. +- Encrypted handlers keep their ciphertext envelope in L1, and every hit on it decrypts (protocol PRE-10, + PRE-11: the secure preset holds only ciphertext in L1). +""" + +from __future__ import annotations + +import os +import uuid +from collections.abc import Callable +from typing import Any + +import msgpack +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from cachekit import cache +from cachekit.cache_handler import CacheSerializationHandler, VerifiedMsgpack +from cachekit.config.nested import L1CacheConfig +from cachekit.l1_cache import L1Cache, get_l1_cache +from cachekit.serializers.base import SerializationError +from cachekit.serializers.encryption_wrapper import EncryptionWrapper + +pytestmark = pytest.mark.unit + +MASTER_KEY = "a" * 64 + + +class _Backend: + """In-memory byte store that counts its reads.""" + + def __init__(self) -> None: + self.store: dict[str, bytes] = {} + self.gets = 0 + + def get(self, key: str) -> bytes | None: + self.gets += 1 + return self.store.get(key) + + def set(self, key: str, value: bytes, ttl: int | None = None) -> None: + self.store[key] = bytes(value) + + 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, {} + + +def _namespace() -> str: + return f"l1vm-{uuid.uuid4().hex}" # backed-mode L1 is shared by namespace across decorations + + +def _stored(backend: _Backend, namespace: str) -> tuple[bytes, bytes | None]: + """The one L2 envelope and the L1 entry under its key.""" + ((key, envelope),) = backend.store.items() + return envelope, get_l1_cache(namespace).peek(key) + + +def _corrupt(envelope: bytes) -> bytes: + """``envelope`` with one byte of its ByteStorage payload flipped: the frame still parses, retrieve() fails.""" + corrupt = bytearray(envelope) + corrupt[-20] ^= 0xFF + return bytes(corrupt) + + +# ── what _put_l1 stores ─────────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "value", ["hello", 42, {"id": 7, "tags": ["a"]}, os.urandom(5000)], ids=["str", "int", "dict", "random"] +) +def test_a_value_no_longer_than_its_envelope_is_kept_as_verified_msgpack(value: Any) -> None: + backend, namespace, runs = _Backend(), _namespace(), [] + + @cache(backend=backend, ttl=60, namespace=namespace) + def compute(x: int) -> Any: + runs.append(x) + return value + + assert compute(1) == value + envelope, entry = _stored(backend, namespace) + assert type(entry) is VerifiedMsgpack + assert entry == msgpack.packb(value, use_bin_type=True) + assert len(entry) <= len(envelope) + + before = compute.cache_info().l1_hits # type: ignore[attr-defined] # stats are per function name + assert compute(1) == value # the L1 hit decodes it + assert (runs, backend.gets) == ([1], 1) + assert compute.cache_info().l1_hits == before + 1 # type: ignore[attr-defined] + + +def test_a_value_longer_than_its_envelope_keeps_the_envelope() -> None: + backend, namespace = _Backend(), _namespace() + + @cache(backend=backend, ttl=60, namespace=namespace) + def compute(x: int) -> str: + return "ab" * 600 + + assert compute(1) == "ab" * 600 + envelope, entry = _stored(backend, namespace) + assert len(msgpack.packb("ab" * 600)) > len(envelope) + assert type(entry) is bytes and entry == envelope + assert compute(1) == "ab" * 600 + assert backend.gets == 1 + + +def test_an_envelope_over_the_admission_cap_is_refused_even_when_its_msgpack_would_fit() -> None: + """Admission is judged on the envelope. Judged on the MessagePack, this entry would get in and evict others.""" + backend, namespace = _Backend(), _namespace() + value = os.urandom(131_067) # MessagePack 131,072 B, the cap at 1 MiB; the envelope is longer + + @cache(backend=backend, ttl=60, namespace=namespace, l1=L1CacheConfig(max_size_mb=1)) + def compute(x: int) -> bytes: + return value + + assert compute(1) == value + envelope, entry = _stored(backend, namespace) + l1 = get_l1_cache(namespace) + assert not l1.admits(len(envelope)) + assert l1.admits(len(CacheSerializationHandler().l1_entry(envelope))) # the shorter form alone would get in + assert entry is None + + +def test_an_l2_hit_backfills_verified_msgpack() -> None: + backend, namespace = _Backend(), _namespace() + + @cache(backend=backend, ttl=60, namespace=namespace) + def compute(x: int) -> dict[str, int]: + return {"x": x} + + assert compute(1) == {"x": 1} + get_l1_cache(namespace).clear() + assert compute(1) == {"x": 1} # L2 hit, backfilled + _, entry = _stored(backend, namespace) + assert type(entry) is VerifiedMsgpack + assert compute(1) == {"x": 1} + assert backend.gets == 2 + + +def test_a_backfill_from_a_corrupt_envelope_writes_no_msgpack(monkeypatch: pytest.MonkeyPatch) -> None: + """MessagePack enters L1 only from a retrieve() that verified it. The L2 decode is stubbed to succeed, as + if it had not checked the bytes: the backfill still puts the envelope, which the next L1 read refuses.""" + backend, namespace = _Backend(), _namespace() + + @cache(backend=backend, ttl=60, namespace=namespace) + def compute(x: int) -> dict[str, int]: + return {"x": x} + + compute(1) + ((key, envelope),) = backend.store.items() + backend.store[key] = corrupt = _corrupt(envelope) + get_l1_cache(namespace).clear() + real = CacheSerializationHandler.deserialize_data + monkeypatch.setattr( + CacheSerializationHandler, + "deserialize_data", + lambda self, data, *a, **kw: {"x": 1} if bytes(data) == corrupt else real(self, data, *a, **kw), + ) + + assert compute(1) == {"x": 1} + entry = get_l1_cache(namespace).peek(key) + assert type(entry) is bytes and entry == corrupt + + +@pytest.mark.parametrize("damage", [_corrupt, lambda e: e[:-30]], ids=["flipped-byte", "truncated"]) +def test_l1_entry_keeps_an_envelope_retrieve_refuses(damage: Callable[[bytes], bytes]) -> None: + handler = CacheSerializationHandler() + envelope = damage(handler.serialize_data({"x": 1}, cache_key="k")) + with pytest.raises(SerializationError): + handler.deserialize_data(envelope, cache_key="k") + assert handler.l1_entry(envelope) is envelope + + +_VALUES = st.recursive( + st.none() | st.booleans() | st.integers(-(2**63), 2**64 - 1) | st.floats(allow_nan=False) | st.text() | st.binary(), + lambda inner: st.lists(inner, max_size=8) | st.dictionaries(st.text(max_size=8), inner, max_size=8), + max_leaves=40, +) + + +@settings(max_examples=300, deadline=None) +@given(value=_VALUES | st.binary(min_size=1000, max_size=20_000) | st.text(min_size=1000, max_size=20_000)) +def test_l1_entry_is_never_longer_than_the_envelope_and_decodes_to_the_same_value(value: Any) -> None: + handler = CacheSerializationHandler() + envelope = handler.serialize_data(value, cache_key="k") + entry = handler.l1_entry(envelope) + assert len(entry) <= len(envelope) + assert type(entry) is (VerifiedMsgpack if len(msgpack.packb(value, use_bin_type=True)) <= len(envelope) else bytes) + assert handler.deserialize_data(entry, cache_key="k") == handler.deserialize_data(envelope, cache_key="k") + + +@pytest.mark.parametrize( + "handler", + [ + CacheSerializationHandler(serializer_name="auto"), + CacheSerializationHandler(enable_integrity_checking=False), + CacheSerializationHandler(encryption=True, master_key=MASTER_KEY, single_tenant_mode=True), + ], + ids=["auto", "integrity-off", "encrypted"], +) +def test_other_handlers_keep_the_envelope_and_refuse_verified_msgpack(handler: CacheSerializationHandler) -> None: + envelope = handler.serialize_data({"x": 1}, cache_key="k") + assert handler.l1_entry(envelope) is envelope + with pytest.raises(SerializationError): # SuspiciousCacheEntryError under encryption: a plaintext entry + handler.deserialize_data(VerifiedMsgpack(msgpack.packb({"x": 2})), cache_key="k") + + +# ── encrypted handlers: ciphertext in L1, every hit decrypts ────────────────────────────────── + + +_SECURE = { + "secure": lambda ns, b: cache.secure(master_key=MASTER_KEY, backend=b, ttl=60, namespace=ns), + "secure-default-serializer": lambda ns, b: cache.secure( + master_key=MASTER_KEY, backend=b, ttl=60, namespace=ns, serializer="default" + ), + "encryption-true": lambda ns, b: cache( + backend=b, ttl=60, namespace=ns, encryption=True, single_tenant_mode=True, master_key=MASTER_KEY + ), +} + + +@pytest.mark.parametrize("decorate", list(_SECURE.values()), ids=list(_SECURE)) +def test_encrypted_l1_holds_the_l2_ciphertext_and_every_hit_decrypts( + decorate: Callable[[str, _Backend], Any], monkeypatch: pytest.MonkeyPatch +) -> None: + decrypts: list[int] = [] + real = EncryptionWrapper.deserialize + monkeypatch.setattr(EncryptionWrapper, "deserialize", lambda self, *a, **kw: decrypts.append(1) or real(self, *a, **kw)) + backend, namespace = _Backend(), _namespace() + + @decorate(namespace, backend) + def compute(x: int) -> dict[str, int]: + return {"x": x} + + assert compute(1) == {"x": 1} # miss: L1 gets the very envelope L2 got + envelope, entry = _stored(backend, namespace) + assert type(entry) is bytes and entry == envelope + assert compute(1) == {"x": 1} # L1 hit + assert (len(decrypts), backend.gets) == (1, 1) + + get_l1_cache(namespace).clear() + assert compute(1) == {"x": 1} # L2 hit: the backfill is the ciphertext too + envelope, entry = _stored(backend, namespace) + assert type(entry) is bytes and entry == envelope + assert compute(1) == {"x": 1} + assert (len(decrypts), backend.gets) == (3, 2) + + +def test_an_encrypted_reader_refuses_verified_msgpack_in_its_l1() -> None: + """A plaintext wrapper sharing the namespace and key cannot make an encrypted one serve plaintext.""" + backend, namespace = _Backend(), _namespace() + + @_SECURE["secure"](namespace, backend) + def compute(x: int) -> dict[str, int]: + return {"x": x} + + compute(1) + ((key, _),) = backend.store.items() + get_l1_cache(namespace).put(key, VerifiedMsgpack(msgpack.packb({"x": "planted"}))) + assert compute(1) == {"x": 1} # read from L2 instead + assert type(get_l1_cache(namespace).peek(key)) is bytes + + +# ── L1 hits: never fewer than with envelopes ────────────────────────────────────────────────── +# +# A replay over the real L1Cache. An access reads the key and, on a miss, puts the entry that a rule +# picks from the entry's envelope and MessagePack sizes. _kept_rule is _put_l1's: the MessagePack only +# when L1 admits the envelope (L1Cache.admits) and the MessagePack is no longer (l1_entry); the tests +# above pin that _put_l1 applies it. The two rejected rules are the counterexamples an adversarial +# review found; they show the pinned traces bite. + +Rule = Callable[[L1Cache, int, int], int] + + +def _envelope_rule(l1: L1Cache, envelope: int, raw: int) -> int: + return envelope + + +def _kept_rule(l1: L1Cache, envelope: int, raw: int) -> int: + return raw if l1.admits(envelope) and raw <= envelope else envelope + + +def _growth_allowance_rule(l1: L1Cache, envelope: int, raw: int) -> int: + return raw if l1.admits(envelope) and raw <= envelope * 1.1 else envelope + + +def _admit_on_raw_rule(l1: L1Cache, envelope: int, raw: int) -> int: + return raw if raw <= envelope else envelope + + +class _Sized(bytes): + """A replayed L1 value: L1Cache accounts len(), and the replay never reads the bytes, so none are allocated.""" + + def __new__(cls, size: int) -> _Sized: + value = super().__new__(cls) + value.size = size # type: ignore[attr-defined] + return value + + def __len__(self) -> int: + return self.size # type: ignore[attr-defined] + + +def _replay(trace: list[tuple[str, str, int, int]], budget_mb: int, rule: Rule) -> list[int]: + """The indices of the trace's reads that hit L1. + + Ops: ("read", key, ...) reads and, on a miss, puts; ("write", key, ...) puts whatever L1 holds (a + revalidation, another writer); ("set", key, envelope, raw) is a new value at L2: L1 drops the key, and + later puts use the new sizes. A key's first op fixes its sizes until a "set". + """ + l1 = L1Cache(max_memory_mb=budget_mb, namespace=_namespace()) + sizes: dict[str, tuple[int, int]] = {} + hits = [] + for i, (op, key, envelope, raw) in enumerate(trace): + if op == "set": + l1.invalidate(key) + sizes[key] = (envelope, raw) + continue + if op == "read" and l1.get(key)[0]: + hits.append(i) + continue + l1.put(key, _Sized(rule(l1, *sizes.setdefault(key, (envelope, raw))))) + return hits + + +def _msgpack_sizes(e: int, cap: int) -> st.SearchStrategy[tuple[int, int]]: + """An envelope of ``e`` bytes and its MessagePack: the same, a little shorter or longer, anywhere up to + double, or either side of the admission cap.""" + return st.tuples( + st.just(e), + st.one_of(st.just(e), st.integers(max(1, e - 512), e + 512), st.integers(1, 2 * e), st.integers(cap - 256, cap + 256)), + ) + + +@st.composite +def _traces(draw: st.DrawFn, budget_mb: int) -> list[tuple[str, str, int, int]]: + """50 to 200 ops over n keys that together about fill L1, two keys of any size, and two that straddle the + admission cap: one whose envelope is refused but whose MessagePack would fit, and the reverse. The trace + opens with a "set" fixing each key's sizes; later "set"s draw new ones.""" + budget = budget_mb * 1024 * 1024 + cap = budget // 8 + n = draw(st.integers(2, 12)) + fill = min(cap, budget // n) + any_size = st.one_of(st.integers(cap - 256, cap + 256), st.integers(cap // 2, cap), st.integers(1, cap // 32)).flatmap( + lambda e: _msgpack_sizes(e, cap) + ) + sizes = {f"fill{i}": st.integers(max(1, fill - 512), fill).flatmap(lambda e: _msgpack_sizes(e, cap)) for i in range(n)} + sizes |= {"any0": any_size, "any1": any_size} + sizes |= { + "refused": st.tuples(st.integers(cap + 1, cap + 512), st.integers(cap - 512, cap)), + "admitted": st.tuples(st.integers(cap - 512, cap), st.integers(cap + 1, cap + 512)), + } + trace = [("set", key, *draw(strategy)) for key, strategy in sizes.items()] + ops = st.sampled_from(["read", "read", "read", "write", "set"]) + for op, key in draw(st.lists(st.tuples(ops, st.sampled_from(sorted(sizes))), min_size=50, max_size=200)): + trace.append((op, key, *draw(sizes[key])) if op == "set" else (op, key, 0, 0)) + return trace + + +@pytest.mark.parametrize("budget_mb", [1, 100], ids=["1MiB-minimum", "100MiB-default"]) +@settings(max_examples=100, deadline=None) # enough: each rejected rule below fails it in every run tried +@given(data=st.data()) +def test_no_access_trace_gets_fewer_l1_hits_than_with_envelopes(budget_mb: int, data: st.DataObject) -> None: + trace = data.draw(_traces(budget_mb)) + with_envelopes = _replay(trace, budget_mb, _envelope_rule) + kept = _replay(trace, budget_mb, _kept_rule) + assert set(with_envelopes) <= set(kept) # every read that hit before still hits + + +def test_entries_growing_past_their_envelope_keep_it_so_a_near_full_lru_keeps_its_hits() -> None: + """104 keys cycling through 100 MiB, each 1,000,000 B as an envelope and 1,040,000 B as MessagePack. The + envelopes fit; with a 1.1x growth allowance the MessagePack would not, and LRU would miss every read.""" + keys = [f"k{i}" for i in range(104)] + trace = [("read", k, 1_000_000, 1_040_000) for _ in range(3) for k in keys] + hot = [i for i in range(len(trace)) if i >= len(keys)] + + assert _replay(trace, 100, _envelope_rule) == hot # 208/208 + assert _replay(trace, 100, _kept_rule) == hot + assert _replay(trace, 100, _growth_allowance_rule) == [] # 0/208 + + +def test_an_entry_shrunk_under_the_admission_cap_stays_out_so_the_hot_set_keeps_its_hits() -> None: + """Under 1 MiB (cap 131,072 B), an entry whose envelope is 131,080 B is refused. Admitted on its 131,000 B + MessagePack, it would evict the least recent hot entry, and each hot read would then evict the next.""" + hot_keys = [f"h{i}" for i in range(10)] + warm = [("read", k, 100_000, 100_000) for k in hot_keys] + rounds = [op for _ in range(2) for op in [("read", "big", 131_080, 131_000), *warm]] + trace = warm + rounds + + assert len(_replay(trace, 1, _envelope_rule)) == 20 + assert len(_replay(trace, 1, _kept_rule)) == 20 + assert _replay(trace, 1, _admit_on_raw_rule) == [] # 20 hits fell to 0 diff --git a/tests/unit/test_l2_hit_l1_backfill.py b/tests/unit/test_l2_hit_l1_backfill.py index 33d07eca..b2aff77c 100644 --- a/tests/unit/test_l2_hit_l1_backfill.py +++ b/tests/unit/test_l2_hit_l1_backfill.py @@ -1,9 +1,9 @@ """Sync/async L2-hit parity (cachekit-py#164). An L2 hit must (a) backfill L1 so the next read of the same key never -re-pays the L2 round-trip, and (b) record ``size_bytes`` as the serialized -envelope's length, not ``len(str(value))`` — a repr is neither the payload -size nor cheap to build on every hit. The async wrapper always did (a); the +re-pays the L2 round-trip, and (b) record ``size_bytes`` as the length of the +bytes the hit read (the L2 envelope, or the L1 entry), not ``len(str(value))`` — +a repr is neither the payload size nor cheap to build on every hit. The async wrapper always did (a); the sync wrapper did neither. Both are pinned here through the real decorator stack against a plain (non-SWR, non-locking) byte store. """ @@ -16,6 +16,7 @@ import pytest from cachekit import cache +from cachekit.l1_cache import get_l1_cache from cachekit.reliability.async_metrics import AsyncMetricsCollector VALUE = {"answer": 42} @@ -67,12 +68,14 @@ def _force_l2_only(backend: _CountingBackend, l2_snapshot: dict[str, bytes]) -> backend.gets = 0 -def _assert_parity(backend: _CountingBackend, recorded: list[dict[str, Any]], info: Any) -> None: - envelope = next(iter(backend.store.values())) +def _assert_parity(backend: _CountingBackend, recorded: list[dict[str, Any]], info: Any, namespace: str) -> None: + ((key, envelope),) = backend.store.items() + l1_entry = get_l1_cache(namespace).peek(key) # the backfill: VALUE's verified MessagePack (l1_entry) + assert l1_entry is not None and len(l1_entry) < len(envelope) assert len(recorded) == 2 # one L2 hit, one L1 hit — one record each, nothing else assert [c["operation"] for c in recorded] == ["get", "get"] assert [c["serializer"] for c in recorded] == ["rust", "l1_memory"] - assert [c["size_bytes"] for c in recorded] == [len(envelope)] * 2 + assert [c["size_bytes"] for c in recorded] == [len(envelope), len(l1_entry)] # the bytes each hit read assert len(envelope) != len(str(VALUE).encode("utf-8")) # the old str(value) accounting would differ assert backend.gets == 1 # second read never reached L2 assert (info.l1_hits, info.l2_hits) == (1, 1) @@ -98,7 +101,7 @@ def compute() -> dict[str, int]: assert compute() == VALUE # L1 miss -> L2 hit -> backfill L1 assert compute() == VALUE # served from L1 assert calls["n"] == 1 - _assert_parity(backend, recorded, compute.cache_info()) # type: ignore[attr-defined] + _assert_parity(backend, recorded, compute.cache_info(), "l2-parity-sync") # type: ignore[attr-defined] async def test_async_l2_hit_backfills_l1_and_records_envelope_size(self, recorded: list[dict[str, Any]]) -> None: backend = _CountingBackend() @@ -118,7 +121,7 @@ async def compute() -> dict[str, int]: assert await compute() == VALUE assert await compute() == VALUE assert calls["n"] == 1 - _assert_parity(backend, recorded, compute.cache_info()) # type: ignore[attr-defined] + _assert_parity(backend, recorded, compute.cache_info(), "l2-parity-async") # type: ignore[attr-defined] def test_sync_l2_hit_survives_refused_l1_backfill(self, caplog: pytest.LogCaptureFixture) -> None: """A refused backfill is logged and skipped; the served hit is never demoted to a recompute.""" diff --git a/tests/unit/test_single_flight.py b/tests/unit/test_single_flight.py index 53b0b999..966162f1 100644 --- a/tests/unit/test_single_flight.py +++ b/tests/unit/test_single_flight.py @@ -37,6 +37,7 @@ from cachekit import cache from cachekit import object_cache as object_cache_module from cachekit.backends.errors import BackendError +from cachekit.cache_handler import CacheSerializationHandler, VerifiedMsgpack from cachekit.config.nested import L1CacheConfig from cachekit.decorators import single_flight from cachekit.decorators.single_flight import AsyncFlights, ThreadFlights @@ -945,6 +946,56 @@ def compute(x: int) -> dict[str, int]: assert counts == (1, 0, 1) +@pytest.fixture +def decoded(monkeypatch: pytest.MonkeyPatch) -> list[type]: + """The type of every entry a backed-mode handler decodes: VerifiedMsgpack from L1, bytes for an envelope.""" + seen: list[type] = [] + real = CacheSerializationHandler.deserialize_data + + def recording(self: CacheSerializationHandler, data: Any, *args: Any, **kwargs: Any) -> Any: + seen.append(type(data)) + return real(self, data, *args, **kwargs) + + monkeypatch.setattr(CacheSerializationHandler, "deserialize_data", recording) + return seen + + +def test_sync_backed_recheck_decodes_a_verified_msgpack_l1_entry_without_a_trip( + monkeypatch: pytest.MonkeyPatch, decoded: list[type] +) -> None: + """The trip that settled left the value's verified MessagePack in L1 (l1_entry); the re-check hands it to + _decode_shared, which decodes it without retrieve() and without a trip of its own.""" + runs = 0 + + def compute(x: int) -> dict[str, int]: + nonlocal runs + runs += 1 + return {"x": x} + + backend = _Backend() + fn = _decorator("backed", backend)(compute) + _settle_another_trip_first(monkeypatch, fn) + assert fn(1) == {"x": 1} + + assert (runs, backend.requests) == (1, ["get", "set"]) # one function call and one backend read in all + assert decoded == [VerifiedMsgpack] + + +def test_sync_backed_joiner_decodes_the_trips_envelope(waiting: list[int], decoded: list[type]) -> None: + """A joined caller decodes its own copy of the trip's envelope, as before: never the L1 entry.""" + + def compute(x: int) -> dict[str, int]: + _until(lambda: waiting[0] == HERD - 1) + return {"x": x} + + backend = _Backend() + results = _thread_herd(_decorator("backed", backend)(compute)) + + assert results == [{"x": 1}] * HERD + assert backend.requests == ["get", "set"] + assert decoded == [bytes] * (HERD - 1) + + def test_sync_backed_recheck_hit_hands_its_half_open_probe_slot_back( live_breakers: list[CircuitBreaker], monkeypatch: pytest.MonkeyPatch ) -> None: From a3d39a8df46f7f52e0fe0a32570e6acf63af1f5b Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 29 Oct 2026 17:39:15 +1100 Subject: [PATCH 2/8] perf(l1): swap an L1 entry for its verified MessagePack on its first hit Storing MessagePack at write time cost every L1 write a second unwrap and retrieve, about a third of an in-process miss, whether or not the entry was ever read again. The first L1 hit already runs retrieve; it now keeps that MessagePack and swaps it into the entry in place, only when it is no longer, and only if the entry still holds the object the hit read. Writes and backfills store envelopes exactly as before, so admission and eviction never see a shorter form. --- .secrets.baseline | 4 +- docs/data-flow-architecture.md | 14 +- docs/features/prometheus-metrics.md | 6 +- docs/performance.md | 2 +- src/cachekit/cache_handler.py | 75 ++++--- src/cachekit/decorators/wrapper.py | 19 +- src/cachekit/l1_cache.py | 31 ++- .../serializers/standard_serializer.py | 85 +++----- tests/unit/test_async_get_record_labels.py | 15 +- tests/unit/test_error_path_key_redaction.py | 6 +- tests/unit/test_l1_verified_msgpack.py | 191 ++++++++++++------ tests/unit/test_l2_hit_l1_backfill.py | 19 +- tests/unit/test_single_flight.py | 17 +- 13 files changed, 265 insertions(+), 219 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index bc0d34cf..c9287b39 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -215,7 +215,7 @@ "filename": "src/cachekit/cache_handler.py", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 660 + "line_number": 649 } ], "src/cachekit/serializers/interop_serializer.py": [ @@ -2020,5 +2020,5 @@ } ] }, - "generated_at": "2026-10-29T05:54:44Z" + "generated_at": "2026-10-29T06:39:11Z" } diff --git a/docs/data-flow-architecture.md b/docs/data-flow-architecture.md index 650e1243..8f0273e8 100644 --- a/docs/data-flow-architecture.md +++ b/docs/data-flow-architecture.md @@ -31,7 +31,7 @@ cachekit uses a hybrid Python-Rust architecture to provide production caching wi > **Architecture Principles:** > - **L1 + L2 Caching**: In-memory bytes cache (L1) + pluggable backend storage (L2) for optimal performance > - **Backend Abstraction**: Protocol-based L2 backend layer (Redis, CachekitIO, File, Memcached, custom) -> - **Bytes-Only Storage**: with a backend configured, L1 stores serialized bytes (encrypted or plaintext), not Python objects; L1-only mode (`backend=None`) stores the object itself. A plaintext entry of the default serializer with integrity checking on is kept as its verified MessagePack when that is no longer than its envelope, so its hit skips the LZ4 decompress and checksum; encrypted caches keep the ciphertext +> - **Bytes-Only Storage**: with a backend configured, L1 stores serialized bytes (encrypted or plaintext), not Python objects; L1-only mode (`backend=None`) stores the object itself. From its first L1 hit on, a plaintext entry of the default serializer with integrity checking on is held as its verified MessagePack when that is no longer than its envelope, so later hits skip the LZ4 decompress and checksum; encrypted caches keep the ciphertext > - **Security First**: Decompression bomb protection, checksum validation, size limits, encrypted-at-rest > - **Performance Optimized**: Thread affinity, connection pooling, pre-cached serializers > - **Flexible Configuration**: L1+L2, L1-only, L2-only, or observability-only modes @@ -308,11 +308,9 @@ cachekit uses a hybrid Python-Rust architecture to provide production caching wi │ Note: with a backend, L1 stores bytes (encrypted or plaintext), not Python │ │ objects. L1-only mode (backend=None) stores the object itself. │ │ │ -│ Before the put, the wrapper (_put_l1) swaps an envelope L1 admits for its │ -│ verified MessagePack when the cache is plaintext, the serializer is the │ -│ default one with integrity on, and the MessagePack is no longer │ -│ (CacheSerializationHandler.l1_entry). Admission is judged on the envelope, │ -│ so L1 never holds fewer entries. Encrypted caches keep the ciphertext. │ +│ L1 admits and evicts envelopes. A later L1 hit on a plaintext entry of the │ +│ default serializer (integrity on) may shrink it in place to its verified │ +│ MessagePack: see Storage Architecture. Encrypted caches keep ciphertext. │ └──────────────────────────────────┬──────────────────────────────────────────┘ │ ▼ @@ -486,10 +484,10 @@ L1-only mode (`backend=None`) skips this layer: it stores the Python object itse - **After refactor**: Stores bytes (encrypted or plaintext) → `len(bytes)` for size estimation - **Performance improvement**: O(1) size calculation vs recursive traversal - **Security improvement**: Encrypted bytes at rest (decrypt-at-read-time only) -- **Verified MessagePack**: a plaintext entry of the default serializer with integrity checking on is kept as the MessagePack a successful `ByteStorage.retrieve()` returned, in place of its envelope, when that is no longer and L1 admits the envelope. Its hit decodes the MessagePack without decompressing or checksumming it again. Compressible values are usually longer as MessagePack, so they keep the envelope; small and incompressible ones switch. Bytes from L2 never take this path: L1 marks the entry by its type (`VerifiedMsgpack`), which no backend returns +- **Verified MessagePack**: L1 stores, admits and evicts envelopes. The first L1 hit on a plaintext entry of the default serializer with integrity checking on decodes the envelope as before, and when the MessagePack its `ByteStorage.retrieve()` verified is no longer than the envelope, L1 holds that MessagePack in its place from then on (same expiry and LRU place, fewer bytes). Later hits decode it without decompressing or checksumming it again. Compressible values are usually longer as MessagePack, so they keep the envelope; small and incompressible ones switch. The swap only shrinks an entry, so L1 never holds fewer entries than it would hold envelopes, and it never touches an entry stored or removed since the hit read it. Bytes from L2 never take this path: L1 marks the entry by its type (`VerifiedMsgpack`), which no backend returns **Hierarchical Read Path:** -1. Check L1 cache (bytes) → if hit, deserialize (verified MessagePack skips the envelope's decompress and checksum) and return +1. Check L1 cache (bytes) → if hit, deserialize and return (a plaintext entry's first hit may swap it for its verified MessagePack, which later hits decode without the decompress and checksum) 2. If L1 miss, check L2 backend (bytes) → if hit, deserialize, populate L1 with bytes, return 3. If L2 miss, execute function → serialize to bytes → store in L2 and L1 → return diff --git a/docs/features/prometheus-metrics.md b/docs/features/prometheus-metrics.md index d31e2a45..7879871a 100644 --- a/docs/features/prometheus-metrics.md +++ b/docs/features/prometheus-metrics.md @@ -150,9 +150,9 @@ What each `cache_operation_duration_ms` series times, measured with a monotonic | `operation="set"` | Serialization plus the L2 write: not the miss read, the function, a lock wait, the L1 put or key tracking | `cache_operation_size_bytes` records the length of the bytes the operation read or wrote. For an L1 `get`, that is the L1 entry: -on a plaintext cache with the default serializer and integrity checking on, an entry whose MessagePack is no longer than its -envelope is held as that MessagePack, so its L1 `get` records a smaller size than its L2 `get`. Earlier releases recorded the -envelope's length for both. +on a plaintext cache with the default serializer and integrity checking on, an entry's first L1 hit swaps its envelope for its +MessagePack when that is no longer, so later L1 `get`s of it record a smaller size than its L2 `get`. Earlier releases recorded +the envelope's length on every hit. > **Changed durations.** Earlier releases recorded every L1 hit as a constant 0.001 ms, and timed > `set` from before the L2 read, so each `set` sample also held the miss round trip and the diff --git a/docs/performance.md b/docs/performance.md index eabb3258..3ad5ffed 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -73,7 +73,7 @@ With `backend=None`, cachekit keeps the returned object itself in memory and han With an L2 backend configured, L1 holds the serialized bytes instead, and a hit deserializes them, so that hit costs more as the payload grows. For the 100-user dict it takes 310 ± 20μs on the same runner, about 340 times an L1-only hit of the same dict. Nearly all of the difference is deserializing 23.5KB of MessagePack back into about 400 nested dicts. It is still about half of a Redis L2 hit there (551 ± 18μs), which deserializes the same bytes after the round trip. -For a plaintext cache with the default serializer and integrity checking on, L1 holds an entry as its verified MessagePack instead of its envelope when the MessagePack is no longer, which is true of small values and incompressible ones. Such a hit decodes the MessagePack without the envelope's LZ4 decompress and xxHash3-64 checksum. The 100-user dict compresses to a 3,171-byte envelope from 23.5KB of MessagePack, so L1 keeps its envelope and the figures above are unchanged. Encrypted caches always keep the ciphertext envelope. +For a plaintext cache with the default serializer and integrity checking on, an entry's first L1 hit swaps its envelope for its verified MessagePack when the MessagePack is no longer, which is true of small values and incompressible ones. Later hits decode the MessagePack without the envelope's LZ4 decompress and xxHash3-64 checksum. The 100-user dict compresses to a 3,171-byte envelope from 23.5KB of MessagePack, so L1 keeps its envelope and the figures above are unchanged. Encrypted caches always keep the ciphertext envelope. ### Decorator + L1 Hit (Hot Path) diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index 93d640ad..ba3dca10 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -64,7 +64,7 @@ KeyringConfigurationError, require_cross_sdk_serializer, ) -from cachekit.serializers.standard_serializer import StandardSerializer +from cachekit.serializers.standard_serializer import StandardSerializer, VerifiedMsgpack from cachekit.serializers.wrapper import SerializationWrapper if TYPE_CHECKING: @@ -77,17 +77,6 @@ CROSS_SDK_SERIALIZER_NAMES = ("default", "std", "standard", "orjson", "arrow") -class VerifiedMsgpack(bytes): - """An L1 entry that holds the MessagePack a successful ``ByteStorage.retrieve()`` returned, in place of - its envelope (:meth:`CacheSerializationHandler.l1_entry`). It decodes without another retrieve(). - - The type is the mark, not the bytes: no backend returns this type, so bytes from L2 never take - the unverified decode, whatever they hold. - """ - - __slots__ = () - - # Uncompressed Arrow IPC alignment: the body follows the File header, the CK frame # prefix and ArrowSerializer's 8-byte xxHash3-64 checksum; 64 is Arrow's recommended alignment. _ARROW_IPC_ALIGN = 64 @@ -908,7 +897,7 @@ def __init__( else: self._base_serializer = _get_cached_serializer_instance(serializer_name, enable_integrity_checking) self._custom_serializer = not isinstance(serializer_name, str) - # Whether L1 may hold this handler's entries as VerifiedMsgpack (l1_entry): plaintext CK-frame + # Whether L1 may hold this handler's entries as VerifiedMsgpack (deserialize_l1): plaintext CK-frame # entries of the built-in StandardSerializer with integrity on. Encrypted handlers keep ciphertext in # L1 (protocol PRE-10, PRE-11). self._l1_msgpack = ( @@ -1304,38 +1293,48 @@ def write_serialized_to(self, sink: BinaryIO, data: Any) -> None: budget = get_settings().max_value_size - len(prefix) serializer.serialize_to_sink(data, sink, max_bytes=budget) - def l1_entry(self, envelope: bytes) -> bytes: - """What L1 holds for ``envelope``, an entry this handler wrote or decoded: its MessagePack as - :class:`VerifiedMsgpack` when retrieve() verifies it and it is no longer than the envelope, else the - envelope itself. + def deserialize_l1( + self, + data: str | bytes | memoryview, + cache_key: str = "", + args: tuple[Any, ...] = (), + kwargs: dict[str, Any] | None = None, + ) -> tuple[Any, VerifiedMsgpack | None]: + """:meth:`deserialize_data` for an entry read from L1, plus what L1 should hold in its place, if anything. - Never longer than ``envelope``, so an L1 that admits the envelope admits the result and holds - no fewer entries. Call it only for an envelope L1 admits: a result shorter than the envelope - could pass an admission check the envelope fails. Encrypted handlers, and every handler other - than the built-in StandardSerializer with integrity on, get the envelope back unchanged. + That is the entry's verified MessagePack, when this handler can hold it (the built-in + StandardSerializer with integrity on, no encryption), the entry is its own plaintext envelope, + and the MessagePack is no longer. It comes from the retrieve() this decode ran anyway, so the + caller's swap (``L1Cache.replace``) costs no second retrieve(), and later hits skip it. Every other + entry gets None, and is decoded exactly as deserialize_data decodes it. Raises as deserialize_data. Examples: >>> handler = CacheSerializationHandler() >>> envelope = handler.serialize_data("hello", cache_key="greeting") - >>> entry = handler.l1_entry(envelope) - >>> type(entry).__name__, len(entry) <= len(envelope) - ('VerifiedMsgpack', True) - >>> handler.deserialize_data(entry, cache_key="greeting") - 'hello' + >>> value, entry = handler.deserialize_l1(envelope, cache_key="greeting") + >>> value, type(entry).__name__, len(entry) <= len(envelope) + ('hello', 'VerifiedMsgpack', True) + >>> handler.deserialize_l1(entry, cache_key="greeting") # already MessagePack + ('hello', None) >>> compressible = handler.serialize_data("ab" * 600, cache_key="k") - >>> handler.l1_entry(compressible) is compressible # its MessagePack is longer + >>> handler.deserialize_l1(compressible, cache_key="k")[1] is None # its MessagePack is longer True """ - if not self._l1_msgpack: - return envelope - try: - payload, metadata, serializer_name = SerializationWrapper.unwrap_metadata(envelope) - except (AttributeError, KeyError, TypeError, ValueError): - return envelope # its own read reports the corrupt frame - if serializer_name != self._serializer_string_name or metadata.encrypted or not metadata.compressed: - return envelope # a config-drift entry: an encrypted one keeps its ciphertext in L1 - msgpack_data = self._base_serializer.verified_msgpack(payload, len(envelope)) # type: ignore[union-attr] - return envelope if msgpack_data is None else VerifiedMsgpack(msgpack_data) + # Inline, not helpers: this is every L1 hit's decode, and an envelope it keeps must cost what + # deserialize_data costs. + if self._l1_msgpack: + if type(data) is VerifiedMsgpack: + return self._base_serializer.deserialize_verified(data), None # type: ignore[union-attr] + try: + payload, metadata, serializer_name = SerializationWrapper.unwrap_metadata(data) + except (AttributeError, KeyError, TypeError, ValueError): + pass # a corrupt frame: deserialize_data raises for it below + else: + # Only this handler's own plaintext envelope. Anything else, such as a config-drift + # entry whose ciphertext must stay in L1, is decoded below exactly as before. + if serializer_name == self._serializer_string_name and not metadata.encrypted and metadata.compressed: + return self._base_serializer.deserialize_keeping_msgpack(payload, len(data)) # type: ignore[union-attr] + return self.deserialize_data(data, cache_key, args, kwargs), None def deserialize_data( self, @@ -1416,7 +1415,7 @@ def deserialize_data( True """ if type(data) is VerifiedMsgpack: - # Only L1 holds this type (l1_entry), so only a handler that writes it decodes it. Another + # Only L1 holds this type (deserialize_l1), so only a handler that writes it decodes it. Another # handler sharing the namespace and key refuses it as it would the plaintext envelope. if not self._l1_msgpack: error = SuspiciousCacheEntryError if self.encryption else SerializationError diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index 1a944d3c..bb58e920 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -1252,11 +1252,6 @@ def _put_l1(cache_key: str, serialized_data: Any, l1_ttl: int | None, *, twin: s """The only L1 write in this wrapper: put the serialized bytes (str payloads encoded), then record the key, and its pre-0.20.0 twin if it has one (_twin_key), in _cached_keys. - An envelope L1 admits is put in the form l1_entry gives it: its verified MessagePack when that - is no longer, so a hit skips retrieve(). Admission is judged on the envelope, as before: judged on - a shorter form, an entry L1 used to refuse would get in and evict others. So L1 never holds fewer - entries than it would hold envelopes. - Recording after the put makes "in L1 => in _cached_keys" hold by construction, so a whole-function invalidation that trims _cached_keys before evicting L1 cannot miss an entry. The key is recorded even when there is nothing to put (L1 disabled, a streamed @@ -1267,8 +1262,6 @@ def _put_l1(cache_key: str, serialized_data: Any, l1_ttl: int | None, *, twin: s """ if _l1_cache and serialized_data: _b = serialized_data.encode("utf-8") if isinstance(serialized_data, str) else serialized_data - if type(_b) is bytes and _l1_cache.admits(len(_b)): - _b = serialization_handler.l1_entry(_b) _l1_cache.put(cache_key, _b, redis_ttl=l1_ttl) _record(cache_key, twin) @@ -2219,7 +2212,11 @@ def _sync_call( # noqa: PLR0912 if l1_found and l1_bytes: # L1 cache hit - deserialize bytes try: - l1_value = operation_handler.serialization_handler.deserialize_data(l1_bytes, cache_key, args, kwargs) + l1_value, l1_kept = operation_handler.serialization_handler.deserialize_l1( + l1_bytes, cache_key, args, kwargs + ) + if l1_kept is not None: # its verified MessagePack: later hits skip retrieve() + _l1_cache.replace(cache_key, l1_bytes, l1_kept) l1_duration_ms = (time.perf_counter() - l1_start) * 1000 # L1 get plus deserialize # Record L1 cache hit metrics @@ -2713,7 +2710,11 @@ async def _async_call( if l1_found and l1_bytes: # L1 cache hit - deserialize bytes try: - l1_value = operation_handler.serialization_handler.deserialize_data(l1_bytes, cache_key, args, kwargs) + l1_value, l1_kept = operation_handler.serialization_handler.deserialize_l1( + l1_bytes, cache_key, args, kwargs + ) + if l1_kept is not None: # as in sync_wrapper + _l1_cache.replace(cache_key, l1_bytes, l1_kept) l1_duration_ms = (time.perf_counter() - l1_start) * 1000 # L1 get plus deserialize # Record L1 cache hit metrics (same labels as the sync L1 hit) diff --git a/src/cachekit/l1_cache.py b/src/cachekit/l1_cache.py index 260a0599..f8aefd79 100644 --- a/src/cachekit/l1_cache.py +++ b/src/cachekit/l1_cache.py @@ -164,15 +164,6 @@ def _estimate_size(self, value: bytes) -> int: """ return len(value) - def admits(self, size: int) -> bool: - """Whether ``put`` stores a value of ``size`` bytes: at most an eighth of max_memory_bytes (_MAX_ENTRY_SHARE). - - Examples: - >>> L1Cache(max_memory_mb=1).admits(131_072), L1Cache(max_memory_mb=1).admits(131_073) - (True, False) - """ - return size <= self.max_memory_bytes * _MAX_ENTRY_SHARE - def get(self, key: str) -> tuple[bool, Optional[bytes]]: """Get value from L1 cache if present and not expired. @@ -223,6 +214,23 @@ def peek(self, key: str) -> Optional[bytes]: entry = s.cache.get(key) return None if entry is None or entry.is_expired() else entry.value + def replace(self, key: str, old: bytes, new: bytes) -> None: + """Hold ``new`` in place of ``old``, the very object a read of ``key`` returned, if the entry still holds + it and ``new`` is no longer. The entry keeps its expiry and LRU place. + + For a hit that re-encodes the entry it read. The identity check leaves alone an entry that was + removed, or stored again, since that read, so an invalidation is never undone. Never growing an + entry means L1 never evicts for it. + """ + size = self._estimate_size(new) + s = self._state + with s.lock: + entry = s.cache.get(key) + if entry is None or entry.value is not old or size > entry.size_bytes: + return + s.memory_bytes -= entry.size_bytes - size + entry.value, entry.size_bytes = new, size + def put( self, key: str, @@ -287,13 +295,14 @@ def put( # would also push L1 permanently over its limit (a multi-GB DataFrame envelope is a direct # OOM vector). We only decline to mirror the value in L1. If a smaller entry for this key # was cached, drop it so L1 stops serving the stale value. - if not self.admits(size): + max_entry_bytes = self.max_memory_bytes * _MAX_ENTRY_SHARE + if size > max_entry_bytes: self._on_current_state(_L1State.remove, key) logger.debug( "Skipping L1 cache for key %s - value %d bytes exceeds L1 per-entry limit %d bytes (not kept in L1)", redact_key_for_log(key), size, - self.max_memory_bytes * _MAX_ENTRY_SHARE, + max_entry_bytes, ) return diff --git a/src/cachekit/serializers/standard_serializer.py b/src/cachekit/serializers/standard_serializer.py index f62e441e..4bd51d60 100644 --- a/src/cachekit/serializers/standard_serializer.py +++ b/src/cachekit/serializers/standard_serializer.py @@ -46,10 +46,18 @@ # lz4_flex's get_maximum_output_size for that budget: the most compressed bytes any envelope within it carries. _ENVELOPE_PROBE_MAX_COMPRESSED = 20 + _ENVELOPE_PROBE_MAX_SIZE * 110 // 100 _CROSS_CONFIG_ERROR = "Cache entry was written with integrity checking on but this reader has integrity checking disabled" -# Length-field width of each bin marker, the payload slot's header in the current envelope layout. -_BIN_LENGTH_WIDTH = {0xC4: 1, 0xC5: 2, 0xC6: 4} -# Bytes after the payload slot that hold the checksum (eight uint8, at most 17 bytes) and original_size (at most 9). -_ENVELOPE_TAIL_PREFIX = 32 + + +class VerifiedMsgpack(bytes): + """MessagePack a successful ``ByteStorage.retrieve()`` returned, which an L1 entry holds in place of its + envelope (``CacheSerializationHandler.deserialize_l1``). It decodes without another retrieve(). + + The type is the mark, not the bytes: no backend returns this type, so bytes from L2 never take + the unverified decode, whatever they hold. + """ + + __slots__ = () + # Error message constants for unsupported types (Task 2) NUMPY_ERROR_MESSAGE = ( @@ -396,39 +404,30 @@ def deserialize(self, data: bytes | memoryview, metadata: SerializationMetadata except PAYLOAD_DECODE_ERRORS as e: raise SerializationError(f"Failed to deserialize MessagePack data: {e}") from e - def verified_msgpack(self, envelope: bytes | memoryview, limit: int) -> bytes | None: - """The MessagePack ``envelope`` holds, if it is at most ``limit`` bytes and ``retrieve()`` verifies it; else None. - - The size the envelope declares is read first, so an envelope whose payload would exceed ``limit`` - is never decompressed. Any retrieve() failure, and any layout other than the current bin - layout, gives None: the caller keeps the envelope, whose own read reports the fault. + def deserialize_keeping_msgpack(self, data: bytes | memoryview, limit: int) -> tuple[Any, VerifiedMsgpack | None]: + """:meth:`deserialize` of a ByteStorage envelope, with integrity checking on, plus the MessagePack + ``retrieve()`` verified when it is at most ``limit`` bytes, for the caller to keep and decode later + without retrieve() (:meth:`deserialize_verified`). Raises as deserialize does. Examples: >>> serializer = StandardSerializer() - >>> envelope, _ = serializer.serialize("x" * 8) - >>> serializer.verified_msgpack(envelope, len(envelope)) - b'\\xa8xxxxxxxx' - >>> serializer.verified_msgpack(envelope, 8) is None # the payload is 9 bytes - True + >>> envelope, _ = serializer.serialize({"test": 123}) + >>> value, msgpack_data = serializer.deserialize_keeping_msgpack(envelope, len(envelope)) + >>> value, serializer.deserialize_verified(msgpack_data) + ({'test': 123}, {'test': 123}) + >>> serializer.deserialize_keeping_msgpack(envelope, 4) # the MessagePack is 7 bytes + ({'test': 123}, None) """ - size = _declared_size(envelope) - if size is None or size > limit: - return None try: - msgpack_data, _ = self._byte_storage.retrieve(envelope) - except Exception: # noqa: BLE001 - not verified, whatever the cause: the envelope is kept - return None - return msgpack_data if len(msgpack_data) <= limit else None + msgpack_data, _ = self._byte_storage.retrieve(data) + value = unpackb_bounded(msgpack_data, **self._msgpack_unpack_opts) + except PAYLOAD_DECODE_ERRORS as e: + raise SerializationError(f"Failed to deserialize MessagePack data: {e}") from e + return value, VerifiedMsgpack(msgpack_data) if len(msgpack_data) <= limit else None def deserialize_verified(self, msgpack_data: bytes) -> Any: - """Decode MessagePack that :meth:`verified_msgpack` returned, without another ``retrieve()``. - - Examples: - >>> serializer = StandardSerializer() - >>> envelope, _ = serializer.serialize({"test": 123}) - >>> serializer.deserialize_verified(serializer.verified_msgpack(envelope, len(envelope))) - {'test': 123} - """ + """Decode MessagePack that :meth:`deserialize_keeping_msgpack` returned, without another ``retrieve()``. + Raises SerializationError as deserialize does.""" try: return unpackb_bounded(msgpack_data, **self._msgpack_unpack_opts) except PAYLOAD_DECODE_ERRORS as e: @@ -455,32 +454,6 @@ def _is_verified_envelope(self, data: bytes | memoryview, value: Any, markers: f return True -def _declared_size(envelope: bytes | memoryview) -> int | None: - """The original_size a bin-layout ByteStorage envelope declares, read without decompressing; None for - any other layout. Unverified: retrieve() checks it. - - Examples: - >>> envelope, _ = StandardSerializer().serialize("x" * 1000) - >>> _declared_size(envelope) - 1003 - >>> _declared_size(b"\\x93") is None - True - """ - width = _BIN_LENGTH_WIDTH.get(envelope[1]) if len(envelope) > 1 and envelope[0] == 0x94 else None - if width is None: - return None - start = 2 + width - end = start + int.from_bytes(envelope[2:start], "big") - tail = msgpack.Unpacker() - tail.feed(envelope[end : end + _ENVELOPE_TAIL_PREFIX]) - try: - next(tail) # the checksum - size = next(tail) - except (StopIteration, ValueError): # msgpack's decode errors are ValueErrors - return None - return size if type(size) is int else None - - # Default instance for convenience standard_serializer = StandardSerializer() diff --git a/tests/unit/test_async_get_record_labels.py b/tests/unit/test_async_get_record_labels.py index a7c53e0e..fb61d0e0 100644 --- a/tests/unit/test_async_get_record_labels.py +++ b/tests/unit/test_async_get_record_labels.py @@ -15,7 +15,7 @@ import pytest from cachekit import cache -from cachekit.l1_cache import get_l1_cache, get_l1_cache_manager +from cachekit.l1_cache import get_l1_cache_manager from cachekit.reliability.async_metrics import AsyncMetricsCollector @@ -80,12 +80,9 @@ async def compute() -> dict[str, int]: assert len(recorded) == 1 # one hit, one record assert (recorded[0]["operation"], recorded[0].get("serializer")) == ("get", tier) # what the sync hit sites pass - # Exact served size, not just positive: each hit site records the length of the bytes it read, - # the L2 envelope for an L2 hit and the L1 entry for an L1 hit. This one's L1 entry is its - # verified MessagePack, shorter than the envelope (CacheSerializationHandler.l1_entry). A `> 0` - # assertion would pass on a wrong constant (e.g. size_bytes=1); this pins the real value. - ((key, envelope),) = backend.store.items() - l1_entry = get_l1_cache("async-get-labels").peek(key) - assert l1_entry is not None and len(l1_entry) < len(envelope) - expected_size = len(envelope) if tier == "rust" else len(l1_entry) + # Exact served size, not just positive: both hit sites record the raw serialized + # envelope's length — the L1 backfill stores the same bytes L2 returned, so the + # single L2 store value is the ground truth for either tier. A `> 0` assertion + # would pass on a wrong constant (e.g. size_bytes=1); this pins the real value. + expected_size = len(next(iter(backend.store.values()))) assert recorded[0].get("size_bytes") == expected_size diff --git a/tests/unit/test_error_path_key_redaction.py b/tests/unit/test_error_path_key_redaction.py index 497eee79..ca431b5a 100644 --- a/tests/unit/test_error_path_key_redaction.py +++ b/tests/unit/test_error_path_key_redaction.py @@ -554,9 +554,9 @@ class TestDecoratorWrapperRedaction: @staticmethod def _poison_deserialize(monkeypatch: pytest.MonkeyPatch, error: Exception) -> None: - # Class-level patch: the wrapper reaches deserialize_data through the handler instance - # it built at decoration time, so an instance patch has nothing to attach to. - monkeypatch.setattr(CacheSerializationHandler, "deserialize_data", MagicMock(side_effect=error)) + # Class-level patch: the wrapper reaches deserialize_l1 (the L1 hit sites' decode) through the + # handler instance it built at decoration time, so an instance patch has nothing to attach to. + monkeypatch.setattr(CacheSerializationHandler, "deserialize_l1", MagicMock(side_effect=error)) @staticmethod def _assert_l1_sink(caplog: pytest.LogCaptureFixture, cache_key: str, error: Exception) -> None: diff --git a/tests/unit/test_l1_verified_msgpack.py b/tests/unit/test_l1_verified_msgpack.py index 6ca22015..0de998c8 100644 --- a/tests/unit/test_l1_verified_msgpack.py +++ b/tests/unit/test_l1_verified_msgpack.py @@ -1,11 +1,13 @@ -"""A non-encrypted backed cache keeps verified MessagePack in L1 where it is no longer than the envelope. +"""A non-encrypted backed cache's L1 hit swaps its entry for the verified MessagePack, where that is no longer. -An L1 hit on such an entry decodes without ``ByteStorage.retrieve()``. Three rules keep that from costing -L1 hits or security, and each is pinned here: +The first L1 hit on an entry decodes the envelope as before, and its retrieve() yields the MessagePack; +L1 then holds that in place of the envelope (``CacheSerializationHandler.deserialize_l1``, +``L1Cache.replace``), and later hits decode it without retrieve(). Each rule that keeps this from costing +L1 hits or security is pinned here: -- Admission is judged on the envelope, as before; the shorter form only replaces an envelope L1 admits. - So L1 never holds fewer entries than it would hold envelopes (the replay property below). -- MessagePack enters L1 only from a ``retrieve()`` that verified it; a corrupt envelope stays an envelope. +- L1 admits and stores envelopes exactly as before; a hit only ever shrinks an entry in place. So L1 never + holds fewer entries than it would hold envelopes (the replay property below). +- MessagePack enters L1 only from a retrieve() that verified it; a corrupt envelope is never swapped. - Encrypted handlers keep their ciphertext envelope in L1, and every hit on it decrypts (protocol PRE-10, PRE-11: the secure preset holds only ciphertext in L1). """ @@ -75,13 +77,13 @@ def _corrupt(envelope: bytes) -> bytes: return bytes(corrupt) -# ── what _put_l1 stores ─────────────────────────────────────────────────────────────────────── +# ── what L1 holds ───────────────────────────────────────────────────────────────────────────── @pytest.mark.parametrize( "value", ["hello", 42, {"id": 7, "tags": ["a"]}, os.urandom(5000)], ids=["str", "int", "dict", "random"] ) -def test_a_value_no_longer_than_its_envelope_is_kept_as_verified_msgpack(value: Any) -> None: +def test_the_first_hit_swaps_an_envelope_for_its_msgpack_when_no_longer(value: Any) -> None: backend, namespace, runs = _Backend(), _namespace(), [] @cache(backend=backend, ttl=60, namespace=namespace) @@ -89,35 +91,40 @@ def compute(x: int) -> Any: runs.append(x) return value - assert compute(1) == value + assert compute(1) == value # miss: L1 gets the envelope L2 got, as before envelope, entry = _stored(backend, namespace) + assert type(entry) is bytes and entry == envelope + + before = compute.cache_info().l1_hits # type: ignore[attr-defined] # stats are per function name + assert compute(1) == value # first hit: decodes the envelope, keeps its MessagePack + _, entry = _stored(backend, namespace) assert type(entry) is VerifiedMsgpack assert entry == msgpack.packb(value, use_bin_type=True) assert len(entry) <= len(envelope) - before = compute.cache_info().l1_hits # type: ignore[attr-defined] # stats are per function name - assert compute(1) == value # the L1 hit decodes it + assert compute(1) == value # decodes the MessagePack assert (runs, backend.gets) == ([1], 1) - assert compute.cache_info().l1_hits == before + 1 # type: ignore[attr-defined] + assert compute.cache_info().l1_hits == before + 2 # type: ignore[attr-defined] -def test_a_value_longer_than_its_envelope_keeps_the_envelope() -> None: +def test_a_value_longer_as_msgpack_keeps_its_envelope() -> None: backend, namespace = _Backend(), _namespace() @cache(backend=backend, ttl=60, namespace=namespace) def compute(x: int) -> str: return "ab" * 600 - assert compute(1) == "ab" * 600 + for _ in range(3): + assert compute(1) == "ab" * 600 envelope, entry = _stored(backend, namespace) assert len(msgpack.packb("ab" * 600)) > len(envelope) assert type(entry) is bytes and entry == envelope - assert compute(1) == "ab" * 600 assert backend.gets == 1 -def test_an_envelope_over_the_admission_cap_is_refused_even_when_its_msgpack_would_fit() -> None: - """Admission is judged on the envelope. Judged on the MessagePack, this entry would get in and evict others.""" +def test_an_envelope_over_the_admission_cap_stays_out_though_its_msgpack_would_fit() -> None: + """Admission is judged on the envelope, as before. Judged on the MessagePack, this entry would get in and + evict others.""" backend, namespace = _Backend(), _namespace() value = os.urandom(131_067) # MessagePack 131,072 B, the cap at 1 MiB; the envelope is longer @@ -125,15 +132,15 @@ def test_an_envelope_over_the_admission_cap_is_refused_even_when_its_msgpack_wou def compute(x: int) -> bytes: return value - assert compute(1) == value + for _ in range(3): + assert compute(1) == value envelope, entry = _stored(backend, namespace) - l1 = get_l1_cache(namespace) - assert not l1.admits(len(envelope)) - assert l1.admits(len(CacheSerializationHandler().l1_entry(envelope))) # the shorter form alone would get in + assert len(envelope) > 131_072 >= len(msgpack.packb(value)) assert entry is None + assert backend.gets == 3 # every call reads L2 -def test_an_l2_hit_backfills_verified_msgpack() -> None: +def test_an_l2_hit_backfills_the_envelope_and_the_next_hit_swaps_it() -> None: backend, namespace = _Backend(), _namespace() @cache(backend=backend, ttl=60, namespace=namespace) @@ -142,16 +149,18 @@ def compute(x: int) -> dict[str, int]: assert compute(1) == {"x": 1} get_l1_cache(namespace).clear() - assert compute(1) == {"x": 1} # L2 hit, backfilled + assert compute(1) == {"x": 1} # L2 hit + envelope, entry = _stored(backend, namespace) + assert type(entry) is bytes and entry == envelope + assert compute(1) == {"x": 1} _, entry = _stored(backend, namespace) assert type(entry) is VerifiedMsgpack - assert compute(1) == {"x": 1} assert backend.gets == 2 -def test_a_backfill_from_a_corrupt_envelope_writes_no_msgpack(monkeypatch: pytest.MonkeyPatch) -> None: - """MessagePack enters L1 only from a retrieve() that verified it. The L2 decode is stubbed to succeed, as - if it had not checked the bytes: the backfill still puts the envelope, which the next L1 read refuses.""" +def test_a_corrupt_envelope_backfilled_into_l1_is_never_swapped(monkeypatch: pytest.MonkeyPatch) -> None: + """MessagePack enters L1 only from a retrieve() that verified it. The L2 decode is stubbed to accept the + corrupt envelope, so the backfill puts it in L1; the next hit's retrieve() refuses it, and L1 drops it.""" backend, namespace = _Backend(), _namespace() @cache(backend=backend, ttl=60, namespace=namespace) @@ -161,7 +170,8 @@ def compute(x: int) -> dict[str, int]: compute(1) ((key, envelope),) = backend.store.items() backend.store[key] = corrupt = _corrupt(envelope) - get_l1_cache(namespace).clear() + l1 = get_l1_cache(namespace) + l1.clear() real = CacheSerializationHandler.deserialize_data monkeypatch.setattr( CacheSerializationHandler, @@ -169,18 +179,19 @@ def compute(x: int) -> dict[str, int]: lambda self, data, *a, **kw: {"x": 1} if bytes(data) == corrupt else real(self, data, *a, **kw), ) - assert compute(1) == {"x": 1} - entry = get_l1_cache(namespace).peek(key) + assert compute(1) == {"x": 1} # L2 hit on the corrupt envelope: backfilled as it is + assert l1.peek(key) == corrupt + assert compute(1) == {"x": 1} # the L1 hit refuses it; L2 serves and backfills it again + entry = l1.peek(key) assert type(entry) is bytes and entry == corrupt @pytest.mark.parametrize("damage", [_corrupt, lambda e: e[:-30]], ids=["flipped-byte", "truncated"]) -def test_l1_entry_keeps_an_envelope_retrieve_refuses(damage: Callable[[bytes], bytes]) -> None: +def test_deserialize_l1_refuses_an_envelope_retrieve_refuses(damage: Callable[[bytes], bytes]) -> None: handler = CacheSerializationHandler() envelope = damage(handler.serialize_data({"x": 1}, cache_key="k")) with pytest.raises(SerializationError): - handler.deserialize_data(envelope, cache_key="k") - assert handler.l1_entry(envelope) is envelope + handler.deserialize_l1(envelope, cache_key="k") _VALUES = st.recursive( @@ -192,13 +203,17 @@ def test_l1_entry_keeps_an_envelope_retrieve_refuses(damage: Callable[[bytes], b @settings(max_examples=300, deadline=None) @given(value=_VALUES | st.binary(min_size=1000, max_size=20_000) | st.text(min_size=1000, max_size=20_000)) -def test_l1_entry_is_never_longer_than_the_envelope_and_decodes_to_the_same_value(value: Any) -> None: +def test_deserialize_l1_keeps_msgpack_only_when_no_longer_and_it_decodes_the_same(value: Any) -> None: handler = CacheSerializationHandler() envelope = handler.serialize_data(value, cache_key="k") - entry = handler.l1_entry(envelope) - assert len(entry) <= len(envelope) - assert type(entry) is (VerifiedMsgpack if len(msgpack.packb(value, use_bin_type=True)) <= len(envelope) else bytes) - assert handler.deserialize_data(entry, cache_key="k") == handler.deserialize_data(envelope, cache_key="k") + decoded, entry = handler.deserialize_l1(envelope, cache_key="k") + expected = handler.deserialize_data(envelope, cache_key="k") + assert decoded == expected + if len(msgpack.packb(value, use_bin_type=True)) > len(envelope): + assert entry is None + else: + assert type(entry) is VerifiedMsgpack and len(entry) <= len(envelope) + assert handler.deserialize_l1(entry, cache_key="k") == (expected, None) @pytest.mark.parametrize( @@ -210,13 +225,53 @@ def test_l1_entry_is_never_longer_than_the_envelope_and_decodes_to_the_same_valu ], ids=["auto", "integrity-off", "encrypted"], ) -def test_other_handlers_keep_the_envelope_and_refuse_verified_msgpack(handler: CacheSerializationHandler) -> None: +def test_other_handlers_keep_their_envelope_and_refuse_verified_msgpack(handler: CacheSerializationHandler) -> None: envelope = handler.serialize_data({"x": 1}, cache_key="k") - assert handler.l1_entry(envelope) is envelope + assert handler.deserialize_l1(envelope, cache_key="k") == ({"x": 1}, None) with pytest.raises(SerializationError): # SuspiciousCacheEntryError under encryption: a plaintext entry handler.deserialize_data(VerifiedMsgpack(msgpack.packb({"x": 2})), cache_key="k") +# ── L1Cache.replace ─────────────────────────────────────────────────────────────────────────── + + +def test_replace_shrinks_the_entry_in_place() -> None: + l1 = L1Cache(max_memory_mb=1, namespace=_namespace()) + old = b"e" * 100 + l1.put("a", old, redis_ttl=60) + l1.put("b", b"b" * 10, redis_ttl=60) + expires_at = l1._state.cache["a"].expires_at + + l1.replace("a", old, b"m" * 40) + + assert l1.peek("a") == b"m" * 40 + assert l1._state.memory_bytes == 50 + assert l1._state.cache["a"].expires_at == expires_at + assert list(l1._state.cache) == ["a", "b"] # LRU place kept + + +@pytest.mark.parametrize( + "change", ["removed", "stored-again", "longer"], ids=["removed-since-read", "stored-again-since-read", "longer"] +) +def test_replace_leaves_an_entry_it_did_not_read_or_would_grow(change: str) -> None: + l1 = L1Cache(max_memory_mb=1, namespace=_namespace()) + old = b"e" * 100 + l1.put("a", old, redis_ttl=60) + new = b"m" * 40 + if change == "removed": + l1.invalidate("a") + elif change == "stored-again": + l1.put("a", bytes(bytearray(old)), redis_ttl=60) # equal bytes, another object: a newer write + else: + new = b"m" * 101 + held = l1.peek("a") + + l1.replace("a", old, new) + + assert l1.peek("a") is held + assert l1._state.memory_bytes == (0 if held is None else 100) + + # ── encrypted handlers: ciphertext in L1, every hit decrypts ────────────────────────────────── @@ -245,17 +300,18 @@ def compute(x: int) -> dict[str, int]: return {"x": x} assert compute(1) == {"x": 1} # miss: L1 gets the very envelope L2 got - envelope, entry = _stored(backend, namespace) - assert type(entry) is bytes and entry == envelope - assert compute(1) == {"x": 1} # L1 hit - assert (len(decrypts), backend.gets) == (1, 1) + for hits in (1, 2): + assert compute(1) == {"x": 1} # L1 hit + envelope, entry = _stored(backend, namespace) + assert type(entry) is bytes and entry == envelope + assert (len(decrypts), backend.gets) == (hits, 1) get_l1_cache(namespace).clear() assert compute(1) == {"x": 1} # L2 hit: the backfill is the ciphertext too + assert compute(1) == {"x": 1} envelope, entry = _stored(backend, namespace) assert type(entry) is bytes and entry == envelope - assert compute(1) == {"x": 1} - assert (len(decrypts), backend.gets) == (3, 2) + assert (len(decrypts), backend.gets) == (4, 2) def test_an_encrypted_reader_refuses_verified_msgpack_in_its_l1() -> None: @@ -275,11 +331,11 @@ def compute(x: int) -> dict[str, int]: # ── L1 hits: never fewer than with envelopes ────────────────────────────────────────────────── # -# A replay over the real L1Cache. An access reads the key and, on a miss, puts the entry that a rule -# picks from the entry's envelope and MessagePack sizes. _kept_rule is _put_l1's: the MessagePack only -# when L1 admits the envelope (L1Cache.admits) and the MessagePack is no longer (l1_entry); the tests -# above pin that _put_l1 applies it. The two rejected rules are the counterexamples an adversarial -# review found; they show the pinned traces bite. +# A replay over the real L1Cache. A read that misses puts the entry a put rule picks from the entry's +# envelope and MessagePack sizes; with promote, a read that hits an envelope swaps it for its MessagePack +# when that is no longer, as the L1 hit sites do (deserialize_l1, then L1Cache.replace). The shipped design +# is the envelope rule with promote. The two rejected put rules are the counterexamples an adversarial +# review found against storing MessagePack at write time; they show the pinned traces bite. Rule = Callable[[L1Cache, int, int], int] @@ -288,12 +344,8 @@ def _envelope_rule(l1: L1Cache, envelope: int, raw: int) -> int: return envelope -def _kept_rule(l1: L1Cache, envelope: int, raw: int) -> int: - return raw if l1.admits(envelope) and raw <= envelope else envelope - - def _growth_allowance_rule(l1: L1Cache, envelope: int, raw: int) -> int: - return raw if l1.admits(envelope) and raw <= envelope * 1.1 else envelope + return raw if raw <= envelope * 1.1 and envelope <= l1.max_memory_bytes / 8 else envelope def _admit_on_raw_rule(l1: L1Cache, envelope: int, raw: int) -> int: @@ -312,7 +364,11 @@ def __len__(self) -> int: return self.size # type: ignore[attr-defined] -def _replay(trace: list[tuple[str, str, int, int]], budget_mb: int, rule: Rule) -> list[int]: +class _SizedMsgpack(_Sized): + """A replayed entry a hit swapped in.""" + + +def _replay(trace: list[tuple[str, str, int, int]], budget_mb: int, rule: Rule, *, promote: bool = False) -> list[int]: """The indices of the trace's reads that hit L1. Ops: ("read", key, ...) reads and, on a miss, puts; ("write", key, ...) puts whatever L1 holds (a @@ -327,10 +383,15 @@ def _replay(trace: list[tuple[str, str, int, int]], budget_mb: int, rule: Rule) l1.invalidate(key) sizes[key] = (envelope, raw) continue - if op == "read" and l1.get(key)[0]: - hits.append(i) - continue - l1.put(key, _Sized(rule(l1, *sizes.setdefault(key, (envelope, raw))))) + envelope, raw = sizes.setdefault(key, (envelope, raw)) + if op == "read": + found, held = l1.get(key) + if found: + hits.append(i) + if promote and not isinstance(held, _SizedMsgpack) and raw <= envelope: + l1.replace(key, held, _SizedMsgpack(raw)) # type: ignore[arg-type] + continue + l1.put(key, _Sized(rule(l1, envelope, raw))) return hits @@ -374,8 +435,8 @@ def _traces(draw: st.DrawFn, budget_mb: int) -> list[tuple[str, str, int, int]]: def test_no_access_trace_gets_fewer_l1_hits_than_with_envelopes(budget_mb: int, data: st.DataObject) -> None: trace = data.draw(_traces(budget_mb)) with_envelopes = _replay(trace, budget_mb, _envelope_rule) - kept = _replay(trace, budget_mb, _kept_rule) - assert set(with_envelopes) <= set(kept) # every read that hit before still hits + promoted = _replay(trace, budget_mb, _envelope_rule, promote=True) + assert set(with_envelopes) <= set(promoted) # every read that hit before still hits def test_entries_growing_past_their_envelope_keep_it_so_a_near_full_lru_keeps_its_hits() -> None: @@ -386,7 +447,7 @@ def test_entries_growing_past_their_envelope_keep_it_so_a_near_full_lru_keeps_it hot = [i for i in range(len(trace)) if i >= len(keys)] assert _replay(trace, 100, _envelope_rule) == hot # 208/208 - assert _replay(trace, 100, _kept_rule) == hot + assert _replay(trace, 100, _envelope_rule, promote=True) == hot assert _replay(trace, 100, _growth_allowance_rule) == [] # 0/208 @@ -399,5 +460,5 @@ def test_an_entry_shrunk_under_the_admission_cap_stays_out_so_the_hot_set_keeps_ trace = warm + rounds assert len(_replay(trace, 1, _envelope_rule)) == 20 - assert len(_replay(trace, 1, _kept_rule)) == 20 + assert len(_replay(trace, 1, _envelope_rule, promote=True)) == 20 assert _replay(trace, 1, _admit_on_raw_rule) == [] # 20 hits fell to 0 diff --git a/tests/unit/test_l2_hit_l1_backfill.py b/tests/unit/test_l2_hit_l1_backfill.py index b2aff77c..33d07eca 100644 --- a/tests/unit/test_l2_hit_l1_backfill.py +++ b/tests/unit/test_l2_hit_l1_backfill.py @@ -1,9 +1,9 @@ """Sync/async L2-hit parity (cachekit-py#164). An L2 hit must (a) backfill L1 so the next read of the same key never -re-pays the L2 round-trip, and (b) record ``size_bytes`` as the length of the -bytes the hit read (the L2 envelope, or the L1 entry), not ``len(str(value))`` — -a repr is neither the payload size nor cheap to build on every hit. The async wrapper always did (a); the +re-pays the L2 round-trip, and (b) record ``size_bytes`` as the serialized +envelope's length, not ``len(str(value))`` — a repr is neither the payload +size nor cheap to build on every hit. The async wrapper always did (a); the sync wrapper did neither. Both are pinned here through the real decorator stack against a plain (non-SWR, non-locking) byte store. """ @@ -16,7 +16,6 @@ import pytest from cachekit import cache -from cachekit.l1_cache import get_l1_cache from cachekit.reliability.async_metrics import AsyncMetricsCollector VALUE = {"answer": 42} @@ -68,14 +67,12 @@ def _force_l2_only(backend: _CountingBackend, l2_snapshot: dict[str, bytes]) -> backend.gets = 0 -def _assert_parity(backend: _CountingBackend, recorded: list[dict[str, Any]], info: Any, namespace: str) -> None: - ((key, envelope),) = backend.store.items() - l1_entry = get_l1_cache(namespace).peek(key) # the backfill: VALUE's verified MessagePack (l1_entry) - assert l1_entry is not None and len(l1_entry) < len(envelope) +def _assert_parity(backend: _CountingBackend, recorded: list[dict[str, Any]], info: Any) -> None: + envelope = next(iter(backend.store.values())) assert len(recorded) == 2 # one L2 hit, one L1 hit — one record each, nothing else assert [c["operation"] for c in recorded] == ["get", "get"] assert [c["serializer"] for c in recorded] == ["rust", "l1_memory"] - assert [c["size_bytes"] for c in recorded] == [len(envelope), len(l1_entry)] # the bytes each hit read + assert [c["size_bytes"] for c in recorded] == [len(envelope)] * 2 assert len(envelope) != len(str(VALUE).encode("utf-8")) # the old str(value) accounting would differ assert backend.gets == 1 # second read never reached L2 assert (info.l1_hits, info.l2_hits) == (1, 1) @@ -101,7 +98,7 @@ def compute() -> dict[str, int]: assert compute() == VALUE # L1 miss -> L2 hit -> backfill L1 assert compute() == VALUE # served from L1 assert calls["n"] == 1 - _assert_parity(backend, recorded, compute.cache_info(), "l2-parity-sync") # type: ignore[attr-defined] + _assert_parity(backend, recorded, compute.cache_info()) # type: ignore[attr-defined] async def test_async_l2_hit_backfills_l1_and_records_envelope_size(self, recorded: list[dict[str, Any]]) -> None: backend = _CountingBackend() @@ -121,7 +118,7 @@ async def compute() -> dict[str, int]: assert await compute() == VALUE assert await compute() == VALUE assert calls["n"] == 1 - _assert_parity(backend, recorded, compute.cache_info(), "l2-parity-async") # type: ignore[attr-defined] + _assert_parity(backend, recorded, compute.cache_info()) # type: ignore[attr-defined] def test_sync_l2_hit_survives_refused_l1_backfill(self, caplog: pytest.LogCaptureFixture) -> None: """A refused backfill is logged and skipped; the served hit is never demoted to a recompute.""" diff --git a/tests/unit/test_single_flight.py b/tests/unit/test_single_flight.py index 966162f1..5dc9046a 100644 --- a/tests/unit/test_single_flight.py +++ b/tests/unit/test_single_flight.py @@ -963,8 +963,9 @@ def recording(self: CacheSerializationHandler, data: Any, *args: Any, **kwargs: def test_sync_backed_recheck_decodes_a_verified_msgpack_l1_entry_without_a_trip( monkeypatch: pytest.MonkeyPatch, decoded: list[type] ) -> None: - """The trip that settled left the value's verified MessagePack in L1 (l1_entry); the re-check hands it to - _decode_shared, which decodes it without retrieve() and without a trip of its own.""" + """Another caller's trip and then its L1 hit settle before this caller's flight lookup: the hit left the + value's verified MessagePack in L1 (deserialize_l1). The re-check hands it to _decode_shared, which decodes + it without retrieve() and without a trip of its own.""" runs = 0 def compute(x: int) -> dict[str, int]: @@ -974,7 +975,17 @@ def compute(x: int) -> dict[str, int]: backend = _Backend() fn = _decorator("backed", backend)(compute) - _settle_another_trip_first(monkeypatch, fn) + real_run = ThreadFlights.run + lookups = 0 + + def run_after_another_trip_and_hit(self: ThreadFlights, *args: Any) -> Any: + nonlocal lookups + lookups += 1 + if lookups == 1: + assert fn(1) == fn(1) == {"x": 1} # another caller's trip, then its L1 hit + return real_run(self, *args) + + monkeypatch.setattr(ThreadFlights, "run", run_after_another_trip_and_hit) assert fn(1) == {"x": 1} assert (runs, backend.requests) == (1, ["get", "set"]) # one function call and one backend read in all From 4ab7daa6e0569b3d44a48d2e5343b1eab82f58df Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 29 Oct 2026 18:15:20 +1100 Subject: [PATCH 3/8] perf(l1): hold the verified MessagePack in a wrapper, not a bytes copy Copying retrieve()'s bytes into a bytes subclass zeroed and copied the whole payload on every swap: about 200K instructions on a 64 KB value's first hit. VerifiedMsgpack now holds retrieve()'s own bytes object and reports its length, so a swap copies nothing. L1Cache.put still takes bytes only; replace() is the one way the wrapper enters L1. --- src/cachekit/cache_handler.py | 4 +-- src/cachekit/decorators/wrapper.py | 5 ++-- src/cachekit/l1_cache.py | 18 ++++++++----- .../serializers/standard_serializer.py | 26 ++++++++++++------- tests/unit/test_l1_verified_msgpack.py | 20 ++++++++------ tests/unit/test_single_flight.py | 3 ++- 6 files changed, 47 insertions(+), 29 deletions(-) diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index ba3dca10..7212eb03 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -1295,7 +1295,7 @@ def write_serialized_to(self, sink: BinaryIO, data: Any) -> None: def deserialize_l1( self, - data: str | bytes | memoryview, + data: str | bytes | memoryview | VerifiedMsgpack, cache_key: str = "", args: tuple[Any, ...] = (), kwargs: dict[str, Any] | None = None, @@ -1338,7 +1338,7 @@ def deserialize_l1( def deserialize_data( self, - data: str | bytes | memoryview, + data: str | bytes | memoryview | VerifiedMsgpack, cache_key: str = "", args: tuple[Any, ...] = (), kwargs: dict[str, Any] | None = None, diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index bb58e920..c8fcd0d4 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -76,6 +76,7 @@ from ..backends.base import BaseBackend, LockableBackend from ..serializers.base import SerializerProtocol + from ..serializers.standard_serializer import VerifiedMsgpack def _resolve_lazy_backend() -> BaseBackend: @@ -1894,7 +1895,7 @@ def _flight_tenant(args: tuple[Any, ...], kwargs: dict[str, Any], cache_key: str return None def _decode_shared( - envelope: bytes | None, cache_key: str, args: tuple[Any, ...], kwargs: dict[str, Any] + envelope: bytes | VerifiedMsgpack | None, cache_key: str, args: tuple[Any, ...], kwargs: dict[str, Any] ) -> tuple[bool, Any]: """A joined backed-mode caller's own copy of the trip's envelope, decoded as an L1 hit would be: every caller gets its own object, and the decrypt checks bind to this caller's tenant. @@ -2370,7 +2371,7 @@ def _start_trip() -> tuple[Any, bytes | None]: decoded, value = _decode_shared(envelope, cache_key, args, kwargs) return value if decoded else _own_trip_sync(cache_key, args, kwargs, twin_key)[0] - def _peek_l1(cache_key: str) -> tuple[bool, tuple[None, bytes | None]]: + def _peek_l1(cache_key: str) -> tuple[bool, tuple[None, bytes | VerifiedMsgpack | None]]: """The sync backed flight's re-check (ThreadFlights.run): the L1 entry a trip that settled since the caller's own L1 lookup left, as the envelope a joined caller decodes. Never an L2 read: it runs under the flight map's lock.""" diff --git a/src/cachekit/l1_cache.py b/src/cachekit/l1_cache.py index f8aefd79..27216be0 100644 --- a/src/cachekit/l1_cache.py +++ b/src/cachekit/l1_cache.py @@ -14,11 +14,14 @@ from collections.abc import Callable, Iterable from dataclasses import dataclass from itertools import islice -from typing import Any, Concatenate, Optional, ParamSpec +from typing import TYPE_CHECKING, Any, Concatenate, Optional, ParamSpec from cachekit import _fork from cachekit.hash_utils import redact_error_for_log, redact_key_for_log +if TYPE_CHECKING: + from cachekit.serializers.standard_serializer import VerifiedMsgpack + # Default L1 entry lifetime when the caller supplies no TTL. Shared with the # decorator's freshness backfill bound: the server's Fresh-For may only ever # SHORTEN the L1 lifetime relative to this default, never extend it. @@ -49,7 +52,7 @@ class CacheEntry: read time in CacheHandler, not storage time. """ - value: bytes + value: "bytes | VerifiedMsgpack" # VerifiedMsgpack once a hit swapped it in (replace) expires_at: float size_bytes: int @@ -150,7 +153,7 @@ def __init__( ttl_buffer_seconds, ) - def _estimate_size(self, value: bytes) -> int: + def _estimate_size(self, value: "bytes | VerifiedMsgpack") -> int: """Estimate memory size of bytes value. Simplified from recursive sys.getsizeof() - faster and more accurate @@ -164,7 +167,7 @@ def _estimate_size(self, value: bytes) -> int: """ return len(value) - def get(self, key: str) -> tuple[bool, Optional[bytes]]: + def get(self, key: str) -> "tuple[bool, Optional[bytes | VerifiedMsgpack]]": """Get value from L1 cache if present and not expired. Args: @@ -172,7 +175,8 @@ def get(self, key: str) -> tuple[bool, Optional[bytes]]: Returns: Tuple of (found, value) where found is True if hit. - Value is bytes (encrypted or plaintext msgpack), not deserialized object. + Value is bytes (encrypted or plaintext msgpack), or the VerifiedMsgpack a hit swapped in + (replace), not deserialized object. Caller (CacheHandler) is responsible for decryption/deserialization. """ s = self._state @@ -197,7 +201,7 @@ def get(self, key: str) -> tuple[bool, Optional[bytes]]: return True, entry.value - def peek(self, key: str) -> Optional[bytes]: + def peek(self, key: str) -> "Optional[bytes | VerifiedMsgpack]": """The bytes ``get`` would return, without counting a hit or miss or moving the entry in LRU order. For a second lookup within one call, such as the single-flight re-check after a miss, which @@ -214,7 +218,7 @@ def peek(self, key: str) -> Optional[bytes]: entry = s.cache.get(key) return None if entry is None or entry.is_expired() else entry.value - def replace(self, key: str, old: bytes, new: bytes) -> None: + def replace(self, key: str, old: "bytes | VerifiedMsgpack", new: "VerifiedMsgpack") -> None: """Hold ``new`` in place of ``old``, the very object a read of ``key`` returned, if the entry still holds it and ``new`` is no longer. The entry keeps its expiry and LRU place. diff --git a/src/cachekit/serializers/standard_serializer.py b/src/cachekit/serializers/standard_serializer.py index 4bd51d60..414ee8ae 100644 --- a/src/cachekit/serializers/standard_serializer.py +++ b/src/cachekit/serializers/standard_serializer.py @@ -21,7 +21,7 @@ from __future__ import annotations from datetime import date, datetime, time -from typing import Any, ClassVar +from typing import Any, ClassVar, final import msgpack @@ -48,15 +48,23 @@ _CROSS_CONFIG_ERROR = "Cache entry was written with integrity checking on but this reader has integrity checking disabled" -class VerifiedMsgpack(bytes): +@final # the read paths test `type(data) is VerifiedMsgpack` +class VerifiedMsgpack: """MessagePack a successful ``ByteStorage.retrieve()`` returned, which an L1 entry holds in place of its envelope (``CacheSerializationHandler.deserialize_l1``). It decodes without another retrieve(). The type is the mark, not the bytes: no backend returns this type, so bytes from L2 never take - the unverified decode, whatever they hold. + the unverified decode, whatever they hold. It holds retrieve()'s own bytes object rather than + copying it into a bytes subclass, and its length is theirs, which L1 accounts. """ - __slots__ = () + __slots__ = ("data",) + + def __init__(self, data: bytes) -> None: + self.data = data + + def __len__(self) -> int: + return len(self.data) # Error message constants for unsupported types (Task 2) @@ -412,8 +420,8 @@ def deserialize_keeping_msgpack(self, data: bytes | memoryview, limit: int) -> t Examples: >>> serializer = StandardSerializer() >>> envelope, _ = serializer.serialize({"test": 123}) - >>> value, msgpack_data = serializer.deserialize_keeping_msgpack(envelope, len(envelope)) - >>> value, serializer.deserialize_verified(msgpack_data) + >>> value, kept = serializer.deserialize_keeping_msgpack(envelope, len(envelope)) + >>> value, serializer.deserialize_verified(kept) ({'test': 123}, {'test': 123}) >>> serializer.deserialize_keeping_msgpack(envelope, 4) # the MessagePack is 7 bytes ({'test': 123}, None) @@ -425,11 +433,11 @@ def deserialize_keeping_msgpack(self, data: bytes | memoryview, limit: int) -> t raise SerializationError(f"Failed to deserialize MessagePack data: {e}") from e return value, VerifiedMsgpack(msgpack_data) if len(msgpack_data) <= limit else None - def deserialize_verified(self, msgpack_data: bytes) -> Any: - """Decode MessagePack that :meth:`deserialize_keeping_msgpack` returned, without another ``retrieve()``. + def deserialize_verified(self, kept: VerifiedMsgpack) -> Any: + """Decode MessagePack that :meth:`deserialize_keeping_msgpack` kept, without another ``retrieve()``. Raises SerializationError as deserialize does.""" try: - return unpackb_bounded(msgpack_data, **self._msgpack_unpack_opts) + return unpackb_bounded(kept.data, **self._msgpack_unpack_opts) except PAYLOAD_DECODE_ERRORS as e: raise SerializationError(f"Failed to deserialize MessagePack data: {e}") from e diff --git a/tests/unit/test_l1_verified_msgpack.py b/tests/unit/test_l1_verified_msgpack.py index 0de998c8..8cdae579 100644 --- a/tests/unit/test_l1_verified_msgpack.py +++ b/tests/unit/test_l1_verified_msgpack.py @@ -25,11 +25,12 @@ from hypothesis import strategies as st from cachekit import cache -from cachekit.cache_handler import CacheSerializationHandler, VerifiedMsgpack +from cachekit.cache_handler import CacheSerializationHandler from cachekit.config.nested import L1CacheConfig from cachekit.l1_cache import L1Cache, get_l1_cache from cachekit.serializers.base import SerializationError from cachekit.serializers.encryption_wrapper import EncryptionWrapper +from cachekit.serializers.standard_serializer import VerifiedMsgpack pytestmark = pytest.mark.unit @@ -99,7 +100,7 @@ def compute(x: int) -> Any: assert compute(1) == value # first hit: decodes the envelope, keeps its MessagePack _, entry = _stored(backend, namespace) assert type(entry) is VerifiedMsgpack - assert entry == msgpack.packb(value, use_bin_type=True) + assert entry.data == msgpack.packb(value, use_bin_type=True) assert len(entry) <= len(envelope) assert compute(1) == value # decodes the MessagePack @@ -242,9 +243,10 @@ def test_replace_shrinks_the_entry_in_place() -> None: l1.put("b", b"b" * 10, redis_ttl=60) expires_at = l1._state.cache["a"].expires_at - l1.replace("a", old, b"m" * 40) + new = VerifiedMsgpack(b"m" * 40) + l1.replace("a", old, new) - assert l1.peek("a") == b"m" * 40 + assert l1.peek("a") is new assert l1._state.memory_bytes == 50 assert l1._state.cache["a"].expires_at == expires_at assert list(l1._state.cache) == ["a", "b"] # LRU place kept @@ -257,13 +259,13 @@ def test_replace_leaves_an_entry_it_did_not_read_or_would_grow(change: str) -> N l1 = L1Cache(max_memory_mb=1, namespace=_namespace()) old = b"e" * 100 l1.put("a", old, redis_ttl=60) - new = b"m" * 40 + new = VerifiedMsgpack(b"m" * 40) if change == "removed": l1.invalidate("a") elif change == "stored-again": l1.put("a", bytes(bytearray(old)), redis_ttl=60) # equal bytes, another object: a newer write else: - new = b"m" * 101 + new = VerifiedMsgpack(b"m" * 101) held = l1.peek("a") l1.replace("a", old, new) @@ -324,9 +326,11 @@ def compute(x: int) -> dict[str, int]: compute(1) ((key, _),) = backend.store.items() - get_l1_cache(namespace).put(key, VerifiedMsgpack(msgpack.packb({"x": "planted"}))) + l1 = get_l1_cache(namespace) + l1.replace(key, l1.peek(key), VerifiedMsgpack(msgpack.packb({"x": "planted"}))) # type: ignore[arg-type] + assert type(l1.peek(key)) is VerifiedMsgpack assert compute(1) == {"x": 1} # read from L2 instead - assert type(get_l1_cache(namespace).peek(key)) is bytes + assert type(l1.peek(key)) is bytes # ── L1 hits: never fewer than with envelopes ────────────────────────────────────────────────── diff --git a/tests/unit/test_single_flight.py b/tests/unit/test_single_flight.py index 5dc9046a..e7f1b72c 100644 --- a/tests/unit/test_single_flight.py +++ b/tests/unit/test_single_flight.py @@ -37,13 +37,14 @@ from cachekit import cache from cachekit import object_cache as object_cache_module from cachekit.backends.errors import BackendError -from cachekit.cache_handler import CacheSerializationHandler, VerifiedMsgpack +from cachekit.cache_handler import CacheSerializationHandler from cachekit.config.nested import L1CacheConfig from cachekit.decorators import single_flight from cachekit.decorators.single_flight import AsyncFlights, ThreadFlights from cachekit.decorators.tenant_context import ContextVarExtractor from cachekit.l1_cache import L1Cache, get_l1_cache_manager from cachekit.reliability.circuit_breaker import CircuitBreaker, CircuitState +from cachekit.serializers.standard_serializer import VerifiedMsgpack pytestmark = pytest.mark.unit From 54ab1c5ae46f0f675f4d3cf74c803ff1d12641e4 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 29 Oct 2026 18:56:58 +1100 Subject: [PATCH 4/8] perf(l1): leave every other handler's L1 hit on deserialize_data Routing every L1 hit through deserialize_l1 cost an encrypted cache's hit a call frame and a tuple it never uses: +0.57% instructions on the secure L1 hit. The wrapper now picks the decode once per decoration; only a handler whose hits can swap in verified MessagePack takes deserialize_l1. deserialize_data no longer checks for the wrapper type, so L2 reads are as before. --- src/cachekit/cache_handler.py | 22 +++++++------- src/cachekit/decorators/wrapper.py | 41 +++++++++++++++++++------- tests/unit/test_l1_verified_msgpack.py | 7 +++-- tests/unit/test_single_flight.py | 9 +++--- 4 files changed, 49 insertions(+), 30 deletions(-) diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index 7212eb03..f7bb19a1 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -900,7 +900,7 @@ def __init__( # Whether L1 may hold this handler's entries as VerifiedMsgpack (deserialize_l1): plaintext CK-frame # entries of the built-in StandardSerializer with integrity on. Encrypted handlers keep ciphertext in # L1 (protocol PRE-10, PRE-11). - self._l1_msgpack = ( + self.l1_msgpack = ( not encryption and not interop_mode and not self._custom_serializer @@ -1322,9 +1322,14 @@ def deserialize_l1( """ # Inline, not helpers: this is every L1 hit's decode, and an envelope it keeps must cost what # deserialize_data costs. - if self._l1_msgpack: - if type(data) is VerifiedMsgpack: - return self._base_serializer.deserialize_verified(data), None # type: ignore[union-attr] + if type(data) is VerifiedMsgpack: + # Only L1 holds this type, so only a handler that swaps it in decodes it. Another handler sharing + # the namespace and key refuses it as it would the plaintext envelope. + if not self.l1_msgpack: + error = SuspiciousCacheEntryError if self.encryption else SerializationError + raise error("L1 entry holds plaintext MessagePack another handler verified; this handler does not read it") + return self._base_serializer.deserialize_verified(data), None # type: ignore[union-attr] + if self.l1_msgpack: try: payload, metadata, serializer_name = SerializationWrapper.unwrap_metadata(data) except (AttributeError, KeyError, TypeError, ValueError): @@ -1338,7 +1343,7 @@ def deserialize_l1( def deserialize_data( self, - data: str | bytes | memoryview | VerifiedMsgpack, + data: str | bytes | memoryview, cache_key: str = "", args: tuple[Any, ...] = (), kwargs: dict[str, Any] | None = None, @@ -1414,13 +1419,6 @@ def deserialize_data( >>> result["flag"] is False True """ - if type(data) is VerifiedMsgpack: - # Only L1 holds this type (deserialize_l1), so only a handler that writes it decodes it. Another - # handler sharing the namespace and key refuses it as it would the plaintext envelope. - if not self._l1_msgpack: - error = SuspiciousCacheEntryError if self.encryption else SerializationError - raise error("L1 entry holds plaintext MessagePack another handler verified; this handler does not read it") - return self._base_serializer.deserialize_verified(data) # type: ignore[union-attr] if self.interop_mode: return self._deserialize_interop(data, cache_key) diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index c8fcd0d4..ca80f744 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -1145,6 +1145,9 @@ def _on_l2_deserialize_error(error: Exception, key: str) -> None: # applies only when this namespace's cache is first created — namespaces share one # L1Cache, so give functions with distinct budgets distinct namespaces (issue #163). _l1_cache = get_l1_cache(namespace or "default", max_size_mb=l1_max_size_mb) if l1_enabled else None + # Whether an L1 hit may swap its entry for the verified MessagePack (deserialize_l1). Every other handler's + # hit decodes exactly as before, at no extra cost. + _l1_swaps_msgpack = serialization_handler.l1_msgpack # L1-only mode: use ObjectCache for raw Python object storage (no serialization). # This preserves types (tuples, sets, frozensets) that MessagePack would degrade. @@ -1905,7 +1908,7 @@ def _decode_shared( """ if envelope is not None: try: - value = operation_handler.serialization_handler.deserialize_data(envelope, cache_key, args, kwargs) + value = operation_handler.serialization_handler.deserialize_l1(envelope, cache_key, args, kwargs)[0] except KeyringConfigurationError: raise # a LOCAL keyring config fault: see the L1 guard in _sync_call except Exception as e: @@ -2213,11 +2216,19 @@ def _sync_call( # noqa: PLR0912 if l1_found and l1_bytes: # L1 cache hit - deserialize bytes try: - l1_value, l1_kept = operation_handler.serialization_handler.deserialize_l1( - l1_bytes, cache_key, args, kwargs - ) - if l1_kept is not None: # its verified MessagePack: later hits skip retrieve() - _l1_cache.replace(cache_key, l1_bytes, l1_kept) + if _l1_swaps_msgpack: + l1_value, l1_kept = operation_handler.serialization_handler.deserialize_l1( + l1_bytes, cache_key, args, kwargs + ) + if l1_kept is not None: # its verified MessagePack: later hits skip retrieve() + _l1_cache.replace(cache_key, l1_bytes, l1_kept) + else: # decoded as before; an entry another handler swapped in fails as a corrupt frame + l1_value = operation_handler.serialization_handler.deserialize_data( + l1_bytes, # pyright: ignore[reportArgumentType] + cache_key, + args, + kwargs, + ) l1_duration_ms = (time.perf_counter() - l1_start) * 1000 # L1 get plus deserialize # Record L1 cache hit metrics @@ -2711,11 +2722,19 @@ async def _async_call( if l1_found and l1_bytes: # L1 cache hit - deserialize bytes try: - l1_value, l1_kept = operation_handler.serialization_handler.deserialize_l1( - l1_bytes, cache_key, args, kwargs - ) - if l1_kept is not None: # as in sync_wrapper - _l1_cache.replace(cache_key, l1_bytes, l1_kept) + if _l1_swaps_msgpack: # as in sync_wrapper + l1_value, l1_kept = operation_handler.serialization_handler.deserialize_l1( + l1_bytes, cache_key, args, kwargs + ) + if l1_kept is not None: + _l1_cache.replace(cache_key, l1_bytes, l1_kept) + else: + l1_value = operation_handler.serialization_handler.deserialize_data( + l1_bytes, # pyright: ignore[reportArgumentType] + cache_key, + args, + kwargs, + ) l1_duration_ms = (time.perf_counter() - l1_start) * 1000 # L1 get plus deserialize # Record L1 cache hit metrics (same labels as the sync L1 hit) diff --git a/tests/unit/test_l1_verified_msgpack.py b/tests/unit/test_l1_verified_msgpack.py index 8cdae579..12a1e633 100644 --- a/tests/unit/test_l1_verified_msgpack.py +++ b/tests/unit/test_l1_verified_msgpack.py @@ -28,7 +28,7 @@ from cachekit.cache_handler import CacheSerializationHandler from cachekit.config.nested import L1CacheConfig from cachekit.l1_cache import L1Cache, get_l1_cache -from cachekit.serializers.base import SerializationError +from cachekit.serializers.base import SerializationError, SuspiciousCacheEntryError from cachekit.serializers.encryption_wrapper import EncryptionWrapper from cachekit.serializers.standard_serializer import VerifiedMsgpack @@ -229,8 +229,9 @@ def test_deserialize_l1_keeps_msgpack_only_when_no_longer_and_it_decodes_the_sam def test_other_handlers_keep_their_envelope_and_refuse_verified_msgpack(handler: CacheSerializationHandler) -> None: envelope = handler.serialize_data({"x": 1}, cache_key="k") assert handler.deserialize_l1(envelope, cache_key="k") == ({"x": 1}, None) - with pytest.raises(SerializationError): # SuspiciousCacheEntryError under encryption: a plaintext entry - handler.deserialize_data(VerifiedMsgpack(msgpack.packb({"x": 2})), cache_key="k") + refusal = SuspiciousCacheEntryError if handler.encryption else SerializationError # as for a plaintext envelope + with pytest.raises(refusal): + handler.deserialize_l1(VerifiedMsgpack(msgpack.packb({"x": 2})), cache_key="k") # ── L1Cache.replace ─────────────────────────────────────────────────────────────────────────── diff --git a/tests/unit/test_single_flight.py b/tests/unit/test_single_flight.py index e7f1b72c..614ae359 100644 --- a/tests/unit/test_single_flight.py +++ b/tests/unit/test_single_flight.py @@ -949,15 +949,16 @@ def compute(x: int) -> dict[str, int]: @pytest.fixture def decoded(monkeypatch: pytest.MonkeyPatch) -> list[type]: - """The type of every entry a backed-mode handler decodes: VerifiedMsgpack from L1, bytes for an envelope.""" + """The type of every entry an L1 hit or a joined caller decodes (deserialize_l1): VerifiedMsgpack, or bytes for + an envelope.""" seen: list[type] = [] - real = CacheSerializationHandler.deserialize_data + real = CacheSerializationHandler.deserialize_l1 def recording(self: CacheSerializationHandler, data: Any, *args: Any, **kwargs: Any) -> Any: seen.append(type(data)) return real(self, data, *args, **kwargs) - monkeypatch.setattr(CacheSerializationHandler, "deserialize_data", recording) + monkeypatch.setattr(CacheSerializationHandler, "deserialize_l1", recording) return seen @@ -990,7 +991,7 @@ def run_after_another_trip_and_hit(self: ThreadFlights, *args: Any) -> Any: assert fn(1) == {"x": 1} assert (runs, backend.requests) == (1, ["get", "set"]) # one function call and one backend read in all - assert decoded == [VerifiedMsgpack] + assert decoded == [bytes, VerifiedMsgpack] # the other caller's hit, then this caller's re-check def test_sync_backed_joiner_decodes_the_trips_envelope(waiting: list[int], decoded: list[type]) -> None: From e205697fe74dbacd5ac2dcad6c146b4fe60fe763 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 29 Oct 2026 19:06:49 +1100 Subject: [PATCH 5/8] style: drop a stray blank line --- .secrets.baseline | 4 ++-- src/cachekit/cache_handler.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index c9287b39..c9fe43b8 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -215,7 +215,7 @@ "filename": "src/cachekit/cache_handler.py", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 649 + "line_number": 648 } ], "src/cachekit/serializers/interop_serializer.py": [ @@ -2020,5 +2020,5 @@ } ] }, - "generated_at": "2026-10-29T06:39:11Z" + "generated_at": "2026-10-29T08:06:46Z" } diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index f7bb19a1..767317f9 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -76,7 +76,6 @@ # it chooses its decoder by inspecting the bytes. CROSS_SDK_SERIALIZER_NAMES = ("default", "std", "standard", "orjson", "arrow") - # Uncompressed Arrow IPC alignment: the body follows the File header, the CK frame # prefix and ArrowSerializer's 8-byte xxHash3-64 checksum; 64 is Arrow's recommended alignment. _ARROW_IPC_ALIGN = 64 From 1b92cb7d84d76b54571c2bbf395011a6e84448cd Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 29 Oct 2026 19:19:18 +1100 Subject: [PATCH 6/8] refactor(l1): one refusal path, clearer names, a parity test A handler that cannot keep MessagePack in L1 now decodes every L1 entry, the flight re-check's included, through deserialize_data, which refuses another handler's swapped entry as a corrupt frame. The capability flag is keeps_msgpack_in_l1, and the swap is L1Cache.shrink_if_held, which reports whether it swapped. A parity test pins that deserialize_l1 decodes or refuses every kind of entry as deserialize_data does. The hit-count replay now leaves the no-grow check to shrink_if_held itself. --- docs/data-flow-architecture.md | 2 +- src/cachekit/cache_handler.py | 33 ++++----- src/cachekit/decorators/wrapper.py | 39 ++++++---- src/cachekit/l1_cache.py | 19 ++--- .../serializers/standard_serializer.py | 7 +- tests/unit/test_l1_verified_msgpack.py | 71 +++++++++++++++---- tests/unit/test_single_flight.py | 4 +- 7 files changed, 111 insertions(+), 64 deletions(-) diff --git a/docs/data-flow-architecture.md b/docs/data-flow-architecture.md index 8f0273e8..1bacb8cd 100644 --- a/docs/data-flow-architecture.md +++ b/docs/data-flow-architecture.md @@ -31,7 +31,7 @@ cachekit uses a hybrid Python-Rust architecture to provide production caching wi > **Architecture Principles:** > - **L1 + L2 Caching**: In-memory bytes cache (L1) + pluggable backend storage (L2) for optimal performance > - **Backend Abstraction**: Protocol-based L2 backend layer (Redis, CachekitIO, File, Memcached, custom) -> - **Bytes-Only Storage**: with a backend configured, L1 stores serialized bytes (encrypted or plaintext), not Python objects; L1-only mode (`backend=None`) stores the object itself. From its first L1 hit on, a plaintext entry of the default serializer with integrity checking on is held as its verified MessagePack when that is no longer than its envelope, so later hits skip the LZ4 decompress and checksum; encrypted caches keep the ciphertext +> - **Bytes-Only Storage**: with a backend configured, L1 stores serialized bytes (encrypted or plaintext), not Python objects; L1-only mode (`backend=None`) stores the object itself. After its first hit, a plaintext entry may be held as its verified MessagePack instead (see [Storage Architecture](#storage-architecture)); encrypted caches keep the ciphertext > - **Security First**: Decompression bomb protection, checksum validation, size limits, encrypted-at-rest > - **Performance Optimized**: Thread affinity, connection pooling, pre-cached serializers > - **Flexible Configuration**: L1+L2, L1-only, L2-only, or observability-only modes diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index 767317f9..0090bd64 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -896,12 +896,11 @@ def __init__( else: self._base_serializer = _get_cached_serializer_instance(serializer_name, enable_integrity_checking) self._custom_serializer = not isinstance(serializer_name, str) - # Whether L1 may hold this handler's entries as VerifiedMsgpack (deserialize_l1): plaintext CK-frame - # entries of the built-in StandardSerializer with integrity on. Encrypted handlers keep ciphertext in - # L1 (protocol PRE-10, PRE-11). - self.l1_msgpack = ( + # Whether an L1 hit may swap this handler's entry for its VerifiedMsgpack (deserialize_l1): plaintext + # CK-frame entries of the built-in StandardSerializer with integrity on (interop mode's InteropSerializer + # is not one). Encrypted handlers keep ciphertext in L1 (protocol PRE-10, PRE-11). + self.keeps_msgpack_in_l1 = ( not encryption - and not interop_mode and not self._custom_serializer and type(self._base_serializer) is StandardSerializer and self._base_serializer.enable_integrity_checking @@ -1301,11 +1300,11 @@ def deserialize_l1( ) -> tuple[Any, VerifiedMsgpack | None]: """:meth:`deserialize_data` for an entry read from L1, plus what L1 should hold in its place, if anything. - That is the entry's verified MessagePack, when this handler can hold it (the built-in - StandardSerializer with integrity on, no encryption), the entry is its own plaintext envelope, - and the MessagePack is no longer. It comes from the retrieve() this decode ran anyway, so the - caller's swap (``L1Cache.replace``) costs no second retrieve(), and later hits skip it. Every other - entry gets None, and is decoded exactly as deserialize_data decodes it. Raises as deserialize_data. + That is the entry's verified MessagePack, when this handler keeps it (``keeps_msgpack_in_l1``), the + entry is its own plaintext envelope, and the MessagePack is no longer. It comes from the retrieve() + this decode ran anyway, so the caller's swap (``L1Cache.shrink_if_held``) costs no second retrieve(), + and later hits skip it. Every other entry gets None and is decoded by deserialize_data, which refuses + a VerifiedMsgpack as a corrupt frame. Raises as deserialize_data. Examples: >>> handler = CacheSerializationHandler() @@ -1321,14 +1320,9 @@ def deserialize_l1( """ # Inline, not helpers: this is every L1 hit's decode, and an envelope it keeps must cost what # deserialize_data costs. - if type(data) is VerifiedMsgpack: - # Only L1 holds this type, so only a handler that swaps it in decodes it. Another handler sharing - # the namespace and key refuses it as it would the plaintext envelope. - if not self.l1_msgpack: - error = SuspiciousCacheEntryError if self.encryption else SerializationError - raise error("L1 entry holds plaintext MessagePack another handler verified; this handler does not read it") - return self._base_serializer.deserialize_verified(data), None # type: ignore[union-attr] - if self.l1_msgpack: + if self.keeps_msgpack_in_l1: + if type(data) is VerifiedMsgpack: # only L1 holds this type: only such a handler swapped it in + return self._base_serializer.deserialize_verified(data), None # type: ignore[union-attr] try: payload, metadata, serializer_name = SerializationWrapper.unwrap_metadata(data) except (AttributeError, KeyError, TypeError, ValueError): @@ -1338,7 +1332,8 @@ def deserialize_l1( # entry whose ciphertext must stay in L1, is decoded below exactly as before. if serializer_name == self._serializer_string_name and not metadata.encrypted and metadata.compressed: return self._base_serializer.deserialize_keeping_msgpack(payload, len(data)) # type: ignore[union-attr] - return self.deserialize_data(data, cache_key, args, kwargs), None + # A VerifiedMsgpack here (another handler's, under the same key) fails the frame check there as corrupt. + return self.deserialize_data(data, cache_key, args, kwargs), None # pyright: ignore[reportArgumentType] def deserialize_data( self, diff --git a/src/cachekit/decorators/wrapper.py b/src/cachekit/decorators/wrapper.py index ca80f744..2fdd344b 100644 --- a/src/cachekit/decorators/wrapper.py +++ b/src/cachekit/decorators/wrapper.py @@ -1147,7 +1147,7 @@ def _on_l2_deserialize_error(error: Exception, key: str) -> None: _l1_cache = get_l1_cache(namespace or "default", max_size_mb=l1_max_size_mb) if l1_enabled else None # Whether an L1 hit may swap its entry for the verified MessagePack (deserialize_l1). Every other handler's # hit decodes exactly as before, at no extra cost. - _l1_swaps_msgpack = serialization_handler.l1_msgpack + _l1_keeps_msgpack = serialization_handler.keeps_msgpack_in_l1 # L1-only mode: use ObjectCache for raw Python object storage (no serialization). # This preserves types (tuples, sets, frozensets) that MessagePack would degrade. @@ -1898,17 +1898,26 @@ def _flight_tenant(args: tuple[Any, ...], kwargs: dict[str, Any], cache_key: str return None def _decode_shared( - envelope: bytes | VerifiedMsgpack | None, cache_key: str, args: tuple[Any, ...], kwargs: dict[str, Any] + entry: bytes | VerifiedMsgpack | None, cache_key: str, args: tuple[Any, ...], kwargs: dict[str, Any] ) -> tuple[bool, Any]: - """A joined backed-mode caller's own copy of the trip's envelope, decoded as an L1 hit would be: - every caller gets its own object, and the decrypt checks bind to this caller's tenant. + """A joined backed-mode caller's own copy of the trip's envelope, or of the L1 entry the re-check + found (which may be a VerifiedMsgpack), decoded as an L1 hit would be: every caller gets its own + object, and the decrypt checks bind to this caller's tenant. It never swaps the L1 entry. - (False, None) when there is no envelope or it does not decode for this caller, who then takes + (False, None) when there is no entry or it does not decode for this caller, who then takes its own trip, unshared: its probe slot went back on joining, so it is admitted afresh. """ - if envelope is not None: + if entry is not None: try: - value = operation_handler.serialization_handler.deserialize_l1(envelope, cache_key, args, kwargs)[0] + if _l1_keeps_msgpack: + value = operation_handler.serialization_handler.deserialize_l1(entry, cache_key, args, kwargs)[0] + else: + value = operation_handler.serialization_handler.deserialize_data( + entry, # pyright: ignore[reportArgumentType] + cache_key, + args, + kwargs, + ) except KeyringConfigurationError: raise # a LOCAL keyring config fault: see the L1 guard in _sync_call except Exception as e: @@ -2216,12 +2225,12 @@ def _sync_call( # noqa: PLR0912 if l1_found and l1_bytes: # L1 cache hit - deserialize bytes try: - if _l1_swaps_msgpack: + if _l1_keeps_msgpack: l1_value, l1_kept = operation_handler.serialization_handler.deserialize_l1( l1_bytes, cache_key, args, kwargs ) if l1_kept is not None: # its verified MessagePack: later hits skip retrieve() - _l1_cache.replace(cache_key, l1_bytes, l1_kept) + _l1_cache.shrink_if_held(cache_key, l1_bytes, l1_kept) else: # decoded as before; an entry another handler swapped in fails as a corrupt frame l1_value = operation_handler.serialization_handler.deserialize_data( l1_bytes, # pyright: ignore[reportArgumentType] @@ -2384,11 +2393,11 @@ def _start_trip() -> tuple[Any, bytes | None]: def _peek_l1(cache_key: str) -> tuple[bool, tuple[None, bytes | VerifiedMsgpack | None]]: """The sync backed flight's re-check (ThreadFlights.run): the L1 entry a trip that settled since - the caller's own L1 lookup left, as the envelope a joined caller decodes. Never an L2 read: it - runs under the flight map's lock.""" + the caller's own L1 lookup left (its envelope, or the VerifiedMsgpack a hit swapped in), for a joined + caller to decode. Never an L2 read: it runs under the flight map's lock.""" assert _l1_cache is not None # noqa: S101 - passed only with L1 on - envelope = _l1_cache.peek(cache_key) - return bool(envelope), (None, envelope) + entry = _l1_cache.peek(cache_key) + return bool(entry), (None, entry) def _found_nothing() -> tuple[bool, tuple[None, None]]: """The re-check with L1 off: no entry to peek.""" @@ -2722,12 +2731,12 @@ async def _async_call( if l1_found and l1_bytes: # L1 cache hit - deserialize bytes try: - if _l1_swaps_msgpack: # as in sync_wrapper + if _l1_keeps_msgpack: # as in sync_wrapper l1_value, l1_kept = operation_handler.serialization_handler.deserialize_l1( l1_bytes, cache_key, args, kwargs ) if l1_kept is not None: - _l1_cache.replace(cache_key, l1_bytes, l1_kept) + _l1_cache.shrink_if_held(cache_key, l1_bytes, l1_kept) else: l1_value = operation_handler.serialization_handler.deserialize_data( l1_bytes, # pyright: ignore[reportArgumentType] diff --git a/src/cachekit/l1_cache.py b/src/cachekit/l1_cache.py index 27216be0..311823a4 100644 --- a/src/cachekit/l1_cache.py +++ b/src/cachekit/l1_cache.py @@ -52,7 +52,7 @@ class CacheEntry: read time in CacheHandler, not storage time. """ - value: "bytes | VerifiedMsgpack" # VerifiedMsgpack once a hit swapped it in (replace) + value: "bytes | VerifiedMsgpack" # VerifiedMsgpack once a hit swapped it in (shrink_if_held) expires_at: float size_bytes: int @@ -176,7 +176,7 @@ def get(self, key: str) -> "tuple[bool, Optional[bytes | VerifiedMsgpack]]": Returns: Tuple of (found, value) where found is True if hit. Value is bytes (encrypted or plaintext msgpack), or the VerifiedMsgpack a hit swapped in - (replace), not deserialized object. + (shrink_if_held), not deserialized object. Caller (CacheHandler) is responsible for decryption/deserialization. """ s = self._state @@ -218,22 +218,23 @@ def peek(self, key: str) -> "Optional[bytes | VerifiedMsgpack]": entry = s.cache.get(key) return None if entry is None or entry.is_expired() else entry.value - def replace(self, key: str, old: "bytes | VerifiedMsgpack", new: "VerifiedMsgpack") -> None: - """Hold ``new`` in place of ``old``, the very object a read of ``key`` returned, if the entry still holds - it and ``new`` is no longer. The entry keeps its expiry and LRU place. + def shrink_if_held(self, key: str, held: "bytes | VerifiedMsgpack", smaller: "VerifiedMsgpack") -> bool: + """Hold ``smaller`` in place of ``held``, the very object a read of ``key`` returned, if the entry still + holds it and ``smaller`` is no longer. The entry keeps its expiry and LRU place. Returns whether it did. For a hit that re-encodes the entry it read. The identity check leaves alone an entry that was removed, or stored again, since that read, so an invalidation is never undone. Never growing an entry means L1 never evicts for it. """ - size = self._estimate_size(new) + size = self._estimate_size(smaller) s = self._state with s.lock: entry = s.cache.get(key) - if entry is None or entry.value is not old or size > entry.size_bytes: - return + if entry is None or entry.value is not held or size > entry.size_bytes: + return False s.memory_bytes -= entry.size_bytes - size - entry.value, entry.size_bytes = new, size + entry.value, entry.size_bytes = smaller, size + return True def put( self, diff --git a/src/cachekit/serializers/standard_serializer.py b/src/cachekit/serializers/standard_serializer.py index 414ee8ae..52a564c4 100644 --- a/src/cachekit/serializers/standard_serializer.py +++ b/src/cachekit/serializers/standard_serializer.py @@ -413,9 +413,10 @@ def deserialize(self, data: bytes | memoryview, metadata: SerializationMetadata raise SerializationError(f"Failed to deserialize MessagePack data: {e}") from e def deserialize_keeping_msgpack(self, data: bytes | memoryview, limit: int) -> tuple[Any, VerifiedMsgpack | None]: - """:meth:`deserialize` of a ByteStorage envelope, with integrity checking on, plus the MessagePack - ``retrieve()`` verified when it is at most ``limit`` bytes, for the caller to keep and decode later - without retrieve() (:meth:`deserialize_verified`). Raises as deserialize does. + """What :meth:`deserialize` returns for a ByteStorage envelope when integrity checking is on, plus the + MessagePack ``retrieve()`` verified when it is at most ``limit`` bytes, for the caller to keep and decode + later without retrieve() (:meth:`deserialize_verified`). It always unwraps the envelope, so call it + only for an integrity-on serializer. Raises SerializationError as deserialize does then. Examples: >>> serializer = StandardSerializer() diff --git a/tests/unit/test_l1_verified_msgpack.py b/tests/unit/test_l1_verified_msgpack.py index 12a1e633..7f8e8bff 100644 --- a/tests/unit/test_l1_verified_msgpack.py +++ b/tests/unit/test_l1_verified_msgpack.py @@ -2,7 +2,7 @@ The first L1 hit on an entry decodes the envelope as before, and its retrieve() yields the MessagePack; L1 then holds that in place of the envelope (``CacheSerializationHandler.deserialize_l1``, -``L1Cache.replace``), and later hits decode it without retrieve(). Each rule that keeps this from costing +``L1Cache.shrink_if_held``), and later hits decode it without retrieve(). Each rule that keeps this from costing L1 hits or security is pinned here: - L1 admits and stores envelopes exactly as before; a hit only ever shrinks an entry in place. So L1 never @@ -28,7 +28,7 @@ from cachekit.cache_handler import CacheSerializationHandler from cachekit.config.nested import L1CacheConfig from cachekit.l1_cache import L1Cache, get_l1_cache -from cachekit.serializers.base import SerializationError, SuspiciousCacheEntryError +from cachekit.serializers.base import SerializationError from cachekit.serializers.encryption_wrapper import EncryptionWrapper from cachekit.serializers.standard_serializer import VerifiedMsgpack @@ -227,14 +227,54 @@ def test_deserialize_l1_keeps_msgpack_only_when_no_longer_and_it_decodes_the_sam ids=["auto", "integrity-off", "encrypted"], ) def test_other_handlers_keep_their_envelope_and_refuse_verified_msgpack(handler: CacheSerializationHandler) -> None: + """The L1 hit sites send these handlers' entries to deserialize_data, which refuses a swapped entry another + handler left under the same key as a corrupt frame.""" envelope = handler.serialize_data({"x": 1}, cache_key="k") + assert not handler.keeps_msgpack_in_l1 assert handler.deserialize_l1(envelope, cache_key="k") == ({"x": 1}, None) - refusal = SuspiciousCacheEntryError if handler.encryption else SerializationError # as for a plaintext envelope - with pytest.raises(refusal): - handler.deserialize_l1(VerifiedMsgpack(msgpack.packb({"x": 2})), cache_key="k") + planted = VerifiedMsgpack(msgpack.packb({"x": 2})) + for decode in (handler.deserialize_l1, handler.deserialize_data): + with pytest.raises(SerializationError, match="Corrupt cache envelope"): + decode(planted, cache_key="k") # type: ignore[arg-type] + + +def _frames() -> dict[str, bytes]: + """Entries a plaintext default-serializer handler must decode exactly as deserialize_data does.""" + k = "k" + own = CacheSerializationHandler(encryption=False).serialize_data({"x": 1}, cache_key=k) + return { + "own-envelope": own, + "other-serializer": CacheSerializationHandler(serializer_name="auto").serialize_data({"x": 1}, cache_key=k), + "encrypted-drift": CacheSerializationHandler( + encryption=True, master_key=MASTER_KEY, single_tenant_mode=True + ).serialize_data({"x": 1}, cache_key=k), + "integrity-off": CacheSerializationHandler(enable_integrity_checking=False).serialize_data({"x": 1}, cache_key=k), + "corrupt-header": b"\x00" + own[1:], + "corrupt-payload": _corrupt(own), + "not-a-frame": msgpack.packb({"x": 1}), + } + + +@pytest.mark.parametrize("name", list(_frames())) +def test_deserialize_l1_decodes_or_refuses_every_entry_as_deserialize_data_does( + name: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """deserialize_l1's fast path repeats deserialize_data's frame checks, so the two must agree on every entry.""" + data = _frames()[name] + monkeypatch.setenv("CACHEKIT_MASTER_KEY", MASTER_KEY) # lets the encrypted-drift entry decrypt, as it would + handler = CacheSerializationHandler(encryption=False) + + def outcome(decode: Callable[[], Any]) -> Any: + try: + return decode() + except Exception as e: # noqa: BLE001 - the outcome under comparison + return type(e) + + expected = outcome(lambda: handler.deserialize_data(data, cache_key="k")) + assert outcome(lambda: handler.deserialize_l1(data, cache_key="k")[0]) == expected -# ── L1Cache.replace ─────────────────────────────────────────────────────────────────────────── +# ── L1Cache.shrink_if_held ─────────────────────────────────────────────────────────────────────────── def test_replace_shrinks_the_entry_in_place() -> None: @@ -245,7 +285,7 @@ def test_replace_shrinks_the_entry_in_place() -> None: expires_at = l1._state.cache["a"].expires_at new = VerifiedMsgpack(b"m" * 40) - l1.replace("a", old, new) + assert l1.shrink_if_held("a", old, new) assert l1.peek("a") is new assert l1._state.memory_bytes == 50 @@ -269,7 +309,7 @@ def test_replace_leaves_an_entry_it_did_not_read_or_would_grow(change: str) -> N new = VerifiedMsgpack(b"m" * 101) held = l1.peek("a") - l1.replace("a", old, new) + assert not l1.shrink_if_held("a", old, new) assert l1.peek("a") is held assert l1._state.memory_bytes == (0 if held is None else 100) @@ -328,7 +368,7 @@ def compute(x: int) -> dict[str, int]: compute(1) ((key, _),) = backend.store.items() l1 = get_l1_cache(namespace) - l1.replace(key, l1.peek(key), VerifiedMsgpack(msgpack.packb({"x": "planted"}))) # type: ignore[arg-type] + assert l1.shrink_if_held(key, l1.peek(key), VerifiedMsgpack(msgpack.packb({"x": "planted"}))) # type: ignore[arg-type] assert type(l1.peek(key)) is VerifiedMsgpack assert compute(1) == {"x": 1} # read from L2 instead assert type(l1.peek(key)) is bytes @@ -338,7 +378,8 @@ def compute(x: int) -> dict[str, int]: # # A replay over the real L1Cache. A read that misses puts the entry a put rule picks from the entry's # envelope and MessagePack sizes; with promote, a read that hits an envelope swaps it for its MessagePack -# when that is no longer, as the L1 hit sites do (deserialize_l1, then L1Cache.replace). The shipped design +# when that is no longer, as the L1 hit sites do (deserialize_l1, then L1Cache.shrink_if_held, which alone +# refuses a longer value here). The shipped design # is the envelope rule with promote. The two rejected put rules are the counterexamples an adversarial # review found against storing MessagePack at write time; they show the pinned traces bite. @@ -376,8 +417,8 @@ class _SizedMsgpack(_Sized): def _replay(trace: list[tuple[str, str, int, int]], budget_mb: int, rule: Rule, *, promote: bool = False) -> list[int]: """The indices of the trace's reads that hit L1. - Ops: ("read", key, ...) reads and, on a miss, puts; ("write", key, ...) puts whatever L1 holds (a - revalidation, another writer); ("set", key, envelope, raw) is a new value at L2: L1 drops the key, and + Ops: ("read", key, ...) reads and, on a miss, puts; ("write", key, ...) puts whether or not L1 holds the + key (a revalidation, another writer); ("set", key, envelope, raw) is a new value at L2: L1 drops the key, and later puts use the new sizes. A key's first op fixes its sizes until a "set". """ l1 = L1Cache(max_memory_mb=budget_mb, namespace=_namespace()) @@ -393,8 +434,8 @@ def _replay(trace: list[tuple[str, str, int, int]], budget_mb: int, rule: Rule, found, held = l1.get(key) if found: hits.append(i) - if promote and not isinstance(held, _SizedMsgpack) and raw <= envelope: - l1.replace(key, held, _SizedMsgpack(raw)) # type: ignore[arg-type] + if promote and not isinstance(held, _SizedMsgpack): + l1.shrink_if_held(key, held, _SizedMsgpack(raw)) # type: ignore[arg-type] continue l1.put(key, _Sized(rule(l1, envelope, raw))) return hits @@ -435,7 +476,7 @@ def _traces(draw: st.DrawFn, budget_mb: int) -> list[tuple[str, str, int, int]]: @pytest.mark.parametrize("budget_mb", [1, 100], ids=["1MiB-minimum", "100MiB-default"]) -@settings(max_examples=100, deadline=None) # enough: each rejected rule below fails it in every run tried +@settings(max_examples=100, deadline=None) @given(data=st.data()) def test_no_access_trace_gets_fewer_l1_hits_than_with_envelopes(budget_mb: int, data: st.DataObject) -> None: trace = data.draw(_traces(budget_mb)) diff --git a/tests/unit/test_single_flight.py b/tests/unit/test_single_flight.py index 614ae359..4bf0991e 100644 --- a/tests/unit/test_single_flight.py +++ b/tests/unit/test_single_flight.py @@ -994,8 +994,8 @@ def run_after_another_trip_and_hit(self: ThreadFlights, *args: Any) -> Any: assert decoded == [bytes, VerifiedMsgpack] # the other caller's hit, then this caller's re-check -def test_sync_backed_joiner_decodes_the_trips_envelope(waiting: list[int], decoded: list[type]) -> None: - """A joined caller decodes its own copy of the trip's envelope, as before: never the L1 entry.""" +def test_sync_backed_joiners_each_decode_their_own_copy_of_the_trips_envelope(waiting: list[int], decoded: list[type]) -> None: + """Each joined caller decodes the trip's envelope once, for its own copy, as before.""" def compute(x: int) -> dict[str, int]: _until(lambda: waiting[0] == HERD - 1) From 36ad0dda7d21f325de4f99dd18b9ecbe8fba9958 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 29 Oct 2026 20:19:28 +1100 Subject: [PATCH 7/8] fix(l1): count a swapped-entry collision as suspicious_envelope; isolate test settings An encrypted handler that met a plaintext handler's swapped L1 entry under the same key refused it as corruption; it now raises SuspiciousCacheEntryError, as the plaintext envelope it replaced would. deserialize_l1 is flattened into guard clauses and takes only what L1 holds, and the serializer helpers it calls are private. The new tests build their handlers inside each test with no CACHEKIT_* variable in reach, pin the compressed-header check and the as-long boundary, and the hit-count replay asserts that a swap never grows an entry. --- src/cachekit/cache_handler.py | 43 +++++--- .../serializers/standard_serializer.py | 14 +-- tests/unit/test_l1_verified_msgpack.py | 103 ++++++++++++------ 3 files changed, 103 insertions(+), 57 deletions(-) diff --git a/src/cachekit/cache_handler.py b/src/cachekit/cache_handler.py index 0090bd64..f4338faa 100644 --- a/src/cachekit/cache_handler.py +++ b/src/cachekit/cache_handler.py @@ -1293,7 +1293,7 @@ def write_serialized_to(self, sink: BinaryIO, data: Any) -> None: def deserialize_l1( self, - data: str | bytes | memoryview | VerifiedMsgpack, + data: bytes | VerifiedMsgpack, cache_key: str = "", args: tuple[Any, ...] = (), kwargs: dict[str, Any] | None = None, @@ -1303,8 +1303,10 @@ def deserialize_l1( That is the entry's verified MessagePack, when this handler keeps it (``keeps_msgpack_in_l1``), the entry is its own plaintext envelope, and the MessagePack is no longer. It comes from the retrieve() this decode ran anyway, so the caller's swap (``L1Cache.shrink_if_held``) costs no second retrieve(), - and later hits skip it. Every other entry gets None and is decoded by deserialize_data, which refuses - a VerifiedMsgpack as a corrupt frame. Raises as deserialize_data. + and later hits skip it. Everything else gets None: a VerifiedMsgpack this handler keeps decodes + without retrieve(), an envelope whose MessagePack is longer decodes as deserialize does, and any + other entry goes to deserialize_data, which refuses another handler's VerifiedMsgpack. Raises as + deserialize_data. Examples: >>> handler = CacheSerializationHandler() @@ -1320,20 +1322,20 @@ def deserialize_l1( """ # Inline, not helpers: this is every L1 hit's decode, and an envelope it keeps must cost what # deserialize_data costs. - if self.keeps_msgpack_in_l1: - if type(data) is VerifiedMsgpack: # only L1 holds this type: only such a handler swapped it in - return self._base_serializer.deserialize_verified(data), None # type: ignore[union-attr] - try: - payload, metadata, serializer_name = SerializationWrapper.unwrap_metadata(data) - except (AttributeError, KeyError, TypeError, ValueError): - pass # a corrupt frame: deserialize_data raises for it below - else: - # Only this handler's own plaintext envelope. Anything else, such as a config-drift - # entry whose ciphertext must stay in L1, is decoded below exactly as before. - if serializer_name == self._serializer_string_name and not metadata.encrypted and metadata.compressed: - return self._base_serializer.deserialize_keeping_msgpack(payload, len(data)) # type: ignore[union-attr] - # A VerifiedMsgpack here (another handler's, under the same key) fails the frame check there as corrupt. - return self.deserialize_data(data, cache_key, args, kwargs), None # pyright: ignore[reportArgumentType] + if not self.keeps_msgpack_in_l1: + return self.deserialize_data(data, cache_key, args, kwargs), None # pyright: ignore[reportArgumentType] + if type(data) is VerifiedMsgpack: # only L1 holds this type: only such a handler swapped it in + return self._base_serializer._deserialize_verified(data), None # type: ignore[union-attr] # noqa: SLF001 + try: + payload, metadata, serializer_name = SerializationWrapper.unwrap_metadata(data) + except (AttributeError, KeyError, TypeError, ValueError): + return self.deserialize_data(data, cache_key, args, kwargs), None # a corrupt frame: it raises + # Only this handler's own plaintext envelope, whose header says what this handler writes + # (compressed: integrity on). Anything else, such as a config-drift entry whose ciphertext must + # stay in L1, or a header that misdescribes its payload, is decoded by deserialize_data, not kept. + if serializer_name != self._serializer_string_name or metadata.encrypted or not metadata.compressed: + return self.deserialize_data(data, cache_key, args, kwargs), None + return self._base_serializer._deserialize_keeping_msgpack(payload, len(data)) # type: ignore[union-attr] # noqa: SLF001 def deserialize_data( self, @@ -1428,6 +1430,13 @@ def deserialize_data( data, shared=not self._custom_serializer ) except (AttributeError, KeyError, TypeError, ValueError) as e: + if self.encryption and type(data) is VerifiedMsgpack: # pyright: ignore[reportUnnecessaryComparison] + # A plaintext handler's swapped L1 entry under this key: refused as the plaintext + # envelope it replaced would be, and counted the same way (suspicious_envelope). + raise SuspiciousCacheEntryError( + "Encryption is enabled but the L1 entry is another handler's plaintext MessagePack. " + "Refusing it (fail closed)." + ) from e raise SerializationError(f"Corrupt cache envelope: {bounded_error(e)}") from e # Get base serializer diff --git a/src/cachekit/serializers/standard_serializer.py b/src/cachekit/serializers/standard_serializer.py index 52a564c4..f42a992a 100644 --- a/src/cachekit/serializers/standard_serializer.py +++ b/src/cachekit/serializers/standard_serializer.py @@ -412,19 +412,19 @@ def deserialize(self, data: bytes | memoryview, metadata: SerializationMetadata except PAYLOAD_DECODE_ERRORS as e: raise SerializationError(f"Failed to deserialize MessagePack data: {e}") from e - def deserialize_keeping_msgpack(self, data: bytes | memoryview, limit: int) -> tuple[Any, VerifiedMsgpack | None]: + def _deserialize_keeping_msgpack(self, data: bytes | memoryview, limit: int) -> tuple[Any, VerifiedMsgpack | None]: """What :meth:`deserialize` returns for a ByteStorage envelope when integrity checking is on, plus the MessagePack ``retrieve()`` verified when it is at most ``limit`` bytes, for the caller to keep and decode - later without retrieve() (:meth:`deserialize_verified`). It always unwraps the envelope, so call it + later without retrieve() (:meth:`_deserialize_verified`). It always unwraps the envelope, so call it only for an integrity-on serializer. Raises SerializationError as deserialize does then. Examples: >>> serializer = StandardSerializer() >>> envelope, _ = serializer.serialize({"test": 123}) - >>> value, kept = serializer.deserialize_keeping_msgpack(envelope, len(envelope)) - >>> value, serializer.deserialize_verified(kept) + >>> value, kept = serializer._deserialize_keeping_msgpack(envelope, len(envelope)) + >>> value, serializer._deserialize_verified(kept) ({'test': 123}, {'test': 123}) - >>> serializer.deserialize_keeping_msgpack(envelope, 4) # the MessagePack is 7 bytes + >>> serializer._deserialize_keeping_msgpack(envelope, 4) # the MessagePack is 7 bytes ({'test': 123}, None) """ try: @@ -434,8 +434,8 @@ def deserialize_keeping_msgpack(self, data: bytes | memoryview, limit: int) -> t raise SerializationError(f"Failed to deserialize MessagePack data: {e}") from e return value, VerifiedMsgpack(msgpack_data) if len(msgpack_data) <= limit else None - def deserialize_verified(self, kept: VerifiedMsgpack) -> Any: - """Decode MessagePack that :meth:`deserialize_keeping_msgpack` kept, without another ``retrieve()``. + def _deserialize_verified(self, kept: VerifiedMsgpack) -> Any: + """Decode MessagePack that :meth:`_deserialize_keeping_msgpack` kept, without another ``retrieve()``. Raises SerializationError as deserialize does.""" try: return unpackb_bounded(kept.data, **self._msgpack_unpack_opts) diff --git a/tests/unit/test_l1_verified_msgpack.py b/tests/unit/test_l1_verified_msgpack.py index 7f8e8bff..3d6c3c75 100644 --- a/tests/unit/test_l1_verified_msgpack.py +++ b/tests/unit/test_l1_verified_msgpack.py @@ -26,17 +26,29 @@ from cachekit import cache from cachekit.cache_handler import CacheSerializationHandler +from cachekit.config import reset_settings from cachekit.config.nested import L1CacheConfig from cachekit.l1_cache import L1Cache, get_l1_cache -from cachekit.serializers.base import SerializationError +from cachekit.serializers.base import SerializationError, SuspiciousCacheEntryError from cachekit.serializers.encryption_wrapper import EncryptionWrapper -from cachekit.serializers.standard_serializer import VerifiedMsgpack +from cachekit.serializers.standard_serializer import StandardSerializer, VerifiedMsgpack +from cachekit.serializers.wrapper import SerializationWrapper +from tests.fixtures.zero_config import clear_cachekit_env pytestmark = pytest.mark.unit MASTER_KEY = "a" * 64 +@pytest.fixture(autouse=True) +def _no_cachekit_env(monkeypatch: pytest.MonkeyPatch) -> Any: + """Handlers here read key settings when built, so no CACHEKIT_* variable in the shell may reach them.""" + clear_cachekit_env(monkeypatch) + reset_settings() + yield + reset_settings() + + class _Backend: """In-memory byte store that counts its reads.""" @@ -187,14 +199,6 @@ def compute(x: int) -> dict[str, int]: assert type(entry) is bytes and entry == corrupt -@pytest.mark.parametrize("damage", [_corrupt, lambda e: e[:-30]], ids=["flipped-byte", "truncated"]) -def test_deserialize_l1_refuses_an_envelope_retrieve_refuses(damage: Callable[[bytes], bytes]) -> None: - handler = CacheSerializationHandler() - envelope = damage(handler.serialize_data({"x": 1}, cache_key="k")) - with pytest.raises(SerializationError): - handler.deserialize_l1(envelope, cache_key="k") - - _VALUES = st.recursive( st.none() | st.booleans() | st.integers(-(2**63), 2**64 - 1) | st.floats(allow_nan=False) | st.text() | st.binary(), lambda inner: st.lists(inner, max_size=8) | st.dictionaries(st.text(max_size=8), inner, max_size=8), @@ -217,33 +221,46 @@ def test_deserialize_l1_keeps_msgpack_only_when_no_longer_and_it_decodes_the_sam assert handler.deserialize_l1(entry, cache_key="k") == (expected, None) -@pytest.mark.parametrize( - "handler", - [ - CacheSerializationHandler(serializer_name="auto"), - CacheSerializationHandler(enable_integrity_checking=False), - CacheSerializationHandler(encryption=True, master_key=MASTER_KEY, single_tenant_mode=True), - ], - ids=["auto", "integrity-off", "encrypted"], -) -def test_other_handlers_keep_their_envelope_and_refuse_verified_msgpack(handler: CacheSerializationHandler) -> None: +def test_msgpack_exactly_as_long_as_the_limit_is_kept() -> None: + serializer = StandardSerializer() + envelope, _ = serializer.serialize({"x": 1}) + size = len(msgpack.packb({"x": 1})) + assert serializer._deserialize_keeping_msgpack(envelope, size)[1] is not None + assert serializer._deserialize_keeping_msgpack(envelope, size - 1)[1] is None + + +_OTHER_HANDLERS: dict[str, Callable[[], CacheSerializationHandler]] = { + "auto": lambda: CacheSerializationHandler(serializer_name="auto"), + "integrity-off": lambda: CacheSerializationHandler(enable_integrity_checking=False), + "encrypted": lambda: CacheSerializationHandler(encryption=True, master_key=MASTER_KEY, single_tenant_mode=True), +} + + +@pytest.mark.parametrize("make", list(_OTHER_HANDLERS.values()), ids=list(_OTHER_HANDLERS)) +def test_other_handlers_keep_their_envelope_and_refuse_verified_msgpack(make: Callable[[], CacheSerializationHandler]) -> None: """The L1 hit sites send these handlers' entries to deserialize_data, which refuses a swapped entry another - handler left under the same key as a corrupt frame.""" + handler left under the same key: under encryption as the plaintext envelope it replaced would be.""" + handler = make() envelope = handler.serialize_data({"x": 1}, cache_key="k") assert not handler.keeps_msgpack_in_l1 assert handler.deserialize_l1(envelope, cache_key="k") == ({"x": 1}, None) planted = VerifiedMsgpack(msgpack.packb({"x": 2})) + refusal = SuspiciousCacheEntryError if handler.encryption else SerializationError for decode in (handler.deserialize_l1, handler.deserialize_data): - with pytest.raises(SerializationError, match="Corrupt cache envelope"): + with pytest.raises(refusal) as raised: decode(planted, cache_key="k") # type: ignore[arg-type] + assert handler.encryption or "Corrupt cache envelope" in str(raised.value) def _frames() -> dict[str, bytes]: - """Entries a plaintext default-serializer handler must decode exactly as deserialize_data does.""" + """Entries a plaintext default-serializer handler must decode exactly as deserialize_data does. Only the + first is its own plaintext envelope, the one entry L1 may swap.""" k = "k" own = CacheSerializationHandler(encryption=False).serialize_data({"x": 1}, cache_key=k) + payload, metadata, name = SerializationWrapper.unwrap(own) return { "own-envelope": own, + "header-not-compressed": SerializationWrapper.wrap(bytes(payload), metadata | {"compressed": False}, name), "other-serializer": CacheSerializationHandler(serializer_name="auto").serialize_data({"x": 1}, cache_key=k), "encrypted-drift": CacheSerializationHandler( encryption=True, master_key=MASTER_KEY, single_tenant_mode=True @@ -251,17 +268,33 @@ def _frames() -> dict[str, bytes]: "integrity-off": CacheSerializationHandler(enable_integrity_checking=False).serialize_data({"x": 1}, cache_key=k), "corrupt-header": b"\x00" + own[1:], "corrupt-payload": _corrupt(own), + "truncated": own[:-30], "not-a-frame": msgpack.packb({"x": 1}), } -@pytest.mark.parametrize("name", list(_frames())) +_FRAME_NAMES = ( + "own-envelope", + "header-not-compressed", + "other-serializer", + "encrypted-drift", + "integrity-off", + "corrupt-header", + "corrupt-payload", + "truncated", + "not-a-frame", +) + + +@pytest.mark.parametrize("name", _FRAME_NAMES) def test_deserialize_l1_decodes_or_refuses_every_entry_as_deserialize_data_does( name: str, monkeypatch: pytest.MonkeyPatch ) -> None: - """deserialize_l1's fast path repeats deserialize_data's frame checks, so the two must agree on every entry.""" + """deserialize_l1's fast path repeats deserialize_data's frame checks, so the two must agree on every entry, + and only the handler's own plaintext envelope may come back with MessagePack to keep.""" data = _frames()[name] monkeypatch.setenv("CACHEKIT_MASTER_KEY", MASTER_KEY) # lets the encrypted-drift entry decrypt, as it would + reset_settings() handler = CacheSerializationHandler(encryption=False) def outcome(decode: Callable[[], Any]) -> Any: @@ -271,24 +304,27 @@ def outcome(decode: Callable[[], Any]) -> Any: return type(e) expected = outcome(lambda: handler.deserialize_data(data, cache_key="k")) - assert outcome(lambda: handler.deserialize_l1(data, cache_key="k")[0]) == expected + got = outcome(lambda: handler.deserialize_l1(data, cache_key="k")) + assert (got[0] if isinstance(got, tuple) else got) == expected + assert (isinstance(got, tuple) and got[1] is not None) == (name == "own-envelope") -# ── L1Cache.shrink_if_held ─────────────────────────────────────────────────────────────────────────── +# ── L1Cache.shrink_if_held ─────────────────────────────────────────────────────────────────── -def test_replace_shrinks_the_entry_in_place() -> None: +@pytest.mark.parametrize("size", [40, 100], ids=["shorter", "as-long"]) +def test_shrink_if_held_swaps_the_entry_in_place(size: int) -> None: l1 = L1Cache(max_memory_mb=1, namespace=_namespace()) old = b"e" * 100 l1.put("a", old, redis_ttl=60) l1.put("b", b"b" * 10, redis_ttl=60) expires_at = l1._state.cache["a"].expires_at - new = VerifiedMsgpack(b"m" * 40) + new = VerifiedMsgpack(b"m" * size) assert l1.shrink_if_held("a", old, new) assert l1.peek("a") is new - assert l1._state.memory_bytes == 50 + assert l1._state.memory_bytes == size + 10 assert l1._state.cache["a"].expires_at == expires_at assert list(l1._state.cache) == ["a", "b"] # LRU place kept @@ -296,7 +332,7 @@ def test_replace_shrinks_the_entry_in_place() -> None: @pytest.mark.parametrize( "change", ["removed", "stored-again", "longer"], ids=["removed-since-read", "stored-again-since-read", "longer"] ) -def test_replace_leaves_an_entry_it_did_not_read_or_would_grow(change: str) -> None: +def test_shrink_if_held_leaves_an_entry_it_did_not_read_or_would_grow(change: str) -> None: l1 = L1Cache(max_memory_mb=1, namespace=_namespace()) old = b"e" * 100 l1.put("a", old, redis_ttl=60) @@ -380,8 +416,8 @@ def compute(x: int) -> dict[str, int]: # envelope and MessagePack sizes; with promote, a read that hits an envelope swaps it for its MessagePack # when that is no longer, as the L1 hit sites do (deserialize_l1, then L1Cache.shrink_if_held, which alone # refuses a longer value here). The shipped design -# is the envelope rule with promote. The two rejected put rules are the counterexamples an adversarial -# review found against storing MessagePack at write time; they show the pinned traces bite. +# is the envelope rule with promote. The two rejected put rules are counterexamples against storing +# MessagePack at write time; they show the pinned traces bite. Rule = Callable[[L1Cache, int, int], int] @@ -436,6 +472,7 @@ def _replay(trace: list[tuple[str, str, int, int]], budget_mb: int, rule: Rule, hits.append(i) if promote and not isinstance(held, _SizedMsgpack): l1.shrink_if_held(key, held, _SizedMsgpack(raw)) # type: ignore[arg-type] + assert l1._state.cache[key].size_bytes <= envelope # a swap never grows an entry continue l1.put(key, _Sized(rule(l1, envelope, raw))) return hits From ad0f55e99738dd58c126f2101c53d81fa961b6b0 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Thu, 29 Oct 2026 22:03:15 +0000 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20address=20coderabbit=20review=20?= =?UTF-8?q?=E2=80=94=20joiner=20copy=20identity,=20fixture=20return=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The joiners test now checks that every caller gets a distinct object, not just an equal one, so a regression that hands one shared dict to all callers fails. The autouse env fixture is a generator yielding None once, so it is typed Iterator[None], the idiom the rest of the suite uses. CodeRabbit-Resolved: test_single_flight.py:1007:Assert that joined calle Kody-Resolved: test_l1_verified_msgpack.py:44:The fixture's return typ --- tests/unit/test_l1_verified_msgpack.py | 4 ++-- tests/unit/test_single_flight.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_l1_verified_msgpack.py b/tests/unit/test_l1_verified_msgpack.py index 3d6c3c75..f835b762 100644 --- a/tests/unit/test_l1_verified_msgpack.py +++ b/tests/unit/test_l1_verified_msgpack.py @@ -16,7 +16,7 @@ import os import uuid -from collections.abc import Callable +from collections.abc import Callable, Iterator from typing import Any import msgpack @@ -41,7 +41,7 @@ @pytest.fixture(autouse=True) -def _no_cachekit_env(monkeypatch: pytest.MonkeyPatch) -> Any: +def _no_cachekit_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: """Handlers here read key settings when built, so no CACHEKIT_* variable in the shell may reach them.""" clear_cachekit_env(monkeypatch) reset_settings() diff --git a/tests/unit/test_single_flight.py b/tests/unit/test_single_flight.py index 4bf0991e..f766f1fe 100644 --- a/tests/unit/test_single_flight.py +++ b/tests/unit/test_single_flight.py @@ -1005,6 +1005,7 @@ def compute(x: int) -> dict[str, int]: results = _thread_herd(_decorator("backed", backend)(compute)) assert results == [{"x": 1}] * HERD + assert len({id(result) for result in results}) == HERD assert backend.requests == ["get", "set"] assert decoded == [bytes] * (HERD - 1)