Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,343 @@
# Design — Complete @suaveplan/cache-storage
> Non-normative. Normative requirements live in `./specs/cache-storage/spec.md`.
## Goal
Take `@suaveplan/cache-storage` from a minimum-viable `StorageCache` (header + 2 codecs + namespacing + lazy TTL) to a feature-complete enterprise cache: third codec, pluggable eviction, background sweep, stampede protection, stale-while-revalidate, bulk + atomic ops, configurable size limits, OTel observability, 12-code error registry, and a multi-backend conformance harness — all without touching backend implementations and without breaking the existing public surface.
## Composition over rewrite
The current `StorageCache.ts` (240 LoC) is correct and small. Instead of replacing it, this change extends it via composition:
```text
StorageCache (orchestrator, ~250 LoC)
├─ Codec<T> — ./codec/{json,identity,structured-clone}
├─ EvictionPolicy — ./eviction/{lru,lfu,fifo,ttl-only,policy.types}
├─ InflightMap — ./stampede/inflight-map
├─ TtlSweeper — ./sweep/ttl-sweeper
├─ SwrScheduler — ./swr/swr-scheduler
├─ HeaderV2 codec — ./header (extended)
├─ MetricsRecorder — ./observability/metrics
├─ TraceRecorder — ./observability/trace
└─ NamespaceStats — ./stats/namespace-stats
```
Every collaborator is a plain class with constructor-injected dependencies, fully unit-testable in isolation, and reachable via subpath import. `StorageCache` becomes the glue layer; today's 240-line file stays under the 300-line cap by extracting collaborators into their own modules.
## Header v2 — version field
```text
byte: 0 1 2 3 4 5 6 7 8 9 ...15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
field: createdAt expiresAt flags hitCount version reserved
width: u64 BE u64 BE u32 BE u32 BE u32 BE zeros
```
- Bytes 24..27 — previously reserved-zero — now carry `version: u32 BE`. Legacy entries (where every reserved byte is zero) decode as `version: 0`. New entries written by this version of `cache-storage` start at `version: 1` and increment on every `set` / successful `compareAndSet`.
- Bytes 28..31 remain reserved (zero). The next on-disk evolution (HMAC tag, codec discriminant, namespace hash) can claim them without breaking v2 readers.
- `flags` bit 2 (`FLAG_HAS_VERSION = 1 << 2`) is set when the writer is v2-aware. A v2 reader trusts bytes 24..27 only when this flag is set; otherwise it returns `version: 0`. This preserves the "reserved bytes happened to be zero on a v1 write" case.
The header codec stays in `src/header/header.ts`. The reserved-byte reinterpretation is an internal change — `HEADER_BYTES = 32` and the existing `encodeHeader`/`decodeHeader` signatures are unchanged.
## Eviction — pluggable policy
```ts
// src/eviction/policy.types.ts
export interface EvictionPolicy {
/** Called after a successful set; returns keys to evict (may be empty). */
onSet(key: string, sizeBytes: number, now: number): readonly string[];
/** Called after a hit; lets the policy update its recency / frequency index. */
onHit(key: string, now: number): void;
/** Called after a delete / expire to drop the key from the policy's index. */
onDelete(key: string): void;
/** Clear the policy's internal state when the cache is cleared. */
reset(): void;
/** Snapshot the policy's view of size (entries, bytes). */
snapshot(): { entries: number; bytes: number };
}
export interface EvictionConfig {
policy: EvictionPolicy;
maxEntries?: number;
maxBytes?: number;
}
```
Four built-ins:
| Policy | Eviction rule | Index |
|---|---|---|
| `LruPolicy` | Drop the least-recently-touched key when `entries > maxEntries` or `bytes > maxBytes`. | Doubly-linked list keyed by `prefixedKey` for O(1) touch/evict. |
| `LfuPolicy` | Drop the lowest-frequency key. Tie-break by oldest first-insertion. | Frequency buckets (`Map<freq, Set<key>>`) for O(1) eviction; per-key `freq` counter saturates at `u32`. |
| `FifoPolicy` | Drop the oldest-inserted key. | Insertion-ordered `Map` (JS spec guarantees insertion order). |
| `TtlOnlyPolicy` | Never evict on count/size; relies entirely on TTL + background sweeper. | None. |
Eviction is applied by `StorageCache.set` after a successful write:
```ts
const victims = this.policy.onSet(prefixed, blob.length, now);
for (const victim of victims) {
try {
await this.storage.delete(victim);
this.policy.onDelete(victim);
this.size -= 1;
this.evictions += 1;
this.emit("evict", { key: this.stripNamespace(victim), reason: policyReason });
} catch (cause) {
throw new StorageCacheError(CACHE_STORAGE_CODES.EVICTION_FAILED, "error.cache-storage.eviction.delete", { cause, key: victim });
}
}
```
The policy lives entirely in-memory inside `StorageCache`. A second `StorageCache` instance pointed at the same backend will keep its own policy view; this is documented as the contract — coherent eviction across instances requires a shared coordination layer (out of scope; backend-specific).
## Background TTL sweep
```ts
// src/sweep/ttl-sweeper.ts
export class TtlSweeper {
constructor(private deps: {
storage: AbstractStorage;
clock: Clock;
timers: TimersLike;
namespace: string;
intervalMs: number;
batchSize: number;
onExpired: (key: string) => Promise<void>;
onSweepError: (cause: unknown) => void;
}) {}
start(): void { /* setInterval via timers.setTimeout chain */ }
stop(): void { /* clear handle */ }
}
```
`timers: TimersLike` comes from `@suaveplan/types` and provides `setTimeout`/`clearTimeout`/`queueMicrotask`/`now` so tests can drive the sweeper with `FakeTimers` without touching globals. The sweeper:
1. Lists keys via `storage.list({ prefix: namespace, limit: batchSize, cursor })`.
2. For each key, reads the header only (caller passes a `headerProbe` helper that fetches `HEADER_BYTES` and parses), no body decode.
3. If `expiresAt !== 0 && now >= expiresAt`, `storage.delete(key)` and `onExpired(strippedKey)`.
4. On any error, calls `onSweepError(cause)` (which records `CACHE_STORAGE.SWEEP_FAILED` and emits a `sweep-error` event) but continues — one bad key never poisons the sweep.
5. After all pages, schedules the next sweep `intervalMs` later.
`start()`/`stop()` are idempotent. `stop()` clears the pending timer and resolves any in-flight page before returning.
## Stampede protection — single-flight
```ts
// src/stampede/inflight-map.ts
export class InflightMap<T> {
#map = new Map<string, Promise<T>>();
constructor(private deps: { timers: TimersLike; defaultTimeoutMs: number }) {}
async run(key: string, loader: () => Promise<T>, timeoutMs = this.deps.defaultTimeoutMs): Promise<T> {
const existing = this.#map.get(key);
if (existing !== undefined) return existing;
const work = (async () => { ... })();
const timed = withTimeout(work, timeoutMs, this.deps.timers);
this.#map.set(key, timed);
try {
return await timed;
} finally {
this.#map.delete(key);
}
}
get pending(): number { return this.#map.size; }
}
```
`withTimeout` races the loader against a `timers.setTimeout` that rejects with `CACHE_STORAGE.STAMPEDE_TIMEOUT`. The timeout cancels the timer on resolution to keep the event loop clean. The counter `stampedePrevented` increments on every `run` call that returns the existing promise instead of starting new work.
`StorageCache.wrap` consults the in-flight map inside the miss path so concurrent misses share the loader; `getOrSet` is just `wrap` with stampede enabled by default.
## Stale-while-revalidate
`cache.wrap(key, loader, { staleWhileRevalidate: true, staleThresholdMs })`:
1. Read the entry. If `clock() < expiresAt - staleThresholdMs`, return the fresh value.
2. If `clock() >= expiresAt - staleThresholdMs` AND `clock() < expiresAt`, return the stale value immediately AND schedule a background refresh.
3. If `clock() >= expiresAt`, treat as a miss — go through the stampede-protected loader path.
The background refresh runs via `timers.queueMicrotask`; failure increments `swrRefreshFailures` and emits `swr-refresh-failed` but never reaches the caller. The refresh path also goes through the same `InflightMap` so a stampede of stale callers fires the loader once.
## Bulk ops — bulk-aware backend dispatch
```ts
interface BulkCapableStorage extends AbstractStorage {
getMultiple(keys: string[]): Promise<Map<string, Uint8Array>>;
setMultiple(entries: Array<{ key: string; value: Uint8Array }>): Promise<void>;
}
function hasBulk(s: AbstractStorage): s is BulkCapableStorage {
return typeof (s as Partial<BulkCapableStorage>).getMultiple === "function"
&& typeof (s as Partial<BulkCapableStorage>).setMultiple === "function";
}
```
`StorageCache.mget` checks `hasBulk(storage)`:
- If yes, one `storage.getMultiple(prefixedKeys)` call; iterate the returned `Map`, decode each header + body, build the result map. Single TTL check per key.
- If no, fall back to `Promise.all(keys.map(k => this.get(k)))`.
`mset` mirrors the dispatch. `mdelete` returns the count of keys that actually existed and were removed. All three open an OTel span with `cache.bulk.size = keys.length`.
## Atomic ops — `compareAndSet`
```ts
async compareAndSet(key: string, expectedVersion: number, value: T, options?: CacheSetOptions): Promise<{ version: number }> {
const prefixed = this.prefixed(key);
const current = await this.readWithHeader(prefixed); // throws BACKEND_ERROR / ENTRY_CORRUPT
const observed = current?.meta.version ?? 0;
if (observed !== expectedVersion) {
throw new StorageCacheError(CACHE_STORAGE_CODES.CAS_CONFLICT, "error.cache-storage.cas-conflict", { key, context: { expected: expectedVersion, observed } });
}
const nextVersion = (observed + 1) >>> 0;
await this.writeWithVersion(prefixed, value, options, nextVersion);
return { version: nextVersion };
}
```
This is **optimistic** CAS — there is no backend-level read-modify-write atomicity, so two concurrent CAS calls against the same key from different processes can both observe the same `version` and both write. For coherent CAS across instances callers need a backend that supports atomic check-and-set (e.g. Redis `WATCH`/`MULTI`); the spec text makes this explicit and the doc points at `cache-redis` for the strong variant.
`getWithVersion(key): Promise<{ value: T; version: number } | undefined>` returns the decoded value alongside the header version so callers can drive the CAS loop without re-reading.
## StructuredCloneCodec (reused from superseded change)
Lift table reused verbatim from `openspec/changes/_superseded-narrow-deferrals-2026-05-17/add-cache-storage-cbor-codec/design.md`:
| JS type | CBOR tag | Payload |
|---------------|----------|---------------------------------------------------------------|
| `Date` | `0` | RFC 3339 string |
| `bigint > 0` | `2` | byte string, big-endian magnitude |
| `bigint < 0` | `3` | byte string, big-endian magnitude of `-1 - n` |
| `URL` | `32` | `URL.href` string |
| `RegExp` | `35` | `regex.source` string (flags dropped — documented trade-off) |
| `Uint8Array` | `64` | byte string |
| `Set` | `258` | array of items |
| `Map` | `259` | array of `[k, v]` pairs |
Encode walks the input, lifts carriers to `Tagged(tag, walk(payload))`, then calls `encode` from `@suaveplan/cbor`. Decode calls `decode` with the registry; any leftover `Tagged` post-walk triggers `CODEC_UNKNOWN_TAG`. Unencodable types (`Function`, `Symbol`, `WeakMap`, `WeakSet`, `Promise`, unregistered class instances) trigger `CODEC_UNSUPPORTED_TYPE`.
## Conformance harnesses
Two harnesses ship inside the package:
1. **`runCodecConformance(codec, { name, profile })`** — `profile` is `"json" | "structured-clone" | "bytes"`. Each profile defines the round-trip cases the codec must pass: `json` is primitives + plain objects + arrays; `structured-clone` adds `Date`/`Map`/`Set`/`Uint8Array`/`bigint`/`RegExp`/`URL` + deep nesting; `bytes` is `Uint8Array` round-trip only. Self-test feeds a deliberately broken codec and asserts at least one harness check fails.
2. **`runStorageCacheConformance({ createStorage, name })`** — accepts a factory `() => AbstractStorage` so the same suite can run against `MemoryStorage`, a Redis-backed storage, an OPFS storage, etc. Covers the full API (`get`/`set`/`delete`/`has`/`clear`/`getStats`/`mget`/`mset`/`mdelete`/`wrap`/`getOrSet`/`compareAndSet`/`getWithVersion`), every eviction policy, stampede dedup, SWR, and the 12 error codes. Lives in `./testing/conformance` so it ships with the package but is tree-shakeable.
The conformance harness lives **in this package**, not in `@suaveplan/testing`, because it imports `StorageCache` directly — lifting it would invert the dep graph (rule 8).
## Observability
```ts
const tracer = trace.getTracer("@suaveplan/cache-storage");
const meter = metrics.getMeter("@suaveplan/cache-storage");
const hitsCtr = meter.createCounter("cache.hits");
const missesCtr = meter.createCounter("cache.misses");
const evictionsCtr = meter.createCounter("cache.evictions");
const stampedeCtr = meter.createCounter("cache.stampede_prevented");
const swrCtr = meter.createCounter("cache.swr_refreshes");
const rejectedCtr = meter.createCounter("cache.rejected");
const payloadHist = meter.createHistogram("cache.payload_bytes");
```
Every public method opens a span named `cache.<op>` with attributes `cache.namespace`, `cache.key` (when low-cardinality safe — caller opts in via `{ traceKey: true }`), `cache.hit`, `cache.size_bytes`. Span ends with `SpanStatusCode.OK` on success or `SpanStatusCode.ERROR` carrying the `SuaveplanError.code`.
No exporter registered ⇒ all of this is no-op per the `@opentelemetry/api` contract — zero allocations on the hot path beyond the constant `tracer`/`meter` lookups.
## Error registry — 12 codes
```ts
export const CACHE_STORAGE_CODES = defineCodes("CACHE_STORAGE", [
"ENTRY_CORRUPT", // existing
"CODEC_ERROR", // existing
"BACKEND_ERROR", // existing
"KEY_TOO_LONG", // existing
"VALUE_TOO_LARGE", // existing
"STAMPEDE_TIMEOUT", // new
"CAS_CONFLICT", // new
"SWEEP_FAILED", // new
"EVICTION_FAILED", // new
"CODEC_UNSUPPORTED_TYPE", // new
"CODEC_UNKNOWN_TAG", // new
"NAMESPACE_CONFLICT", // new — raised when a child namespace tries to register a name already in use
] as const);
```
Every code is reachable from at least one test call site — verified by the `errors.test.ts` reachability assertion.
## File layout
```text
packages/flow/cache-storage/src/
codec/
codec.types.ts # unchanged
identity.{ts,test.ts,md} # unchanged
json.{ts,test.ts,md} # extended with conformance harness call
structured-clone.{ts,test.ts,md} # NEW
conformance.{ts,test.ts,md} # NEW
index.ts
eviction/
policy.types.ts # NEW — interface only
lru/{lru,lru.test,lru.md}.ts # NEW
lfu/{lfu,lfu.test,lfu.md}.ts # NEW
fifo/{fifo,fifo.test,fifo.md}.ts # NEW
ttl/{ttl,ttl.test,ttl.md}.ts # NEW — TtlOnlyPolicy
index.ts # NEW
stampede/
inflight-map/{inflight-map,...}.ts # NEW
index.ts # NEW
sweep/
ttl-sweeper/{ttl-sweeper,...}.ts # NEW
index.ts # NEW
swr/
swr-scheduler/{swr-scheduler,...}.ts # NEW
index.ts # NEW
observability/
metrics/{metrics,...}.ts # NEW
trace/{trace,...}.ts # NEW
index.ts # NEW
stats/
namespace-stats/{namespace-stats,...}.ts # NEW
index.ts # NEW
header/
header.ts # MODIFIED — version field
header.test.ts # extended
header.md # extended
index.ts
errors/
errors.ts # MODIFIED — 5 → 12 codes
errors.test.ts # extended — reachability for all 12
errors.md # extended
index.ts
storage-cache/
storage-cache.ts # MODIFIED — composes new collaborators (~280 LoC)
storage-cache.types.ts # MODIFIED — new options
storage-cache.test.ts # extended
storage-cache.md # extended
index.ts
registry-augment/ # unchanged
testing/
conformance/{conformance,...}.ts # NEW — runStorageCacheConformance
index.ts # NEW
index.ts # MODIFIED — new exports
```
Every new file respects the 300-line soft cap. The `StorageCache` itself stays under 300 lines because every cross-cutting concern (eviction, sweep, stampede, SWR, metrics) lives in a co-located collaborator.
## Trade-offs
- **In-memory eviction index.** A `StorageCache` instance keeps the eviction policy's index in memory. Multiple instances pointed at the same backend do not coordinate — documented contract. Coordinated eviction belongs to the backend (e.g. Redis `maxmemory-policy lru`).
- **Optimistic CAS.** `compareAndSet` is not backend-atomic. The doc says so and points at backend-native primitives (`WATCH`/`MULTI` in Redis). Strong CAS requires backend support.
- **Sweep cost.** The background sweep reads every key's header in `batchSize` pages. For backends where `list` is cheap (memory, OPFS) this is negligible; for cloud backends (S3) callers should tune `intervalMs` and `batchSize` accordingly. Documented.
- **OTel overhead when no exporter registered.** Negligible (the no-op tracer is a singleton, span/counter calls are no-op functions) — verified by mitata baseline.
- **Header v2 reserved-byte reinterpretation.** Backwards-compatible because v1 reserved bytes were defined as zero; flag bit 2 (`FLAG_HAS_VERSION`) disambiguates v1-from-v2 unambiguously.
## Future work (explicitly deferred — out of scope)
- Backend-specific implementations (`cache-opfs`, `cache-s3`) — each is its own package.
- Public `StructuredCloneCodec` tag-registration API for caller-defined carriers.
- Mutation-resistant CAS via backend primitives (Redis `WATCH`/`MULTI`) — belongs in `cache-redis`.
- Distributed stampede dedup across cache instances (single-flight via Redis `SETNX` lock) — belongs in `cache-redis`.
- Tiered cache (L1 memory in front of L2 storage) — belongs in a new `cache-tiered` package.