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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ What a call costs inside your process, from the wall-clock guards in `tests/perf
| Redis L2 hit with L1 disabled, same dict, Redis in a container on the same machine | 539 ± 3 µs |

- With no backend, L1 keeps the returned object itself, whatever the payload's size, and a repeat call with a few short scalar arguments is answered by one call into the Rust extension that takes no lock ([which calls](https://github.com/cachekit-io/cachekit-py/blob/main/docs/performance.md#decorator--l1-hit-hot-path)). Any other call builds its key every time, as does every call to a function with a backend.
- With an L2 backend, L1 holds the serialized bytes and every hit deserializes them: for this dict, about 910 times the L1-only hit. This run predates hits skipping the structure walk over bytes they already checked, which took about 20% off this hit. Encryption adds a decrypt to each such hit and keeps the walk a plaintext hit now skips; before that skip, this run measured the difference at 9.3 ± 1.0 µs.
- With an L2 backend, L1 holds the serialized bytes and every hit deserializes them: for this dict, about 910 times the L1-only hit. This run predates the Rust decode every such hit now runs, which bounds and decodes the MessagePack in one pass at about half the instructions. Encryption adds a decrypt to each such hit; this run measured the difference at 9.3 ± 1.0 µs.
- An L2 hit adds the backend's round trip. A Redis on another machine adds its network latency to the last row.

Hosted runners come with other CPU models too: in a run of an earlier commit, an EPYC 9V45 runner took about half as long on every row, and other machines give other figures. The [Performance Guide](https://github.com/cachekit-io/cachekit-py/blob/main/docs/performance.md#github-hosted-runner) names the guard behind each figure and how to run it, and the repository's `Bench` workflow repeats the run.
Expand Down
34 changes: 18 additions & 16 deletions docs/data-flow-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. After its first hit, a plaintext entry of the default serializer with integrity checking on is held marked as walked, as its verified MessagePack where that is no longer than the envelope, so later hits skip re-checking it (see [Storage Architecture](#storage-architecture)); 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 of the default serializer with integrity checking on is held as its verified MessagePack where that is no longer than the envelope, so later hits skip the decompress and checksum; every hit still runs the bounded decode (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
Expand Down Expand Up @@ -134,7 +134,7 @@ cachekit uses a hybrid Python-Rust architecture to provide production caching wi
│ STEP 6: DESERIALIZATION (Bytes → Python Objects) │
│ File: src/cachekit/cache_handler.py (_deserialize_bytes) │
│ File: src/cachekit/serializers/standard_serializer.py │
│ File: rust/src/byte_storage.rs │
│ File: rust/src/python_bindings.rs (cachekit-core ByteStorage) │
├─────────────────────────────────────────────────────────────────────────────┤
│ _deserialize_bytes(data: bytes) → Python object │
│ │
Expand All @@ -153,7 +153,7 @@ cachekit uses a hybrid Python-Rust architecture to provide production caching wi
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Path 2: Rust ByteStorage (Compressed MessagePack) │ │
│ │ ByteStorage.retrieve(envelope_bytes) │ │
│ │ ↓ Rust layer (byte_storage.rs) │ │
│ │ ↓ Rust layer (cachekit-core) │ │
│ │ 1. Deserialize envelope (MessagePack) │ │
│ │ 2. Security checks (size limits, compression ratio) │ │
│ │ 3. LZ4 decompress │ │
Expand Down Expand Up @@ -239,7 +239,7 @@ cachekit uses a hybrid Python-Rust architecture to provide production caching wi
┌─────────────────────────────────────────────────────────────────────────────┐
│ STEP 14: SERIALIZATION (Python Objects → Redis) │
│ File: src/cachekit/serializers/standard_serializer.py │
│ File: rust/src/byte_storage.rs │
│ File: rust/src/python_bindings.rs (cachekit-core ByteStorage) │
├─────────────────────────────────────────────────────────────────────────────┤
│ StandardSerializer.serialize(result) → (bytes, metadata) │
│ │
Expand Down Expand Up @@ -315,8 +315,8 @@ cachekit uses a hybrid Python-Rust architecture to provide production caching wi
│ objects. L1-only mode (backend=None) stores the object itself. │
│ │
│ L1 admits and evicts envelopes. A later L1 hit on a plaintext entry of the │
│ default serializer (integrity on) marks it in place as walked, shrinking │
│ it to its verified MessagePack where that is no longer than the envelope: │
│ default serializer (integrity on) shrinks it in place to its verified │
│ MessagePack where that is no longer than the envelope: │
│ see Storage Architecture. Encrypted caches keep ciphertext. │
└──────────────────────────────────┬──────────────────────────────────────────┘
│
Expand Down Expand Up @@ -491,10 +491,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)
- **Kept entries**: 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 every read does: `ByteStorage.retrieve()` verifies the MessagePack, the structure walk (`check_msgpack_structure`) bounds it, and the decoder's object hook restores datetimes. L1 then holds the entry marked as walked from then on (same expiry and LRU place): the verified MessagePack (`VerifiedMsgpack`) when it is no longer than the envelope, else the envelope itself (`WalkedEnvelope`). Each records whether any of the strings `__datetime__`, `__date__` or `__time__` (or text starting `__date`) appears anywhere in the MessagePack. Later hits skip the walk, since the bytes cannot change, and skip the object hook only when none appears, since the hook would then return every map unchanged; held MessagePack also skips the decompress and checksum. Compressible values are usually longer as MessagePack, so they keep the envelope; small and incompressible ones switch. The swap never grows 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, and bytes still held as bytes, never take this path: L1 marks a kept entry by its type, which no backend returns, so they are walked on every decode
- **Kept entries**: 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 every read does: `ByteStorage.retrieve()` checks the envelope and returns the MessagePack its checksum covers, and the Rust decode (`unpackb_standard`) bounds and decodes it in one pass. When the verified MessagePack is no longer than the envelope, L1 then holds it in the envelope's place (`VerifiedMsgpack`, same expiry and LRU place), and later hits skip the decompress and checksum; otherwise L1 keeps the envelope. Every hit still runs the bounded decode. Compressible values are usually longer as MessagePack, so they keep the envelope; small and incompressible ones switch. The swap never grows 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, and bytes still held as bytes, never skip `retrieve()`: L1 marks a kept entry by its type, which no backend returns

**Hierarchical Read Path:**
1. Check L1 cache (bytes) → if hit, deserialize and return (the first hit on a plaintext entry of the default serializer with integrity checking on marks it as walked, so later hits skip the structure walk, and the object hook when no datetime marker string appears in it)
1. Check L1 cache (bytes) → if hit, deserialize and return (the first hit on a plaintext entry of the default serializer with integrity checking on marks it as verified, so later hits on held MessagePack skip 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

Expand Down Expand Up @@ -595,7 +595,7 @@ def observability_function():

## Serialization Path

**Files:** `src/cachekit/serializers/standard_serializer.py`, `rust/src/byte_storage.rs`
**Files:** `src/cachekit/serializers/standard_serializer.py`, `rust/src/python_bindings.rs` (cachekit-core's ByteStorage)

cachekit v0.1.0 uses only:

Expand Down Expand Up @@ -642,7 +642,7 @@ import msgpack
# Python layer
msgpack_data = msgpack.packb(obj)

# Rust layer (byte_storage.rs) - conceptual flow:
# Rust layer (cachekit-core ByteStorage) - conceptual flow:
# 1. LZ4 compress
# 2. xxHash3-64 checksum
# 3. Create StorageEnvelope {
Expand Down Expand Up @@ -716,13 +716,12 @@ except BackendError as e:

## Deserialization Path

**Files:** `src/cachekit/serializers/standard_serializer.py`, `rust/src/byte_storage.rs`
**Files:** `src/cachekit/serializers/standard_serializer.py`, `rust/src/python_bindings.rs` (cachekit-core's ByteStorage), `rust/src/msgpack_decode.rs`

### Deserialization Flow

```python notest
from typing import Any
import msgpack

def deserialize(data: bytes, metadata) -> Any:
# ULTRA-FAST: NumPy arrays
Expand All @@ -739,8 +738,11 @@ def deserialize(data: bytes, metadata) -> Any:
# 4. xxHash3-64 validate
# 5. Size validate

# MessagePack unpack
return msgpack.unpackb(original_data)
# MessagePack decode (StandardSerializer): one Rust pass that enforces the untrusted-decode
# bounds (nesting <= 1,024; no header claiming more than the input can back), builds the
# Python objects, and runs the datetime hook only on maps holding a marker key. A decode that
# fails re-runs the structure walk over the whole input, so the walk's error wins.
return unpackb_standard(original_data, MSGPACK_MAX_NESTING, _standard_object_hook) # illustrative
```

### Security Protections
Expand Down Expand Up @@ -824,8 +826,8 @@ For measured latencies, see the Performance Guide's [GitHub-Hosted Runner](perfo
**Serialization Layer:**
- `src/cachekit/serializers/standard_serializer.py` - StandardSerializer (MessagePack + ByteStorage)
- `src/cachekit/serializers/encryption_wrapper.py` - EncryptionWrapper (AES-256-GCM)
- `rust/src/byte_storage.rs` - Rust ByteStorage (LZ4 + xxHash3-64)
- `rust/src/encryption/` - Rust encryption module
- `rust/src/python_bindings.rs` - bindings to cachekit-core's ByteStorage (LZ4 + xxHash3-64) and encryption
- `rust/src/msgpack_decode.rs` - StandardSerializer's bounded one-pass MessagePack decode

**Connection Layer:**
- `src/cachekit/connection.py` - Redis connection pooling (used by RedisBackend)
Expand Down
Loading
Loading