Skip to content

feat(cache): tag invalidation — cacheTag / updateTag / revalidateTag - #534

Merged
ivogt merged 14 commits into
mainfrom
feat/cache-tags-v2
Jun 11, 2026
Merged

ivogt merged 14 commits into
mainfrom
feat/cache-tags-v2

Conversation

@ivogt

@ivogt ivogt commented Jun 5, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Rango could cache route segments, "use cache" function results, and full-page documents, but nothing could invalidate a cached entry by tag. A consumer who cached product data had to wait out the TTL — a mutation in a Server Action could not make the next render fresh, and a webhook could not purge a specific tag. On Cloudflare there was no distributed invalidation signal at all: once a segment / use cache result / page was in the edge cache, only its TTL freed it.

This PR adds the missing primitive: tag at write time, invalidate on demand, across the in-memory and Cloudflare stores.

What a consumer writes now

Before — no invalidation; stuck with the TTL:

async function getProducts() {
  "use cache";                 // cached for the profile TTL, period
  return db.products.all();
}
// a mutation could not refresh this until the TTL elapsed

After — tag, then invalidate:

async function getProducts() {
  "use cache";
  cacheTag("products");                  // runtime tag on this entry
  return db.products.all();
}

// Server Action — read-your-own-writes
async function addProduct(form: FormData) {
  "use server";
  await db.products.create(form);
  await updateTag("products");           // awaited; the action's own re-render is fresh
}

// Route handler / webhook — fire-and-forget background purge
export async function POST() {
  "use server";
  revalidateTag("products");             // not awaited; hard-purges in the background
  return new Response("ok");
}

Tags can also come from the route DSL or loaders (same invalidation verbs):

cache({ ttl: 600, tags: ["catalog"] }, () => [
  path.json("/catalog/:id", getCatalog),
]);

End-to-end (Cloudflare)

// router.tsx
const store = new CFCacheStore({
  ctx: env.ctx,
  kv: env.CACHE_KV,            // REQUIRED for distributed tag invalidation (markers live here)
  // optional:
  //   tagCacheTtl        — per-colo L1 cache of tag markers (read-cost reducer)
  //   onRevalidateTag    — CDN purge-by-tag hook for prompt cross-colo invalidation
  //   tagInvalidationTtl — marker expiry (defaults to no-expiry; must exceed max entry TTL+SWR)
});
export const router = createRouter({ /* ... */, cache: { store } });

await updateTag("catalog") in an action: writes the per-tag KV marker, makes the action's own re-render fresh via a per-request memo, and causes every other request/colo to treat any entry tagged catalog before the marker as a miss (re-render fresh).

Semantics

Verb Awaited? Consistency Use in On store failure (CF)
updateTag(...tags) yes read-your-own-writes Server Actions rejects — a failed durable KV marker write surfaces so you can retry (no false success)
revalidateTag(...tags) no (waitUntil) background hard-purge route handlers / webhooks logged via waitUntil
cacheTag(...tags) n/a tags the current "use cache" entry inside "use cache" throws if called outside a "use cache" function

Both updateTag and revalidateTag hard-purge (the next read is a miss that re-renders); the only difference is awaitability. Neither serves stale content — despite the Next.js name parallel, revalidateTag here is background-purge, not stale-while-revalidate.

Store Tag mechanism Invalidate
MemorySegmentCacheStore forward + reverse tag index eager delete; next read is a miss
CFCacheStore per-tag KV marker vs. entry taggedAt (+ optional L1 marker cache, CDN purge hook) marker write; reads compare marker >= taggedAt and miss

Reconciled with #558 (CFCacheStore L1/L2 hardening) — inherited fail-safes

This branch was brought up to date with main, which landed #558 (CFCacheStore latency budgets, a per-read debug sink, and SWR retention/herd fixes). Both PRs rewrote the same get / getItem / kvGet* / promote* methods, so the merge is a semantic reconcile that keeps both sides' fail-safes — and extends #558's guard-rails to the new tag-marker read path. Consumer-visible upshot: tag invalidation never comes at the cost of a stalled or failed cache read.

  • Tag-marker reads inherit the latency budgets and fail open. isGloballyInvalidated resolves per-tag markers through a per-request memo → optional per-colo L1 cache (tagCacheTtl) → KV cascade. Each tier is now bounded by the same budgets as data reads (edgeLookupTimeoutMs / edgeReadTimeoutMs / kvReadTimeoutMs); on a KV-marker timeout the read fails OPEN (the entry is served, not wrongly treated as invalidated), so a degraded KV namespace can never pin every tagged read behind a slow global lookup.
  • Unified fail-safe read contract. A transient L1 error (a match rejection) or a corrupt L1 body is now reported to the router's onError (metadata.category of cache-read vs cache-corrupt) and still degrades to L2/KV — serving a good cross-colo copy rather than forcing a render. A corrupt L1 entry self-heals: it is evicted only when no good L2 copy exists, so the evict can never race the KV→L1 promote (which would otherwise clobber the just-restored entry).
  • getResponse (document tier) now surfaces a transient match rejection as cache-read, matching get / getItem (the document path had been swallowing it after fix(router): harden CFCacheStore L1/L2 reads — latency budgets, debug sink, SWR retention/herd fixes #558 routed it through the bounded match).
  • Writes stay non-blocking and never throw to the request; updateTag's durable KV marker write is awaited (read-your-own-writes) while the per-colo L1 write-through is fanned out.

These were found by a fan-out adversarial review of the merged store (5 dimensions → adversarially verify each finding). The two confirmed reconcile defects — the racing corrupt-L1 evict and the dropped getResponse report — are fixed here with dedicated regression tests proven red-before-green.

Post-reconcile hardening (review follow-ups)

A second adversarial review of the merged store surfaced ten findings; the worthwhile ones are folded in here (one commit) rather than deferred, so the single CI run covers them. None change the tag-invalidation contract; all keep reads/writes fail-safe and add no request-path latency.

Area Fix Consumer-visible effect
Read-your-own-writes fetchTagMarker no longer clobbers a concurrent invalidateTags() memo write that lands mid-read a parallel-slot updateTag() stays RYW for the rest of the request
Store isolation the tag-marker memo/in-flight maps are keyed per store instance, not just per request an app store + a cache({ store }) override on a different KV binding can no longer cross-pollinate marker values (serve-invalidated / false-invalidate)
Corruption self-heal kvGetOrEvict guards a KV body that parses to null/a primitive before validate() a stored 'null'/'42' is evicted as corrupt instead of throwing past the validator into a transient cache-read that never self-heals
Input safety Array.isArray at the write guards and the central isGloballyInvalidated check a non-array tags (direct store misuse / skewed envelope) fails safe instead of throwing .map on every read
Fail-safe latency the corrupt-entry KV eviction is dispatched via waitUntil, never awaited on the read path an unbounded kv.delete on a degraded namespace can no longer stall the (already-resolved) read
Observability the transient L1 tag-marker match error is reported as cache-read like the data paths; the L1 marker write-through failure on invalidate is surfaced (cache-invalidate) + the stale marker evicted tag-path infra failures are no longer silently swallowed
DX tagInvalidationTtl below KV's 60s expirationTtl floor is raised (one-time warn); a non-finite tagCacheTtl is coerced to 0 (disabled) a too-small tagInvalidationTtl no longer makes every invalidation throw; a NaN/Infinity tagCacheTtl no longer writes an invalid max-age=Infinity marker
Observability new markerMs field on CFCacheReadDebugEvent (the tagged-read marker-resolution tail, measured only under debug); new match-error outcome a tagged read's serial marker latency is no longer invisible in debug; an L1 match() rejection is distinguishable from a genuine l1-miss and agrees with the cache-read on onError
API completeness export CacheItemOptions / CacheItemResult from @rangojs/router/cache a custom SegmentCacheStore author can now name every type its getItem/setItem methods use (alongside the already-exported CacheGetResult)
Hygiene removed the now-dead parseOrEvict (replaced during the reconcile); real-timer teardown added to both CF unit suites —

Eleven of these have regression tests proven red-before-green. The markerMs/match-error additions are backward-compatible (a new optional field + a new union member). Two findings were deliberately left as documented limitations (a cross-colo render-vs-KV-promote optimization on a tag-invalidated L1 hit, and a tagCacheTtl-bounded same-colo re-cache race that has no Cache-API compare-and-set to close). The corrupt-entry self-heal table in the caching skill and the healCorruptL1 / tag-invalidated comments were corrected to match the merged behavior.

Code change (high level)

  • New: src/cache/cache-tag.ts (cacheTag, runtime tag scope, normalizeTag(s)), src/cache/tag-invalidation.ts (updateTag/revalidateTag, multi-store allSettled fan-out that rethrows on failure).
  • cf/cf-cache-store.ts: KV markers + isGloballyInvalidated, per-request memo + in-flight read dedup, encodeURIComponent tag header (ByteString-safe), 512-byte marker-key guard, failed-write rejection, optional tagCacheTtl L1 + onRevalidateTag purge.
  • memory-segment-store.ts: forward/reverse tag index.
  • cache-runtime.ts / cache-scope.ts / loader-cache.ts: tag merge (normalized + deduped) and recordRequestTags into a per-request tag union.
  • document-cache.ts: tag full-page entries from the request tag union, snapshotted after the render stream drains (so Suspense-streamed tags are captured).
  • request-context.ts: _requestTags, _explicitTaggedStores (LRU-bounded, handler-scoped). Server-only exports stubbed in index.ts, real impls in index.rsc.ts. Docs/skills (caching, migrate-nextjs, cache-guide, use-cache, rango) + design docs updated.
  • fix(router): harden CFCacheStore L1/L2 reads — latency budgets, debug sink, SWR retention/herd fixes #558 reconcile (cf/cf-cache-store.ts): kvGetOrEvict is budget-aware (readWithTimeout + { value, timedOut }) and string-tolerant; the tag-marker cascade (fetchTagMarker) inherits the L1/L2 budgets and fails open; healCorruptL1 reports + falls through + evicts only when L2 has no copy; markRevalidating / promotes / KV envelopes carry both the hard-expiry header and the tag headers; get / getItem / getResponse report a transient match error as cache-read; a new tag-invalidated debug outcome. The merged ./cache export adds CacheErrorCategory; the caching skill documents the latency budgets + debug.

Test plan

  • pnpm run typecheck
  • pnpm run test:unit — 3562 passed (190 files) — +15 over the reconcile baseline (the hardening pass)
  • pnpm run lint — 0 errors
  • pnpm run format — clean
  • e2e dev + production, both apps — cloudflare-basic (real CFCacheStore + miniflare KV) and the memory-store e2e test-app:
    • cacheTag() + cache({ tags }) + updateTag() invalidation
    • revalidateTag() background invalidation (poll-based, since not awaited)
    • cross-tag isolation; invalidating an unknown tag is a safe no-op
    • document-cache full-page invalidation (/tagged-document)
    • Suspense-streamed tag invalidation (/streamed-document) — pins the stream-drain snapshot barrier (silent no-op under the old handler-settlement barrier)
  • New unit coverage pins (cache-tags): failed-KV-write rejection, oversized-tag rejection, non-Latin1 header round-trip, memo-not-advanced-on-failure, registry LRU bound, write/invalidate tag-normalization parity.
  • New unit coverage pins (fix(router): harden CFCacheStore L1/L2 reads — latency budgets, debug sink, SWR retention/herd fixes #558 reconcile): resilient L2 fall-through on an L1 error / corrupt body (served from KV, reported, no false evict), the corrupt-L1 evict not racing the promote, the getResponse cache-read report, and the tag-marker fail-open on a KV-read timeout. Both review-fix regression tests proven red-before-green. Also added vi.restoreAllMocks() to the tags-suite beforeEach (a leaked cache.put reject was masking L1 writes in the new tests).
  • New unit coverage pins (post-reconcile hardening, +15): the in-flight-marker memo no-clobber (RYW), two-store memo isolation, corrupt-'null' eviction, background (not awaited) corrupt-KV evict, transient L1 tag-marker match error report, non-array tags fail-safe, tagInvalidationTtl 60s floor, non-finite tagCacheTtl disable, L1 marker write-through failure surfaced, markerMs on a tagged-read debug event, and the match-error debug outcome — 11 proven red-before-green (each fails on the pre-hardening store, all pass after). Strengthened the corrupt-L1-with-KV-copy tests to assert the heal-by-overwrite promote actually lands, and the fail-open test to assert the served entry's data + no eviction (and to key its mock on the exported TAG_MARKER_PREFIX, so a prefix rename can't make it pass vacuously).

Notes / known limitations

  • Document cache + segment cache({ tags }) DSL: a tag attached only via the segment DSL (resolved in the deferred cacheRoute waitUntil) can race the document tag snapshot, so the full-page entry may not carry it — the segment entry itself still invalidates correctly. Documented in document-cache.ts. Runtime cacheTag() / "use cache" / loader tags are always captured.
  • Prerendered (B-segment) routes are not tag-invalidatable (they are refreshed by rebuild, not updateTag); noted in prerender-api-design.md. A dev-time warning is a deferred follow-up (no clean "currently prerendering" signal to gate it on yet).
  • Marker cardinality: tagInvalidationTtl defaults to no-expiry (markers are one tiny timestamp each); keep invalidation tags low-cardinality and never derive them from untrusted input.
  • tagCacheTtl same-colo race (opt-in only): a read already in flight when an invalidation lands can re-cache the prior marker into that colo's L1 after the write-through, so a racing concurrent reader in the same colo may miss the invalidation for up to tagCacheTtl. The Cache API has no compare-and-set to close this fully; tagCacheTtl is documented as a staleness ceiling, not zero-latency. No data is ever served wrong cross-colo — KV stays authoritative.
  • Tag-invalidated L1 hit re-renders locally rather than falling through to KV. When another colo has already re-rendered a fresher KV entry, a fall-through could serve it instead of re-rendering; capturing that cross-colo optimization is a deferred follow-up (not a correctness gap — this colo self-heals on its own next read). The misleading "L1 and KV share the same taggedAt" comment was corrected.

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.
@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

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

commit: bcb8477

ivogt added 6 commits June 5, 2026 10:01
…idateTag/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).
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.
# Conflicts:
#	packages/rangojs-router/docs/internal/feature-file-map.md
…rvability)

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.
- 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
@ivogt

ivogt commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

@copilot resolve the merge conflicts in this pull request

# Conflicts:
#	packages/rangojs-router/docs/internal/feature-map.md

Copilot AI commented Jun 10, 2026

Copy link
Copy Markdown

@copilot resolve the merge conflicts in this pull request

Merge conflicts resolved in be17db52. There was one conflict in packages/rangojs-router/docs/internal/feature-map.md — the table row for the root . export had diverged. I combined both sides: kept the tag functions (cacheTag(), updateTag(), revalidateTag()) from this branch and incorporated buildPerRouterTrie that was added to main's ./build row.

Copilot finished work on behalf of ivogt June 10, 2026 09:20
ivogt added 6 commits June 10, 2026 13:17
# Conflicts:
#	packages/rangojs-router/src/cache/cf/cf-cache-store.ts
#	tests/cloudflare-basic/src/urls.tsx
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.
…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.
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.
…kip 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.
… 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.
@ivogt
ivogt merged commit 89c262f into main Jun 11, 2026
@ivogt
ivogt deleted the feat/cache-tags-v2 branch June 11, 2026 19:33
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.

2 participants