Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,8 @@ jobs:
- name: Python-frame verify (stdlib only)
run: python3 tools/python-frame-reference.py verify

- name: File-backend format verify (stdlib only)
run: python3 tools/file-backend-reference.py

- name: Python-frame JS cross-check (zero-dep independent reader, full round-trip)
run: node tools/frame-crosscheck.mjs
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ layer's own store/retrieve flows are specified in
| [spec/encryption.md](spec/encryption.md) | AES-256-GCM encryption, HKDF-SHA256 key derivation, AAD v0x03, counter-based nonces, key rotation |
| [spec/saas-api.md](spec/saas-api.md) | REST API endpoints, binary wire protocol, error codes, metrics headers |
| [spec/interop-mode.md](spec/interop-mode.md) | Cross-SDK cache sharing — language-neutral key format, canonical argument normalization *(specified, not yet implemented in any SDK)* |
| [spec/file-backend-format.md](spec/file-backend-format.md) | Shared local File backend filename, header, expiry, and fail-closed flag negotiation |
| [sdk-feature-matrix.md](sdk-feature-matrix.md) | Feature parity tracking across Python, Rust, TypeScript, and PHP SDKs |
| [decisions/key-rotation.md](decisions/key-rotation.md) | Decision records — master-key rotation via client-side keyring (rationale, rejected options, operator runbooks) |

Expand Down
17 changes: 9 additions & 8 deletions sdk-feature-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

**Feature parity and compliance status across all CacheKit SDK implementations.**

*Last updated: 2026-07-24 — LAB-446: Python File backend gains full TTL inspection/refresh; Memcached gains `refresh_ttl` (touch) only (see [TTL management note](#reliability-features)). LAB-595 shipped: ts Cloudflare Workers flipped ❌ → ✅ via the `@cachekit-io/cachekit/workers` entrypoint on a wasm32 cachekit-core build (~55 KB gz measured); footnote ¹ records the phase-1 surface and semantics deltas. LAB-519: ts cold-miss single-flight (in-process, always on) + LockableBackend wired into `wrap()`'s miss path (opt-in); ts backpressure decision recorded; ts Redis lock/TTL capability cells refreshed for LAB-427. LAB-272 code-verified protocol-adherence audit (2026-07-22): interop/v1 merged in Python ([cachekit-py#220](https://github.com/cachekit-io/cachekit-py/pull/220)), TypeScript ([cachekit-ts#71](https://github.com/cachekit-io/cachekit-ts/pull/71)), and Rust ([cachekit-rs#33](https://github.com/cachekit-io/cachekit-rs/pull/33)); test-vector CI coverage corrected*
*Last updated: 2026-07-25 — LAB-430 shipped TypeScript Node-only Memcached and File backends; the protocol-owned File format and vectors now define fail-closed flag negotiation. LAB-446: Python File backend gains full TTL inspection/refresh; Memcached gains `refresh_ttl` (touch) only (see [TTL management note](#reliability-features)). LAB-595 shipped: ts Cloudflare Workers flipped ❌ → ✅ via the `@cachekit-io/cachekit/workers` entrypoint on a wasm32 cachekit-core build (~55 KB gz measured); footnote ¹ records the phase-1 surface and semantics deltas. LAB-519: ts cold-miss single-flight (in-process, always on) + LockableBackend wired into `wrap()`'s miss path (opt-in); ts backpressure decision recorded; ts Redis lock/TTL capability cells refreshed for LAB-427. LAB-272 code-verified protocol-adherence audit (2026-07-22): interop/v1 merged in Python ([cachekit-py#220](https://github.com/cachekit-io/cachekit-py/pull/220)), TypeScript ([cachekit-ts#71](https://github.com/cachekit-io/cachekit-ts/pull/71)), and Rust ([cachekit-rs#33](https://github.com/cachekit-io/cachekit-rs/pull/33)); test-vector CI coverage corrected*

</div>

Expand Down Expand Up @@ -72,8 +72,8 @@
| Backend | Python | Rust | TypeScript | PHP |
| :--- | :---: | :---: | :---: | :---: |
| Redis (direct) | ✅ `backends/redis` (redis-py, required dep) | ✅ `redis` feature (fred) | ✅ `backends/redis.ts` (ioredis) | ❌ |
| Memcached | ✅ `backends/memcached` (`memcached` extra) | ✅ `memcached` feature (rust-memcache)³ | | ❌ |
| File (local) | ✅ `backends/file` (stdlib + mmap) | ✅ `file` feature (byte-compatible with py)³ | | ❌ |
| Memcached | ✅ `backends/memcached` (`memcached` extra) | ✅ `memcached` feature (rust-memcache)³ | ✅ `backends/memcached.ts` (optional `memjs` peer; Node-only)⁴ | ❌ |
| File (local) | ✅ `backends/file` (stdlib + mmap) | ✅ `file` feature (byte-compatible with py)³ | ✅ `backends/file.ts` (Node-only; [format](spec/file-backend-format.md))⁴ | ❌ |
| CacheKit SaaS (HTTP) | ✅ `backends/cachekitio` (httpx) | ✅ `cachekitio` feature (reqwest, default) | ✅ `backends/cachekitio.ts` (fetch) | 🔜 Planned |
| Cloudflare Workers | N/A | ✅ `workers` feature (`worker::Fetch`) | ✅ `@cachekit-io/cachekit/workers` (wasm32 core)¹ | N/A |
| DynamoDB | ❌² | ❌ | ❌ | ❌ |
Expand All @@ -85,6 +85,8 @@
>
> ³ Added 2026-07-24 (LAB-429). Both are cargo features on cachekit-rs (`--features memcached` / `--features file`), native targets only (compile-error guarded against `workers`). The rs File backend shares py's on-disk format — Blake2b-128 hashed filenames, the 14-byte `CK` header, atomic write-then-rename, lazy expiry — verified by running the actual py `FileBackend` against an rs-written directory and vice versa. Not yet ported from py: LRU eviction/size caps and the mmap buffer read. rs Memcached is single-server (py's `HashClient` shards across servers); CI exercises it against a live memcached 1.6 container.

> ⁴ Added 2026-07-25 (LAB-430). TypeScript exposes these Node-only backends only through subpath exports, keeping `memjs` optional and `node:fs` out of browser and edge bundles. File implements full `TTLBackend`; Memcached deliberately offers refresh-only `touch`, not `TTLBackend`, because it cannot inspect remaining TTL. File names and headers follow [spec/file-backend-format.md](spec/file-backend-format.md); unknown reserved or flag values are misses preserved for a newer reader.

**Backend selection / env auto-detection:** Python is the only SDK that auto-detects *which* backend to use from the environment — a single unambiguous selector (`CACHEKIT_API_KEY` → SaaS, `CACHEKIT_REDIS_URL` → Redis, `CACHEKIT_MEMCACHED_SERVERS` → Memcached, `CACHEKIT_FILE_CACHE_DIR` → File; fallback `REDIS_URL` / localhost Redis; setting more than one selector raises `ConfigurationError`). Rust `CacheKit::from_env()` reads SaaS credentials only (`CACHEKIT_API_KEY` / `CACHEKIT_API_URL` / `CACHEKIT_MASTER_KEY` / `CACHEKIT_DEFAULT_TTL`) and always builds the CachekitIO backend — Redis/Memcached/File are wired explicitly via `.backend()`. TypeScript has no backend auto-detection: each preset fixes the backend type and env vars (`CACHEKIT_API_KEY`, `CACHEKIT_MASTER_KEY`) serve only as credential fallbacks. Whether rs/ts should gain selector-style auto-detection is an open product question, not a recorded decision (LAB-273 finding).

---
Expand All @@ -107,7 +109,7 @@ The contract a storage backend must satisfy per SDK (bytes in / bytes out; seria

| Capability | Python | Rust | TypeScript |
| :--- | :--- | :--- | :--- |
| TTL inspect / refresh | `TTLInspectableBackend` — Redis ✅, SaaS ✅, File ✅, Memcached ⚠️ `refresh_ttl` only (LAB-446) | `TtlInspectable` — Redis ✅, SaaS ✅, File ✅, Memcached ⚠️ `refresh_ttl` only (LAB-429) | `TTLBackend` — Redis ✅ (LAB-427), SaaS ✅ (`TTLCachekitIO`) |
| TTL inspect / refresh | `TTLInspectableBackend` — Redis ✅, SaaS ✅, File ✅, Memcached ⚠️ `refresh_ttl` only (LAB-446) | `TtlInspectable` — Redis ✅, SaaS ✅, File ✅, Memcached ⚠️ `refresh_ttl` only (LAB-429) | `TTLBackend` — Redis ✅, SaaS ✅ (`TTLCachekitIO`), File ✅; Memcached ⚠️ `refreshTTL` only (LAB-430) |
| Distributed locking | `LockableBackend` — Redis ✅ (`redis.lock.Lock`), SaaS ✅ | `LockableBackend` — SaaS ✅, Redis ✅ (`SET NX PX` + Lua compare-and-delete, `<key>:lock` namespace shared with py; LAB-426), Workers ❌ | `LockableBackend` — Redis ✅ (LAB-427), SaaS ✅ (`LockableCachekitIO`) |
| Per-operation timeout | `TimeoutConfigurableBackend` — Redis ✅ (SaaS ships a non-protocol `with_timeout` variant) | — no equivalent | — no equivalent |
| Zero-copy buffer read | `BufferReadableBackend` / `BufferHandle` — File ✅ (mmap; #171) | — no equivalent | — no equivalent |
Expand All @@ -128,7 +130,7 @@ The contract a storage backend must satisfy per SDK (bytes in / bytes out; seria
| Distributed locking | ✅ Redis + SaaS backends | ✅ Redis + SaaS (`LockableBackend`; LAB-426. Workers lacks an impl) | ✅ Redis + SaaS backends; wired into `wrap()` cold miss (opt-in `stampede.distributedLock`, LAB-519) | ❌ |
| L1/L2 dual-layer cache | ✅ | ✅ moka (native) / `l1` feature | ✅ | ❌ |
| Cache stampede prevention | ✅ | ❌ | ✅ Cold-miss single-flight + SWR version tokens (LAB-519) | ❌ |
| TTL management | ✅ Redis + SaaS + File; Memcached refresh-only (see note) | ✅ Redis + SaaS + File (`TtlInspectable`); Memcached refresh-only (LAB-429) | ✅ Redis + SaaS (`TTLBackend`, LAB-427) | ❌ |
| TTL management | ✅ Redis + SaaS + File; Memcached refresh-only (see note) | ✅ Redis + SaaS + File (`TtlInspectable`); Memcached refresh-only (LAB-429) | ✅ Redis + SaaS + File (`TTLBackend`); Memcached refresh-only (LAB-430) | ❌ |
| Stale-while-revalidate (server stale-grace) | 🚧 LAB-381 | ❌ | ❌ | ❌ |

> **Lock id transport (CWE-532):** the unlock call carries the lock capability token in the `X-CacheKit-Lock-Id` request header, never the `?lock_id=` query string (which leaks via access/proxy logs and OTel `http.url` spans). **Migration complete in all three SDKs** (verified 2026-07-20, LAB-273): Python (#131, closed), Rust (#24, closed), TypeScript ships the header (ts#63 remains open only for an unrelated NAPI-rebuild item). SaaS dual-reads both during the rollout window. See [spec/saas-api.md](spec/saas-api.md#delete-v1cachekeylock).
Expand All @@ -146,9 +148,7 @@ The contract a storage backend must satisfy per SDK (bytes in / bytes out; seria
> `refresh_ttl_on_get` does not apply to it (it warns once, then serves the hit).
> - **Rust (LAB-429):** File implements the full capability; Memcached implements the
> refresh-only `touch` wrapper outside `TtlInspectable`, matching Python's split.
> - **TypeScript:** parity for its existing backends is tracked separately; when a
> Memcached/File backend lands there it must match this Python split (refresh-only Memcached,
> full File) — see LAB-430.
> - **TypeScript (LAB-430):** File implements the full `TTLBackend`; Memcached ships its `touch`-based `refreshTTL` method outside that capability, matching the Python and Rust split.

---

Expand Down Expand Up @@ -248,6 +248,7 @@ its spec:

- Monorepo: `@cachekit-io/cachekit` (SDK) + `@cachekit-io/cachekit-core-ts` (Rust NAPI bindings)
- Redis backend via ioredis, CacheKit SaaS backend via fetch API
- Node-only Memcached (`memjs` optional peer) and File backends via subpath exports; File format is byte-verified against the shared protocol vectors
- Encryption via Rust NAPI (AES-256-GCM, HKDF-SHA256, counter-based nonces)
- AAD v0x03 compliant with Python cross-SDK test vectors
- L1 LRU cache with background refresh, version tokens, namespace invalidation
Expand Down
38 changes: 38 additions & 0 deletions spec/file-backend-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# File Backend Storage Format

## Scope

This optional transport format lets File backends in different CacheKit SDKs share one cache directory. It specifies only the file name and container header; the payload is the opaque byte sequence supplied by the SDK backend. It does not make an SDK auto-mode value representation cross-language compatible.

## File name

For a logical backend key encoded as UTF-8, calculate `BLAKE2b-128(key_utf8)` and write its lowercase hexadecimal encoding. The result is a 32-character file name in a flat cache directory.

The File backend does not add a key prefix. Interop mode must therefore reject a configured hidden prefix before this mapping is used.

## Entry layout

Every entry is a 14-byte header followed by zero or more payload bytes.

| Offset | Size | Field | Required value |
| ---: | ---: | --- | --- |
| 0 | 2 | Magic | ASCII `CK` (`0x43 0x4b`) |
| 2 | 1 | Version | `1` |
| 3 | 1 | Reserved | `0` |
| 4 | 2 | Flags | unsigned 16-bit big-endian; `0` |
| 6 | 8 | Expiry | unsigned 64-bit big-endian Unix seconds; `0` means no expiry |
| 14 | variable | Payload | opaque backend bytes |

Writers for version 1 MUST set the reserved byte and flags to zero. The expiry is the integer UTC Unix-second deadline. An entry with a nonzero expiry is expired when the reader wall clock reaches that timestamp; readers MAY lazily remove expired entries.

## Version and flag negotiation

A truncated header or wrong magic/version is corrupt and may be removed as a cache miss. A nonzero reserved byte or flag value is different: it can indicate a future payload transform. A reader that does not implement every indicated transform MUST return a miss and MUST NOT delete, rewrite, or return the payload. This fails closed rather than exposing transformed bytes as plaintext.

A future nonzero flag assignment requires a protocol update and canonical test vector. A writer MUST NOT set an unknown flag. A reader that implements a future flag must preserve the established fields and verify the transform before returning payload bytes.

## Write and TTL behavior

Entries are written through a temporary file in the same directory and atomically renamed into place. Refreshing TTL rewrites only bytes 6 through 13. Writers calculate the absolute deadline and store its whole-second Unix timestamp; a positive sub-second TTL can therefore expire within the current second.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

The canonical examples are in [`test-vectors/file-backend.json`](../test-vectors/file-backend.json).
40 changes: 40 additions & 0 deletions test-vectors/file-backend.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"version": "1.0.0",
"generator": "cachekit-py FileBackend format v1",
"format": "File name = BLAKE2b-128(UTF-8 key) lowercase hex; header = CK | version | reserved | flags u16 BE | expiry u64 BE | payload",
"vectors": [
{
"name": "permanent_ascii",
"key_utf8": "test-key",
"filename": "0e2a03b49262c15a063c04d5a29c0158",
"header_hex": "434b010000000000000000000000",
"payload_hex": "68656c6c6f",
"file_hex": "434b01000000000000000000000068656c6c6f",
"expiry_unix_seconds": 0,
"flags": 0,
"reader_action": "return_payload"
},
{
"name": "future_expiry",
"key_utf8": "unicode-ключ-🔑",
"filename": "f13b0df717f97cf1d2fe3da650525c79",
"header_hex": "434b0100000000000000f4865700",
"payload_hex": "68656c6c6f",
"file_hex": "434b0100000000000000f486570068656c6c6f",
"expiry_unix_seconds": 4102444800,
"flags": 0,
"reader_action": "return_payload"
},
{
"name": "unknown_flag_preserved",
"key_utf8": "test-key",
"filename": "0e2a03b49262c15a063c04d5a29c0158",
"header_hex": "434b010000010000000000000000",
"payload_hex": "667574757265",
"file_hex": "434b010000010000000000000000667574757265",
"expiry_unix_seconds": 0,
"flags": 1,
"reader_action": "miss_preserve"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
]
}
54 changes: 54 additions & 0 deletions tools/file-backend-reference.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Validate the protocol-owned File backend vectors with Python stdlib."""

from __future__ import annotations
Comment thread
27Bslash6 marked this conversation as resolved.

import hashlib
import json
from pathlib import Path
import struct
import sys

ROOT = Path(__file__).resolve().parents[1]
VECTORS = ROOT / "test-vectors" / "file-backend.json"


def check(condition: bool, name: str, detail: str) -> None:
"""Fail closed even under ``python -O`` (asserts would be stripped)."""
if not condition:
raise ValueError(f"{name}: {detail}")


def main() -> None:
try:
document = json.loads(VECTORS.read_text(encoding="utf-8"))
except OSError as exc:
sys.exit(f"cannot read {VECTORS}: {exc}")
except json.JSONDecodeError as exc:
sys.exit(f"invalid JSON in {VECTORS}: {exc}")

for vector in document["vectors"]:
name = vector["name"]
key = vector["key_utf8"].encode("utf-8")
filename = hashlib.blake2b(key, digest_size=16).hexdigest()
check(filename == vector["filename"], name, "filename mismatch")

image = bytes.fromhex(vector["file_hex"])
check(len(image) >= 14, name, "file shorter than 14-byte header")
check(image[:14].hex() == vector["header_hex"], name, "header_hex mismatch")
check(image[14:].hex() == vector["payload_hex"], name, "payload_hex mismatch")
check(image[:2] == b"CK" and image[2] == 1, name, "bad magic or version")
check(image[3] == 0, name, "reserved byte not zero")

flags = struct.unpack(">H", image[4:6])[0]
expiry = struct.unpack(">Q", image[6:14])[0]
check(flags == vector["flags"], name, "flags mismatch")
check(expiry == vector["expiry_unix_seconds"], name, "expiry mismatch")
expected_action = "return_payload" if flags == 0 else "miss_preserve"
check(vector["reader_action"] == expected_action, name, "reader_action mismatch")
Comment thread
27Bslash6 marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

print("validated", len(document["vectors"]), "File backend vectors")
Comment thread
27Bslash6 marked this conversation as resolved.
Outdated


if __name__ == "__main__":
main()
Loading