Files
olp/lib/cache/store.mjs
T
taodengandClaude Opus 4.7 (noreply@anthropic.com) 8dd02e77ac feat(phase-1): land cache layer (D1+D4) + Anthropic E2E gate (D5)
Phase 1 Day 3. ADR 0005 cache layer ships: content-hash key over (provider,
model, IR), D1 per-key isolation, D4 singleflight. server.mjs wires cache
into the dispatch path with X-OLP-Cache: hit|miss|bypass header. Suite 10
adds a gated real Anthropic spawn E2E (OLP_RUN_E2E=1) — orchestrator ran
it once on Mac mini: 7.2s wall-clock, real claude-haiku-4-5 spawn via
keychain OAuth, returned "OK", asserted X-OLP-Provider-Used: anthropic +
X-OLP-Cache: miss on first request. The plugin chain (IR to claude -p to
IR to OpenAI) now works end-to-end on a real binary.

Files:
  NEW:  lib/cache/keys.mjs (199 lines) — computeCacheKey + cache_control
        extraction. sha256(stable-JSON(provider, model, normalized messages,
        tools, temperature, response_format, cache_control_markers)).
  NEW:  lib/cache/store.mjs (348 lines, includes peek() fold-in) — in-memory
        CacheStore with D1 per-key isolation (nested Maps), D4 singleflight
        via getOrCompute(keyId, cacheKey, computeFn), TTL expiry,
        injectable _nowFn for deterministic tests, stats reporting,
        scoped clear(keyId?).
  MOD:  server.mjs (+136/-26 lines) — cache lookup before spawn, bypass on
        cache_control markers, getOrCompute for D4 singleflight, header
        annotation. authContext changed from {} to null so providers
        correctly fall back to readAuthArtifact.
  MOD:  test-features.mjs (+614 lines, 30 new Suite 9 tests + 1 gated
        Suite 10 E2E).

Test count: 98 → 128 (+30) at default. With OLP_RUN_E2E=1: 129/129
(verified by orchestrator on Mac mini, Node 25.8.0).

Authority citations:
  Cache key composition: ADR 0005 § Cache key composition (v1.0). All 7
    spec fields present (provider, model, normalized messages, tools,
    temperature, response_format, cache_control markers).
  Per-key isolation D1: ADR 0005 § D1 + OCP keys.mjs precedent (nested
    Map keyId → cacheKey → entry).
  Singleflight D4: ADR 0005 § D4 + OCP server.mjs inflight Promise
    precedent. getOrCompute synchronously registers the inflight promise
    BEFORE any await, so concurrent callers cannot observe an empty
    inflight slot — JavaScript event-loop guarantee on this is the
    correctness anchor.
  cache_control bypass D2 basic structure: ADR 0005 § D2. Full marker-
    strip-for-non-Anthropic-provider behaviour deferred (only anthropic
    plugin exists at D4, so the marker-strip branch is unreachable).
  Chunked stream replay D3 basic structure: ADR 0005 § D3. Full timing-
    accurate replay deferred per orchestrator spec; D5 stores collected
    chunks and replays sequentially without timing fidelity.

Architectural decisions:
  1. In-memory cache at D5. ADR 0005 § Cache directory structure shows
     ~/.olp/cache/<keyId>/... as the eventual layout; D5 ships the
     structural equivalent (nested Maps) in memory. File backing lands
     in a later Phase. The Map structure is identical to the eventual
     filesystem layout; migration is a serializer/deserializer pair.
  2. keyId = '__anonymous__' at D5. Per-OLP-API-key namespacing infra
     lands in Phase 2 multi-key. The constant is hardcoded in
     server.mjs with a comment explaining the Phase 2 transition.
  3. authContext changed from {} to null. {} ?? readAuthArtifact()
     never falls back (empty object is truthy under ??); null
     correctly triggers the fallback. Confirmed by D5 E2E test which
     used the keychain OAuth path end-to-end.
  4. cache_control side-channel via raw body. The IR translator
     (lib/ir/openai-to-ir.mjs) strips cache_control because it is not
     an IR v1.0 field. server.mjs bypass check uses both hasCacheControl
     (ir) and extractCacheControlMarkers(body?.messages) to compensate.
     The proper fix is an ADR 0003 amendment to preserve cache_control
     in IR; tracked as a Phase 2 backlog item. Suite 9 Test 30 verifies
     the dual-check works end-to-end over HTTP.
  5. collectAllChunks throws ProviderError on type:error chunks. This
     prevents cache_store.set() from being called on error-terminated
     responses per ADR 0005 § Cache write conditions item 1. Anthropic
     plugin currently never emits type:error chunks (throws instead),
     so this is defensive code for future provider plugins.

Reviewer chain (Iron Rule 10):
  Implementer: sonnet (general-purpose).
  Fresh-context reviewer: opus (ecc:code-reviewer). Verdict
    APPROVE_WITH_MINOR.
  Reviewer ran npm test (128/128 pass with Suite 10 skipped), opened
    ADR 0005 end-to-end, opened OCP keys.mjs + server.mjs to verify
    singleflight precedent, verified the singleflight invariant by code
    inspection (5-concurrent Test 23 plus event-loop guarantee proof).

Reviewer non-blocking findings folded in this commit:
  1. peek() added to CacheStore — stats-neutral existence check.
     server.mjs preCheck now uses peek() instead of has(), fixing the
     hit/miss counter double-count bug. Documentation on has() updated
     to point callers at peek() for stats-sensitive paths.
  2. collectAllChunks now throws ProviderError on type:error chunks
     instead of returning an error-terminated array, preventing cache
     pollution per ADR 0005 § Cache write conditions item 1.
  3. Cache key composition comment clarifies that cache_control slot is
     forward-compat infrastructure for the future ADR 0003 amendment;
     v1.0 IR strips cache_control so the slot is always null at the
     key-composition site, with the D2 bypass side-channeling through
     the raw body in server.mjs.

Reviewer findings deferred:
  - IR amendment to preserve cache_control as first-class IR field
    (would let server.mjs drop the dual-check). Tracked as Phase 2
    backlog: "amend ADR 0003 to preserve cache_control markers in IR".
  - LRU-by-linked-list eviction at maxEntriesPerKey scale. Current
    O(n log n) sort-on-evict is fine at personal/family scale.
    Tracked for revisit if cap is raised significantly.

Verification:
  node --check on all touched files: clean.
  npm test on Node 25.8.0: 128/128 pass in 210ms (default mode).
  OLP_RUN_E2E=1 npm test on Mac mini: 129/129 pass in 7.4s (real
    claude-haiku-4-5 spawn, "OK" response, all OLP headers correct).
  hygiene grep: no personal names, no /Users literal paths, no real
    OAuth tokens (test fixtures use "<fake-token>" placeholders).

Co-Authored-By: Claude Opus 4.7 (noreply@anthropic.com)
2026-05-23 18:48:37 +10:00

340 lines
11 KiB
JavaScript

/**
* lib/cache/store.mjs — In-process cache store with per-key isolation + singleflight
*
* Authority: ADR 0005 § D1 (per-key isolation) + D4 (singleflight)
*
* This module implements the OLP response cache. At D5 the backing store is
* an in-memory Map (not file-backed). File backing lands in a later Phase.
* The architecture mirrors OCP v3.13.0 (D1+D4) generalized to OLP's
* (provider, model) keyed cache key space.
*
* D1 — Per-key isolation:
* Outer Map: keyId → inner Map
* Inner Map: cacheKey (SHA-256 hex) → CacheEntry
*
* keyId is the OLP API key identifier (Phase 2 multi-key). At D5, the server
* passes '__anonymous__' as keyId for all requests.
*
* D4 — Singleflight:
* Concurrent requests with the same (keyId, cacheKey) share one compute
* invocation. The first caller triggers computeFn(); subsequent callers
* await the same Promise. After completion, all callers receive the same
* value, and the result is cached for future callers.
*
* D2/D3 are placeholders at D5:
* D2 (cache_control bypass) — checked in server.mjs before entering getOrCompute.
* D3 (chunked stream replay with timing fidelity) — at D5 the cache stores
* full collected chunks and replays them sequentially without timing.
* Full D3 (timing-accurate replay) lands in a later Phase.
*
* Per OCP learning (MEMORY.md learnings/concurrency_dedup_test_signals.md):
* Singleflight is verified by observing that N concurrent requests for the
* same key return within ~0ms of each other (not by counting spawn log lines).
* The content-hash for the cache key must NOT include randomUUID or any
* per-request random component — only the semantic content that affects output.
*/
// ── Types ─────────────────────────────────────────────────────────────────
/**
* @typedef {Object} CacheEntry
* @property {*} value - the cached response (IR chunk array for streaming, response object for non-streaming)
* @property {number} createdAt - epoch ms (Date.now()) when the entry was created
* @property {number} ttlMs - time-to-live in milliseconds
*/
/**
* @typedef {Object} CacheStats
* @property {number} hits
* @property {number} misses
* @property {number} size
* @property {number} inflightCount
*/
// ── CacheStore ────────────────────────────────────────────────────────────
export class CacheStore {
/**
* @param {object} [opts]
* @param {number} [opts.maxEntriesPerKey=1000] — evict oldest entries when exceeded
* @param {number} [opts.maxAgeMs=86400000] — default TTL: 24 hours
* @param {() => number} [opts._nowFn=Date.now] — injectable for TTL testing
*/
constructor(opts = {}) {
this._maxEntriesPerKey = opts.maxEntriesPerKey ?? 1000;
this._maxAgeMs = opts.maxAgeMs ?? 24 * 60 * 60 * 1000; // 24 hours
this._nowFn = opts._nowFn ?? (() => Date.now());
// D1 per-key isolation: keyId → Map<cacheKey, CacheEntry>
/** @type {Map<string, Map<string, CacheEntry>>} */
this._store = new Map();
// D4 singleflight: `${keyId}:${cacheKey}` → Promise<value>
/** @type {Map<string, Promise<*>>} */
this._inflight = new Map();
// Stats per keyId
/** @type {Map<string, { hits: number, misses: number }>} */
this._stats = new Map();
}
// ── Internal helpers ──────────────────────────────────────────────────
/**
* Returns or creates the inner Map for a given keyId.
* @param {string} keyId
* @returns {Map<string, CacheEntry>}
*/
_getNamespace(keyId) {
if (!this._store.has(keyId)) {
this._store.set(keyId, new Map());
}
return this._store.get(keyId);
}
/**
* Returns or creates the stats object for a given keyId.
* @param {string} keyId
* @returns {{ hits: number, misses: number }}
*/
_getStats(keyId) {
if (!this._stats.has(keyId)) {
this._stats.set(keyId, { hits: 0, misses: 0 });
}
return this._stats.get(keyId);
}
/**
* Returns true if entry has not yet expired.
* @param {CacheEntry} entry
* @returns {boolean}
*/
_isAlive(entry) {
const age = this._nowFn() - entry.createdAt;
return age < entry.ttlMs;
}
/**
* Evicts oldest entries in a namespace when it exceeds maxEntriesPerKey.
* @param {Map<string, CacheEntry>} ns
*/
_evictIfNeeded(ns) {
if (ns.size <= this._maxEntriesPerKey) return;
// Collect entries sorted by createdAt ascending (oldest first)
const entries = [...ns.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
const toEvict = ns.size - this._maxEntriesPerKey;
for (let i = 0; i < toEvict; i++) {
ns.delete(entries[i][0]);
}
}
// ── Public API ────────────────────────────────────────────────────────
/**
* Retrieves a cached entry. Returns null on miss or TTL expiry.
* Updates stats.
*
* @param {string} keyId - OLP API key namespace (use '__anonymous__' at D5)
* @param {string} cacheKey - SHA-256 hex from computeCacheKey()
* @returns {Promise<CacheEntry|null>}
*/
async get(keyId, cacheKey) {
const ns = this._getNamespace(keyId);
const entry = ns.get(cacheKey);
if (!entry) {
this._getStats(keyId).misses++;
return null;
}
if (!this._isAlive(entry)) {
ns.delete(cacheKey);
this._getStats(keyId).misses++;
return null;
}
this._getStats(keyId).hits++;
return entry;
}
/**
* Stores a value in the cache.
*
* @param {string} keyId
* @param {string} cacheKey
* @param {*} value
* @param {number} [ttlMs] - overrides default maxAgeMs
* @returns {Promise<void>}
*/
async set(keyId, cacheKey, value, ttlMs) {
const ns = this._getNamespace(keyId);
const entry = {
value,
createdAt: this._nowFn(),
ttlMs: ttlMs ?? this._maxAgeMs,
};
ns.set(cacheKey, entry);
this._evictIfNeeded(ns);
}
/**
* Returns true if a valid (non-expired) entry exists for (keyId, cacheKey).
*
* Stats side-effect: this method calls get(), which increments hit/miss
* counters. Callers that need to peek WITHOUT touching stats should use
* peek() instead. Server-side cache-status reporting MUST use peek() to
* avoid double-counting when getOrCompute() is called immediately after.
*
* @param {string} keyId
* @param {string} cacheKey
* @returns {Promise<boolean>}
*/
async has(keyId, cacheKey) {
const entry = await this.get(keyId, cacheKey);
return entry !== null;
}
/**
* Stats-neutral cache peek. Returns true if a valid entry exists without
* incrementing hit/miss counters. Use this from server.mjs for pre-check
* cache-status reporting; the subsequent getOrCompute() call is the one
* that increments the canonical stats counter at the semantic boundary.
*
* Fixes the codex-flagged double-count bug where has() + getOrCompute()
* both incremented the same counter on the same logical request.
*
* @param {string} keyId
* @param {string} cacheKey
* @returns {Promise<boolean>}
*/
async peek(keyId, cacheKey) {
const ns = this._getNamespace(keyId);
const entry = ns.get(cacheKey);
if (!entry) return false;
if (!this._isAlive(entry)) {
ns.delete(cacheKey);
return false;
}
return true;
}
/**
* D4 singleflight: get from cache or compute exactly once for concurrent callers.
*
* - Cache hit: return cached value immediately (no computeFn invocation).
* - Inflight hit: await the existing inflight Promise (singleflight — computeFn
* runs exactly once for N concurrent callers with the same key).
* - Miss: register inflight promise, invoke computeFn(), cache the result,
* release the inflight promise, return the value.
* - On computeFn error: release the inflight promise (so subsequent calls retry),
* rethrow the error to all awaiting callers.
*
* Per OCP MEMORY.md learning (concurrency_dedup_test_signals.md):
* Singleflight is verified by timing: N concurrent calls return within ~ms
* of each other, and computeFn is called exactly once.
*
* @param {string} keyId
* @param {string} cacheKey
* @param {() => Promise<*>} computeFn - async function producing the value
* @param {number} [ttlMs]
* @returns {Promise<*>}
*/
async getOrCompute(keyId, cacheKey, computeFn, ttlMs) {
// 1. Cache hit — return immediately, no singleflight overhead
const ns = this._getNamespace(keyId);
const existing = ns.get(cacheKey);
if (existing && this._isAlive(existing)) {
this._getStats(keyId).hits++;
return existing.value;
}
// 2. Inflight hit — singleflight: await the in-progress compute
const inflightKey = `${keyId}:${cacheKey}`;
const inFlight = this._inflight.get(inflightKey);
if (inFlight) {
// Another caller is already computing this key. Await their result.
// The first caller will cache the result; we return the same value.
return inFlight;
}
// 3. Miss — we are the first caller; register inflight promise
const computePromise = (async () => {
try {
const value = await computeFn();
// Cache the result so future calls are cache hits
await this.set(keyId, cacheKey, value, ttlMs);
this._getStats(keyId).misses++;
return value;
} finally {
// Always release the inflight slot, even on error
this._inflight.delete(inflightKey);
}
})();
this._inflight.set(inflightKey, computePromise);
return computePromise;
}
/**
* Returns stats for a specific keyId, or aggregate stats across all keyIds.
*
* @param {string} [keyId] - if omitted, returns aggregate across all keys
* @returns {CacheStats}
*/
stats(keyId) {
if (keyId !== undefined) {
const s = this._stats.get(keyId) ?? { hits: 0, misses: 0 };
const ns = this._store.get(keyId);
const size = ns ? ns.size : 0;
return {
hits: s.hits,
misses: s.misses,
size,
inflightCount: this._inflight.size,
};
}
// Aggregate
let totalHits = 0;
let totalMisses = 0;
let totalSize = 0;
for (const s of this._stats.values()) {
totalHits += s.hits;
totalMisses += s.misses;
}
for (const ns of this._store.values()) {
totalSize += ns.size;
}
return {
hits: totalHits,
misses: totalMisses,
size: totalSize,
inflightCount: this._inflight.size,
};
}
/**
* Clears cache entries (and stats) for a specific keyId, or ALL entries.
*
* @param {string} [keyId] - if omitted, clears all namespaces
*/
clear(keyId) {
if (keyId !== undefined) {
this._store.delete(keyId);
this._stats.delete(keyId);
// Also remove any inflight entries for this keyId
for (const k of this._inflight.keys()) {
if (k.startsWith(`${keyId}:`)) {
this._inflight.delete(k);
}
}
} else {
this._store.clear();
this._stats.clear();
this._inflight.clear();
}
}
}