@rangojs/router 0.0.0-experimental.121 → 0.0.0-experimental.124

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/bin/rango.js +7 -2
  2. package/dist/vite/index.js +47 -6
  3. package/package.json +61 -21
  4. package/skills/cache-guide/SKILL.md +8 -6
  5. package/skills/caching/SKILL.md +148 -1
  6. package/skills/hooks/SKILL.md +38 -27
  7. package/skills/host-router/SKILL.md +16 -2
  8. package/skills/intercept/SKILL.md +4 -2
  9. package/skills/layout/SKILL.md +11 -6
  10. package/skills/loader/SKILL.md +6 -2
  11. package/skills/middleware/SKILL.md +4 -2
  12. package/skills/migrate-nextjs/SKILL.md +38 -16
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +27 -15
  15. package/skills/route/SKILL.md +4 -2
  16. package/skills/testing/SKILL.md +129 -0
  17. package/skills/testing/bindings.md +89 -0
  18. package/skills/testing/cache-prerender.md +98 -0
  19. package/skills/testing/client-components.md +122 -0
  20. package/skills/testing/e2e-parity.md +125 -0
  21. package/skills/testing/flight.md +89 -0
  22. package/skills/testing/handles.md +129 -0
  23. package/skills/testing/loader.md +128 -0
  24. package/skills/testing/middleware.md +99 -0
  25. package/skills/testing/render-handler.md +118 -0
  26. package/skills/testing/response-routes.md +95 -0
  27. package/skills/testing/reverse-and-types.md +84 -0
  28. package/skills/testing/server-actions.md +107 -0
  29. package/skills/testing/server-tree.md +128 -0
  30. package/skills/testing/setup.md +120 -0
  31. package/skills/use-cache/SKILL.md +9 -7
  32. package/src/browser/action-fence.ts +37 -0
  33. package/src/browser/cookie-name.ts +140 -0
  34. package/src/browser/invalidate-client-cache.ts +52 -0
  35. package/src/browser/navigation-bridge.ts +14 -1
  36. package/src/browser/navigation-client.ts +14 -1
  37. package/src/browser/navigation-store-handle.ts +39 -0
  38. package/src/browser/navigation-store.ts +26 -12
  39. package/src/browser/prefetch/fetch.ts +7 -0
  40. package/src/browser/rango-state.ts +176 -97
  41. package/src/browser/react/index.ts +0 -6
  42. package/src/browser/rsc-router.tsx +12 -4
  43. package/src/browser/server-action-bridge.ts +77 -15
  44. package/src/browser/types.ts +7 -1
  45. package/src/cache/cache-error.ts +104 -0
  46. package/src/cache/cache-policy.ts +95 -1
  47. package/src/cache/cache-runtime.ts +79 -13
  48. package/src/cache/cache-scope.ts +55 -4
  49. package/src/cache/cache-tag.ts +135 -0
  50. package/src/cache/cf/cf-cache-store.ts +2080 -224
  51. package/src/cache/cf/index.ts +15 -1
  52. package/src/cache/document-cache.ts +74 -7
  53. package/src/cache/index.ts +17 -0
  54. package/src/cache/memory-segment-store.ts +164 -14
  55. package/src/cache/tag-invalidation.ts +230 -0
  56. package/src/cache/types.ts +27 -0
  57. package/src/client.rsc.tsx +1 -1
  58. package/src/client.tsx +0 -6
  59. package/src/component-utils.ts +19 -0
  60. package/src/handle.ts +29 -9
  61. package/src/host/testing.ts +43 -14
  62. package/src/index.rsc.ts +29 -1
  63. package/src/index.ts +43 -1
  64. package/src/loader.rsc.ts +24 -3
  65. package/src/loader.ts +16 -2
  66. package/src/prerender.ts +24 -3
  67. package/src/router/basename.ts +14 -0
  68. package/src/router/match-handlers.ts +62 -20
  69. package/src/router/prerender-match.ts +6 -0
  70. package/src/router/router-interfaces.ts +7 -0
  71. package/src/router/router-options.ts +30 -0
  72. package/src/router/segment-resolution/loader-cache.ts +8 -17
  73. package/src/router/state-cookie-name.ts +33 -0
  74. package/src/router/telemetry.ts +99 -0
  75. package/src/router.ts +36 -7
  76. package/src/rsc/handler.ts +13 -1
  77. package/src/rsc/helpers.ts +19 -0
  78. package/src/rsc/progressive-enhancement.ts +2 -0
  79. package/src/rsc/response-route-handler.ts +8 -1
  80. package/src/rsc/rsc-rendering.ts +2 -0
  81. package/src/rsc/types.ts +2 -0
  82. package/src/runtime-env.ts +18 -0
  83. package/src/server/cookie-store.ts +52 -1
  84. package/src/server/request-context.ts +105 -2
  85. package/src/static-handler.ts +25 -3
  86. package/src/testing/cache-status.ts +166 -0
  87. package/src/testing/collect-handle.ts +63 -0
  88. package/src/testing/dispatch.ts +581 -0
  89. package/src/testing/dom.entry.ts +22 -0
  90. package/src/testing/e2e/fixture.ts +188 -0
  91. package/src/testing/e2e/index.ts +149 -0
  92. package/src/testing/e2e/matchers.ts +51 -0
  93. package/src/testing/e2e/page-helpers.ts +272 -0
  94. package/src/testing/e2e/parity.ts +387 -0
  95. package/src/testing/e2e/server.ts +195 -0
  96. package/src/testing/flight-matchers.ts +110 -0
  97. package/src/testing/flight-normalize.ts +38 -0
  98. package/src/testing/flight-runtime.d.ts +57 -0
  99. package/src/testing/flight-tree.ts +682 -0
  100. package/src/testing/flight.entry.ts +52 -0
  101. package/src/testing/flight.ts +234 -0
  102. package/src/testing/generated-routes.ts +223 -0
  103. package/src/testing/index.ts +119 -0
  104. package/src/testing/internal/context.ts +390 -0
  105. package/src/testing/internal/flight-client-globals.ts +30 -0
  106. package/src/testing/internal/seed-vars.ts +80 -0
  107. package/src/testing/render-handler.ts +360 -0
  108. package/src/testing/render-route.tsx +594 -0
  109. package/src/testing/run-loader.ts +474 -0
  110. package/src/testing/run-middleware.ts +231 -0
  111. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  112. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  113. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  114. package/src/testing/vitest-stubs/version.ts +5 -0
  115. package/src/testing/vitest.ts +305 -0
  116. package/src/types/cache-types.ts +13 -4
  117. package/src/types/error-types.ts +5 -1
  118. package/src/types/global-namespace.ts +11 -1
  119. package/src/types/handler-context.ts +16 -5
  120. package/src/browser/react/use-client-cache.ts +0 -58
@@ -40,11 +40,17 @@ import {
40
40
  type RequestContext,
41
41
  } from "../../server/request-context.js";
42
42
  import { VERSION } from "@rangojs/router:version";
43
+ import {
44
+ isPerClientSignalHeader,
45
+ stripPerClientSignals,
46
+ } from "../../browser/cookie-name.js";
43
47
  import {
44
48
  resolveTtl,
45
49
  resolveSwrWindow,
46
50
  DEFAULT_FUNCTION_TTL,
47
51
  } from "../cache-policy.js";
52
+ import { reportCacheError, reportingAsync } from "../cache-error.js";
53
+ import type { CacheErrorCategory } from "../cache-error.js";
48
54
 
49
55
  // ============================================================================
50
56
  // Constants
@@ -56,6 +62,66 @@ export const CACHE_STALE_AT_HEADER = "x-edge-cache-stale-at";
56
62
  /** Header storing cache status: HIT | REVALIDATING */
57
63
  export const CACHE_STATUS_HEADER = "x-edge-cache-status";
58
64
 
65
+ /**
66
+ * Header storing this entry's cache tags as a JSON array. JSON-encoded (not the
67
+ * comma-delimited CF `Cache-Tag` format) so tags containing commas round-trip
68
+ * safely; the read paths parse this to run the tag-invalidation check.
69
+ */
70
+ export const CACHE_TAGS_HEADER = "x-edge-cache-tags";
71
+
72
+ /** Header storing the ms-epoch timestamp when this entry's tags were attached. */
73
+ export const CACHE_TAGGED_AT_HEADER = "x-edge-cache-tagged-at";
74
+
75
+ /**
76
+ * KV key prefix for tag-invalidation markers. A marker stores the ms-epoch
77
+ * timestamp of the most recent invalidation of a tag; reads treat any entry
78
+ * whose taggedAt is older than its tags' latest marker as invalidated. Markers
79
+ * live in the SAME KV namespace as the cached entries - there is no separate
80
+ * tag-invalidation store.
81
+ */
82
+ export const TAG_MARKER_PREFIX = "__tag__/";
83
+
84
+ /**
85
+ * Cache-API path prefix for the optional per-colo L1 cache of tag-invalidation
86
+ * markers (enabled by tagCacheTtl). Distinct from data keys (doc:/fn:/segment)
87
+ * and from the KV marker prefix so the two never collide.
88
+ */
89
+ const TAG_MARKER_CACHE_PREFIX = "__tagmarker__/";
90
+
91
+ /**
92
+ * Sentinel body for an L1-cached marker meaning "this tag has no invalidation
93
+ * marker." Distinct from any real ms-epoch timestamp (always a large positive
94
+ * integer). A Cache API miss (match() === undefined) always means "re-read KV",
95
+ * never "no marker" - absence is only ever represented by this cached sentinel.
96
+ */
97
+ const TAG_MARKER_ABSENT = "none";
98
+
99
+ /**
100
+ * Header storing the epoch-ms timestamp when an entry was marked REVALIDATING.
101
+ * The SWR thundering-herd guard reads this to decide whether the in-flight
102
+ * revalidation is still recent. It replaces a prior reliance on the HTTP `Age`
103
+ * header: CF's Cache API does not populate `Age` reliably per-colo (and our own
104
+ * unit MockCache never set it), so an absent `Age` defaulted to 0 and made every
105
+ * REVALIDATING entry look "just revalidated" forever -- a dropped/never-finished
106
+ * background revalidation could then pin an entry stale until hard expiry. An
107
+ * explicit timestamp we write ourselves (same pattern as CACHE_STALE_AT_HEADER)
108
+ * is reliable and lets the MAX_REVALIDATION_INTERVAL re-arm actually fire.
109
+ */
110
+ export const CACHE_REVALIDATING_AT_HEADER = "x-edge-cache-revalidating-at";
111
+
112
+ /**
113
+ * Header storing the absolute epoch-ms hard-expiry deadline (staleAt +
114
+ * swrWindow*1000) of an L1 entry. The stale-path REVALIDATING re-put reads this
115
+ * to recompute a SHRINKING Cache-Control max-age instead of copying set()'s
116
+ * original full-window max-age. Without it, every MAX_REVALIDATION_INTERVAL
117
+ * re-arm re-puts the full window and restarts CF's retention clock, pinning a
118
+ * perpetually-stale entry (one whose background revalidation keeps failing) past
119
+ * its intended hard-expiry indefinitely. Mirrors the KVSegmentEnvelope `e`
120
+ * field and the remaining-ttl math in promoteSegmentToL1/promoteItemToL1.
121
+ * @internal
122
+ */
123
+ const CACHE_EXPIRES_AT_HEADER = "x-edge-cache-expires-at";
124
+
59
125
  /**
60
126
  * Header stashing the route author's original Cache-Control on L1 document
61
127
  * entries. putResponse/promoteResponseToL1 overwrite Cache-Control with a long
@@ -72,6 +138,118 @@ const CACHE_ORIG_CC_HEADER = "x-edge-cache-orig-cc";
72
138
  */
73
139
  export const MAX_REVALIDATION_INTERVAL = 30;
74
140
 
141
+ /**
142
+ * Per-request memo of tag-invalidation markers (tag -> latest invalidatedAt, or
143
+ * null when no marker exists). Keyed first by the request context object (so it
144
+ * is naturally request-scoped and garbage-collected with the request) and then
145
+ * by the store INSTANCE.
146
+ *
147
+ * The per-store nesting matters because a single request can run more than one
148
+ * CFCacheStore - the app-level store plus a route's `cache({ store })` override,
149
+ * which may point at a DIFFERENT KV binding or version. A module-level map keyed
150
+ * by request alone (the inner map keyed by the raw tag name) would let store B's
151
+ * memoized marker for a tag mask store A's own KV marker, so A could serve an
152
+ * entry A's own KV says is invalidated. Keying by the instance isolates them;
153
+ * two reads through the SAME store still share the memo. A read through one
154
+ * store never populates another's memo, so each store always consults its own KV
155
+ * binding. Markers are read only through isGloballyInvalidated(), which already
156
+ * short-circuits when a store has no KV, so a store without KV never allocates.
157
+ *
158
+ * Without the memo, isGloballyInvalidated() issues a KV read per tag on every
159
+ * tagged cache read, so a page composed of many segments/items sharing a tag
160
+ * pays that cost N times. The memo collapses it to one KV read per distinct tag
161
+ * per (request, store). invalidateTags() writes through so a same-request
162
+ * updateTag() stays read-your-own-writes consistent (the action's own re-render
163
+ * sees its own invalidation from the memo, without a re-read).
164
+ *
165
+ * It does NOT span requests, so a hot single-entry route still pays one KV read
166
+ * per request; that read hits Cloudflare KV's own edge read cache for hot keys.
167
+ */
168
+ const tagMarkerMemo = new WeakMap<
169
+ object,
170
+ WeakMap<object, Map<string, number | null>>
171
+ >();
172
+
173
+ function getTagMarkerMemo(
174
+ ctx: object,
175
+ store: object,
176
+ ): Map<string, number | null> {
177
+ let byStore = tagMarkerMemo.get(ctx);
178
+ if (!byStore) {
179
+ byStore = new WeakMap();
180
+ tagMarkerMemo.set(ctx, byStore);
181
+ }
182
+ let memo = byStore.get(store);
183
+ if (!memo) {
184
+ memo = new Map();
185
+ byStore.set(store, memo);
186
+ }
187
+ return memo;
188
+ }
189
+
190
+ /**
191
+ * Per-request map of IN-FLIGHT marker reads (tag -> the pending read promise).
192
+ * The resolved-value memo above only collapses SEQUENTIAL reads of a tag; the
193
+ * router resolves sibling segments in PARALLEL, so without this several
194
+ * concurrently-resolving segments sharing a tag would each issue their own KV
195
+ * read before any of them populates the memo. Sharing the in-flight promise
196
+ * collapses those to a single KV read. Entries are dropped once resolved (the
197
+ * value is then in the memo), so this only spans the concurrent read window.
198
+ */
199
+ const tagMarkerInflight = new WeakMap<
200
+ object,
201
+ WeakMap<object, Map<string, Promise<number | null>>>
202
+ >();
203
+
204
+ function getTagMarkerInflight(
205
+ ctx: object,
206
+ store: object,
207
+ ): Map<string, Promise<number | null>> {
208
+ let byStore = tagMarkerInflight.get(ctx);
209
+ if (!byStore) {
210
+ byStore = new WeakMap();
211
+ tagMarkerInflight.set(ctx, byStore);
212
+ }
213
+ let inflight = byStore.get(store);
214
+ if (!inflight) {
215
+ inflight = new Map();
216
+ byStore.set(store, inflight);
217
+ }
218
+ return inflight;
219
+ }
220
+
221
+ /** KV key byte-length ceiling. Cloudflare KV rejects keys larger than this. */
222
+ const KV_MAX_KEY_BYTES = 512;
223
+
224
+ /**
225
+ * Cloudflare KV's minimum `expirationTtl` (seconds). A `put` with a smaller
226
+ * expirationTtl is rejected outright. Tag-invalidation markers (the only writes
227
+ * that take a consumer-supplied TTL via tagInvalidationTtl) are floored to this
228
+ * so a too-small value cannot make EVERY updateTag/revalidateTag throw.
229
+ */
230
+ const KV_MIN_EXPIRATION_TTL = 60;
231
+
232
+ const kvKeyEncoder = new TextEncoder();
233
+
234
+ /** UTF-8 byte length of a KV key (multibyte tags can exceed the char count). */
235
+ function kvKeyByteLength(key: string): number {
236
+ return kvKeyEncoder.encode(key).length;
237
+ }
238
+
239
+ /**
240
+ * Stores (by namespace) already warned about tag machinery configured without a
241
+ * KV namespace, so the warning fires once per process rather than per request
242
+ * (CFCacheStore is constructed per request).
243
+ */
244
+ const warnedNoKvReadInvalidation = new Set<string>();
245
+
246
+ /**
247
+ * Stores (by namespace) already warned about a tagInvalidationTtl below KV's
248
+ * expirationTtl floor, so the floor warning fires once per process rather than
249
+ * once per request (CFCacheStore is constructed per request).
250
+ */
251
+ const warnedTagInvalidationTtlFloor = new Set<string>();
252
+
75
253
  /**
76
254
  * Maximum time (ms) to wait for an L1 edge cache (CF Cache API) read before
77
255
  * giving up and treating it as a miss. The Cache API is normally sub-millisecond
@@ -84,6 +262,63 @@ export const MAX_REVALIDATION_INTERVAL = 30;
84
262
  */
85
263
  export const EDGE_LOOKUP_TIMEOUT_MS = 10;
86
264
 
265
+ /**
266
+ * Maximum time (ms) to wait for the BODY of a matched L1 entry to be read
267
+ * (response.json()) before treating the read as a miss.
268
+ *
269
+ * This is separate from {@link EDGE_LOOKUP_TIMEOUT_MS} on purpose. CF's Cache
270
+ * API resolves `match()` with a lazily-streamed body, so a fast `match` can be
271
+ * followed by a multi-second stall while the body bytes are fetched -- the
272
+ * latency tail lives here, after the match budget has already passed. The
273
+ * default bounds that tail aggressively: a healthy per-colo body read (fetch +
274
+ * JSON parse) settles in low single-digit milliseconds, so 20ms clears a
275
+ * healthy read while still failing fast to L2/KV (or render) on a degraded colo
276
+ * instead of pinning the request behind a seconds-long read. Raise it per store
277
+ * if large Flight payloads legitimately need longer.
278
+ *
279
+ * Override per store via `CFCacheStoreOptions.edgeReadTimeoutMs` (<= 0 disables).
280
+ */
281
+ export const EDGE_READ_TIMEOUT_MS = 20;
282
+
283
+ /**
284
+ * Maximum time (ms) to wait for an L2 (KV) read (`kv.get(key, {type:"json"})`)
285
+ * before treating it as a miss. Unlike the L1 budgets, KV is a GLOBAL store: the
286
+ * file header documents ~50ms healthy reads, and a degraded namespace can tail
287
+ * to seconds. KV is the LAST cache tier before a full render, so an unbounded
288
+ * read here pins the whole request behind a degraded global lookup.
289
+ *
290
+ * The default (170ms) sits a few multiples above the documented ~50ms healthy
291
+ * read, leaving headroom for legitimate latency tails (larger payloads,
292
+ * far-from-colo regions) so a healthy-but-slow read does not false-miss into a
293
+ * render, while still abandoning a genuinely degraded namespace well before its
294
+ * multi-second tail can pin the request. A deployment with a tighter SLA can
295
+ * lower it, and one whose healthy p99 runs higher should raise it: measure the
296
+ * KV read p99 (Workers Analytics) and add margin. It is a degradation
297
+ * guard-rail, not a tuning lever for "slow KV is normal here".
298
+ *
299
+ * Override per store via `CFCacheStoreOptions.kvReadTimeoutMs` (<= 0 disables).
300
+ */
301
+ export const KV_READ_TIMEOUT_MS = 170;
302
+
303
+ /**
304
+ * Compute the Cache-Control directive for a stale-path REVALIDATING re-put from
305
+ * the entry's stored hard-expiry deadline (CACHE_EXPIRES_AT_HEADER). Returns the
306
+ * REMAINING ttl so the re-put preserves the original retention deadline instead
307
+ * of restarting it -- copying set()'s original full-window max-age would reset
308
+ * CF's retention clock on every re-arm and pin a perpetually-stale entry forever.
309
+ * An entry lacking a valid deadline (legacy/tampered) floors to max-age=1, so it
310
+ * hard-expires in ~1s and self-heals via KV. Mirrors promoteSegmentToL1's math.
311
+ * @internal
312
+ */
313
+ function remainingCacheControl(headers: Headers, now: number): string {
314
+ const expiresAt = Number(headers.get(CACHE_EXPIRES_AT_HEADER));
315
+ const remainingTtl =
316
+ Number.isFinite(expiresAt) && expiresAt > 0
317
+ ? Math.max(1, Math.floor((expiresAt - now) / 1000))
318
+ : 1;
319
+ return `public, max-age=${remainingTtl}`;
320
+ }
321
+
87
322
  // ============================================================================
88
323
  // Types
89
324
  // ============================================================================
@@ -134,6 +369,10 @@ interface KVItemEnvelope {
134
369
  s: number;
135
370
  /** When entry hard-expires (ms epoch) */
136
371
  e: number;
372
+ /** Cache tags (for distributed tag invalidation) */
373
+ t?: string[];
374
+ /** Timestamp when tags were attached (ms epoch) */
375
+ ta?: number;
137
376
  }
138
377
 
139
378
  /**
@@ -147,14 +386,112 @@ interface KVResponseEnvelope {
147
386
  st: number;
148
387
  /** HTTP status text */
149
388
  stx: string;
150
- /** Serialized headers as key-value pairs */
389
+ /** Serialized headers as key-value pairs (client-facing; no internal headers) */
151
390
  hd: [string, string][];
152
391
  /** When entry becomes stale (ms epoch) */
153
392
  s: number;
154
393
  /** When entry hard-expires (ms epoch) */
155
394
  e: number;
395
+ /** Cache tags (for distributed tag invalidation) */
396
+ t?: string[];
397
+ /** Timestamp when tags were attached (ms epoch) */
398
+ ta?: number;
156
399
  }
157
400
 
401
+ /**
402
+ * One L1 read decision, surfaced when `debug` is enabled. Lets an operator
403
+ * confirm on a real deployment (e.g. via `wrangler tail`) that the store's
404
+ * observed inputs match its decision: which tier answered, the entry's status,
405
+ * the stale/revalidating timestamps, the raw CF `Age` header (so its
406
+ * unreliability can be seen next to the explicit revalidating-at stamp), and
407
+ * the measured match/body-read durations (where the latency tail shows up).
408
+ */
409
+ export interface CFCacheReadDebugEvent {
410
+ /**
411
+ * Which read method produced this event. Only the JSON read paths (segment
412
+ * `get` and function `getItem`) participate in debug; the document
413
+ * `getResponse` path streams its body and is intentionally out of scope.
414
+ */
415
+ op: "get" | "getItem";
416
+ /** Cache key (without the internal fn:/doc: prefix or version path). */
417
+ key: string;
418
+ /**
419
+ * What the read resolved to:
420
+ * - l1-fresh / l1-stale-revalidate / l1-revalidating-guarded: L1 hit outcomes
421
+ * - match-timeout / body-timeout: the L1 latency budgets fired
422
+ * - match-error: the L1 match() itself rejected (a transient Cache API infra
423
+ * error) -- a miss that falls through to L2/KV and is reported cache-read,
424
+ * distinct from a genuine l1-miss (absence) so the two are separable
425
+ * - body-error: the L1 body read failed fast (corrupt/non-JSON body) -- a miss
426
+ * that falls through to L2/KV, distinct from a body-timeout
427
+ * - non-200: L1 returned a non-200 (treated as a miss)
428
+ * - l1-miss: no L1 entry
429
+ * - kv-fresh / kv-stale / kv-miss: L2 fallback outcomes
430
+ * - kv-stale-suppressed: a stale L2 hit served WITHOUT revalidation because
431
+ * the L1 fall-through was degraded (body-timeout / non-200) -- the herd
432
+ * mitigation, distinct from kv-stale so the suppression is visible
433
+ * - kv-timeout: the L2/KV read budget fired (read abandoned, NOT a genuine
434
+ * absence -- distinct from kv-miss so a degradation signal is separable)
435
+ * - tag-invalidated: a live L1/KV entry whose cache tags were invalidated
436
+ * after it was written -- treated as a miss so the next render re-populates
437
+ * it (the tag-invalidation read path, distinct from a plain miss)
438
+ * - error: the read threw
439
+ */
440
+ outcome:
441
+ | "l1-fresh"
442
+ | "l1-stale-revalidate"
443
+ | "l1-revalidating-guarded"
444
+ | "match-timeout"
445
+ | "match-error"
446
+ | "body-timeout"
447
+ | "body-error"
448
+ | "non-200"
449
+ | "tag-invalidated"
450
+ | "l1-miss"
451
+ | "kv-fresh"
452
+ | "kv-stale"
453
+ | "kv-stale-suppressed"
454
+ | "kv-miss"
455
+ | "kv-timeout"
456
+ | "error";
457
+ /** HTTP status of the matched L1 response, when one was returned. */
458
+ status?: number;
459
+ /**
460
+ * Stored cache status header (CACHE_STATUS_HEADER): "HIT" or "REVALIDATING".
461
+ * Distinct from `isRevalidating`, which also factors in stamp recency -- this
462
+ * is the raw stored value, so a REVALIDATING entry whose stamp aged out (so
463
+ * `isRevalidating` is false) is still distinguishable from a plain HIT.
464
+ */
465
+ cacheStatus?: string | null;
466
+ /** Epoch-ms when the entry goes stale (from CACHE_STALE_AT_HEADER). */
467
+ staleAt?: number;
468
+ /** Epoch-ms the entry was marked REVALIDATING (from the explicit stamp). */
469
+ revalidatingAt?: number;
470
+ /** Raw CF `Age` header, for comparison against revalidatingAt (may be null). */
471
+ ageHeader?: string | null;
472
+ isStale?: boolean;
473
+ isRevalidating?: boolean;
474
+ shouldRevalidate?: boolean;
475
+ /** Wall-clock ms spent in cache.match (bounded by edgeLookupTimeoutMs). */
476
+ matchMs?: number;
477
+ /**
478
+ * Wall-clock ms spent resolving the entry's tag-invalidation markers (the
479
+ * per-request memo -> optional per-colo L1 marker cache -> KV cascade), for a
480
+ * tagged entry. 0/absent for an untagged entry or a memo hit; a non-trivial
481
+ * value is the serial marker-read tail that sits between matchMs and
482
+ * bodyReadMs. Only measured when debug is enabled.
483
+ */
484
+ markerMs?: number;
485
+ /** Wall-clock ms spent reading the body (bounded by edgeReadTimeoutMs). */
486
+ bodyReadMs?: number;
487
+ }
488
+
489
+ /**
490
+ * Debug sink. `true` logs each {@link CFCacheReadDebugEvent} to console; a
491
+ * function receives the events for programmatic capture.
492
+ */
493
+ export type CFCacheDebug = boolean | ((event: CFCacheReadDebugEvent) => void);
494
+
158
495
  export interface CFCacheStoreOptions<TEnv = unknown> {
159
496
  /**
160
497
  * Cache namespace. If not provided, uses caches.default (recommended).
@@ -196,9 +533,92 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
196
533
  * ```typescript
197
534
  * new CFCacheStore({ ctx: env.ctx, kv: env.CACHE_KV })
198
535
  * ```
536
+ *
537
+ * Tag-based invalidation (updateTag/revalidateTag) requires KV: the
538
+ * tag-invalidation markers are stored in this same namespace. There is no
539
+ * separate tag-invalidation store to configure.
199
540
  */
200
541
  kv?: KVNamespace;
201
542
 
543
+ /**
544
+ * Optional eager-purge hook, called ONCE per updateTag()/revalidateTag() with
545
+ * the namespaced Cloudflare Cache-Tags to purge (one batched call for the
546
+ * whole invalidation, not one per tag). These exactly match the `Cache-Tag`
547
+ * header this store writes on its tag-lookup marker entries
548
+ * (`rg:{namespace}:lk:{encodedTag}`), so forwarding them to Cloudflare's
549
+ * purge-by-tag API evicts the cached lookups in every colo - making
550
+ * cross-colo invalidation prompt instead of waiting out `tagCacheTtl`.
551
+ *
552
+ * Only meaningful with `tagCacheTtl > 0` (otherwise there are no cached
553
+ * lookups to purge). The values are pre-encoded, so commas in tag names are
554
+ * safe to pass straight to the purge API.
555
+ *
556
+ * @example
557
+ * ```ts
558
+ * onRevalidateTag: async (cacheTags) => {
559
+ * await fetch(`https://api.cloudflare.com/client/v4/zones/${ZONE}/purge_cache`, {
560
+ * method: "POST",
561
+ * headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
562
+ * body: JSON.stringify({ tags: cacheTags }),
563
+ * });
564
+ * }
565
+ * ```
566
+ */
567
+ onRevalidateTag?: (cacheTags: string[]) => Promise<void>;
568
+
569
+ /**
570
+ * Optional expiration (seconds) for tag-invalidation markers in KV. A marker
571
+ * must outlive every entry tagged before the invalidation, so this MUST
572
+ * exceed your largest entry TTL+SWR. Defaults to no expiration (markers
573
+ * persist; they are tiny - one timestamp per distinct invalidated tag).
574
+ *
575
+ * Note the opposite sizing from `tagCacheTtl` below: `tagInvalidationTtl` must
576
+ * be LARGE (outlive data); `tagCacheTtl` should be SMALL (a staleness ceiling).
577
+ *
578
+ * Cardinality matters: each DISTINCT invalidated tag writes one permanent KV
579
+ * marker (with the no-expiry default). Keep tags LOW-cardinality and never
580
+ * derive an invalidation tag from untrusted input (e.g.
581
+ * `revalidateTag(req.query.tag)`) - an attacker could otherwise grow your KV
582
+ * namespace without bound. Set a `tagInvalidationTtl` only if your tags are
583
+ * unavoidably high-cardinality AND it can still safely exceed your max entry
584
+ * TTL+SWR.
585
+ */
586
+ tagInvalidationTtl?: number;
587
+
588
+ /**
589
+ * Optional TTL (seconds) for caching tag-invalidation markers in the per-colo
590
+ * Cache API (L1), to avoid a KV marker read on every tagged cache read.
591
+ *
592
+ * Default `0` = disabled: the marker is read from KV on every tagged read
593
+ * (today's behavior), giving the strongest cross-colo invalidation latency
594
+ * (~KV consistency). A positive value caches each marker (including the
595
+ * "no marker yet" state) in L1 for that many seconds, so within the window a
596
+ * colo answers from L1 with no KV read.
597
+ *
598
+ * The colo that runs `updateTag`/`revalidateTag` writes the fresh marker
599
+ * straight into its own L1 (write-through), so the invalidating request and
600
+ * later reads in that colo observe the invalidation immediately. One caveat: a
601
+ * read already in flight when the invalidation lands (one that began its KV
602
+ * marker fetch first) can re-cache the PRIOR marker into L1 after the
603
+ * write-through, so a racing concurrent reader in the same colo may miss the
604
+ * invalidation for up to `tagCacheTtl` -- the Cache API exposes no
605
+ * compare-and-set to close this fully. `tagCacheTtl` is therefore a staleness
606
+ * CEILING, not a promise of zero same-colo latency; keep it small (or wire
607
+ * `onRevalidateTag`) when that matters. By default OTHER colos only converge
608
+ * when their cached marker expires, so `tagCacheTtl` is the MAXIMUM extra
609
+ * cross-colo invalidation latency for them. Recommended 30-60 for high-read,
610
+ * low-mutation tags; leave at 0 when prompt global invalidation matters and
611
+ * you cannot wire a purge.
612
+ *
613
+ * To make other colos prompt WITHOUT a short TTL, wire `onRevalidateTag` to a
614
+ * Cloudflare purge-by-tag call: each marker entry carries a namespaced
615
+ * `Cache-Tag`, and `onRevalidateTag` is handed exactly those tags to purge, so
616
+ * the cached lookups are evicted everywhere on invalidation. With a purge
617
+ * wired, `tagCacheTtl` becomes purely a read-cost reducer + fallback window
618
+ * (safe to set large) rather than the invalidation-latency ceiling.
619
+ */
620
+ tagCacheTtl?: number;
621
+
202
622
  /**
203
623
  * Cache version string override. When this changes, all cached entries are
204
624
  * effectively invalidated (new keys won't match old entries).
@@ -219,11 +639,54 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
219
639
  */
220
640
  edgeLookupTimeoutMs?: number;
221
641
 
642
+ /**
643
+ * Latency budget (ms) for reading the BODY of a matched L1 entry
644
+ * (response.json()). CF streams the cache body lazily, so the multi-second
645
+ * tail can appear after `match` already resolved; this bounds it. On timeout
646
+ * the read is treated as a miss and falls through to L2/KV or render.
647
+ *
648
+ * Separate from {@link edgeLookupTimeoutMs} because a healthy body read
649
+ * (fetch + JSON parse of a potentially large Flight payload) takes a little
650
+ * longer than a `match`. Defaults to {@link EDGE_READ_TIMEOUT_MS} (20), which
651
+ * clears a healthy per-colo read yet fails fast on a degraded one. Set to 0
652
+ * (or any value <= 0) to disable and always await the body.
653
+ */
654
+ edgeReadTimeoutMs?: number;
655
+
656
+ /**
657
+ * Latency budget (ms) for an L2 (KV) read. KV is the last cache tier before a
658
+ * full render and is a global store (~50ms healthy, seconds when degraded);
659
+ * this bounds it so a slow namespace cannot pin the request. On timeout the
660
+ * read is treated as a miss (no L1 promote) and falls through to render.
661
+ *
662
+ * Defaults to {@link KV_READ_TIMEOUT_MS} (170) -- a few multiples above the
663
+ * ~50ms healthy read, with headroom for legitimate tails (large payloads / far
664
+ * regions) yet still well under a degraded namespace's multi-second tail.
665
+ * Lower it for a tighter SLA, raise it if your healthy KV p99 runs higher; it
666
+ * is a degradation guard-rail, not a tuning lever. Set to 0 (or any value
667
+ * <= 0) to disable and always await KV.
668
+ */
669
+ kvReadTimeoutMs?: number;
670
+
671
+ /**
672
+ * Emit a {@link CFCacheReadDebugEvent} per L1 read. `true` logs to console
673
+ * (visible via `wrangler tail`); pass a function to capture events directly.
674
+ * Off by default. Intended for validating cache behavior on a real
675
+ * deployment before relying on it; not for steady-state production.
676
+ */
677
+ debug?: CFCacheDebug;
678
+
222
679
  /**
223
680
  * Custom key generator applied to all cache operations.
224
681
  * Receives the full RequestContext (including env) and the default-generated key.
225
682
  * Return value becomes the final cache key (unless route overrides with `key` option).
226
683
  *
684
+ * Reserved prefixes: tag-invalidation markers live in the SAME KV namespace as
685
+ * data, keyed `__tag__/<tag>` (and `__tagmarker__/<tag>` for the L1 cache). A
686
+ * returned key must NOT begin with `__tag__/` or `__tagmarker__/`, or it can
687
+ * collide with a tag marker and corrupt invalidation. The documented
688
+ * prepend-style generators below are safe.
689
+ *
227
690
  * @example Using headers for user segmentation
228
691
  * ```typescript
229
692
  * keyGenerator: (ctx, defaultKey) => {
@@ -276,7 +739,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
276
739
  private readonly waitUntil?: (fn: () => Promise<void>) => void;
277
740
  private readonly version?: string;
278
741
  private readonly edgeLookupTimeoutMs: number;
742
+ private readonly edgeReadTimeoutMs: number;
743
+ private readonly kvReadTimeoutMs: number;
744
+ private readonly debug?: (event: CFCacheReadDebugEvent) => void;
279
745
  private readonly kv?: KVNamespace;
746
+ private readonly onRevalidateTag?: (tags: string[]) => Promise<void>;
747
+ private readonly tagInvalidationTtl?: number;
748
+ private readonly tagCacheTtl: number;
280
749
 
281
750
  constructor(options: CFCacheStoreOptions<TEnv>) {
282
751
  if (!options.ctx) {
@@ -296,11 +765,122 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
296
765
  this.explicitBaseUrl = options.baseUrl;
297
766
  this.defaults = options.defaults;
298
767
  this.version = options.version ?? VERSION;
299
- this.edgeLookupTimeoutMs =
300
- options.edgeLookupTimeoutMs ?? EDGE_LOOKUP_TIMEOUT_MS;
768
+ // Coalesce only finite numbers to the override; a non-finite value (NaN from
769
+ // `Number(env.UNSET)`, or Infinity) would otherwise sail past `?? DEFAULT`
770
+ // (which only replaces null/undefined) into setTimeout, where NaN/Infinity
771
+ // are spec-coerced to ~1ms and silently turn the budget into a near-100%
772
+ // false-miss on that tier. A genuine finite 0 or negative still passes
773
+ // through and disables the budget per the documented `<= 0` contract.
774
+ const finiteBudget = (
775
+ value: number | undefined,
776
+ fallback: number,
777
+ ): number =>
778
+ typeof value === "number" && Number.isFinite(value) ? value : fallback;
779
+ this.edgeLookupTimeoutMs = finiteBudget(
780
+ options.edgeLookupTimeoutMs,
781
+ EDGE_LOOKUP_TIMEOUT_MS,
782
+ );
783
+ this.edgeReadTimeoutMs = finiteBudget(
784
+ options.edgeReadTimeoutMs,
785
+ EDGE_READ_TIMEOUT_MS,
786
+ );
787
+ this.kvReadTimeoutMs = finiteBudget(
788
+ options.kvReadTimeoutMs,
789
+ KV_READ_TIMEOUT_MS,
790
+ );
791
+ this.debug =
792
+ options.debug === true
793
+ ? (event) =>
794
+ console.log(`[CFCacheStore:debug] ${JSON.stringify(event)}`)
795
+ : typeof options.debug === "function"
796
+ ? options.debug
797
+ : undefined;
301
798
  this.keyGenerator = options.keyGenerator;
302
799
  this.waitUntil = (fn) => options.ctx.waitUntil(fn());
303
800
  this.kv = options.kv;
801
+ this.onRevalidateTag = options.onRevalidateTag;
802
+ // tagInvalidationTtl feeds KV's expirationTtl, which CF rejects below
803
+ // KV_MIN_EXPIRATION_TTL (60s) -- a too-small finite value would make EVERY
804
+ // marker write throw and break ALL invalidation. Floor it (and warn once);
805
+ // a non-finite/non-positive value falls back to the no-expiry default
806
+ // (markers persist) rather than silently sailing a NaN into expirationTtl.
807
+ this.tagInvalidationTtl = this.sanitizeTagInvalidationTtl(
808
+ options.tagInvalidationTtl,
809
+ );
810
+ // tagCacheTtl gates the L1 marker cache via `> 0`. A non-finite value (NaN
811
+ // from `Number(env.UNSET)`) is not null/undefined, so `?? 0` would let it
812
+ // through and silently disable the cache while reading as "configured".
813
+ // Coerce any non-finite/non-positive value to the documented 0 = disabled.
814
+ this.tagCacheTtl =
815
+ typeof options.tagCacheTtl === "number" &&
816
+ Number.isFinite(options.tagCacheTtl) &&
817
+ options.tagCacheTtl > 0
818
+ ? options.tagCacheTtl
819
+ : 0;
820
+
821
+ // Read-side tag invalidation requires KV: isGloballyInvalidated() compares an
822
+ // entry's taggedAt against the per-tag KV marker and short-circuits to "not
823
+ // invalidated" when no KV namespace is configured. A consumer who wires the
824
+ // tag machinery (tagCacheTtl for L1 markers, or onRevalidateTag for CDN purge)
825
+ // but omits kv gets only the purge fired - marker writes are skipped without
826
+ // kv - yet every tagged read still serves stale data with no other signal.
827
+ // Surface that misconfiguration.
828
+ if (!this.kv && (this.tagCacheTtl > 0 || this.onRevalidateTag)) {
829
+ const id = this.namespace ?? "default";
830
+ if (!warnedNoKvReadInvalidation.has(id)) {
831
+ warnedNoKvReadInvalidation.add(id);
832
+ console.warn(
833
+ `[CFCacheStore] tagCacheTtl/onRevalidateTag is configured without a KV ` +
834
+ `namespace, so tag invalidation has NO read-side effect: tagged reads ` +
835
+ `are never treated as invalidated and serve stale data. Configure ` +
836
+ `{ kv } for distributed tag invalidation.`,
837
+ );
838
+ }
839
+ }
840
+ }
841
+
842
+ /**
843
+ * Validate a consumer-supplied tagInvalidationTtl against CF KV's expirationTtl
844
+ * floor. A finite value below KV_MIN_EXPIRATION_TTL is raised to it (with a
845
+ * one-time warning) so invalidation keeps working instead of every marker
846
+ * write throwing; a non-finite or non-positive value returns undefined (the
847
+ * no-expiry default). The warning still notes the sizing rule: the TTL must
848
+ * exceed the largest entry TTL+SWR or invalidated entries can resurrect.
849
+ * @internal
850
+ */
851
+ private sanitizeTagInvalidationTtl(
852
+ value: number | undefined,
853
+ ): number | undefined {
854
+ if (value == null) return undefined;
855
+ if (!Number.isFinite(value) || value <= 0) return undefined;
856
+ if (value < KV_MIN_EXPIRATION_TTL) {
857
+ const id = this.namespace ?? "default";
858
+ if (!warnedTagInvalidationTtlFloor.has(id)) {
859
+ warnedTagInvalidationTtlFloor.add(id);
860
+ console.warn(
861
+ `[CFCacheStore] tagInvalidationTtl ${value} is below Cloudflare KV's ` +
862
+ `${KV_MIN_EXPIRATION_TTL}s expirationTtl floor; raising to ` +
863
+ `${KV_MIN_EXPIRATION_TTL}. It must still exceed your largest entry ` +
864
+ `TTL+SWR or invalidated entries can resurrect when the marker expires.`,
865
+ );
866
+ }
867
+ return KV_MIN_EXPIRATION_TTL;
868
+ }
869
+ return value;
870
+ }
871
+
872
+ /**
873
+ * Emit a debug event if `debug` is enabled. Swallows sink errors so a faulty
874
+ * debug callback can never break a cache read.
875
+ * @internal
876
+ */
877
+ private emitDebug(event: CFCacheReadDebugEvent): void {
878
+ if (!this.debug) return;
879
+ try {
880
+ this.debug(event);
881
+ } catch {
882
+ // A broken debug sink must not affect the request.
883
+ }
304
884
  }
305
885
 
306
886
  /**
@@ -368,56 +948,257 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
368
948
  }
369
949
 
370
950
  /**
371
- * Read from the L1 edge cache with a latency budget. A `match` that takes
372
- * longer than the configured budget (edgeLookupTimeoutMs, default
373
- * EDGE_LOOKUP_TIMEOUT_MS) is abandoned and reported as a miss (undefined) so a
374
- * degraded colo cannot stall the request; callers then fall through to their
375
- * normal miss path (L2/KV or render). The slow `match` is left to settle in
376
- * the background (errors swallowed) rather than aborted, since the Cache API
377
- * exposes no cancellation. A budget <= 0 disables the timeout entirely and
378
- * awaits `match` directly.
951
+ * Race an async cache read against a latency budget. Shared by all three read
952
+ * tiers (L1 match, L1 body, L2/KV) so the timeout policy lives in one place:
953
+ * on timeout it returns `{ value: undefined, timedOut: true }` and logs
954
+ * `${label} exceeded ${budgetMs}ms; treating as miss`; the abandoned read is
955
+ * left to settle in the background (late rejection swallowed) rather than
956
+ * aborted, since the underlying CF primitives expose no cancellation. A budget
957
+ * <= 0 disables the bound and awaits the read directly. `read` is a thunk so
958
+ * the disabled path and the raced path start the read identically.
379
959
  * @internal
380
960
  */
381
- private async matchWithTimeout(
382
- cache: Cache,
383
- request: Request,
384
- ): Promise<Response | undefined> {
385
- const budget = this.edgeLookupTimeoutMs;
386
- if (budget <= 0) {
387
- return cache.match(request);
388
- }
961
+ private async readWithTimeout<T>(
962
+ read: () => Promise<T>,
963
+ budgetMs: number,
964
+ label: string,
965
+ ): Promise<{ value: T | undefined; timedOut: boolean }> {
966
+ if (budgetMs <= 0) return { value: await read(), timedOut: false };
967
+
389
968
  let timer: ReturnType<typeof setTimeout> | undefined;
390
969
  const timeout = new Promise<{ timedOut: true }>((resolve) => {
391
- timer = setTimeout(() => resolve({ timedOut: true }), budget);
970
+ timer = setTimeout(() => resolve({ timedOut: true }), budgetMs);
392
971
  });
393
972
  try {
394
- const matchPromise = cache.match(request);
973
+ const readPromise = read();
395
974
  // The losing branch keeps running; ensure a late rejection can't surface
396
975
  // as an unhandled rejection once we've stopped awaiting it.
397
- matchPromise.catch(() => {});
976
+ readPromise.catch(() => {});
398
977
  const result = await Promise.race([
399
- matchPromise.then((response) => ({
400
- timedOut: false as const,
401
- response,
402
- })),
978
+ readPromise.then((value) => ({ timedOut: false as const, value })),
403
979
  timeout,
404
980
  ]);
405
981
  if (result.timedOut) {
406
982
  console.warn(
407
- `[CFCacheStore] edge cache lookup exceeded ${budget}ms; treating as miss`,
983
+ `[CFCacheStore] ${label} exceeded ${budgetMs}ms; treating as miss`,
408
984
  );
409
- return undefined;
985
+ return { value: undefined, timedOut: true };
410
986
  }
411
- return result.response;
987
+ return { value: result.value, timedOut: false };
412
988
  } finally {
413
989
  if (timer) clearTimeout(timer);
414
990
  }
415
991
  }
416
992
 
993
+ /**
994
+ * Read from the L1 edge cache under the edgeLookupTimeoutMs budget. A `match`
995
+ * slower than the budget is abandoned and reported as a miss
996
+ * (`{ response: undefined, timedOut: true }`) so a degraded colo cannot stall
997
+ * the request; callers fall through to their normal miss path (L2/KV or
998
+ * render). The `timedOut` flag lets callers distinguish an abandoned slow
999
+ * match from a genuine miss for debug reporting; `error` is set when the
1000
+ * `match` itself rejected (a transient L1 infra error) so the caller can
1001
+ * report it as cache-read while still degrading to L2/KV -- distinct from a
1002
+ * genuine miss (no entry), which sets neither flag.
1003
+ * @internal
1004
+ */
1005
+ private async matchWithTimeout(
1006
+ cache: Cache,
1007
+ request: Request,
1008
+ ): Promise<{
1009
+ response: Response | undefined;
1010
+ timedOut: boolean;
1011
+ error?: unknown;
1012
+ }> {
1013
+ let matchError: unknown;
1014
+ const { value, timedOut } = await this.readWithTimeout(
1015
+ // A fast match rejection is caught at the thunk and reported as a miss
1016
+ // (response undefined), so the caller falls through to L2/KV rather than
1017
+ // escaping to the outer catch -- symmetric with the body-read thunk. The
1018
+ // error is captured (not swallowed) so the caller can surface it via
1019
+ // onError as a cache-read degradation.
1020
+ () =>
1021
+ cache.match(request).catch((e) => {
1022
+ matchError = e;
1023
+ return undefined;
1024
+ }),
1025
+ this.edgeLookupTimeoutMs,
1026
+ "edge cache lookup",
1027
+ );
1028
+ return { response: value, timedOut, error: matchError };
1029
+ }
1030
+
1031
+ /**
1032
+ * Read and JSON-parse a matched L1 Response's body under the edgeReadTimeoutMs
1033
+ * budget. CF resolves `match()` with a lazily-streamed body, so the latency
1034
+ * tail surfaces here -- after matchWithTimeout has already passed -- not in the
1035
+ * match itself. On timeout `undefined` is returned so the caller falls through
1036
+ * to L2/KV or render.
1037
+ * @internal
1038
+ */
1039
+ private async readJsonWithTimeout<T>(
1040
+ response: Response,
1041
+ ): Promise<{ value: T | undefined; errored: boolean; error?: unknown }> {
1042
+ // A FAST json() rejection (a corrupt body, or a foreign 200 non-JSON
1043
+ // response that collided on this key) is caught at the thunk and turned into
1044
+ // a miss, so the caller falls through to L2/KV exactly like a body-timeout
1045
+ // -- instead of escaping to get()/getItem()'s outer catch, which returns
1046
+ // null WITHOUT ever consulting KV. The catch lives here, not in
1047
+ // readWithTimeout, so the L2/KV tier keeps propagating a genuine kv.get
1048
+ // rejection to its own error sink. The `errored` flag lets the caller emit a
1049
+ // distinct "body-error" debug outcome rather than masquerading as a timeout.
1050
+ // On a TIMEOUT the json() promise is still pending, so the catch has not
1051
+ // fired: errored stays false and the outcome is correctly a body-timeout. A
1052
+ // late rejection after the timeout only mutates the closure flag, which the
1053
+ // already-returned object no longer reads.
1054
+ let errored = false;
1055
+ let error: unknown;
1056
+ const { value } = await this.readWithTimeout<T | undefined>(
1057
+ () =>
1058
+ (response.json() as Promise<T>).catch((e) => {
1059
+ errored = true;
1060
+ error = e;
1061
+ return undefined;
1062
+ }),
1063
+ this.edgeReadTimeoutMs,
1064
+ "edge cache body read",
1065
+ );
1066
+ return { value, errored, error };
1067
+ }
1068
+
1069
+ /**
1070
+ * Self-heal a corrupt L1 entry, then return the fall-through result. Reports
1071
+ * the corruption as cache-corrupt (so an onError consumer sees it distinctly
1072
+ * from a transient outage), runs the caller's L2/KV fall-through, and evicts
1073
+ * the faulty per-colo entry ONLY when that fall-through found no good copy.
1074
+ *
1075
+ * The conditional evict is the load-bearing detail: when KV DOES serve a copy,
1076
+ * kvGet* has already scheduled a same-key promote (`cache.put`); an eager
1077
+ * `cache.delete` here would race that put with no CF Cache API ordering
1078
+ * guarantee and could clobber the freshly-restored entry. So in that case we
1079
+ * lean on #558's heal-by-overwrite (the non-suppressed fall-through promotes /
1080
+ * a fresh render re-`set`s over the bad entry) and skip the delete. Only when
1081
+ * this request's fall-through found no copy (=== null) is the eager evict
1082
+ * scheduled -- useful then, since nothing else will overwrite the poison entry.
1083
+ * A null fall-through can also be a KV-read TIMEOUT rather than a genuine miss:
1084
+ * a concurrent request that read KV successfully may be promoting the same key,
1085
+ * and this evict could race it. That is benign -- the worst case is one wasted
1086
+ * colo-local promote, never a wrong served value, and the next read self-heals
1087
+ * -- so we accept it rather than suppressing the evict on a timeout (which
1088
+ * would strand the poison entry when KV really is empty). The evict is
1089
+ * non-blocking (waitUntil) so it never adds latency to the degraded read.
1090
+ * @internal
1091
+ */
1092
+ private async healCorruptL1<T>(
1093
+ cache: Cache,
1094
+ request: Request,
1095
+ error: unknown,
1096
+ label: string,
1097
+ fallThrough: () => Promise<T | null>,
1098
+ ): Promise<T | null> {
1099
+ reportCacheError(
1100
+ error ?? new Error("corrupt/partial L1 body"),
1101
+ "cache-corrupt",
1102
+ `[CFCacheStore] ${label}: corrupt L1 body`,
1103
+ );
1104
+ const result = await fallThrough();
1105
+ if (result === null) {
1106
+ const evict = (): Promise<void> =>
1107
+ reportingAsync(
1108
+ () => cache.delete(request),
1109
+ "cache-delete",
1110
+ `[CFCacheStore] ${label}: evict corrupt L1`,
1111
+ );
1112
+ if (this.waitUntil) this.waitUntil(evict);
1113
+ else void evict();
1114
+ }
1115
+ return result;
1116
+ }
1117
+
1118
+ /**
1119
+ * Re-put a stale L1 entry marked REVALIDATING, so concurrent requests serve it
1120
+ * without each triggering a revalidation. Shared by get()/getItem().
1121
+ *
1122
+ * The write is NON-BLOCKING (waitUntil) and best-effort by design:
1123
+ * - It runs in waitUntil, so it never adds the put latency to the served stale
1124
+ * read and a put failure can never turn that good read into a miss. The put
1125
+ * is still initiated synchronously (this.waitUntil invokes its callback
1126
+ * immediately), so concurrent readers see the marker land at the same time an
1127
+ * awaited write would -- awaiting only blocks the current request.
1128
+ * - The background revalidation's fresh set() is gated behind a full re-render,
1129
+ * so it lands well after this put; a stale-clobbers-fresh race would require
1130
+ * this single put to be slower than that entire render+set, and self-heals
1131
+ * within MAX_REVALIDATION_INTERVAL.
1132
+ *
1133
+ * Cache-Control is recomputed to the REMAINING ttl from the stored hard-expiry
1134
+ * deadline (see remainingCacheControl), not copied from the original
1135
+ * full-window header -- copying it would restart CF retention on every re-arm
1136
+ * and pin a perpetually-failing entry past hard-expiry. A legacy/tampered entry
1137
+ * without a valid deadline floors to max-age=1 and self-heals via KV.
1138
+ * @internal
1139
+ */
1140
+ private markRevalidating(
1141
+ cache: Cache,
1142
+ request: Request,
1143
+ sourceHeaders: Headers,
1144
+ status: number,
1145
+ body: string,
1146
+ ): void {
1147
+ const reputNow = Date.now();
1148
+ const headers = new Headers(sourceHeaders);
1149
+ headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
1150
+ headers.set(CACHE_REVALIDATING_AT_HEADER, String(reputNow));
1151
+ headers.set("Cache-Control", remainingCacheControl(headers, reputNow));
1152
+ const markerResponse = new Response(body, { status, headers });
1153
+ const write = async (): Promise<void> => {
1154
+ try {
1155
+ await cache.put(request, markerResponse);
1156
+ } catch {
1157
+ // Best-effort: a failed marker write must not affect the served read;
1158
+ // the entry simply re-arms on the next stale read.
1159
+ }
1160
+ };
1161
+ if (this.waitUntil) this.waitUntil(write);
1162
+ else void write();
1163
+ }
1164
+
417
1165
  // ============================================================================
418
1166
  // Segment Cache Methods
419
1167
  // ============================================================================
420
1168
 
1169
+ /**
1170
+ * Guard the segment tier against a `keyGenerator` that returns a key colliding
1171
+ * with a reserved tag-marker namespace: `__tag__/` (the KV marker key) or
1172
+ * `__tagmarker__/` (the L1 Cache API marker request). The item/doc tiers are
1173
+ * internally prefixed (`fn:`/`doc:`) so only the bare segment key can collide;
1174
+ * a collision would let a segment write clobber - or a segment read/delete
1175
+ * evict - a live tag marker, silently breaking invalidation. Report loudly
1176
+ * (so a misconfigured keyGenerator surfaces immediately) and treat the segment
1177
+ * operation as a miss/no-op rather than corrupting the marker namespace.
1178
+ * @internal
1179
+ */
1180
+ private isReservedSegmentKey(
1181
+ key: string,
1182
+ category: CacheErrorCategory,
1183
+ ): boolean {
1184
+ const reserved = key.startsWith(TAG_MARKER_PREFIX)
1185
+ ? TAG_MARKER_PREFIX
1186
+ : key.startsWith(TAG_MARKER_CACHE_PREFIX)
1187
+ ? TAG_MARKER_CACHE_PREFIX
1188
+ : null;
1189
+ if (!reserved) return false;
1190
+ reportCacheError(
1191
+ new Error(
1192
+ `segment key "${key}" collides with the reserved "${reserved}" ` +
1193
+ `tag-marker namespace; the operation is ignored. Fix the store ` +
1194
+ `keyGenerator so it does not produce keys with this prefix.`,
1195
+ ),
1196
+ category,
1197
+ "[CFCacheStore] reserved key",
1198
+ );
1199
+ return true;
1200
+ }
1201
+
421
1202
  /**
422
1203
  * Get cached entry data by key.
423
1204
  *
@@ -430,48 +1211,219 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
430
1211
  * KV hits are promoted to L1 in the background.
431
1212
  */
432
1213
  async get(key: string): Promise<CacheGetResult | null> {
1214
+ if (this.isReservedSegmentKey(key, "cache-read")) return null;
433
1215
  try {
434
1216
  const cache = await this.getCache();
435
1217
  const request = this.keyToRequest(key);
436
- const response = await this.matchWithTimeout(cache, request);
1218
+ const matchStart = Date.now();
1219
+ const {
1220
+ response,
1221
+ timedOut,
1222
+ error: matchError,
1223
+ } = await this.matchWithTimeout(cache, request);
1224
+ const matchMs = Date.now() - matchStart;
437
1225
 
438
1226
  if (!response) {
1227
+ // A transient L1 match error (matchError set) is reported as cache-read
1228
+ // but, like a genuine miss or an abandoned slow match (timedOut), still
1229
+ // degrades to L2/KV rather than failing the read.
1230
+ if (matchError)
1231
+ reportCacheError(
1232
+ matchError,
1233
+ "cache-read",
1234
+ "[CFCacheStore] get L1 match",
1235
+ );
1236
+ if (this.debug)
1237
+ this.emitDebug({
1238
+ op: "get",
1239
+ key,
1240
+ // A match REJECTION (matchError) is distinct from a genuine absence:
1241
+ // surface it as match-error so debug agrees with the cache-read
1242
+ // already routed to onError, instead of masquerading as l1-miss.
1243
+ outcome: matchError
1244
+ ? "match-error"
1245
+ : timedOut
1246
+ ? "match-timeout"
1247
+ : "l1-miss",
1248
+ matchMs,
1249
+ });
439
1250
  return this.kvGetSegment(key);
440
1251
  }
441
1252
 
1253
+ // A non-200 entry (a cached error response, or a foreign response that
1254
+ // landed on this key) is not valid segment data; treat it as a miss
1255
+ // rather than JSON-parsing garbage and serving it as a hit.
1256
+ if (response.status !== 200) {
1257
+ if (this.debug)
1258
+ this.emitDebug({
1259
+ op: "get",
1260
+ key,
1261
+ outcome: "non-200",
1262
+ status: response.status,
1263
+ matchMs,
1264
+ });
1265
+ // Degraded fall-through: suppress revalidation so a broken L1 entry hit
1266
+ // concurrently serves KV-stale, not a herd. See kvGetSegment.
1267
+ return this.kvGetSegment(key, { suppressRevalidate: true });
1268
+ }
1269
+
1270
+ // Tag invalidation: an entry whose tags were invalidated after it was
1271
+ // cached is treated as a miss, so the next render re-populates it. We
1272
+ // return null (re-render locally) rather than falling through to KV. In
1273
+ // the common case the L1 entry and its KV twin were written together with
1274
+ // the same taggedAt, so kvGetSegment's own tag check would miss too and a
1275
+ // fall-through is pure cost. The tiers CAN diverge -- another colo may have
1276
+ // already re-rendered and written a fresher KV envelope -- in which case a
1277
+ // fall-through could serve that copy instead of re-rendering here.
1278
+ // Capturing that cross-colo optimization is a deferred follow-up, not a
1279
+ // correctness gap: this colo's next read after its own re-render self-heals.
1280
+ const tagInfo = this.readTagInfo(response.headers);
1281
+ // Measure the marker-resolution tail (memo -> L1 marker cache -> KV) only
1282
+ // when debug is on, so the hot path pays nothing. It is the serial read
1283
+ // that sits between matchMs and bodyReadMs for a tagged entry.
1284
+ const markerStart = this.debug ? Date.now() : 0;
1285
+ const invalidated = await this.isGloballyInvalidated(
1286
+ tagInfo.tags,
1287
+ tagInfo.taggedAt,
1288
+ );
1289
+ const markerMs = this.debug ? Date.now() - markerStart : undefined;
1290
+ if (invalidated) {
1291
+ if (this.debug)
1292
+ this.emitDebug({
1293
+ op: "get",
1294
+ key,
1295
+ outcome: "tag-invalidated",
1296
+ status: response.status,
1297
+ matchMs,
1298
+ markerMs,
1299
+ });
1300
+ return null;
1301
+ }
1302
+
442
1303
  // Read status headers
443
1304
  const status = response.headers.get(CACHE_STATUS_HEADER);
444
- const age = Number(response.headers.get("age") ?? "0");
445
1305
  const staleAt = Number(
446
1306
  response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
447
1307
  );
1308
+ const revalidatingAt = Number(
1309
+ response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
1310
+ );
448
1311
 
449
- const isStale = staleAt > 0 && Date.now() > staleAt;
1312
+ const now = Date.now();
1313
+ const isStale = staleAt > 0 && now > staleAt;
1314
+ // Recency comes from our explicit revalidating-at stamp, not CF's `Age`
1315
+ // header (see CACHE_REVALIDATING_AT_HEADER). An absent/zero stamp counts
1316
+ // as "not recent" so a dropped revalidation re-arms instead of pinning.
450
1317
  const isRevalidating =
451
- status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
1318
+ status === "REVALIDATING" &&
1319
+ revalidatingAt > 0 &&
1320
+ now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
1321
+
1322
+ // Single emitter for the post-header L1 outcomes. Undefined (so the event
1323
+ // object is never allocated) when debug is off; the informational-only
1324
+ // `age` header is read lazily inside for the same reason.
1325
+ const debugRead = this.debug
1326
+ ? (
1327
+ outcome: CFCacheReadDebugEvent["outcome"],
1328
+ bodyReadMs: number,
1329
+ shouldRevalidate?: boolean,
1330
+ ) =>
1331
+ this.emitDebug({
1332
+ op: "get",
1333
+ key,
1334
+ outcome,
1335
+ status: response.status,
1336
+ cacheStatus: status,
1337
+ staleAt,
1338
+ revalidatingAt,
1339
+ ageHeader: response.headers.get("age"),
1340
+ isStale,
1341
+ isRevalidating,
1342
+ shouldRevalidate,
1343
+ matchMs,
1344
+ markerMs,
1345
+ bodyReadMs,
1346
+ })
1347
+ : undefined;
452
1348
 
453
1349
  // Case 1: Fresh or already being revalidated - just return data
454
1350
  if (!isStale || isRevalidating) {
455
- const data = (await response.json()) as CachedEntryData;
1351
+ const bodyStart = Date.now();
1352
+ const {
1353
+ value: data,
1354
+ errored,
1355
+ error,
1356
+ } = await this.readJsonWithTimeout<CachedEntryData>(response);
1357
+ const bodyReadMs = Date.now() - bodyStart;
1358
+ if (data === undefined) {
1359
+ debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
1360
+ // A body-ERROR (corrupt/foreign body) self-heals via healCorruptL1:
1361
+ // report cache-corrupt, fall through to L2/KV (which overwrites the
1362
+ // bad entry), and evict only if KV had no good copy to promote. A
1363
+ // body-TIMEOUT is a degraded read of a likely-valid entry: leave it
1364
+ // intact and suppress revalidation so a stalling colo cannot herd.
1365
+ if (errored)
1366
+ return this.healCorruptL1(cache, request, error, "get", () =>
1367
+ this.kvGetSegment(key, { suppressRevalidate: false }),
1368
+ );
1369
+ return this.kvGetSegment(key, { suppressRevalidate: true });
1370
+ }
1371
+ debugRead?.(
1372
+ isRevalidating ? "l1-revalidating-guarded" : "l1-fresh",
1373
+ bodyReadMs,
1374
+ false,
1375
+ );
456
1376
  return { data, shouldRevalidate: false };
457
1377
  }
458
1378
 
459
- // Case 2: Stale and needs revalidation - atomically mark REVALIDATING
460
- const [b1, b2] = response.body!.tee();
461
-
462
- const headers = new Headers(response.headers);
463
- headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
1379
+ // Case 2: Stale and needs revalidation.
1380
+ // Read the body under the edge-read budget BEFORE writing the REVALIDATING
1381
+ // marker. CF can resolve match() fast but stall the body stream; the prior
1382
+ // approach teed the stream and awaited cache.put(b1) first, which blocked
1383
+ // on that same stalled stream so the read budget could never fire on a
1384
+ // stale hit. Reading first bounds the stall and lets us skip marking an
1385
+ // entry we could not even read.
1386
+ const bodyStart = Date.now();
1387
+ const {
1388
+ value: data,
1389
+ errored,
1390
+ error,
1391
+ } = await this.readJsonWithTimeout<CachedEntryData>(response);
1392
+ const bodyReadMs = Date.now() - bodyStart;
1393
+ if (data === undefined) {
1394
+ debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
1395
+ // Heal + conditionally evict a body-error, suppress a body-timeout; see
1396
+ // Case 1.
1397
+ if (errored)
1398
+ return this.healCorruptL1(
1399
+ cache,
1400
+ request,
1401
+ error,
1402
+ "get(revalidating)",
1403
+ () => this.kvGetSegment(key, { suppressRevalidate: false }),
1404
+ );
1405
+ return this.kvGetSegment(key, { suppressRevalidate: true });
1406
+ }
464
1407
 
465
- // Blocking write - must complete before returning to prevent race
466
- await cache.put(
1408
+ // Mark REVALIDATING so concurrent requests don't all revalidate, then
1409
+ // return the stale data. The marker write is non-blocking and best-effort
1410
+ // (see markRevalidating) -- it must not add latency to, or fail, the served
1411
+ // stale read.
1412
+ this.markRevalidating(
1413
+ cache,
467
1414
  request,
468
- new Response(b1, { status: response.status, headers }),
1415
+ response.headers,
1416
+ response.status,
1417
+ JSON.stringify(data),
469
1418
  );
470
1419
 
471
- const data = (await new Response(b2).json()) as CachedEntryData;
1420
+ debugRead?.("l1-stale-revalidate", bodyReadMs, true);
472
1421
  return { data, shouldRevalidate: true };
473
1422
  } catch (error) {
474
- console.error("[CFCacheStore] get failed:", error);
1423
+ // reportCacheError logs and routes to onError (cache-read); the debug
1424
+ // emit is the separate wrangler-tail signal. Keep both observability paths.
1425
+ reportCacheError(error, "cache-read", "[CFCacheStore] get");
1426
+ if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
475
1427
  return null;
476
1428
  }
477
1429
  }
@@ -487,6 +1439,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
487
1439
  ttl: number,
488
1440
  swr?: number,
489
1441
  ): Promise<void> {
1442
+ if (this.isReservedSegmentKey(key, "cache-write")) return;
490
1443
  try {
491
1444
  const cache = await this.getCache();
492
1445
  const request = this.keyToRequest(key);
@@ -496,32 +1449,57 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
496
1449
  const totalTtl = ttl + swrWindow;
497
1450
  const staleAt = Date.now() + ttl * 1000;
498
1451
 
499
- const body = JSON.stringify(data);
1452
+ // Stamp the tag timestamp at write time and carry it (with the tags)
1453
+ // into both the L1 body and the KV envelope so reads can run the
1454
+ // invalidation check.
1455
+ const taggedAt =
1456
+ Array.isArray(data.tags) && data.tags.length > 0
1457
+ ? Date.now()
1458
+ : undefined;
1459
+ const dataToStore: CachedEntryData = taggedAt
1460
+ ? { ...data, taggedAt }
1461
+ : data;
1462
+
1463
+ const body = JSON.stringify(dataToStore);
500
1464
  const response = new Response(body, {
501
1465
  headers: {
502
1466
  "Content-Type": "application/json",
503
1467
  "Cache-Control": `public, max-age=${totalTtl}`,
504
1468
  [CACHE_STALE_AT_HEADER]: String(staleAt),
1469
+ // Absolute hard-expiry deadline so a stale-path re-put can recompute a
1470
+ // shrinking max-age instead of restarting retention (see
1471
+ // remainingCacheControl / CACHE_EXPIRES_AT_HEADER).
1472
+ [CACHE_EXPIRES_AT_HEADER]: String(staleAt + swrWindow * 1000),
505
1473
  [CACHE_STATUS_HEADER]: "HIT",
1474
+ ...this.tagHeaderEntries(dataToStore.tags, taggedAt),
506
1475
  },
507
1476
  });
508
1477
 
509
1478
  const putPromise = cache.put(request, response);
510
1479
 
511
1480
  if (this.waitUntil) {
512
- // Non-blocking write
513
- this.waitUntil(async () => {
514
- await putPromise;
515
- });
1481
+ // Non-blocking write. These store-level background tasks intentionally
1482
+ // omit the reportingAsync ctx argument: the store is a request-agnostic
1483
+ // singleton and this.waitUntil is the execution context's, not a single
1484
+ // request's, so a failure is reported console-loud only (it cannot be
1485
+ // attributed to one request's onError). The request-scoped tag verbs
1486
+ // (revalidateTag / stale-revalidation) DO thread their captured ctx.
1487
+ this.waitUntil(() =>
1488
+ reportingAsync(
1489
+ () => putPromise,
1490
+ "cache-write",
1491
+ "[CFCacheStore] L1 write",
1492
+ ),
1493
+ );
516
1494
  } else {
517
1495
  // Blocking fallback
518
1496
  await putPromise;
519
1497
  }
520
1498
 
521
1499
  // L2: persist to KV
522
- this.kvSetSegment(key, data, staleAt, totalTtl, swrWindow);
1500
+ this.kvSetSegment(key, dataToStore, staleAt, totalTtl, swrWindow);
523
1501
  } catch (error) {
524
- console.error("[CFCacheStore] set failed:", error);
1502
+ reportCacheError(error, "cache-write", "[CFCacheStore] set");
525
1503
  }
526
1504
  }
527
1505
 
@@ -529,6 +1507,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
529
1507
  * Delete a cached entry from L1 and L2.
530
1508
  */
531
1509
  async delete(key: string): Promise<boolean> {
1510
+ if (this.isReservedSegmentKey(key, "cache-delete")) return false;
532
1511
  try {
533
1512
  const cache = await this.getCache();
534
1513
  const result = await cache.delete(this.keyToRequest(key));
@@ -536,18 +1515,18 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
536
1515
  // L2: delete from KV
537
1516
  if (this.kv && this.waitUntil) {
538
1517
  const kvKey = this.toKVKey(key);
539
- this.waitUntil(async () => {
540
- try {
541
- await this.kv!.delete(kvKey);
542
- } catch {
543
- // KV delete failures are non-critical
544
- }
545
- });
1518
+ this.waitUntil(() =>
1519
+ reportingAsync(
1520
+ () => this.kv!.delete(kvKey),
1521
+ "cache-delete",
1522
+ "[CFCacheStore] delete L2",
1523
+ ),
1524
+ );
546
1525
  }
547
1526
 
548
1527
  return result;
549
1528
  } catch (error) {
550
- console.error("[CFCacheStore] delete failed:", error);
1529
+ reportCacheError(error, "cache-delete", "[CFCacheStore] delete");
551
1530
  return false;
552
1531
  }
553
1532
  }
@@ -567,22 +1546,52 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
567
1546
  try {
568
1547
  const cache = await this.getCache();
569
1548
  const request = this.keyToRequest(`doc:${key}`);
570
- const response = await this.matchWithTimeout(cache, request);
1549
+ // The document path is outside the debug surface (op is only get/getItem),
1550
+ // so the match-timeout flag is not surfaced as an event here -- though
1551
+ // matchWithTimeout still warns on a slow match. A miss or timeout falls
1552
+ // through to the KV document path and then render.
1553
+ const { response, error: matchError } = await this.matchWithTimeout(
1554
+ cache,
1555
+ request,
1556
+ );
571
1557
 
572
1558
  if (!response || response.status !== 200) {
1559
+ // A transient L1 match rejection (matchError set; only ever set when
1560
+ // response is undefined) is surfaced as cache-read before degrading to
1561
+ // L2/KV -- matching get()/getItem(). A genuine miss or a non-200 hit
1562
+ // carries no matchError and reports nothing.
1563
+ if (matchError)
1564
+ reportCacheError(
1565
+ matchError,
1566
+ "cache-read",
1567
+ "[CFCacheStore] getResponse L1 match",
1568
+ );
573
1569
  return this.kvGetResponse(key);
574
1570
  }
575
1571
 
1572
+ // Tag invalidation check (treat invalidated entry as a miss).
1573
+ const tagInfo = this.readTagInfo(response.headers);
1574
+ if (await this.isGloballyInvalidated(tagInfo.tags, tagInfo.taggedAt)) {
1575
+ return null;
1576
+ }
1577
+
576
1578
  // Check staleness
577
1579
  const staleAt = Number(response.headers.get(CACHE_STALE_AT_HEADER) || 0);
578
1580
  const isStale = staleAt > 0 && Date.now() > staleAt;
579
1581
 
1582
+ // L1 document bodies are streamed through verbatim - unlike the segment/
1583
+ // item tiers (which JSON-parse and so structurally detect corruption) and
1584
+ // the KV doc tier (validated in kvGetResponse, KV being the real partial-
1585
+ // read vector). Integrity here relies on the Cache API: cache.put stores a
1586
+ // response atomically or fails, so a truncated body is not served back. We
1587
+ // deliberately do NOT buffer+hash the body to re-verify it: that would
1588
+ // defeat streaming the document and add a full read to every cache hit.
580
1589
  return {
581
1590
  response: this.toClientResponse(response),
582
1591
  shouldRevalidate: isStale,
583
1592
  };
584
1593
  } catch (error) {
585
- console.error("[CFCacheStore] getResponse failed:", error);
1594
+ reportCacheError(error, "cache-read", "[CFCacheStore] getResponse");
586
1595
  return null;
587
1596
  }
588
1597
  }
@@ -604,6 +1613,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
604
1613
  headers.delete(CACHE_ORIG_CC_HEADER);
605
1614
  headers.delete(CACHE_STALE_AT_HEADER);
606
1615
  headers.delete(CACHE_STATUS_HEADER);
1616
+ headers.delete(CACHE_TAGS_HEADER);
1617
+ headers.delete(CACHE_TAGGED_AT_HEADER);
1618
+ // Finding #3 (read side): strip per-client signals a pre-fix or
1619
+ // pinned-version L1 entry may carry. See the read-side note in the design doc.
1620
+ stripPerClientSignals(headers);
607
1621
  return new Response(response.body, {
608
1622
  status: response.status,
609
1623
  statusText: response.statusText,
@@ -620,6 +1634,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
620
1634
  response: Response,
621
1635
  ttl: number,
622
1636
  swr?: number,
1637
+ tags?: string[],
623
1638
  ): Promise<void> {
624
1639
  try {
625
1640
  const cache = await this.getCache();
@@ -629,6 +1644,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
629
1644
  const swrWindow = resolveSwrWindow(swr, this.defaults);
630
1645
  const totalTtl = ttl + swrWindow;
631
1646
  const staleAt = Date.now() + ttl * 1000;
1647
+ const taggedAt =
1648
+ Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
632
1649
 
633
1650
  // Clone body for potential KV write before consuming it for L1
634
1651
  const [l1Body, kvBody] = this.kv
@@ -641,12 +1658,21 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
641
1658
  // replaced with a long max-age so the CF Cache API holds the entry across
642
1659
  // the SWR window; getResponse restores the original before serving.
643
1660
  const headers = new Headers(response.headers);
1661
+ // Finding #3: never persist a per-client signal in the shared L1 entry
1662
+ // (the platform's Set-Cookie rejection is unverified and ignores the
1663
+ // directive anyway). See stripPerClientSignals.
1664
+ stripPerClientSignals(headers);
644
1665
  const originalCacheControl = response.headers.get("Cache-Control");
645
1666
  if (originalCacheControl !== null) {
646
1667
  headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
647
1668
  }
648
1669
  headers.set("Cache-Control", `public, max-age=${totalTtl}`);
649
1670
  headers.set(CACHE_STALE_AT_HEADER, String(staleAt));
1671
+ // Internal tag headers (stripped by toClientResponse before serving).
1672
+ const tagHeaders = this.tagHeaderEntries(tags, taggedAt);
1673
+ for (const [name, value] of Object.entries(tagHeaders)) {
1674
+ headers.set(name, value);
1675
+ }
650
1676
 
651
1677
  const toCache = new Response(l1Body, {
652
1678
  status: response.status,
@@ -658,9 +1684,13 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
658
1684
 
659
1685
  if (this.waitUntil) {
660
1686
  // Non-blocking write
661
- this.waitUntil(async () => {
662
- await putPromise;
663
- });
1687
+ this.waitUntil(() =>
1688
+ reportingAsync(
1689
+ () => putPromise,
1690
+ "cache-write",
1691
+ "[CFCacheStore] L1 write",
1692
+ ),
1693
+ );
664
1694
  } else {
665
1695
  // Blocking fallback
666
1696
  await putPromise;
@@ -669,34 +1699,42 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
669
1699
  // L2: persist to KV (KV requires expirationTtl >= 60s)
670
1700
  if (this.kv && this.waitUntil && totalTtl >= 60) {
671
1701
  const kvKey = this.toKVKey(`doc:${key}`);
1702
+ // Finding #3: never persist a per-client signal in the KV envelope.
672
1703
  const headersArray: [string, string][] = [];
673
- response.headers.forEach((v, k) => headersArray.push([k, v]));
1704
+ response.headers.forEach((v, k) => {
1705
+ if (isPerClientSignalHeader(k)) return;
1706
+ headersArray.push([k, v]);
1707
+ });
674
1708
  // Read body as ArrayBuffer and encode to base64 to preserve binary payloads
675
1709
  const bodyBuf = kvBody
676
1710
  ? await new Response(kvBody).arrayBuffer()
677
1711
  : new ArrayBuffer(0);
678
1712
  const bodyBase64 = bufferToBase64(bodyBuf);
679
1713
 
680
- this.waitUntil(async () => {
681
- try {
682
- const envelope: KVResponseEnvelope = {
683
- b: bodyBase64,
684
- st: response.status,
685
- stx: response.statusText,
686
- hd: headersArray,
687
- s: staleAt,
688
- e: staleAt + swrWindow * 1000,
689
- };
690
- await this.kv!.put(kvKey, JSON.stringify(envelope), {
691
- expirationTtl: totalTtl,
692
- });
693
- } catch (error) {
694
- console.error("[CFCacheStore] KV putResponse failed:", error);
695
- }
696
- });
1714
+ this.waitUntil(() =>
1715
+ reportingAsync(
1716
+ () => {
1717
+ const envelope: KVResponseEnvelope = {
1718
+ b: bodyBase64,
1719
+ st: response.status,
1720
+ stx: response.statusText,
1721
+ hd: headersArray,
1722
+ s: staleAt,
1723
+ e: staleAt + swrWindow * 1000,
1724
+ t: tags,
1725
+ ta: taggedAt,
1726
+ };
1727
+ return this.kv!.put(kvKey, JSON.stringify(envelope), {
1728
+ expirationTtl: totalTtl,
1729
+ });
1730
+ },
1731
+ "cache-write",
1732
+ "[CFCacheStore] kvPutResponse",
1733
+ ),
1734
+ );
697
1735
  }
698
1736
  } catch (error) {
699
- console.error("[CFCacheStore] putResponse failed:", error);
1737
+ reportCacheError(error, "cache-write", "[CFCacheStore] putResponse");
700
1738
  }
701
1739
  }
702
1740
 
@@ -713,48 +1751,173 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
713
1751
  try {
714
1752
  const cache = await this.getCache();
715
1753
  const request = this.keyToRequest(`fn:${key}`);
716
- const response = await this.matchWithTimeout(cache, request);
1754
+ const matchStart = Date.now();
1755
+ const {
1756
+ response,
1757
+ timedOut,
1758
+ error: matchError,
1759
+ } = await this.matchWithTimeout(cache, request);
1760
+ const matchMs = Date.now() - matchStart;
1761
+
1762
+ if (!response) {
1763
+ // Transient match error reported cache-read; still degrades to L2/KV.
1764
+ if (matchError)
1765
+ reportCacheError(
1766
+ matchError,
1767
+ "cache-read",
1768
+ "[CFCacheStore] getItem L1 match",
1769
+ );
1770
+ if (this.debug)
1771
+ this.emitDebug({
1772
+ op: "getItem",
1773
+ key,
1774
+ // match-error (rejection) vs l1-miss (absence); see get().
1775
+ outcome: matchError
1776
+ ? "match-error"
1777
+ : timedOut
1778
+ ? "match-timeout"
1779
+ : "l1-miss",
1780
+ matchMs,
1781
+ });
1782
+ return this.kvGetItem(key);
1783
+ }
717
1784
 
718
- if (!response) return this.kvGetItem(key);
1785
+ // Non-200 entry is not a valid cached function result; treat as a miss.
1786
+ if (response.status !== 200) {
1787
+ if (this.debug)
1788
+ this.emitDebug({
1789
+ op: "getItem",
1790
+ key,
1791
+ outcome: "non-200",
1792
+ status: response.status,
1793
+ matchMs,
1794
+ });
1795
+ // Degraded fall-through: suppress revalidation so a broken L1 entry hit
1796
+ // concurrently serves KV-stale instead of spawning a herd (see get()).
1797
+ return this.kvGetItem(key, { suppressRevalidate: true });
1798
+ }
1799
+
1800
+ // Tag invalidation check (treat invalidated entry as a miss). Measure the
1801
+ // marker-resolution tail only under debug (see get()).
1802
+ const tagInfo = this.readTagInfo(response.headers);
1803
+ const markerStart = this.debug ? Date.now() : 0;
1804
+ const invalidated = await this.isGloballyInvalidated(
1805
+ tagInfo.tags,
1806
+ tagInfo.taggedAt,
1807
+ );
1808
+ const markerMs = this.debug ? Date.now() - markerStart : undefined;
1809
+ if (invalidated) {
1810
+ if (this.debug)
1811
+ this.emitDebug({
1812
+ op: "getItem",
1813
+ key,
1814
+ outcome: "tag-invalidated",
1815
+ status: response.status,
1816
+ matchMs,
1817
+ markerMs,
1818
+ });
1819
+ return null;
1820
+ }
719
1821
 
720
1822
  const staleAt = Number(
721
1823
  response.headers.get(CACHE_STALE_AT_HEADER) ?? "0",
722
1824
  );
723
1825
  const status = response.headers.get(CACHE_STATUS_HEADER);
724
- const age = Number(response.headers.get("age") ?? "0");
1826
+ const revalidatingAt = Number(
1827
+ response.headers.get(CACHE_REVALIDATING_AT_HEADER) ?? "0",
1828
+ );
725
1829
 
726
- const isStale = staleAt > 0 && Date.now() > staleAt;
1830
+ const now = Date.now();
1831
+ const isStale = staleAt > 0 && now > staleAt;
1832
+ // Recency from our explicit stamp, not CF's `Age` header (see get()).
727
1833
  const isRevalidating =
728
- status === "REVALIDATING" && age < MAX_REVALIDATION_INTERVAL;
729
-
730
- const data = (await response.json()) as {
1834
+ status === "REVALIDATING" &&
1835
+ revalidatingAt > 0 &&
1836
+ now - revalidatingAt < MAX_REVALIDATION_INTERVAL * 1000;
1837
+
1838
+ // Single emitter for the post-header L1 outcomes (see get()). Undefined
1839
+ // when debug is off, so the event object is never allocated on the hot
1840
+ // path; the informational-only `age` header is read lazily inside.
1841
+ const debugRead = this.debug
1842
+ ? (
1843
+ outcome: CFCacheReadDebugEvent["outcome"],
1844
+ bodyReadMs: number,
1845
+ shouldRevalidate?: boolean,
1846
+ ) =>
1847
+ this.emitDebug({
1848
+ op: "getItem",
1849
+ key,
1850
+ outcome,
1851
+ status: response.status,
1852
+ cacheStatus: status,
1853
+ staleAt,
1854
+ revalidatingAt,
1855
+ ageHeader: response.headers.get("age"),
1856
+ isStale,
1857
+ isRevalidating,
1858
+ shouldRevalidate,
1859
+ matchMs,
1860
+ markerMs,
1861
+ bodyReadMs,
1862
+ })
1863
+ : undefined;
1864
+
1865
+ const bodyStart = Date.now();
1866
+ const {
1867
+ value: data,
1868
+ errored,
1869
+ error,
1870
+ } = await this.readJsonWithTimeout<{
731
1871
  value: string;
732
1872
  handles?: string;
733
- };
1873
+ }>(response);
1874
+ const bodyReadMs = Date.now() - bodyStart;
1875
+ if (data === undefined) {
1876
+ debugRead?.(errored ? "body-error" : "body-timeout", bodyReadMs);
1877
+ // Heal + conditionally evict a body-error, suppress a body-timeout; see
1878
+ // get().
1879
+ if (errored)
1880
+ return this.healCorruptL1(cache, request, error, "getItem", () =>
1881
+ this.kvGetItem(key, { suppressRevalidate: false }),
1882
+ );
1883
+ return this.kvGetItem(key, { suppressRevalidate: true });
1884
+ }
734
1885
 
735
1886
  if (!isStale || isRevalidating) {
1887
+ debugRead?.(
1888
+ isRevalidating ? "l1-revalidating-guarded" : "l1-fresh",
1889
+ bodyReadMs,
1890
+ false,
1891
+ );
736
1892
  return {
737
1893
  value: data.value,
738
1894
  handles: data.handles,
739
1895
  shouldRevalidate: false,
1896
+ tags: tagInfo.tags,
740
1897
  };
741
1898
  }
742
1899
 
743
- // Stale and needs revalidation mark REVALIDATING atomically
744
- const headers = new Headers(response.headers);
745
- headers.set(CACHE_STATUS_HEADER, "REVALIDATING");
746
- await cache.put(
1900
+ // Stale and needs revalidation -- mark REVALIDATING (non-blocking,
1901
+ // best-effort, remaining-ttl) and return the stale value. See get() /
1902
+ // markRevalidating for the full rationale.
1903
+ this.markRevalidating(
1904
+ cache,
747
1905
  request,
748
- new Response(JSON.stringify(data), { status: 200, headers }),
1906
+ response.headers,
1907
+ 200,
1908
+ JSON.stringify(data),
749
1909
  );
750
1910
 
1911
+ debugRead?.("l1-stale-revalidate", bodyReadMs, true);
751
1912
  return {
752
1913
  value: data.value,
753
1914
  handles: data.handles,
754
1915
  shouldRevalidate: true,
1916
+ tags: tagInfo.tags,
755
1917
  };
756
1918
  } catch (error) {
757
- console.error("[CFCacheStore] getItem failed:", error);
1919
+ reportCacheError(error, "cache-read", "[CFCacheStore] getItem");
1920
+ if (this.debug) this.emitDebug({ op: "getItem", key, outcome: "error" });
758
1921
  return null;
759
1922
  }
760
1923
  }
@@ -777,22 +1940,33 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
777
1940
  const totalTtl = ttl + swrWindow;
778
1941
  const staleAt = Date.now() + ttl * 1000;
779
1942
 
1943
+ const tags = options?.tags;
1944
+ const taggedAt =
1945
+ Array.isArray(tags) && tags.length > 0 ? Date.now() : undefined;
1946
+
780
1947
  const body = JSON.stringify({ value, handles: options?.handles });
781
1948
  const response = new Response(body, {
782
1949
  headers: {
783
1950
  "Content-Type": "application/json",
784
1951
  "Cache-Control": `public, max-age=${totalTtl}`,
785
1952
  [CACHE_STALE_AT_HEADER]: String(staleAt),
1953
+ // Absolute hard-expiry deadline; see set() / remainingCacheControl.
1954
+ [CACHE_EXPIRES_AT_HEADER]: String(staleAt + swrWindow * 1000),
786
1955
  [CACHE_STATUS_HEADER]: "HIT",
1956
+ ...this.tagHeaderEntries(tags, taggedAt),
787
1957
  },
788
1958
  });
789
1959
 
790
1960
  const putPromise = cache.put(request, response);
791
1961
 
792
1962
  if (this.waitUntil) {
793
- this.waitUntil(async () => {
794
- await putPromise;
795
- });
1963
+ this.waitUntil(() =>
1964
+ reportingAsync(
1965
+ () => putPromise,
1966
+ "cache-write",
1967
+ "[CFCacheStore] L1 write",
1968
+ ),
1969
+ );
796
1970
  } else {
797
1971
  await putPromise;
798
1972
  }
@@ -800,24 +1974,28 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
800
1974
  // L2: persist to KV (KV requires expirationTtl >= 60s)
801
1975
  if (this.kv && this.waitUntil && totalTtl >= 60) {
802
1976
  const kvKey = this.toKVKey(`fn:${key}`);
803
- this.waitUntil(async () => {
804
- try {
805
- const envelope: KVItemEnvelope = {
806
- v: value,
807
- h: options?.handles,
808
- s: staleAt,
809
- e: staleAt + swrWindow * 1000,
810
- };
811
- await this.kv!.put(kvKey, JSON.stringify(envelope), {
812
- expirationTtl: totalTtl,
813
- });
814
- } catch (error) {
815
- console.error("[CFCacheStore] KV setItem failed:", error);
816
- }
817
- });
1977
+ this.waitUntil(() =>
1978
+ reportingAsync(
1979
+ () => {
1980
+ const envelope: KVItemEnvelope = {
1981
+ v: value,
1982
+ h: options?.handles,
1983
+ s: staleAt,
1984
+ e: staleAt + swrWindow * 1000,
1985
+ t: tags,
1986
+ ta: taggedAt,
1987
+ };
1988
+ return this.kv!.put(kvKey, JSON.stringify(envelope), {
1989
+ expirationTtl: totalTtl,
1990
+ });
1991
+ },
1992
+ "cache-write",
1993
+ "[CFCacheStore] kvSetItem",
1994
+ ),
1995
+ );
818
1996
  }
819
1997
  } catch (error) {
820
- console.error("[CFCacheStore] setItem failed:", error);
1998
+ reportCacheError(error, "cache-write", "[CFCacheStore] setItem");
821
1999
  }
822
2000
  }
823
2001
 
@@ -849,6 +2027,512 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
849
2027
  return `${versionPath}${key}`;
850
2028
  }
851
2029
 
2030
+ /**
2031
+ * Best-effort delete of a single KV key, reporting (not swallowing) a delete
2032
+ * failure as cache-delete. Used by the corrupt-entry self-heal paths.
2033
+ * @internal
2034
+ */
2035
+ private async evictKvKey(kvKey: string, label: string): Promise<void> {
2036
+ try {
2037
+ await this.kv!.delete(kvKey);
2038
+ } catch (error) {
2039
+ reportCacheError(
2040
+ error,
2041
+ "cache-delete",
2042
+ `[CFCacheStore] ${label}: evict failed`,
2043
+ );
2044
+ }
2045
+ }
2046
+
2047
+ /**
2048
+ * Schedule a corrupt-entry KV eviction as a NON-BLOCKING background task
2049
+ * (waitUntil) instead of awaiting it on the request path. The corrupt read has
2050
+ * already resolved to a miss; awaiting an unbounded kv.delete here would re-add
2051
+ * exactly the multi-second stall the read budgets exist to prevent when the KV
2052
+ * namespace is degraded. evictKvKey never rejects (it reports its own failure),
2053
+ * so the fire-and-forget fallback is safe when no waitUntil is available.
2054
+ * @internal
2055
+ */
2056
+ private scheduleKvEvict(kvKey: string, label: string): void {
2057
+ const evict = (): Promise<void> => this.evictKvKey(kvKey, label);
2058
+ if (this.waitUntil) this.waitUntil(evict);
2059
+ else void evict();
2060
+ }
2061
+
2062
+ /**
2063
+ * KV-get a JSON envelope, EVICTING the key only when it is genuinely corrupt.
2064
+ *
2065
+ * Reads as { type: "text" }, NOT { type: "json" }, on purpose: the "json" form
2066
+ * fuses the network read and the JSON parse, so a transient KV outage (5xx/429/
2067
+ * network blip) is indistinguishable from a malformed body and would delete a
2068
+ * still-good cross-colo entry - a self-inflicted miss storm. Reading text lets a
2069
+ * transient read error propagate to the caller's outer catch (reported
2070
+ * cache-read, the entry left intact); only a JSON.parse failure on a body that
2071
+ * WAS successfully read - or an envelope that parses but fails `validate`
2072
+ * (fields missing from a truncated write) - is true corruption that evicts +
2073
+ * reports cache-corrupt. A MISSING key (kv.get -> null) is a normal miss.
2074
+ * @internal
2075
+ */
2076
+ private async kvGetOrEvict<T>(
2077
+ kvKey: string,
2078
+ validate: (envelope: T) => boolean,
2079
+ label: string,
2080
+ ): Promise<{ value: T | null; timedOut: boolean }> {
2081
+ // Bound the read with the KV latency budget (inherited from #558) so a
2082
+ // degraded namespace cannot pin the request. readWithTimeout reports
2083
+ // timedOut on budget expiry; a transient read REJECTION (5xx/429/network)
2084
+ // instead propagates out to the caller's outer catch (reported cache-read,
2085
+ // the entry left intact) -- deliberately NOT caught as corruption.
2086
+ const { value: raw, timedOut } = await this.readWithTimeout<unknown>(
2087
+ () => this.kv!.get(kvKey, { type: "text" }),
2088
+ this.kvReadTimeoutMs,
2089
+ "KV read",
2090
+ );
2091
+ if (timedOut) return { value: null, timedOut: true };
2092
+ if (raw == null) return { value: null, timedOut: false }; // missing = miss
2093
+
2094
+ // Real CF KV with { type: "text" } returns a string: parse + structurally
2095
+ // validate it; a parse/validate failure on a successfully-read body is the
2096
+ // only true corruption (evict + cache-corrupt). A KV binding that already
2097
+ // returns a parsed object (some shims/tests) is used as-is.
2098
+ let envelope: T;
2099
+ if (typeof raw === "string") {
2100
+ try {
2101
+ envelope = JSON.parse(raw) as T;
2102
+ } catch (error) {
2103
+ reportCacheError(
2104
+ error,
2105
+ "cache-corrupt",
2106
+ `[CFCacheStore] ${label}: corrupt JSON in KV, evicting`,
2107
+ );
2108
+ this.scheduleKvEvict(kvKey, label);
2109
+ return { value: null, timedOut: false };
2110
+ }
2111
+ } else {
2112
+ envelope = raw as T;
2113
+ }
2114
+
2115
+ // A body that parses to null or a primitive ('null', '42', 'true', '"x"')
2116
+ // is not a valid envelope. Guard it BEFORE validate(): the property-reading
2117
+ // validators throw on a null/primitive rather than returning false, which
2118
+ // would escape to the caller's outer catch as a transient cache-read and
2119
+ // leave the bad key un-evicted (re-failing every read until its KV TTL). The
2120
+ // typeof check short-circuits validate() so it only ever runs on an object.
2121
+ if (
2122
+ envelope == null ||
2123
+ typeof envelope !== "object" ||
2124
+ !validate(envelope)
2125
+ ) {
2126
+ reportCacheError(
2127
+ new Error("malformed/partial KV envelope"),
2128
+ "cache-corrupt",
2129
+ `[CFCacheStore] ${label}: malformed envelope, evicting`,
2130
+ );
2131
+ this.scheduleKvEvict(kvKey, label);
2132
+ return { value: null, timedOut: false };
2133
+ }
2134
+ return { value: envelope, timedOut: false };
2135
+ }
2136
+
2137
+ // ============================================================================
2138
+ // Tag Invalidation (single-store: markers live in this.kv)
2139
+ // ============================================================================
2140
+
2141
+ /** KV key for a tag's invalidation marker. */
2142
+ private tagMarkerKey(tag: string): string {
2143
+ return this.toKVKey(`${TAG_MARKER_PREFIX}${tag}`);
2144
+ }
2145
+
2146
+ /**
2147
+ * Header entries carrying an entry's tags (JSON-encoded, comma-safe) and the
2148
+ * timestamp they were attached. Returns an empty object when there are no
2149
+ * tags so untagged entries stay header-free and skip the invalidation check.
2150
+ */
2151
+ private tagHeaderEntries(
2152
+ tags: string[] | undefined,
2153
+ taggedAt: number | undefined,
2154
+ ): Record<string, string> {
2155
+ if (!Array.isArray(tags) || tags.length === 0 || !taggedAt) return {};
2156
+ return {
2157
+ // encodeURIComponent so the value is pure ASCII: HTTP header values are
2158
+ // ByteStrings, but JSON.stringify leaves codepoints > U+00FF (emoji/CJK)
2159
+ // verbatim, which makes new Response({ headers }) throw and the outer
2160
+ // try/catch silently drop the whole entry from cache. Decoded in
2161
+ // readTagInfo. The L1 marker Cache-Tag path encodes for the same reason.
2162
+ [CACHE_TAGS_HEADER]: encodeURIComponent(JSON.stringify(tags)),
2163
+ [CACHE_TAGGED_AT_HEADER]: String(taggedAt),
2164
+ };
2165
+ }
2166
+
2167
+ /** Read an entry's tags/taggedAt back from its headers. */
2168
+ private readTagInfo(headers: Headers): {
2169
+ tags?: string[];
2170
+ taggedAt?: number;
2171
+ } {
2172
+ const rawTags = headers.get(CACHE_TAGS_HEADER);
2173
+ const rawTaggedAt = headers.get(CACHE_TAGGED_AT_HEADER);
2174
+ if (!rawTags || !rawTaggedAt) return {};
2175
+ try {
2176
+ return {
2177
+ tags: JSON.parse(decodeURIComponent(rawTags)) as string[],
2178
+ taggedAt: Number(rawTaggedAt),
2179
+ };
2180
+ } catch {
2181
+ return {};
2182
+ }
2183
+ }
2184
+
2185
+ /**
2186
+ * Whether an entry tagged at `taggedAt` with `tags` has been invalidated since.
2187
+ * Reads the per-tag invalidation markers from KV and returns true if any tag's
2188
+ * latest invalidation is at or after taggedAt (>= so a same-millisecond
2189
+ * invalidate wins, favouring freshness over staleness). Fails open: KV errors
2190
+ * never turn a hit into a wrongful miss-storm beyond this single read.
2191
+ */
2192
+ private async isGloballyInvalidated(
2193
+ tags: string[] | undefined,
2194
+ taggedAt: number | undefined,
2195
+ ): Promise<boolean> {
2196
+ // Array.isArray (not just truthiness): a non-array tags value - direct store
2197
+ // misuse like setItem(k, v, { tags: "products" }), or a skewed KV envelope -
2198
+ // must fail safe to "not invalidated" rather than throwing `.map` on every
2199
+ // read (which the outer catch would mis-report as a transient cache-read).
2200
+ if (!this.kv || !Array.isArray(tags) || tags.length === 0 || !taggedAt)
2201
+ return false;
2202
+ const ctx = _getRequestContext();
2203
+ const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
2204
+ const inflight = ctx ? getTagMarkerInflight(ctx, this) : undefined;
2205
+ try {
2206
+ const markers = await Promise.all(
2207
+ tags.map((tag) => this.readTagMarker(tag, memo, inflight)),
2208
+ );
2209
+ for (const marker of markers) {
2210
+ if (marker != null && marker >= taggedAt) return true;
2211
+ }
2212
+ return false;
2213
+ } catch (error) {
2214
+ reportCacheError(
2215
+ error,
2216
+ "cache-read",
2217
+ "[CFCacheStore] tag invalidation check",
2218
+ );
2219
+ return false;
2220
+ }
2221
+ }
2222
+
2223
+ /** Synthetic Cache API request for a tag's L1-cached invalidation marker. */
2224
+ private tagMarkerRequest(tag: string): Request {
2225
+ return this.keyToRequest(`${TAG_MARKER_CACHE_PREFIX}${tag}`);
2226
+ }
2227
+
2228
+ /**
2229
+ * Read a tag's latest invalidation timestamp (or null if never invalidated)
2230
+ * through the cascade: per-request memo -> per-colo L1 cache (only when
2231
+ * tagCacheTtl > 0) -> KV (the global truth). The memo is always consulted
2232
+ * first so it stays authoritative within a request (read-your-own-writes),
2233
+ * and every KV/L1 result is written back into the memo. A Cache API miss
2234
+ * always falls through to KV; absence is represented by a cached sentinel,
2235
+ * never by a miss.
2236
+ *
2237
+ * Concurrent reads of the same tag within a request share one in-flight read
2238
+ * (the resolved-value memo only collapses sequential reads; parallel segment
2239
+ * loading would otherwise issue one KV read per concurrent reader).
2240
+ * @internal
2241
+ */
2242
+ private async readTagMarker(
2243
+ tag: string,
2244
+ memo: Map<string, number | null> | undefined,
2245
+ inflight: Map<string, Promise<number | null>> | undefined,
2246
+ ): Promise<number | null> {
2247
+ if (memo && memo.has(tag)) return memo.get(tag) ?? null;
2248
+
2249
+ // Collapse concurrent (not-yet-resolved) reads of this tag onto one promise.
2250
+ if (inflight) {
2251
+ const pending = inflight.get(tag);
2252
+ if (pending) return pending;
2253
+ const read = this.fetchTagMarker(tag, memo);
2254
+ inflight.set(tag, read);
2255
+ try {
2256
+ return await read;
2257
+ } finally {
2258
+ // Resolved values now live in the memo; drop the in-flight entry.
2259
+ inflight.delete(tag);
2260
+ }
2261
+ }
2262
+
2263
+ return this.fetchTagMarker(tag, memo);
2264
+ }
2265
+
2266
+ /**
2267
+ * Uncached body of readTagMarker: L1 (per-colo Cache API, opt-in via
2268
+ * tagCacheTtl) -> KV. Writes the resolved value back into the memo.
2269
+ * @internal
2270
+ */
2271
+ private async fetchTagMarker(
2272
+ tag: string,
2273
+ memo: Map<string, number | null> | undefined,
2274
+ ): Promise<number | null> {
2275
+ // Write the resolved marker into the memo WITHOUT clobbering a value a
2276
+ // concurrent invalidateTags() wrote during our await. The router resolves
2277
+ // sibling slots in parallel, so a slot's updateTag() can land the
2278
+ // authoritative invalidatedAt into the memo while this read is still in
2279
+ // flight; overwriting it with our (pre-invalidation) read result would break
2280
+ // read-your-own-writes for the rest of the request. If the tag was memoized
2281
+ // mid-read, that value wins and is returned. Without a memo, the read result
2282
+ // stands as-is.
2283
+ const memoize = (read: number | null): number | null => {
2284
+ if (memo && memo.has(tag)) return memo.get(tag) ?? null;
2285
+ memo?.set(tag, read);
2286
+ return read;
2287
+ };
2288
+
2289
+ // L1 (per-colo) marker cache - opt-in via tagCacheTtl. Bounded by the same
2290
+ // edge budgets as data reads (inherited from #558) so a degraded colo cannot
2291
+ // stall a tagged read; a miss, timeout, or error all fall through to KV.
2292
+ if (this.tagCacheTtl > 0) {
2293
+ try {
2294
+ const cache = await this.getCache();
2295
+ const { response: hit, error: matchError } =
2296
+ await this.matchWithTimeout(cache, this.tagMarkerRequest(tag));
2297
+ // A transient match REJECTION is captured (not thrown) by
2298
+ // matchWithTimeout; surface it as cache-read like the data read paths
2299
+ // before falling through to KV, rather than silently dropping it.
2300
+ if (matchError)
2301
+ reportCacheError(
2302
+ matchError,
2303
+ "cache-read",
2304
+ "[CFCacheStore] tag marker L1 match",
2305
+ );
2306
+ if (hit) {
2307
+ const { value: body } = await this.readWithTimeout(
2308
+ () => hit.text(),
2309
+ this.edgeReadTimeoutMs,
2310
+ "tag marker L1 body read",
2311
+ );
2312
+ if (body !== undefined) {
2313
+ const value = body === TAG_MARKER_ABSENT ? null : Number(body);
2314
+ return memoize(value);
2315
+ }
2316
+ }
2317
+ } catch {
2318
+ // Fall through to KV on any L1 read error.
2319
+ }
2320
+ }
2321
+
2322
+ // KV (global truth), bounded by the KV budget. On TIMEOUT fail OPEN: treat
2323
+ // the marker as absent (-> entry not invalidated -> served) so a degraded
2324
+ // namespace cannot pin every tagged read behind a slow global lookup. A
2325
+ // transient REJECTION instead propagates to isGloballyInvalidated's catch
2326
+ // (reported cache-read), which also fails open. Either way one slow tag
2327
+ // never amplifies into a per-segment stall.
2328
+ const { value: raw, timedOut } = await this.readWithTimeout<string | null>(
2329
+ () => this.kv!.get(this.tagMarkerKey(tag), { type: "text" }),
2330
+ this.kvReadTimeoutMs,
2331
+ "tag marker KV read",
2332
+ );
2333
+ if (timedOut) {
2334
+ // Memoize the fail-open result so the rest of this request is consistent
2335
+ // (and does not re-pay the timeout per segment sharing the tag).
2336
+ return memoize(null);
2337
+ }
2338
+ const value = raw != null ? Number(raw) : null;
2339
+ const resolved = memoize(value);
2340
+
2341
+ // Populate L1 for subsequent reads in this colo (non-blocking). Use the
2342
+ // resolved (memo-aware) value so a marker invalidated mid-read is not
2343
+ // re-cached stale into this colo's L1.
2344
+ if (this.tagCacheTtl > 0) {
2345
+ const put = () => this.putTagMarkerL1(tag, resolved);
2346
+ if (this.waitUntil) this.waitUntil(put);
2347
+ else void put();
2348
+ }
2349
+ return resolved;
2350
+ }
2351
+
2352
+ /**
2353
+ * Cloudflare Cache-Tags written on a tag's L1 marker entry, namespaced per
2354
+ * store so purges never collide with other Cache-Tags in the zone. Three
2355
+ * tiers, broad to specific:
2356
+ * rg:{ns} - everything this store cached (deploy/nuclear reset)
2357
+ * rg:{ns}:lk - all tag-lookup markers
2358
+ * rg:{ns}:lk:{tag} - this tag's lookup (the normal updateTag purge target)
2359
+ * The tag value is encodeURIComponent'd so commas/spaces can't corrupt the
2360
+ * comma-delimited Cache-Tag header.
2361
+ * @internal
2362
+ */
2363
+ private lookupCacheTags(tag: string): string[] {
2364
+ const ns = this.namespace ?? "default";
2365
+ return [`rg:${ns}`, `rg:${ns}:lk`, this.lookupPurgeTag(tag)];
2366
+ }
2367
+
2368
+ /** The specific Cache-Tag a consumer purges to evict tag `tag`'s lookup. */
2369
+ private lookupPurgeTag(tag: string): string {
2370
+ const ns = this.namespace ?? "default";
2371
+ return `rg:${ns}:lk:${encodeURIComponent(tag)}`;
2372
+ }
2373
+
2374
+ /**
2375
+ * Write a tag marker value into the per-colo L1 Cache API with tagCacheTtl.
2376
+ * `null` is stored as the TAG_MARKER_ABSENT sentinel so "no marker yet" is
2377
+ * cacheable (most tags are never invalidated - that is where the read savings
2378
+ * come from). The entry also carries a namespaced Cache-Tag so an external
2379
+ * purge-by-tag (via onRevalidateTag) can evict it across colos promptly,
2380
+ * rather than waiting out tagCacheTtl. Best-effort.
2381
+ * @internal
2382
+ */
2383
+ private async putTagMarkerL1(
2384
+ tag: string,
2385
+ value: number | null,
2386
+ opts?: { critical?: boolean },
2387
+ ): Promise<void> {
2388
+ if (this.tagCacheTtl <= 0) return;
2389
+ try {
2390
+ const cache = await this.getCache();
2391
+ const body = value != null ? String(value) : TAG_MARKER_ABSENT;
2392
+ await cache.put(
2393
+ this.tagMarkerRequest(tag),
2394
+ new Response(body, {
2395
+ headers: {
2396
+ "Cache-Control": `public, max-age=${this.tagCacheTtl}`,
2397
+ "Cache-Tag": this.lookupCacheTags(tag).join(","),
2398
+ },
2399
+ }),
2400
+ );
2401
+ } catch (error) {
2402
+ // The read-path populate is best-effort: a failed populate just means the
2403
+ // next read consults KV. The invalidation WRITE-THROUGH (critical) is not
2404
+ // - silently swallowing it would leave this colo's stale marker (often the
2405
+ // ABSENT sentinel) authoritative for tagCacheTtl while updateTag reports
2406
+ // success. Surface it, and best-effort delete the L1 marker so the next
2407
+ // read re-reads KV, which already holds the fresh marker (written before
2408
+ // this write-through in invalidateTags).
2409
+ if (opts?.critical) {
2410
+ reportCacheError(
2411
+ error,
2412
+ "cache-invalidate",
2413
+ "[CFCacheStore] tag marker L1 write-through",
2414
+ );
2415
+ await reportingAsync(
2416
+ async () => {
2417
+ const cache = await this.getCache();
2418
+ await cache.delete(this.tagMarkerRequest(tag));
2419
+ },
2420
+ "cache-delete",
2421
+ "[CFCacheStore] tag marker L1 evict after failed write-through",
2422
+ );
2423
+ }
2424
+ }
2425
+ }
2426
+
2427
+ /**
2428
+ * Invalidate every entry tagged with any of `tags`. Receives the whole batch
2429
+ * from one updateTag()/revalidateTag() call so the eager-purge hook fires
2430
+ * ONCE (one CDN purge request, not one per tag). For each tag: records the KV
2431
+ * marker (the durable cross-colo truth that reads compare taggedAt against),
2432
+ * writes the fresh marker straight into this colo's L1 (write-through, NOT
2433
+ * delete - a delete would let the next read re-read a not-yet-converged KV
2434
+ * value and re-arm the stale window), and memoizes it for same-request
2435
+ * read-your-own-writes. Finally fires onRevalidateTag with the namespaced
2436
+ * lookup Cache-Tags so a consumer purge evicts the cached lookups in other
2437
+ * colos promptly (otherwise they converge within tagCacheTtl).
2438
+ *
2439
+ * Durable-write integrity: the in-memory write-through (memo + L1) for a tag
2440
+ * runs ONLY after that tag's KV marker write is confirmed. If any KV write
2441
+ * fails (transient error, or an over-512-byte key), this rejects with the
2442
+ * failed tags so an awaiting updateTag() surfaces the failure instead of
2443
+ * silently reporting success while other requests/colos serve stale data. The
2444
+ * eager purge still fires for the whole batch first (it is additive).
2445
+ */
2446
+ async invalidateTags(tags: string[]): Promise<void> {
2447
+ if (tags.length === 0) return;
2448
+ const invalidatedAt = Date.now();
2449
+ const ctx = _getRequestContext();
2450
+ const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
2451
+
2452
+ if (!this.kv && !this.onRevalidateTag) {
2453
+ console.warn(
2454
+ `[CFCacheStore] invalidateTags had no effect: configure a KV namespace ` +
2455
+ `for distributed invalidation, or an onRevalidateTag hook.`,
2456
+ );
2457
+ }
2458
+
2459
+ const failedTags = new Set<string>();
2460
+ const errors: unknown[] = [];
2461
+ if (this.kv) {
2462
+ await Promise.all(
2463
+ tags.map(async (tag) => {
2464
+ const markerKey = this.tagMarkerKey(tag);
2465
+ if (kvKeyByteLength(markerKey) > KV_MAX_KEY_BYTES) {
2466
+ failedTags.add(tag);
2467
+ errors.push(
2468
+ new Error(
2469
+ `tag "${tag}" produces a ${kvKeyByteLength(markerKey)}-byte KV ` +
2470
+ `marker key, over the ${KV_MAX_KEY_BYTES}-byte limit`,
2471
+ ),
2472
+ );
2473
+ return;
2474
+ }
2475
+ try {
2476
+ await this.kv!.put(markerKey, String(invalidatedAt), {
2477
+ ...(this.tagInvalidationTtl
2478
+ ? { expirationTtl: this.tagInvalidationTtl }
2479
+ : {}),
2480
+ });
2481
+ } catch (error) {
2482
+ failedTags.add(tag);
2483
+ errors.push(error);
2484
+ }
2485
+ }),
2486
+ );
2487
+ }
2488
+
2489
+ // Write-through memo + L1 only for tags with a confirmed durable marker, and
2490
+ // only when KV is configured. Markers are read exclusively through
2491
+ // isGloballyInvalidated(), which short-circuits to "not invalidated" when
2492
+ // !this.kv; writing memo/L1 markers without KV would be dead state no read
2493
+ // path ever consults. The onRevalidateTag purge below still fires regardless
2494
+ // (it is additive and external to the marker cascade). The memo write is
2495
+ // synchronous (read-your-own-writes); the L1 Cache API writes are
2496
+ // independent, so fan them out in parallel rather than awaiting each.
2497
+ if (this.kv) {
2498
+ const l1Writes: Promise<void>[] = [];
2499
+ for (const tag of tags) {
2500
+ if (failedTags.has(tag)) continue;
2501
+ memo?.set(tag, invalidatedAt);
2502
+ if (this.tagCacheTtl > 0) {
2503
+ l1Writes.push(
2504
+ this.putTagMarkerL1(tag, invalidatedAt, { critical: true }),
2505
+ );
2506
+ }
2507
+ }
2508
+ if (l1Writes.length > 0) await Promise.all(l1Writes);
2509
+ }
2510
+
2511
+ // One batched eager purge of the lookup markers for the whole call. Fired
2512
+ // regardless of KV write outcome (it is additive and uses pure string ops).
2513
+ if (this.onRevalidateTag) {
2514
+ try {
2515
+ await this.onRevalidateTag(tags.map((tag) => this.lookupPurgeTag(tag)));
2516
+ } catch (error) {
2517
+ reportCacheError(
2518
+ error,
2519
+ "cache-invalidate",
2520
+ "[CFCacheStore] onRevalidateTag hook",
2521
+ );
2522
+ }
2523
+ }
2524
+
2525
+ if (failedTags.size > 0) {
2526
+ const err = new Error(
2527
+ `[CFCacheStore] ${failedTags.size}/${tags.length} tag marker write(s) ` +
2528
+ `failed: ${[...failedTags].join(", ")}. Those tags may still serve ` +
2529
+ `stale data across requests/colos; retry the invalidation.`,
2530
+ );
2531
+ (err as Error & { cause?: unknown }).cause = errors[0];
2532
+ throw err;
2533
+ }
2534
+ }
2535
+
852
2536
  // ============================================================================
853
2537
  // KV L2 Helpers
854
2538
  // ============================================================================
@@ -859,28 +2543,78 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
859
2543
  * Promotes hits to L1 via waitUntil.
860
2544
  * @internal
861
2545
  */
862
- private async kvGetSegment(key: string): Promise<CacheGetResult | null> {
2546
+ private async kvGetSegment(
2547
+ key: string,
2548
+ opts?: { suppressRevalidate?: boolean },
2549
+ ): Promise<CacheGetResult | null> {
863
2550
  if (!this.kv) return null;
864
2551
 
865
2552
  try {
866
2553
  const kvKey = this.toKVKey(key);
867
- const raw = await this.kv.get(kvKey, { type: "json" });
868
- if (!raw) return null;
2554
+ const { value: envelope, timedOut } =
2555
+ await this.kvGetOrEvict<KVSegmentEnvelope>(
2556
+ kvKey,
2557
+ (e) =>
2558
+ typeof e.e === "number" && typeof e.s === "number" && e.d != null,
2559
+ "kvGetSegment",
2560
+ );
2561
+ if (timedOut) {
2562
+ // Abandoned slow KV read: no envelope, so no promote-to-L1. Distinct
2563
+ // from a genuine kv-miss so the degradation is visible on wrangler tail.
2564
+ if (this.debug)
2565
+ this.emitDebug({ op: "get", key, outcome: "kv-timeout" });
2566
+ return null;
2567
+ }
2568
+ if (!envelope) {
2569
+ // Missing key, or a corrupt entry already evicted + reported by
2570
+ // kvGetOrEvict. Either way a miss.
2571
+ if (this.debug) this.emitDebug({ op: "get", key, outcome: "kv-miss" });
2572
+ return null;
2573
+ }
869
2574
 
870
- const envelope = raw as KVSegmentEnvelope;
871
2575
  const now = Date.now();
872
2576
 
873
2577
  // Hard-expired — treat as miss
874
- if (now > envelope.e) return null;
2578
+ if (now > envelope.e) {
2579
+ if (this.debug) this.emitDebug({ op: "get", key, outcome: "kv-miss" });
2580
+ return null;
2581
+ }
875
2582
 
876
- const shouldRevalidate = now > envelope.s;
2583
+ // Tag invalidation check (also covers the KV tier, not just L1).
2584
+ if (
2585
+ await this.isGloballyInvalidated(envelope.d.tags, envelope.d.taggedAt)
2586
+ ) {
2587
+ if (this.debug)
2588
+ this.emitDebug({ op: "get", key, outcome: "tag-invalidated" });
2589
+ return null;
2590
+ }
2591
+
2592
+ // When this is a degraded L1 fall-through (body-timeout / non-200), the
2593
+ // caller asks us to suppress revalidation: KV has no REVALIDATING herd
2594
+ // guard, so N concurrent degraded reads would otherwise each spawn a
2595
+ // render exactly when the colo is already struggling. We still serve the
2596
+ // stale data and still promote to L1; only the revalidation is withheld.
2597
+ const stale = now > envelope.s;
2598
+ const shouldRevalidate = stale && !opts?.suppressRevalidate;
877
2599
 
878
2600
  // Promote to L1 in background
879
2601
  this.promoteSegmentToL1(key, envelope);
880
2602
 
2603
+ if (this.debug)
2604
+ this.emitDebug({
2605
+ op: "get",
2606
+ key,
2607
+ outcome: !stale
2608
+ ? "kv-fresh"
2609
+ : opts?.suppressRevalidate
2610
+ ? "kv-stale-suppressed"
2611
+ : "kv-stale",
2612
+ shouldRevalidate,
2613
+ });
881
2614
  return { data: envelope.d, shouldRevalidate };
882
2615
  } catch (error) {
883
- console.error("[CFCacheStore] KV get failed:", error);
2616
+ reportCacheError(error, "cache-read", "[CFCacheStore] kvGetSegment");
2617
+ if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
884
2618
  return null;
885
2619
  }
886
2620
  }
@@ -902,20 +2636,22 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
902
2636
  const kvKey = this.toKVKey(key);
903
2637
  const expiresAt = staleAt + swrWindow * 1000;
904
2638
 
905
- this.waitUntil(async () => {
906
- try {
907
- const envelope: KVSegmentEnvelope = {
908
- d: data,
909
- s: staleAt,
910
- e: expiresAt,
911
- };
912
- await this.kv!.put(kvKey, JSON.stringify(envelope), {
913
- expirationTtl: totalTtl,
914
- });
915
- } catch (error) {
916
- console.error("[CFCacheStore] KV set failed:", error);
917
- }
918
- });
2639
+ this.waitUntil(() =>
2640
+ reportingAsync(
2641
+ () => {
2642
+ const envelope: KVSegmentEnvelope = {
2643
+ d: data,
2644
+ s: staleAt,
2645
+ e: expiresAt,
2646
+ };
2647
+ return this.kv!.put(kvKey, JSON.stringify(envelope), {
2648
+ expirationTtl: totalTtl,
2649
+ });
2650
+ },
2651
+ "cache-write",
2652
+ "[CFCacheStore] kvSetSegment",
2653
+ ),
2654
+ );
919
2655
  }
920
2656
 
921
2657
  /**
@@ -925,58 +2661,115 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
925
2661
  private promoteSegmentToL1(key: string, envelope: KVSegmentEnvelope): void {
926
2662
  if (!this.waitUntil) return;
927
2663
 
928
- this.waitUntil(async () => {
929
- try {
930
- const now = Date.now();
931
- const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
932
- const cache = await this.getCache();
933
- const request = this.keyToRequest(key);
934
-
935
- const response = new Response(JSON.stringify(envelope.d), {
936
- headers: {
937
- "Content-Type": "application/json",
938
- "Cache-Control": `public, max-age=${remainingTtl}`,
939
- [CACHE_STALE_AT_HEADER]: String(envelope.s),
940
- [CACHE_STATUS_HEADER]: "HIT",
941
- },
942
- });
943
-
944
- await cache.put(request, response);
945
- } catch (error) {
946
- console.error("[CFCacheStore] L1 promote failed:", error);
947
- }
948
- });
2664
+ this.waitUntil(() =>
2665
+ reportingAsync(
2666
+ async () => {
2667
+ const now = Date.now();
2668
+ const remainingTtl = Math.max(
2669
+ 1,
2670
+ Math.floor((envelope.e - now) / 1000),
2671
+ );
2672
+ const cache = await this.getCache();
2673
+ const request = this.keyToRequest(key);
2674
+
2675
+ const response = new Response(JSON.stringify(envelope.d), {
2676
+ headers: {
2677
+ "Content-Type": "application/json",
2678
+ "Cache-Control": `public, max-age=${remainingTtl}`,
2679
+ [CACHE_STALE_AT_HEADER]: String(envelope.s),
2680
+ // Carry the hard-expiry deadline so a promoted entry that later
2681
+ // goes stale re-puts with the correct remaining ttl (see set()).
2682
+ [CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
2683
+ [CACHE_STATUS_HEADER]: "HIT",
2684
+ // Preserve tags across KV->L1 promotion so the promoted entry
2685
+ // stays tag-invalidatable.
2686
+ ...this.tagHeaderEntries(envelope.d.tags, envelope.d.taggedAt),
2687
+ },
2688
+ });
2689
+
2690
+ await cache.put(request, response);
2691
+ },
2692
+ "cache-write",
2693
+ "[CFCacheStore] promoteSegmentToL1",
2694
+ ),
2695
+ );
949
2696
  }
950
2697
 
951
2698
  /**
952
2699
  * KV fallback for function cache reads.
953
2700
  * @internal
954
2701
  */
955
- private async kvGetItem(key: string): Promise<CacheItemResult | null> {
2702
+ private async kvGetItem(
2703
+ key: string,
2704
+ opts?: { suppressRevalidate?: boolean },
2705
+ ): Promise<CacheItemResult | null> {
956
2706
  if (!this.kv) return null;
957
2707
 
958
2708
  try {
959
2709
  const kvKey = this.toKVKey(`fn:${key}`);
960
- const raw = await this.kv.get(kvKey, { type: "json" });
961
- if (!raw) return null;
2710
+ const { value: envelope, timedOut } =
2711
+ await this.kvGetOrEvict<KVItemEnvelope>(
2712
+ kvKey,
2713
+ (e) =>
2714
+ typeof e.v === "string" &&
2715
+ typeof e.e === "number" &&
2716
+ typeof e.s === "number",
2717
+ "kvGetItem",
2718
+ );
2719
+ if (timedOut) {
2720
+ if (this.debug)
2721
+ this.emitDebug({ op: "getItem", key, outcome: "kv-timeout" });
2722
+ return null;
2723
+ }
2724
+ if (!envelope) {
2725
+ if (this.debug)
2726
+ this.emitDebug({ op: "getItem", key, outcome: "kv-miss" });
2727
+ return null;
2728
+ }
962
2729
 
963
- const envelope = raw as KVItemEnvelope;
964
2730
  const now = Date.now();
965
2731
 
966
- if (now > envelope.e) return null;
2732
+ if (now > envelope.e) {
2733
+ if (this.debug)
2734
+ this.emitDebug({ op: "getItem", key, outcome: "kv-miss" });
2735
+ return null;
2736
+ }
967
2737
 
968
- const shouldRevalidate = now > envelope.s;
2738
+ // Tag invalidation check (also covers the KV tier, not just L1).
2739
+ if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
2740
+ if (this.debug)
2741
+ this.emitDebug({ op: "getItem", key, outcome: "tag-invalidated" });
2742
+ return null;
2743
+ }
2744
+
2745
+ // Degraded fall-through suppresses revalidation (no KV herd guard); see
2746
+ // kvGetSegment. Still serves stale and still promotes.
2747
+ const stale = now > envelope.s;
2748
+ const shouldRevalidate = stale && !opts?.suppressRevalidate;
969
2749
 
970
2750
  // Promote to L1
971
2751
  this.promoteItemToL1(key, envelope);
972
2752
 
2753
+ if (this.debug)
2754
+ this.emitDebug({
2755
+ op: "getItem",
2756
+ key,
2757
+ outcome: !stale
2758
+ ? "kv-fresh"
2759
+ : opts?.suppressRevalidate
2760
+ ? "kv-stale-suppressed"
2761
+ : "kv-stale",
2762
+ shouldRevalidate,
2763
+ });
973
2764
  return {
974
2765
  value: envelope.v,
975
2766
  handles: envelope.h,
976
2767
  shouldRevalidate,
2768
+ tags: envelope.t,
977
2769
  };
978
2770
  } catch (error) {
979
- console.error("[CFCacheStore] KV getItem failed:", error);
2771
+ reportCacheError(error, "cache-read", "[CFCacheStore] kvGetItem");
2772
+ if (this.debug) this.emitDebug({ op: "getItem", key, outcome: "error" });
980
2773
  return null;
981
2774
  }
982
2775
  }
@@ -988,28 +2781,41 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
988
2781
  private promoteItemToL1(key: string, envelope: KVItemEnvelope): void {
989
2782
  if (!this.waitUntil) return;
990
2783
 
991
- this.waitUntil(async () => {
992
- try {
993
- const now = Date.now();
994
- const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
995
- const cache = await this.getCache();
996
- const request = this.keyToRequest(`fn:${key}`);
997
-
998
- const body = JSON.stringify({ value: envelope.v, handles: envelope.h });
999
- const response = new Response(body, {
1000
- headers: {
1001
- "Content-Type": "application/json",
1002
- "Cache-Control": `public, max-age=${remainingTtl}`,
1003
- [CACHE_STALE_AT_HEADER]: String(envelope.s),
1004
- [CACHE_STATUS_HEADER]: "HIT",
1005
- },
1006
- });
1007
-
1008
- await cache.put(request, response);
1009
- } catch (error) {
1010
- console.error("[CFCacheStore] L1 item promote failed:", error);
1011
- }
1012
- });
2784
+ this.waitUntil(() =>
2785
+ reportingAsync(
2786
+ async () => {
2787
+ const now = Date.now();
2788
+ const remainingTtl = Math.max(
2789
+ 1,
2790
+ Math.floor((envelope.e - now) / 1000),
2791
+ );
2792
+ const cache = await this.getCache();
2793
+ const request = this.keyToRequest(`fn:${key}`);
2794
+
2795
+ const body = JSON.stringify({
2796
+ value: envelope.v,
2797
+ handles: envelope.h,
2798
+ });
2799
+ const response = new Response(body, {
2800
+ headers: {
2801
+ "Content-Type": "application/json",
2802
+ "Cache-Control": `public, max-age=${remainingTtl}`,
2803
+ [CACHE_STALE_AT_HEADER]: String(envelope.s),
2804
+ // Carry the hard-expiry deadline; see promoteSegmentToL1 / set().
2805
+ [CACHE_EXPIRES_AT_HEADER]: String(envelope.e),
2806
+ [CACHE_STATUS_HEADER]: "HIT",
2807
+ // Preserve tags across KV->L1 promotion (the item tier previously
2808
+ // dropped them, permanently disabling tag invalidation here).
2809
+ ...this.tagHeaderEntries(envelope.t, envelope.ta),
2810
+ },
2811
+ });
2812
+
2813
+ await cache.put(request, response);
2814
+ },
2815
+ "cache-write",
2816
+ "[CFCacheStore] promoteItemToL1",
2817
+ ),
2818
+ );
1013
2819
  }
1014
2820
 
1015
2821
  /**
@@ -1023,31 +2829,69 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1023
2829
 
1024
2830
  try {
1025
2831
  const kvKey = this.toKVKey(`doc:${key}`);
1026
- const raw = await this.kv.get(kvKey, { type: "json" });
1027
- if (!raw) return null;
2832
+ // The document path is debug-silent (op is only get/getItem): a KV-read
2833
+ // timeout here is bounded for resilience parity (kvGetOrEvict applies the
2834
+ // budget) but emits no kv-timeout event, so its absence from the debug
2835
+ // stream is expected. A null envelope is a miss -- missing key, a budget
2836
+ // timeout, or a corrupt entry already evicted + reported by kvGetOrEvict.
2837
+ const { value: envelope } = await this.kvGetOrEvict<KVResponseEnvelope>(
2838
+ kvKey,
2839
+ (e) =>
2840
+ typeof e.b === "string" &&
2841
+ typeof e.st === "number" &&
2842
+ typeof e.e === "number" &&
2843
+ typeof e.s === "number" &&
2844
+ Array.isArray(e.hd),
2845
+ "kvGetResponse",
2846
+ );
2847
+ if (!envelope) return null;
1028
2848
 
1029
- const envelope = raw as KVResponseEnvelope;
1030
2849
  const now = Date.now();
1031
2850
 
1032
2851
  if (now > envelope.e) return null;
1033
2852
 
2853
+ // Tag invalidation check (also covers the KV tier, not just L1).
2854
+ if (await this.isGloballyInvalidated(envelope.t, envelope.ta)) {
2855
+ return null;
2856
+ }
2857
+
1034
2858
  const shouldRevalidate = now > envelope.s;
1035
2859
 
1036
- // Reconstruct Response (decode base64 binary)
1037
- const headers = new Headers(envelope.hd);
1038
- const bodyBuffer = base64ToBuffer(envelope.b);
1039
- const response = new Response(bodyBuffer, {
1040
- status: envelope.st,
1041
- statusText: envelope.stx,
1042
- headers,
1043
- });
2860
+ // Reconstruct Response: decode base64 -> binary, rebuild headers/status.
2861
+ // Corrupt/partial base64 throws in atob; malformed `hd` or an out-of-range
2862
+ // `st` throws in new Headers/new Response. Any of these is a faulty entry,
2863
+ // so evict it and miss rather than re-failing every read until TTL.
2864
+ let response: Response;
2865
+ try {
2866
+ // Finding #3 (read side): strip per-client signals a stale envelope may
2867
+ // carry. Inside the try so a malformed `hd` evicts (not throws through);
2868
+ // mutates `hd` in place so promoteResponseToL1 re-seeds from it too.
2869
+ envelope.hd = envelope.hd.filter(
2870
+ ([name]) => !isPerClientSignalHeader(name),
2871
+ );
2872
+ const bodyBuffer = base64ToBuffer(envelope.b);
2873
+ const headers = new Headers(envelope.hd);
2874
+ response = new Response(bodyBuffer, {
2875
+ status: envelope.st,
2876
+ statusText: envelope.stx,
2877
+ headers,
2878
+ });
2879
+ } catch (error) {
2880
+ reportCacheError(
2881
+ error,
2882
+ "cache-corrupt",
2883
+ "[CFCacheStore] kvGetResponse: corrupt response envelope, evicting",
2884
+ );
2885
+ this.scheduleKvEvict(kvKey, "kvGetResponse");
2886
+ return null;
2887
+ }
1044
2888
 
1045
2889
  // Promote to L1
1046
2890
  this.promoteResponseToL1(key, envelope);
1047
2891
 
1048
2892
  return { response, shouldRevalidate };
1049
2893
  } catch (error) {
1050
- console.error("[CFCacheStore] KV getResponse failed:", error);
2894
+ reportCacheError(error, "cache-read", "[CFCacheStore] kvGetResponse");
1051
2895
  return null;
1052
2896
  }
1053
2897
  }
@@ -1059,33 +2903,45 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1059
2903
  private promoteResponseToL1(key: string, envelope: KVResponseEnvelope): void {
1060
2904
  if (!this.waitUntil) return;
1061
2905
 
1062
- this.waitUntil(async () => {
1063
- try {
1064
- const now = Date.now();
1065
- const remainingTtl = Math.max(1, Math.floor((envelope.e - now) / 1000));
1066
- const cache = await this.getCache();
1067
- const request = this.keyToRequest(`doc:${key}`);
1068
-
1069
- const headers = new Headers(envelope.hd);
1070
- const originalCacheControl = headers.get("Cache-Control");
1071
- if (originalCacheControl !== null) {
1072
- headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
1073
- }
1074
- headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
1075
- headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
2906
+ this.waitUntil(() =>
2907
+ reportingAsync(
2908
+ async () => {
2909
+ const now = Date.now();
2910
+ const remainingTtl = Math.max(
2911
+ 1,
2912
+ Math.floor((envelope.e - now) / 1000),
2913
+ );
2914
+ const cache = await this.getCache();
2915
+ const request = this.keyToRequest(`doc:${key}`);
2916
+
2917
+ const headers = new Headers(envelope.hd);
2918
+ const originalCacheControl = headers.get("Cache-Control");
2919
+ if (originalCacheControl !== null) {
2920
+ headers.set(CACHE_ORIG_CC_HEADER, originalCacheControl);
2921
+ }
2922
+ headers.set("Cache-Control", `public, max-age=${remainingTtl}`);
2923
+ headers.set(CACHE_STALE_AT_HEADER, String(envelope.s));
2924
+ // Re-attach the internal tag headers (envelope.hd is client-facing
2925
+ // and intentionally excludes them) so the promoted entry stays
2926
+ // invalidatable.
2927
+ const tagHeaders = this.tagHeaderEntries(envelope.t, envelope.ta);
2928
+ for (const [name, value] of Object.entries(tagHeaders)) {
2929
+ headers.set(name, value);
2930
+ }
1076
2931
 
1077
- const bodyBuffer = base64ToBuffer(envelope.b);
1078
- const response = new Response(bodyBuffer, {
1079
- status: envelope.st,
1080
- statusText: envelope.stx,
1081
- headers,
1082
- });
2932
+ const bodyBuffer = base64ToBuffer(envelope.b);
2933
+ const response = new Response(bodyBuffer, {
2934
+ status: envelope.st,
2935
+ statusText: envelope.stx,
2936
+ headers,
2937
+ });
1083
2938
 
1084
- await cache.put(request, response);
1085
- } catch (error) {
1086
- console.error("[CFCacheStore] L1 response promote failed:", error);
1087
- }
1088
- });
2939
+ await cache.put(request, response);
2940
+ },
2941
+ "cache-write",
2942
+ "[CFCacheStore] promoteResponseToL1",
2943
+ ),
2944
+ );
1089
2945
  }
1090
2946
  }
1091
2947