Skip to content

fix(router): harden CFCacheStore L1/L2 reads — latency budgets, debug sink, SWR retention/herd fixes - #558

Merged
ivogt merged 1 commit into
mainfrom
fix/cf-cache-store-l1-read-hardening
Jun 11, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/cf-cache-store-l1-read-hardening

Conversation

@ivogt

@ivogt ivogt commented Jun 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

CFCacheStore (the Cloudflare edge cache store: L1 = per-colo Cache API, L2 = global KV) had several read-path failure modes a consumer would observe as latency spikes, indefinitely-stale content, or render storms — none of them visible without reaching into the store:

  • A degraded colo or KV namespace pinned the whole request. CF resolves cache.match() with a lazily-streamed body, and KV reads are ~50ms healthy but tail to seconds when degraded. A single slow read blocked the request with no bound.
  • A perpetually-failing background revalidation pinned a stale entry forever. The stale-path re-put copied the original full-window Cache-Control onto a fresh cache.put, restarting CF's retention clock on every re-arm — so a hot key whose origin render kept failing never hard-expired and served stale indefinitely.
  • Corrupt/foreign L1 entries returned an opaque miss instead of falling back to KV. A non-200 entry was parsed and served as a hit; a fast json() rejection returned null without ever consulting KV.
  • No visibility. There was no way to confirm, on a real deployment, which tier answered a read or why.

What a consumer writes differently

The store gains three latency budgets and a debug sink — all opt-in, all with safe defaults:

// before
new CFCacheStore({ ctx: env.ctx, kv: env.CACHE_KV })

// after — same defaults, now with explicit budgets + deployment validation
new CFCacheStore({
  ctx: env.ctx,
  kv: env.CACHE_KV,
  edgeLookupTimeoutMs: 10, // bounds the L1 cache.match  (default 10)
  edgeReadTimeoutMs: 20,   // bounds the L1 body read    (default 20)
  kvReadTimeoutMs: 50,     // bounds the L2/KV read       (default 50)
  debug: true,             // log a CFCacheReadDebugEvent per read (wrangler tail)
})

A consumer who changes nothing still gets all three budgets at their defaults plus the retention / herd / fallthrough fixes — no migration required.

End-to-end consumer example (validating on a real deployment)

// worker wiring — capture every read decision to your own sink
const store = new CFCacheStore({
  ctx: env.ctx,
  kv: env.CACHE_KV,
  debug: (event) => {
    // event: { op, key, outcome, status?, staleAt?, revalidatingAt?,
    //          ageHeader?, isStale?, isRevalidating?, shouldRevalidate?,
    //          matchMs?, bodyReadMs? }
    console.log(JSON.stringify(event)) // visible via `wrangler tail`
  },
})

On wrangler tail you get one event per read; outcome says which tier answered and why:

{"op":"get","key":"/products","outcome":"l1-fresh","matchMs":1,"bodyReadMs":2}
{"op":"get","key":"/products","outcome":"l1-stale-revalidate","shouldRevalidate":true,"bodyReadMs":3}
{"op":"get","key":"/slow","outcome":"body-timeout","bodyReadMs":20}
{"op":"get","key":"/slow","outcome":"kv-stale-suppressed","shouldRevalidate":false}

Read-path semantics (the contract this PR pins down)

Read outcome Cause Result Revalidates?
l1-fresh L1 hit, within TTL serve L1 no
l1-stale-revalidate L1 hit, past TTL, not guarded serve L1, mark REVALIDATING (remaining-ttl re-put) yes
l1-revalidating-guarded L1 hit, past TTL, marked REVALIDATING within 30s serve L1 no (guard)
match-timeout cache.match exceeded edgeLookupTimeoutMs fall to L2 per L2
body-timeout L1 body read exceeded edgeReadTimeoutMs fall to L2 suppressed (degraded; no herd)
body-error L1 body json() rejected fast (corrupt/foreign-200) fall to L2 per L2 (heals the bad entry)
non-200 L1 entry status != 200 fall to L2 suppressed (degraded; no herd)
l1-miss no L1 entry fall to L2 per L2 (cold key still gets SWR)
kv-fresh / kv-stale L2 hit serve L2 + promote to L1 kv-stale -> yes
kv-stale-suppressed L2 stale hit on a degraded L1 fall-through serve L2 + promote no (herd mitigation)
kv-miss / kv-timeout no L2 entry / L2 read exceeded kvReadTimeoutMs render n/a

Key invariants:

  • Retention is never extended. The stale re-put recomputes max-age from a stored hard-expiry deadline (CACHE_EXPIRES_AT_HEADER), so a failing revalidation can no longer pin an entry past its declared SWR window.
  • Herd suppression is scoped to degradation. Only body-timeout and non-200 fall-throughs withhold revalidation; genuine misses, match-timeouts, and corrupt-body (body-error, which heals) keep KV's normal SWR signal. In healthy operation (sub-20ms body reads) the suppressed paths never fire.
  • Non-finite budgets fall back to defaults. edgeReadTimeoutMs: NaN (e.g. Number(env.UNSET)) no longer collapses the tier to ~100% misses.

Code-change summary

All in packages/rangojs-router/src/cache/cf/cf-cache-store.ts (+ index.ts re-exports, feature-map doc):

  • One shared readWithTimeout<T>(read, budgetMs, label) backs all three tiers; matchWithTimeout / readJsonWithTimeout are thin adapters. budgetMs <= 0 disables the bound; the abandoned read settles in the background (late rejection swallowed).
  • CACHE_EXPIRES_AT_HEADER (internal, never streamed to clients) is written in set()/setItem() and both promote paths; remainingCacheControl(headers, now) recomputes the stale re-put's max-age (floors to 1 for legacy entries, which self-heal via KV). The marker re-put is factored into markRevalidating() and runs non-blocking + best-effort (waitUntil + try/catch): it never adds put latency to the served stale read and a marker-write failure can no longer turn a good stale read into a null/miss. Because this.waitUntil invokes its callback synchronously, the put is still initiated at the same instant an awaited write would be, so concurrent readers see the marker land at the same time — awaiting only blocked the current request. (An earlier revision awaited it for a strict ordering guarantee; that prevents only a clobber requiring a single put to outlast a full re-render+set, which self-heals within MAX_REVALIDATION_INTERVAL — not worth a blocking write on every stale read.)
  • Thunk-level rejection handling for the L1 body and match reads (not the shared core, so KV rejections still route to error): a corrupt/foreign-200 body degrades to KV with a distinct body-error outcome.
  • finiteBudget() sanitization in the constructor for all three budgets.
  • suppressRevalidate threaded into kvGetSegment / kvGetItem, passed only on the body-timeout (!errored) and non-200 sites.

Test plan

  • Unit (dev): cf-cache-store.test.ts — 106 in-file, full suite 3445 passed. Every read-path fix added red-before-green (proven failing against the pre-fix code, then passing): retention re-put max-age shrinks (240/210, not 360); body-error -> KV; NaN budget -> default (not NaNms); herd shouldRevalidate true->false; a marker-write failure no longer nulls a stale read.
  • Typecheck (3 tsc configs), lint (oxlint 0/0), format (oxfmt) — all green.
  • Production e2e: this layer is CF-runtime-only (real Cache API max-age eviction, KV degradation) with no browser-observable surface; MockCache cannot model eviction, so retention/herd are asserted at the unit layer on stored header values + shouldRevalidate. Real fault-injection (miniflare) validation is owned separately — see Notes.

Notes / call-outs

  • Aggressive budgets by design. edgeReadTimeoutMs: 20 and kvReadTimeoutMs: 50 sit close to healthy read latencies, trading headroom for a tight tail bound. A deployment whose healthy reads legitimately run slower (large Flight payloads, far-from-colo regions) should raise the relevant budget; the JSDoc documents this, and a per-store override exists.
  • MockCache does not model max-age eviction, so the retention regression test asserts the stored Cache-Control (monotonically shrinking, terminating at hard-expiry), not actual eviction. End-to-end eviction/herd behavior is verifiable only against real CF / a miniflare fault-injection e2e (deferred, owned separately).
  • Deferred follow-up: single-flight stale revalidation. The principled herd fix is a body-independent revalidation latch that both the L1-stale and KV-stale paths consult; it would subsume both the L1 REVALIDATING marker and this PR's kv-stale-suppressed mitigation. The scoped mitigation here closes the degraded-path amplification without precluding it.
  • Validation flow: cut a fresh preview from this branch and confirm the new outcomes (body-error, kv-stale-suppressed, kv-timeout, match-timeout) appear as expected on wrangler tail before relying on the budgets in production.

@pkg-pr-new

pkg-pr-new Bot commented Jun 11, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@558

commit: 662491b

@ivogt
ivogt force-pushed the fix/cf-cache-store-l1-read-hardening branch from b6d7971 to b76dd98 Compare June 11, 2026 06:42
@ivogt

ivogt commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Folded in a round of review follow-ups (amended into the single commit):

  • Round-trip integrity (finding 8) — the guarded reads after a REVALIDATING re-put now assert data/value deep-equality, not just shouldRevalidate, pinning that the re-serialization preserves the payload (segment + getItem twins).
  • Doc scoping (finding 9) — the feature-map row now states the body budget covers the JSON paths (get/getItem); the document path (getResponse) bounds match + KV but streams its L1 body unbounded and is debug-silent.
  • Barrel exports (finding 10) — EDGE_LOOKUP_TIMEOUT_MS / EDGE_READ_TIMEOUT_MS / KV_READ_TIMEOUT_MS are now exported from ./cache, so the CFCacheStoreOptions JSDoc {@link}s resolve and consumers can derive margins from the defaults.
  • Debug observability (finding 15) — CFCacheReadDebugEvent gains cacheStatus (the raw stored HIT/REVALIDATING header), distinct from the computed isRevalidating, so a REVALIDATING entry whose stamp aged out is still distinguishable from a plain HIT on wrangler tail.
  • Comment de-dup (finding 11, partial) — trimmed the duplicated suppress-rationale comments to one canonical explanation (kvGetSegment + Case 1) plus references.

Deliberately deferred (happy to pull any in if you'd prefer):

  • Defaults (findings 4, 7) — edgeReadTimeoutMs: 20 / kvReadTimeoutMs: 50 left as-is; the plan is to validate on the real app via a fresh preview first, and both have per-store overrides.
  • text() vs parse+stringify on the re-put (finding 12) — would flip the shared body read from .json() to .text()+parse across every test fixture; the Content-Length angle it targets was already shown to be non-reproducible (all writers emit canonical JSON), so the win is a marginal stale-path CPU saving not worth the churn here.
  • Full get/getItem dedup (finding 11) and the shared timeout-helper consolidation (findings 13/14) — larger refactors of the hot read path; better as their own change than riding this fix.

Gate: typecheck (3 configs), unit 3443 passed (104 in cf-cache-store.test.ts), lint 0/0, format clean.

…debug sink, and SWR retention/herd fixes

Bound every CFCacheStore read tier with a per-store latency budget so a degraded
Cloudflare colo or KV namespace cannot pin a request behind a slow read:

  - edgeLookupTimeoutMs (10ms) bounds the L1 cache.match
  - edgeReadTimeoutMs (20ms) bounds the lazily-streamed L1 body read
  - kvReadTimeoutMs (50ms) bounds the L2/KV read

All are on by default, a value <= 0 disables, and a non-finite value falls back
to its default. One shared readWithTimeout primitive backs all three tiers.

Add an opt-in `debug` sink (CFCacheReadDebugEvent) for validating cache behavior
on a real deployment via `wrangler tail`, and replace reliance on CF's unreliable
Age header with an explicit CACHE_REVALIDATING_AT_HEADER stamp so the SWR re-arm
fires reliably instead of pinning a dropped revalidation stale forever.

Read-path correctness:

  - The stale-path REVALIDATING re-put recomputes a shrinking remaining-ttl
    Cache-Control from a stored hard-expiry deadline (CACHE_EXPIRES_AT_HEADER)
    instead of copying the original full-window header; otherwise every re-arm
    restarted CF retention and a perpetually-failing revalidation pinned the
    entry past its intended hard-expiry indefinitely.
  - A non-200 L1 entry, a body-read timeout, and a fast json()/match rejection
    all fall through to L2 (KV) instead of serving garbage or returning null
    without consulting KV.
  - Degraded L1 fall-throughs (body-timeout / non-200) suppress revalidation so
    a struggling colo cannot amplify into a thundering herd; genuine misses,
    match-timeouts, and corrupt-body (heal) cases are unaffected.

New debug outcomes: match-timeout, body-timeout, body-error, kv-timeout,
kv-stale-suppressed. All read-path fixes are covered by red-before-green unit
tests in cf-cache-store.test.ts.
@ivogt
ivogt force-pushed the fix/cf-cache-store-l1-read-hardening branch from b76dd98 to 662491b Compare June 11, 2026 07:10
@ivogt

ivogt commented Jun 11, 2026

Copy link
Copy Markdown
Collaborator Author

Made the stale-path REVALIDATING marker re-put non-blocking + best-effort (factored into a shared markRevalidating()), reversing the earlier "keep it awaited" decision after a closer look at the mechanics:

  • this.waitUntil(fn) invokes fn synchronously, so the marker put is initiated at the same instant an inline await would — concurrent readers see the marker land at the same wall-clock time. Awaiting only blocked the current request; it bought no herd-suppression benefit.
  • The strict-ordering guarantee awaiting provided only prevents a clobber that requires a single marker put to outlast a whole re-render + fresh set() (the fresh write is render-gated and lands much later), and that case self-heals within MAX_REVALIDATION_INTERVAL.
  • The awaited version also had a latent issue: the put sat inside get()'s try, so a marker-write failure turned a perfectly good stale read into null → a full render. waitUntil + try/catch fixes that unconditionally.

Net: faster stale reads (the finding-3 "unbounded blocking put on a degraded colo" concern is gone), no null-on-marker-failure, same herd timing. Added red-before-green tests (segment + getItem) asserting a stale read still returns its data when the marker write rejects — proven null against the awaited version, passing now. The factoring also collapses the previously-tripled marker block into one helper.

Gate: typecheck (3 configs), unit 3445 passed (106 in cf-cache-store.test.ts), lint 0/0, format clean. Force-pushed as 662491b3.

@ivogt
ivogt merged commit 6e66687 into main Jun 11, 2026
25 checks passed
@ivogt
ivogt deleted the fix/cf-cache-store-l1-read-hardening branch June 11, 2026 07:27
ivogt added a commit that referenced this pull request Jun 11, 2026
…558)

Merge origin/main into feat/cache-tags-v2. main carried PR #558 (CFCacheStore
latency budgets, debug sink, SWR retention + herd fixes); this branch carries the
cache-tag invalidation + resilience layer. Both rewrote the same get/getItem/
kvGet*/promote* methods, so the merge is a semantic reconcile that keeps BOTH
sides' fail-safes:

- kvGetOrEvict is now budget-aware (readWithTimeout + {value,timedOut}) and
  string-tolerant, preserving the transient-vs-corrupt eviction distinction.
- Tag-marker reads (isGloballyInvalidated/fetchTagMarker) inherit all three
  latency budgets and FAIL OPEN on a KV timeout (a degraded namespace never
  wrongly invalidates a hit).
- Unified L1-error contract: a transient match error or a corrupt L1 body is
  reported via onError (cache-read / cache-corrupt) AND falls through to L2/KV;
  a corrupt L1 entry is evicted only when no good L2 copy exists, so the evict
  cannot race the KV->L1 promote (review finding 1).
- getResponse surfaces the captured match error as cache-read, matching
  get/getItem (review finding 2/3).
- markRevalidating / promotes / KV envelopes carry both the hard-expiry header
  and the tag headers.

Validated: typecheck, 3547 unit tests, lint, format, and cache-tag e2e in dev +
production for both cloudflare-basic (real CFCacheStore + miniflare KV) and the
memory-store e2e test-app (24 e2e tests). New coverage pins the resilient L2
fall-through + no-false-evict and the tag-marker fail-open; both review-fix
regression tests proven red-before-green.
ivogt added a commit that referenced this pull request Jun 11, 2026
Adversarial review of the #534<->#558 reconcile surfaced ten findings; this
folds the worthwhile ones in before the single CI run (cheaper than a second
follow-up round).

Read-your-own-writes / correctness:
- fetchTagMarker no longer clobbers a concurrent invalidateTags() memo write
  that lands mid-read, so a parallel-slot updateTag() stays read-your-own-writes.
- Per-store isolation of the tag-marker memo/inflight maps: two stores in one
  request (app store + a cache({ store }) override on a different KV binding)
  can no longer cross-pollute marker values.
- kvGetOrEvict guards a KV body that parses to null/a primitive BEFORE
  validate(), so 'null'/'42' are evicted as corrupt instead of throwing past the
  validator into a transient cache-read that never self-heals.
- Non-array tags fail safe (Array.isArray at the write guards and the central
  isGloballyInvalidated check) instead of throwing .map on every read.

Fail-safe reads/writes (no perf degradation):
- Corrupt-entry KV eviction is dispatched via waitUntil, never awaited on the
  read path (an unbounded kv.delete would re-add exactly the stall the read
  budgets exist to prevent).
- The transient L1 tag-marker match error is reported as cache-read like the
  data read paths, instead of being silently discarded at the destructuring.
- The L1 marker write-through failure on invalidate is surfaced
  (cache-invalidate) and the stale marker evicted, instead of swallowed while
  updateTag reports success.

DX / hygiene:
- tagInvalidationTtl below KV's 60s expirationTtl floor is raised (with a
  one-time warning) so a too-small value no longer makes EVERY invalidation
  throw; a non-finite tagCacheTtl is coerced to 0 (disabled) rather than
  silently enabling an invalid max-age=Infinity marker.
- Remove the now-dead parseOrEvict (replaced during the reconcile by
  readJsonWithTimeout/healCorruptL1 + kvGetOrEvict).

Observability + API completeness (review below-the-cut items):
- New `markerMs` debug field: the tagged-read marker-resolution tail (memo -> L1
  marker cache -> KV) is no longer invisible in CFCacheReadDebugEvent (measured
  only under debug). New `match-error` debug outcome so a match() rejection is
  distinct from a genuine l1-miss and agrees with the cache-read on onError.
- Export CacheItemOptions / CacheItemResult from ./cache so a custom
  SegmentCacheStore author can name every type its getItem/setItem methods use.

Docs/comments corrected: caching skill corrupt-entry self-heal table (serves
from L2, evicts only when no L2 copy), healCorruptL1 cross-request timeout note,
the tag-invalidated tier-divergence comment, and a tagCacheTtl same-colo race
caveat.

Tests: +15 (11 proven red-before-green); strengthened the corrupt-L1
heal-by-overwrite and fail-open assertions to be non-vacuous; the fail-open mock
keys on the exported TAG_MARKER_PREFIX so a prefix rename can't make it pass
vacuously; added real-timer teardown to both CF suites. Unit 3562 green;
cache-tag e2e green dev+production against the real CFCacheStore.
ivogt added a commit that referenced this pull request Jun 11, 2026
…534)

* feat(cache): tag invalidation — cacheTag / updateTag / revalidateTag

Adds runtime cache-tag invalidation across the in-memory and Cloudflare
cache stores.

Public API (server-only, from @rangojs/router):
- cacheTag(...tags): tag the current "use cache" entry at runtime.
- updateTag(...tags): awaitable, read-your-own-writes invalidation for
  Server Actions (resolves after in-process invalidation completes; on
  CFCacheStore, rejects if a durable KV marker write fails instead of
  reporting a false success).
- revalidateTag(...tags): fire-and-forget background invalidation for
  route handlers / webhooks. Like updateTag it hard-purges; the only
  difference is awaitability (it is NOT stale-while-revalidate).

Tags attach via three axes: runtime cacheTag() inside "use cache",
cache({ tags }) on the route DSL, and loader cache tags. A per-handler
explicit-store registry (LRU-bounded) lets updateTag/revalidateTag reach
every configured store, including per-scope cache({ store }) boundaries.

Stores:
- MemorySegmentCacheStore: forward + reverse tag index; eager delete on
  invalidate.
- CFCacheStore: per-tag KV markers compared against an entry's taggedAt;
  optional per-colo L1 marker cache (tagCacheTtl) and onRevalidateTag CDN
  purge hook; tags encodeURIComponent'd so commas / non-Latin1 stay
  header-safe; over-512-byte marker keys and failed KV writes surface as a
  rejected updateTag rather than silent stale-serve.

Document caching: createDocumentCacheMiddleware tags full-page entries
with the request's resolved tags, snapshotted after the render stream
drains so Suspense-streamed cacheTag()/"use cache" tags are captured, so
updateTag/revalidateTag invalidate cached pages.

Docs and skills updated. e2e covers cacheTag / cache(tags) / updateTag /
revalidateTag and document invalidation in dev and production on both the
memory and the CF + KV stores.

Known limitation: combining whole-page document caching with segment
cache({ tags }) DSL tags can miss the document-level tag (resolved in the
deferred cacheRoute waitUntil); the segment entry itself still invalidates.

* perf+docs(cache): parallelize L1 marker write-through; document revalidateTag/keyGenerator contracts

From a 4-dimension (correctness/performance/consistency/security) review of the
branch:

- perf: invalidateTags wrote the per-colo L1 tag markers sequentially (await per
  tag inside the loop, only when tagCacheTtl>0). The memo write stays synchronous
  (read-your-own-writes); the independent L1 Cache API writes now fan out via
  Promise.all instead of stalling on each other.
- docs (security): revalidateTag JSDoc now states that, being fire-and-forget, a
  failed durable marker write is NOT surfaced to the caller (logged only) - use
  await updateTag() when the invalidation must be confirmed/retried.
- docs (security): CFCacheStore.keyGenerator now documents the reserved __tag__/
  and __tagmarker__/ prefixes a returned key must not collide with (tag markers
  share the data KV namespace).

The review's other confirmed items were verified as deliberate, documented
trade-offs and intentionally not changed: read-side isGloballyInvalidated
fail-open (availability over a KV-outage stampede), unbounded tag cardinality
and the marker/data key-collision (documented; key-prefix hard-namespacing would
invalidate existing caches), and the segment-DSL document-tag race (documented
caveat). Consistency + most security probes verified clean (naming, normalization
symmetry, header encode/decode, no injection/poisoning).

* fix(cache): report cache errors via onError + self-heal corrupt entries

Audited every cache read/write/parse path (67 sites). The resilience contract is
now: report loudly via the router's onError hook, degrade (never throw), and
evict corrupt/partial entries.

- onError routing: a new reportCacheError(error, category) helper logs (loud,
  works in background tasks) AND routes through the request context's
  _reportBackgroundError -> router onError (deduped). Both stores used only
  console.error before, so a consumer's onError never saw cache read/write
  failures. All store + document-cache + cache-runtime/scope sites now route
  through it with a category (cache-read/-write/-delete/-corrupt/-invalidate).
  The non-blocking L1 write (waitUntil) previously swallowed its rejection
  entirely; it now reports too.

- Self-heal on corrupt/partial: CF reads distinguish a PARSE failure (truncated
  Cache API body, malformed KV envelope, bad base64) from a transient infra
  error. On corruption the faulty entry is EVICTED (cache.delete / kv.delete) and
  reported as cache-corrupt, so subsequent reads miss + re-render instead of
  re-failing until TTL; a transient infra error degrades to a miss WITHOUT
  deleting a still-good entry. New parseOrEvict / kvGetOrEvict helpers + envelope
  shape validation. cache-runtime + cache-scope report corrupt deserializes (and
  cache-scope evicts; use-cache items self-heal via the miss-path overwrite).

- Degrade hardening: memory putResponse's un-guarded arrayBuffer() now degrades
  to a no-op instead of throwing.

The deliberate exception is unchanged: updateTag -> invalidateTags still REJECTS
on a failed durable marker write (read-your-own-writes honesty), not swallowed.

Tests: infra read error -> reports cache-read + null; corrupt L1 entry -> evicted
+ cache-corrupt; corrupt KV envelope -> kv.delete + cache-corrupt; write error ->
cache-write.

* fix(cache): harden cache-error resilience (transient vs corrupt, observability)

Review-driven hardening of the cache-tag error path. Three substantive fixes
plus consistency/typing nits, all backed by regression tests.

Correctness / availability:
- kvGetOrEvict no longer deletes a still-good cross-colo KV entry on a transient
  outage. It read via kv.get(key, {type:"json"}), which fused the network read
  and the JSON parse, so a 5xx/429/network blip was treated as corruption and
  evicted the entry (self-inflicted cross-colo miss storm). Now reads as text:
  a transient error propagates to the caller's outer catch (reported cache-read,
  entry intact); only a JSON.parse/validate failure is true corruption -> evict.
- kvGetResponse: the full Response reconstruction (base64 + headers + status) is
  now inside one corrupt-guard, so malformed `hd`/out-of-range `st` self-heals
  too; eviction goes through evictKvKey (reports cache-delete instead of silently
  swallowing the delete failure).
- Reserved-namespace guard: a misconfigured store keyGenerator returning a key
  under a reserved tag-marker prefix - `__tag__/` (KV marker key) or
  `__tagmarker__/` (L1 Cache API marker request) - can no longer clobber (on set)
  or evict (on get/delete) a live tag marker. The segment op is a reported
  no-op/miss, naming which prefix collided.

Observability:
- revalidateTag now routes a failed background invalidation through reportingAsync
  (loud log + onError, category cache-invalidate) instead of swallowing it; the
  JSDoc claim is now true. Passes the captured ctx since the run is detached.
- The stale-while-revalidate ("use cache") failure goes through reportCacheError
  (loud) with the captured ctx, and `stale-revalidation` is now a typed category.
- reportCacheError/reportingAsync accept an explicit ctx for detached waitUntil
  tasks where the ALS context is gone, so onError still fires. The CF store's own
  background writes deliberately omit it (request-agnostic singleton -> console
  loud only); a comment documents the asymmetry.
- CacheErrorCategory is exported from `@rangojs/router/cache`, the
  _reportBackgroundError signature is typed to it, and onError's metadata.category
  is documented.
- cache-scope segment-write failure now uses reportCacheError, not console.error.

#2 (L1 document body served verbatim): documented as a deliberate asymmetry -
hashing the body would require buffering the whole document on every hit,
defeating streaming; integrity relies on the Cache API, and the KV tier (the real
partial-read vector) is structurally validated.

Tests: KV transient-read -> miss WITHOUT eviction (#1 guard) + L1 transient
non-eviction assertion; per-tag L1 marker write-through fan-out; reserved-prefix
get/set/delete for both __tag__/ and __tagmarker__/; revalidateTag failure ->
onError; non-CF (CacheScope) corrupt segment self-heal.

* fix(cache): address tag-invalidation review findings

- record cached entry tags into the request tag union on segment-cache
  hits, so a document re-cached over a segment hit stays tag-invalidatable;
  narrow the document-cache caveat to the first-write waitUntil race
- gate the invalidateTags memo/L1 marker write-through on KV (markers are
  never read without it) and update the stale constructor comment
- warn with the real cause when updateTag/revalidateTag run outside a
  request context (queue consumer / cron) instead of the store-config message
- reword revalidateTag as background hard-purge (not SWR) in consumer JSDoc,
  the export comment, and the unit test title
- annotate URL-param tag e2e fixtures as test-only (untrusted-input warning)
- unit tests pin hit-path tag recording, the no-KV no-op, and the
  no-context warnings; unique namespace for the no-KV warn fixture

* fix(cache): adapt cache-tag e2e to bare-T JSON responses (#539)

The cache-tag e2e helpers read (await res.json()).data.ts from path.json
routes, but #539 (bare-T JSON responses) removed the { data } envelope:
path.json now serializes the handler's return value verbatim. The tagged
routes return a bare { ts, id }, so .data was undefined and reading .ts
threw "Cannot read properties of undefined (reading 'ts')".

This broke the cache-tag invalidation suites in four places (router dev +
production, cloudflare-basic dev + production). On the rangojs-router shards
the failing test's 8s expect.poll retries also destabilized the shard,
cascading into is-action and cache-loader-null timeouts; fixing the root
read clears those too.

- e2e/cache.test.ts: pollTs reads .ts (dev + production helpers)
- cloudflare-basic/e2e/cache-tag.test.ts: ts() reads .ts
- urls/cache-tag.tsx: correct the stale { data } envelope comment
- docs/internal/feature-map.md: oxfmt table padding

Verified locally: router dev cache-tag (6 passed), router production
cache-tag + is-action + cache-loader-null (9 passed), unit (3501),
typecheck, lint, format.

* harden(cache): fold post-reconcile review findings into CFCacheStore

Adversarial review of the #534<->#558 reconcile surfaced ten findings; this
folds the worthwhile ones in before the single CI run (cheaper than a second
follow-up round).

Read-your-own-writes / correctness:
- fetchTagMarker no longer clobbers a concurrent invalidateTags() memo write
  that lands mid-read, so a parallel-slot updateTag() stays read-your-own-writes.
- Per-store isolation of the tag-marker memo/inflight maps: two stores in one
  request (app store + a cache({ store }) override on a different KV binding)
  can no longer cross-pollute marker values.
- kvGetOrEvict guards a KV body that parses to null/a primitive BEFORE
  validate(), so 'null'/'42' are evicted as corrupt instead of throwing past the
  validator into a transient cache-read that never self-heals.
- Non-array tags fail safe (Array.isArray at the write guards and the central
  isGloballyInvalidated check) instead of throwing .map on every read.

Fail-safe reads/writes (no perf degradation):
- Corrupt-entry KV eviction is dispatched via waitUntil, never awaited on the
  read path (an unbounded kv.delete would re-add exactly the stall the read
  budgets exist to prevent).
- The transient L1 tag-marker match error is reported as cache-read like the
  data read paths, instead of being silently discarded at the destructuring.
- The L1 marker write-through failure on invalidate is surfaced
  (cache-invalidate) and the stale marker evicted, instead of swallowed while
  updateTag reports success.

DX / hygiene:
- tagInvalidationTtl below KV's 60s expirationTtl floor is raised (with a
  one-time warning) so a too-small value no longer makes EVERY invalidation
  throw; a non-finite tagCacheTtl is coerced to 0 (disabled) rather than
  silently enabling an invalid max-age=Infinity marker.
- Remove the now-dead parseOrEvict (replaced during the reconcile by
  readJsonWithTimeout/healCorruptL1 + kvGetOrEvict).

Observability + API completeness (review below-the-cut items):
- New `markerMs` debug field: the tagged-read marker-resolution tail (memo -> L1
  marker cache -> KV) is no longer invisible in CFCacheReadDebugEvent (measured
  only under debug). New `match-error` debug outcome so a match() rejection is
  distinct from a genuine l1-miss and agrees with the cache-read on onError.
- Export CacheItemOptions / CacheItemResult from ./cache so a custom
  SegmentCacheStore author can name every type its getItem/setItem methods use.

Docs/comments corrected: caching skill corrupt-entry self-heal table (serves
from L2, evicts only when no L2 copy), healCorruptL1 cross-request timeout note,
the tag-invalidated tier-divergence comment, and a tagCacheTtl same-colo race
caveat.

Tests: +15 (11 proven red-before-green); strengthened the corrupt-L1
heal-by-overwrite and fail-open assertions to be non-vacuous; the fail-open mock
keys on the exported TAG_MARKER_PREFIX so a prefix rename can't make it pass
vacuously; added real-timer teardown to both CF suites. Unit 3562 green;
cache-tag e2e green dev+production against the real CFCacheStore.

* chore(cache): raise CFCacheStore default kvReadTimeoutMs 50 -> 170 [skip ci]

The L2/KV read budget defaulted to 50ms, right at the documented healthy read,
which false-misses legitimate latency tails (larger payloads, far-from-colo
regions) into a full render. Raise the default to 170ms: a few multiples above
the ~50ms healthy read for headroom, still well under a degraded namespace's
multi-second tail. Per-store override (kvReadTimeoutMs) and the <=0-disables
contract are unchanged; the tag-marker KV reads inherit the same budget.

Unit tests reference the KV_READ_TIMEOUT_MS constant (not a literal 50) so they
adapt; the explicit-override tests (200, 0) are independent. SKILL default table
and the option JSDoc updated to 170.

* docs(cache): document markerMs/match-error debug surface + tag-marker read-latency rationale [skip ci]

- skills/caching/SKILL.md: bring the debug outcome list current with the full
CFCacheReadDebugEvent union; document the markerMs marker-resolution diagnostic
and match-error (distinct from l1-miss).
- docs/internal/feature-map.md: add match-error + markerMs to the ./cache
debug-sink description.
- docs/design/cache-tags-flow.md: add a Tag-marker read latency section -
measured baseline (1-6ms warm / ~73ms cold), why an in-isolate marker cache is
rejected, and the tagCacheTtl -> marker-parallel ladder if latency must drop.

Docs-only; the markerMs field and match-error outcome shipped in bcb8477.

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
ivogt added a commit that referenced this pull request Jun 13, 2026
Brings in #560 (rango state cookie + invalidateClientCache/keepClientCache),
#534 (cache tag invalidation cacheTag/updateTag/revalidateTag), and the
CFCacheStore latency-budget/SWR hardening (#558).

Non-trivial conflict resolutions:
- docs/internal/feature-map.md & feature-file-map.md: unioned both sides'
  tables. Took main's updated `.`/`./cache` rows (new cache-tag + client-cache
  exports) plus its `Client cache invalidation` feature row, and kept this
  branch's `./testing/*` export rows and `Consumer testing primitives` row.
- tests/cloudflare-basic/src/urls.tsx: kept main's updateTag/revalidateTag
  imports but dropped `cookies` - this branch extracted the inline
  cookies().set("mw-overlay") into middleware/cookie-overlay.ts.
- src/testing/render-route.tsx: removed the test NavigationBridge's
  updateAppShell member; main removed updateAppShell from the NavigationBridge
  type (app-shell updates now flow only through appShellRef).

Verified green: typecheck (12/12), test:unit:all (3915 passed / 6 todo),
lint (oxlint 0/0), format (oxfmt), check:e2e-bucketing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant