@rangojs/router 0.17.0 → 0.18.0
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.
- package/README.md +3 -2
- package/dist/types/browser/cookie-name.d.ts +6 -0
- package/dist/types/browser/history-state.d.ts +16 -0
- package/dist/types/cache/cache-key-utils.d.ts +17 -2
- package/dist/types/cache/cache-policy.d.ts +17 -0
- package/dist/types/cache/cache-scope.d.ts +98 -14
- package/dist/types/cache/cache-tag.d.ts +92 -7
- package/dist/types/cache/cf/cf-base64.d.ts +6 -0
- package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
- package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
- package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
- package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
- package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
- package/dist/types/cache/handle-snapshot.d.ts +4 -3
- package/dist/types/cache/index.d.ts +1 -0
- package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
- package/dist/types/cache/shell-memo.d.ts +178 -0
- package/dist/types/cache/shell-snapshot.d.ts +110 -21
- package/dist/types/cache/tag-invalidation.d.ts +30 -3
- package/dist/types/cache/types.d.ts +207 -43
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
- package/dist/types/prerender/build-shell-capture.d.ts +11 -4
- package/dist/types/router/instrument.d.ts +8 -0
- package/dist/types/router/middleware-types.d.ts +6 -1
- package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
- package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
- package/dist/types/rsc/full-payload.d.ts +14 -0
- package/dist/types/rsc/handler.d.ts +1 -1
- package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
- package/dist/types/rsc/shell-capture.d.ts +146 -15
- package/dist/types/rsc/shell-serve.d.ts +140 -17
- package/dist/types/rsc/types.d.ts +7 -0
- package/dist/types/server/context.d.ts +109 -15
- package/dist/types/server/cookie-store.d.ts +21 -0
- package/dist/types/server/request-context.d.ts +159 -33
- package/dist/types/ssr/index.d.ts +9 -0
- package/dist/types/ssr/ssr-root.d.ts +9 -8
- package/dist/types/testing/flight.entry.d.ts +7 -0
- package/dist/types/testing/run-loader.d.ts +18 -3
- package/dist/types/testing/serve-shell-request.d.ts +132 -0
- package/dist/types/testing/shell-status.d.ts +23 -10
- package/dist/types/types/handler-context.d.ts +14 -1
- package/dist/types/urls/pattern-types.d.ts +22 -18
- package/dist/types/vite/router-discovery.d.ts +29 -2
- package/dist/vite/index.js +46 -9
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +17 -7
- package/skills/cache-guide/SKILL.md +123 -51
- package/skills/caching/SKILL.md +253 -23
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +11 -6
- package/skills/cloudflare/SKILL.md +15 -0
- package/skills/comparison/references/framework-comparison.md +44 -32
- package/skills/defer-hydration/SKILL.md +50 -30
- package/skills/deployment-caching/SKILL.md +5 -2
- package/skills/document-cache/SKILL.md +8 -2
- package/skills/handler-use/SKILL.md +2 -0
- package/skills/hooks/handle-and-actions.md +3 -1
- package/skills/hooks/state.md +3 -1
- package/skills/loader/SKILL.md +161 -39
- package/skills/middleware/SKILL.md +16 -9
- package/skills/migrate-nextjs/SKILL.md +67 -36
- package/skills/migrate-react-router/SKILL.md +2 -1
- package/skills/migrate-react-router/component-migration.md +3 -1
- package/skills/migrate-react-router/data-and-actions.md +3 -1
- package/skills/migrate-react-router/route-mapping.md +7 -4
- package/skills/observability/SKILL.md +64 -0
- package/skills/parallel/SKILL.md +13 -8
- package/skills/ppr/SKILL.md +655 -363
- package/skills/prerender/SKILL.md +27 -14
- package/skills/rango/SKILL.md +52 -33
- package/skills/route/SKILL.md +12 -7
- package/skills/router-setup/SKILL.md +4 -2
- package/skills/scripts/SKILL.md +8 -1
- package/skills/server-actions/SKILL.md +11 -0
- package/skills/shell-manifest/SKILL.md +21 -12
- package/skills/testing/SKILL.md +25 -21
- package/skills/testing/cache-prerender.md +15 -14
- package/skills/testing/loader.md +18 -1
- package/skills/testing/serve-shell-request.md +188 -0
- package/skills/theme/SKILL.md +52 -4
- package/skills/typesafety/env-and-bindings.md +10 -2
- package/skills/use-cache/SKILL.md +39 -16
- package/skills/vercel/SKILL.md +14 -2
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/cookie-name.ts +23 -0
- package/src/browser/history-state.ts +23 -0
- package/src/browser/rsc-router.tsx +5 -0
- package/src/cache/cache-key-utils.ts +22 -2
- package/src/cache/cache-policy.ts +22 -0
- package/src/cache/cache-runtime.ts +34 -7
- package/src/cache/cache-scope.ts +345 -33
- package/src/cache/cache-tag.ts +375 -11
- package/src/cache/cf/cf-base64.ts +36 -4
- package/src/cache/cf/cf-cache-constants.ts +12 -0
- package/src/cache/cf/cf-cache-store.ts +990 -348
- package/src/cache/cf/cf-cache-types.ts +32 -4
- package/src/cache/cf/cf-shell-frame.ts +258 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
- package/src/cache/handle-snapshot.ts +4 -3
- package/src/cache/index.ts +2 -0
- package/src/cache/isolate-tag-memo.ts +308 -0
- package/src/cache/shell-memo.ts +341 -0
- package/src/cache/shell-snapshot.ts +217 -27
- package/src/cache/tag-invalidation.ts +104 -19
- package/src/cache/types.ts +211 -43
- package/src/cache/vercel/vercel-cache-store.ts +545 -61
- package/src/prerender/build-shell-capture.ts +65 -11
- package/src/route-definition/dsl-helpers.ts +4 -3
- package/src/router/handler-context.ts +38 -4
- package/src/router/instrument.ts +54 -2
- package/src/router/intercept-resolution.ts +19 -7
- package/src/router/loader-resolution.ts +63 -32
- package/src/router/match-api.ts +28 -9
- package/src/router/match-handlers.ts +5 -0
- package/src/router/match-middleware/cache-lookup.ts +17 -0
- package/src/router/match-middleware/cache-store.ts +31 -11
- package/src/router/middleware-types.ts +6 -1
- package/src/router/middleware.ts +23 -4
- package/src/router/prerender-match.ts +6 -0
- package/src/router/segment-resolution/fresh.ts +18 -9
- package/src/router/segment-resolution/helpers.ts +4 -2
- package/src/router/segment-resolution/loader-cache.ts +227 -56
- package/src/router/segment-resolution/loader-snapshot.ts +203 -51
- package/src/router/segment-resolution/mask-nested.ts +136 -42
- package/src/router/segment-resolution/revalidation.ts +27 -10
- package/src/rsc/full-payload.ts +25 -1
- package/src/rsc/handler.ts +14 -2
- package/src/rsc/progressive-enhancement.ts +3 -2
- package/src/rsc/response-cache-serve.ts +9 -6
- package/src/rsc/rsc-rendering.ts +601 -181
- package/src/rsc/shell-build-manifest.ts +27 -9
- package/src/rsc/shell-capture-constants.ts +60 -6
- package/src/rsc/shell-capture.ts +1001 -250
- package/src/rsc/shell-serve.ts +316 -34
- package/src/rsc/types.ts +7 -0
- package/src/server/context.ts +219 -28
- package/src/server/cookie-store.ts +87 -62
- package/src/server/request-context.ts +263 -38
- package/src/ssr/index.tsx +96 -37
- package/src/ssr/ssr-root.tsx +9 -8
- package/src/testing/flight.entry.ts +14 -0
- package/src/testing/run-loader.ts +63 -5
- package/src/testing/serve-shell-request.ts +378 -0
- package/src/testing/shell-status.ts +34 -12
- package/src/theme/ThemeProvider.tsx +7 -6
- package/src/types/handler-context.ts +14 -1
- package/src/urls/pattern-types.ts +22 -18
- package/src/vite/discovery/shell-prerender-phase.ts +9 -0
- package/src/vite/router-discovery.ts +14 -4
|
@@ -35,6 +35,11 @@ import type {
|
|
|
35
35
|
CacheItemResult,
|
|
36
36
|
CacheItemOptions,
|
|
37
37
|
ShellCacheEntry,
|
|
38
|
+
ShellDocumentRead,
|
|
39
|
+
ShellDocumentReadOptions,
|
|
40
|
+
ShellReadStats,
|
|
41
|
+
ShellSnapshotFailure,
|
|
42
|
+
ShellSnapshotRecord,
|
|
38
43
|
CacheReadError,
|
|
39
44
|
} from "../types.js";
|
|
40
45
|
import { CACHE_READ_ERROR } from "../types.js";
|
|
@@ -55,7 +60,30 @@ import {
|
|
|
55
60
|
} from "../cache-policy.js";
|
|
56
61
|
import { reportCacheError, reportingAsync } from "../cache-error.js";
|
|
57
62
|
import type { CacheErrorCategory } from "../cache-error.js";
|
|
58
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
bufferToBase64,
|
|
65
|
+
base64ToBuffer,
|
|
66
|
+
base64ToBytes,
|
|
67
|
+
bytesToBase64,
|
|
68
|
+
} from "./cf-base64.js";
|
|
69
|
+
import {
|
|
70
|
+
ShellFrameReader,
|
|
71
|
+
encodeShellFrame,
|
|
72
|
+
encodeShellSnapshot,
|
|
73
|
+
parseShellSnapshot,
|
|
74
|
+
shellFrameToText,
|
|
75
|
+
type ShellFrameHead,
|
|
76
|
+
} from "./cf-shell-frame.js";
|
|
77
|
+
import {
|
|
78
|
+
ShellMemo,
|
|
79
|
+
RecentTagInvalidations,
|
|
80
|
+
DEFAULT_SHELL_MEMO_MS,
|
|
81
|
+
freshReadsWindowMs,
|
|
82
|
+
isShellFresh,
|
|
83
|
+
resolveShellMemoOptions,
|
|
84
|
+
shellHasAnyTag,
|
|
85
|
+
type ResolvedShellMemoOptions,
|
|
86
|
+
} from "../shell-memo.js";
|
|
59
87
|
import {
|
|
60
88
|
KV_KEY_PRESERVED_PREFIX_BYTES,
|
|
61
89
|
KV_MAX_KEY_BYTES,
|
|
@@ -70,7 +98,19 @@ import {
|
|
|
70
98
|
TAG_MARKER_ABSENT,
|
|
71
99
|
getTagMarkerMemo,
|
|
72
100
|
getTagMarkerInflight,
|
|
101
|
+
getShellMarkerReads,
|
|
102
|
+
isRequestMasked,
|
|
103
|
+
maskRequestTags,
|
|
73
104
|
} from "./cf-tag-marker-memo.js";
|
|
105
|
+
import {
|
|
106
|
+
TagMarkerMemo,
|
|
107
|
+
TagNameHints,
|
|
108
|
+
hintedTags,
|
|
109
|
+
freshReadsRequired,
|
|
110
|
+
recordMarkerRow,
|
|
111
|
+
DEFAULT_CF_MARKER_FRESH_MS,
|
|
112
|
+
DEFAULT_CF_MARKER_MAX_STALE_MS,
|
|
113
|
+
} from "../isolate-tag-memo.js";
|
|
74
114
|
import { createCloudflareZonePurge } from "./cf-zone-purge.js";
|
|
75
115
|
|
|
76
116
|
// ============================================================================
|
|
@@ -94,6 +134,7 @@ import {
|
|
|
94
134
|
EDGE_LOOKUP_TIMEOUT_MS,
|
|
95
135
|
EDGE_READ_TIMEOUT_MS,
|
|
96
136
|
KV_READ_TIMEOUT_MS,
|
|
137
|
+
SHELL_SNAPSHOT_READ_MIN_TIMEOUT_MS,
|
|
97
138
|
} from "./cf-cache-constants.js";
|
|
98
139
|
|
|
99
140
|
// Re-export the public constants so consumers/tests importing them from
|
|
@@ -267,57 +308,110 @@ interface KVItemEnvelope {
|
|
|
267
308
|
}
|
|
268
309
|
|
|
269
310
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
311
|
+
* Key namespace of the shell family. `shell2:` holds the prelude-first frame
|
|
312
|
+
* (cf-shell-frame.ts); `shell:` held the JSON envelope. Every deploy retires
|
|
313
|
+
* the old entries anyway (buildVersion gate); the new namespace also keeps
|
|
314
|
+
* new code from reporting an old body as corrupt and a rollback from parsing
|
|
315
|
+
* a frame. Old keys age out by their TTL.
|
|
272
316
|
*/
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
317
|
+
const SHELL_KEY_PREFIX = "shell2:";
|
|
318
|
+
|
|
319
|
+
/** A shell snapshot read's timeout, and its warning's consequence text. */
|
|
320
|
+
interface SnapshotReadBudget {
|
|
321
|
+
ms: number;
|
|
322
|
+
consequence?: string;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A memoized fresh shell read (shell-memo.ts). */
|
|
326
|
+
interface CFShellMemoValue {
|
|
327
|
+
head: ShellFrameHead;
|
|
328
|
+
prelude: Uint8Array;
|
|
329
|
+
snapshot: ShellSnapshotRecord[] | undefined;
|
|
330
|
+
/** L1 response headers: the KV-less purge-mode marker check reads them. */
|
|
331
|
+
headers?: Headers;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The per-isolate shell memo shared by every CFCacheStore in the isolate. */
|
|
335
|
+
const cfShellMemo = new ShellMemo<CFShellMemoValue>();
|
|
336
|
+
/** Per-isolate tag-marker values (isolate-tag-memo.ts), keyed per namespace. */
|
|
337
|
+
const cfMarkerMemo = new TagMarkerMemo();
|
|
338
|
+
/** Per-isolate shell-key -> tag-name hints for the marker prefetch. */
|
|
339
|
+
const cfTagHints = new TagNameHints();
|
|
340
|
+
|
|
341
|
+
/** Tags this isolate invalidated recently: they keep shells out of cfShellMemo. */
|
|
342
|
+
const recentShellInvalidations = new RecentTagInvalidations();
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Whether cfShellMemo may keep or serve this shell: still fresh (a stale one
|
|
346
|
+
* stays with the store read, which schedules the recapture), and not tagged
|
|
347
|
+
* at or before an invalidation this isolate is running or just ran.
|
|
348
|
+
*/
|
|
349
|
+
function isMemoizableShell(head: ShellFrameHead): boolean {
|
|
350
|
+
return (
|
|
351
|
+
isShellFresh(head.s, head.e) &&
|
|
352
|
+
!recentShellInvalidations.covers(head.t, head.ta)
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** @internal Reset the per-isolate shell and marker memos (tests). */
|
|
357
|
+
export function resetCFShellMemoForTests(): void {
|
|
358
|
+
cfShellMemo.clear();
|
|
359
|
+
recentShellInvalidations.clear();
|
|
360
|
+
cfMarkerMemo.clear();
|
|
361
|
+
cfTagHints.clear();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** openShellFrame outcome: the head and prelude, or why the read failed. */
|
|
365
|
+
type OpenedShellFrame =
|
|
366
|
+
| {
|
|
367
|
+
status: "ok";
|
|
368
|
+
head: ShellFrameHead;
|
|
369
|
+
prelude: Uint8Array;
|
|
370
|
+
reader: ShellFrameReader;
|
|
371
|
+
}
|
|
372
|
+
| { status: "corrupt"; error: Error; head?: undefined }
|
|
373
|
+
| { status: "expired"; head: ShellFrameHead }
|
|
374
|
+
| { status: "timeout"; head?: undefined }
|
|
375
|
+
| { status: "invalidated"; head?: undefined };
|
|
376
|
+
|
|
377
|
+
/** The `[CFCacheStore][shell]` trace's timings, from a read's stats. */
|
|
378
|
+
function debugTimings(
|
|
379
|
+
stats: ShellReadStats | undefined,
|
|
380
|
+
): Pick<CFShellDebugDetails, "matchMs" | "readMs" | "bodyReadMs" | "markerMs"> {
|
|
381
|
+
if (!stats) return {};
|
|
382
|
+
if (stats.tier === "memo")
|
|
383
|
+
return { markerMs: Math.round(stats.markerMs ?? 0) };
|
|
384
|
+
const round = (ms: number | undefined) =>
|
|
385
|
+
ms === undefined ? undefined : Math.round(ms);
|
|
386
|
+
const bodyReadMs =
|
|
387
|
+
stats.headMs === undefined && stats.preludeMs === undefined
|
|
388
|
+
? undefined
|
|
389
|
+
: (stats.headMs ?? 0) + (stats.preludeMs ?? 0);
|
|
390
|
+
return {
|
|
391
|
+
...(stats.tier === "kv"
|
|
392
|
+
? { readMs: round(stats.matchMs) }
|
|
393
|
+
: { matchMs: round(stats.matchMs) }),
|
|
394
|
+
bodyReadMs: round(bodyReadMs),
|
|
395
|
+
markerMs: round(stats.markerMs),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The public entry shape of a frame head (no prelude, no snapshot). */
|
|
400
|
+
function shellHeadToEntry(head: ShellFrameHead): ShellCacheEntry {
|
|
401
|
+
return {
|
|
402
|
+
...(head.po !== undefined ? { postponed: head.po } : {}),
|
|
403
|
+
reactVersion: head.rv,
|
|
404
|
+
buildVersion: head.bv,
|
|
405
|
+
initialTheme: head.i,
|
|
406
|
+
docKey: head.dk,
|
|
407
|
+
prunedRecords: head.pr,
|
|
408
|
+
navigationOnly: head.no,
|
|
409
|
+
createdAt: head.c,
|
|
410
|
+
};
|
|
318
411
|
}
|
|
319
412
|
|
|
320
413
|
type CFShellDebugOutcome =
|
|
414
|
+
| "memo-hit"
|
|
321
415
|
| "l1-hit"
|
|
322
416
|
| "l1-miss"
|
|
323
417
|
| "kv-hit"
|
|
@@ -329,13 +423,12 @@ type CFShellDebugOutcome =
|
|
|
329
423
|
| "write-invalidated";
|
|
330
424
|
|
|
331
425
|
interface CFShellDebugDetails {
|
|
332
|
-
tier?: "l1" | "kv";
|
|
426
|
+
tier?: "memo" | "l1" | "kv";
|
|
333
427
|
reason?:
|
|
334
428
|
| "absent"
|
|
335
429
|
| "timeout"
|
|
336
430
|
| "error"
|
|
337
431
|
| "non-200"
|
|
338
|
-
| "corrupt"
|
|
339
432
|
| "malformed"
|
|
340
433
|
| "expired"
|
|
341
434
|
| "unavailable";
|
|
@@ -349,41 +442,6 @@ interface CFShellDebugDetails {
|
|
|
349
442
|
remainingTtl?: number;
|
|
350
443
|
}
|
|
351
444
|
|
|
352
|
-
/** Validate the coupled PPR shell envelope before any field reaches resume. */
|
|
353
|
-
function isShellEnvelope(value: unknown): value is CFShellEnvelope {
|
|
354
|
-
if (value == null || typeof value !== "object") return false;
|
|
355
|
-
const envelope = value as Partial<CFShellEnvelope>;
|
|
356
|
-
return (
|
|
357
|
-
// Document half: required unless the envelope is navigationOnly (`no`),
|
|
358
|
-
// which stores neither field. Tolerate a legacy navigationOnly envelope
|
|
359
|
-
// that still carries them.
|
|
360
|
-
(typeof envelope.p === "string" ||
|
|
361
|
-
(envelope.p === undefined && envelope.no === true)) &&
|
|
362
|
-
(envelope.po === null ||
|
|
363
|
-
typeof envelope.po === "string" ||
|
|
364
|
-
(envelope.po === undefined && envelope.no === true)) &&
|
|
365
|
-
typeof envelope.rv === "string" &&
|
|
366
|
-
(envelope.bv === undefined || typeof envelope.bv === "string") &&
|
|
367
|
-
typeof envelope.c === "number" &&
|
|
368
|
-
Number.isFinite(envelope.c) &&
|
|
369
|
-
typeof envelope.s === "number" &&
|
|
370
|
-
Number.isFinite(envelope.s) &&
|
|
371
|
-
typeof envelope.e === "number" &&
|
|
372
|
-
Number.isFinite(envelope.e) &&
|
|
373
|
-
(envelope.t === undefined ||
|
|
374
|
-
(Array.isArray(envelope.t) &&
|
|
375
|
-
envelope.t.every((tag) => typeof tag === "string"))) &&
|
|
376
|
-
(envelope.ta === undefined ||
|
|
377
|
-
(typeof envelope.ta === "number" && Number.isFinite(envelope.ta))) &&
|
|
378
|
-
(envelope.i === undefined || typeof envelope.i === "string") &&
|
|
379
|
-
(envelope.sn === undefined || Array.isArray(envelope.sn)) &&
|
|
380
|
-
(envelope.dk === undefined || typeof envelope.dk === "string") &&
|
|
381
|
-
(envelope.lh === undefined || typeof envelope.lh === "boolean") &&
|
|
382
|
-
(envelope.tw === undefined || envelope.tw === true) &&
|
|
383
|
-
(envelope.no === undefined || envelope.no === true)
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
445
|
/**
|
|
388
446
|
* KV envelope for document cache entries.
|
|
389
447
|
* @internal
|
|
@@ -426,6 +484,22 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
426
484
|
private readonly edgeLookupTimeoutMs: number;
|
|
427
485
|
private readonly edgeReadTimeoutMs: number;
|
|
428
486
|
private readonly kvReadTimeoutMs: number;
|
|
487
|
+
/**
|
|
488
|
+
* The shell snapshot read's budgets. `read` (getShell: partial replay, the
|
|
489
|
+
* testing helpers) has nothing committed, so a slow snapshot is a quick
|
|
490
|
+
* miss at kvReadTimeoutMs. `document` (readShellDocument, the serve path)
|
|
491
|
+
* has sent the prelude and cannot finish without the snapshot's doc
|
|
492
|
+
* record; a timeout there reloads the page, so it waits at least
|
|
493
|
+
* SHELL_SNAPSHOT_READ_MIN_TIMEOUT_MS. `kvReadTimeoutMs <= 0` keeps both
|
|
494
|
+
* unbounded.
|
|
495
|
+
*/
|
|
496
|
+
private readonly snapshotBudgets: {
|
|
497
|
+
read: SnapshotReadBudget;
|
|
498
|
+
document: SnapshotReadBudget;
|
|
499
|
+
};
|
|
500
|
+
private readonly memo: ResolvedShellMemoOptions;
|
|
501
|
+
/** @internal SegmentCacheStore.freshReadsWindowMs */
|
|
502
|
+
readonly freshReadsWindowMs: number;
|
|
429
503
|
private readonly debug?: (event: CFCacheReadDebugEvent) => void;
|
|
430
504
|
private readonly kv?: KVNamespace;
|
|
431
505
|
/** True when constructed without KV: no durable tag history (see ctor). */
|
|
@@ -476,6 +550,28 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
476
550
|
options.kvReadTimeoutMs,
|
|
477
551
|
KV_READ_TIMEOUT_MS,
|
|
478
552
|
);
|
|
553
|
+
this.snapshotBudgets = {
|
|
554
|
+
read: { ms: this.kvReadTimeoutMs },
|
|
555
|
+
document:
|
|
556
|
+
this.kvReadTimeoutMs > 0
|
|
557
|
+
? {
|
|
558
|
+
ms: Math.max(
|
|
559
|
+
this.kvReadTimeoutMs,
|
|
560
|
+
SHELL_SNAPSHOT_READ_MIN_TIMEOUT_MS,
|
|
561
|
+
),
|
|
562
|
+
consequence: "the HIT reloads the page into a cache-miss render",
|
|
563
|
+
}
|
|
564
|
+
: { ms: this.kvReadTimeoutMs },
|
|
565
|
+
};
|
|
566
|
+
this.memo = resolveShellMemoOptions(options.memo, {
|
|
567
|
+
markerFreshMs: DEFAULT_CF_MARKER_FRESH_MS,
|
|
568
|
+
markerMaxStaleMs: DEFAULT_CF_MARKER_MAX_STALE_MS,
|
|
569
|
+
});
|
|
570
|
+
// Without KV there are no markers, so no marker memo to outwait.
|
|
571
|
+
this.freshReadsWindowMs = freshReadsWindowMs(
|
|
572
|
+
this.memo,
|
|
573
|
+
Boolean(options.kv),
|
|
574
|
+
);
|
|
479
575
|
this.debug =
|
|
480
576
|
options.debug === true
|
|
481
577
|
? (event) =>
|
|
@@ -602,11 +698,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
602
698
|
}
|
|
603
699
|
}
|
|
604
700
|
|
|
605
|
-
/**
|
|
701
|
+
/**
|
|
702
|
+
* Build-time-gated shell tier trace for deployed cross-colo diagnostics.
|
|
703
|
+
* A read's `stats` print as its timings, after the other details and before
|
|
704
|
+
* `expiresAt`.
|
|
705
|
+
*/
|
|
606
706
|
private debugShell(
|
|
607
707
|
key: string,
|
|
608
708
|
outcome: CFShellDebugOutcome,
|
|
609
|
-
details: CFShellDebugDetails = {},
|
|
709
|
+
details: CFShellDebugDetails & { stats?: ShellReadStats } = {},
|
|
610
710
|
): void {
|
|
611
711
|
if (!INTERNAL_RANGO_DEBUG) return;
|
|
612
712
|
const request = _getRequestContext()?.request as
|
|
@@ -620,6 +720,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
620
720
|
: ray?.includes("-")
|
|
621
721
|
? ray.slice(ray.lastIndexOf("-") + 1)
|
|
622
722
|
: undefined;
|
|
723
|
+
const { stats, expiresAt, ...rest } = details;
|
|
623
724
|
console.log(
|
|
624
725
|
`[CFCacheStore][shell] ${JSON.stringify({
|
|
625
726
|
key,
|
|
@@ -627,7 +728,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
627
728
|
at: Date.now(),
|
|
628
729
|
ray,
|
|
629
730
|
colo,
|
|
630
|
-
...
|
|
731
|
+
...rest,
|
|
732
|
+
...debugTimings(stats),
|
|
733
|
+
expiresAt,
|
|
631
734
|
})}`,
|
|
632
735
|
);
|
|
633
736
|
}
|
|
@@ -723,6 +826,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
723
826
|
read: () => Promise<T>,
|
|
724
827
|
budgetMs: number,
|
|
725
828
|
label: string,
|
|
829
|
+
consequence = "treating as miss",
|
|
726
830
|
): Promise<{ value: T | undefined; timedOut: boolean }> {
|
|
727
831
|
if (budgetMs <= 0) return { value: await read(), timedOut: false };
|
|
728
832
|
|
|
@@ -741,7 +845,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
741
845
|
]);
|
|
742
846
|
if (result.timedOut) {
|
|
743
847
|
console.warn(
|
|
744
|
-
`[CFCacheStore] ${label} exceeded ${budgetMs}ms;
|
|
848
|
+
`[CFCacheStore] ${label} exceeded ${budgetMs}ms; ${consequence}`,
|
|
745
849
|
);
|
|
746
850
|
return { value: undefined, timedOut: true };
|
|
747
851
|
}
|
|
@@ -1246,7 +1350,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1246
1350
|
|
|
1247
1351
|
/**
|
|
1248
1352
|
* Store entry data with TTL and optional SWR window.
|
|
1249
|
-
*
|
|
1353
|
+
* Resolves once the L1 write lands, per the SegmentCacheStore.set contract
|
|
1354
|
+
* (also held by waitUntil when available); the KV write stays in the
|
|
1355
|
+
* background.
|
|
1250
1356
|
* When KV is configured, also persists to L2.
|
|
1251
1357
|
*/
|
|
1252
1358
|
async set(
|
|
@@ -1297,20 +1403,21 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1297
1403
|
|
|
1298
1404
|
const putPromise = cache.put(request, response);
|
|
1299
1405
|
|
|
1406
|
+
let l1Write: Promise<void> | undefined;
|
|
1300
1407
|
if (this.waitUntil) {
|
|
1301
|
-
//
|
|
1302
|
-
//
|
|
1408
|
+
// These store-level background tasks intentionally omit the
|
|
1409
|
+
// reportingAsync ctx argument: the store is a request-agnostic
|
|
1303
1410
|
// singleton and this.waitUntil is the execution context's, not a single
|
|
1304
1411
|
// request's, so a failure is reported console-loud only (it cannot be
|
|
1305
1412
|
// attributed to one request's onError). The request-scoped tag verbs
|
|
1306
1413
|
// (revalidateTag / stale-revalidation) DO thread their captured ctx.
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
"[CFCacheStore] L1 write",
|
|
1312
|
-
),
|
|
1414
|
+
const put = reportingAsync(
|
|
1415
|
+
() => putPromise,
|
|
1416
|
+
"cache-write",
|
|
1417
|
+
"[CFCacheStore] L1 write",
|
|
1313
1418
|
);
|
|
1419
|
+
this.waitUntil(() => put);
|
|
1420
|
+
l1Write = put;
|
|
1314
1421
|
} else {
|
|
1315
1422
|
// Blocking fallback
|
|
1316
1423
|
await putPromise;
|
|
@@ -1318,6 +1425,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1318
1425
|
|
|
1319
1426
|
// L2: persist to KV (reuses `body` as envelope.d)
|
|
1320
1427
|
this.kvSetSegment(key, body, staleAt, totalTtl, swrWindow);
|
|
1428
|
+
|
|
1429
|
+
// The set() contract (SegmentCacheStore.set, #957): resolve once L1 is
|
|
1430
|
+
// readable.
|
|
1431
|
+
await l1Write;
|
|
1321
1432
|
} catch (error) {
|
|
1322
1433
|
reportCacheError(error, "cache-write", "[CFCacheStore] set");
|
|
1323
1434
|
}
|
|
@@ -1952,27 +2063,96 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1952
2063
|
}
|
|
1953
2064
|
|
|
1954
2065
|
/**
|
|
1955
|
-
* Get a cached PPR shell entry
|
|
1956
|
-
*
|
|
1957
|
-
*
|
|
1958
|
-
*
|
|
1959
|
-
*
|
|
2066
|
+
* Get a cached PPR shell entry: the public custom-store contract, built on
|
|
2067
|
+
* readShellDocument. It waits for the snapshot and re-encodes the prelude to
|
|
2068
|
+
* the base64 `ShellCacheEntry.prelude` the contract carries (native
|
|
2069
|
+
* `toBase64` in workerd). Partial replay and the testing helpers read
|
|
2070
|
+
* through here; the document serve path reads readShellDocument directly.
|
|
2071
|
+
* A snapshot that failed to read is a miss here: with no committed response
|
|
2072
|
+
* to protect, an entry without its pins is worse than none.
|
|
1960
2073
|
*/
|
|
1961
2074
|
async getShell(
|
|
1962
2075
|
key: string,
|
|
1963
2076
|
): Promise<{ entry: ShellCacheEntry; shouldRevalidate?: boolean } | null> {
|
|
2077
|
+
const read = await this.readShellDocumentWithin(
|
|
2078
|
+
key,
|
|
2079
|
+
undefined,
|
|
2080
|
+
this.snapshotBudgets.read,
|
|
2081
|
+
);
|
|
2082
|
+
if (!read) return null;
|
|
2083
|
+
const snapshot = await read.snapshot;
|
|
2084
|
+
if (await read.snapshotFailure) return null;
|
|
2085
|
+
const entry: ShellCacheEntry = { ...read.entry };
|
|
2086
|
+
if (!entry.navigationOnly) entry.prelude = bytesToBase64(read.prelude);
|
|
2087
|
+
if (snapshot) entry.snapshot = snapshot;
|
|
2088
|
+
return { entry, shouldRevalidate: read.shouldRevalidate };
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
/**
|
|
2092
|
+
* @internal Prelude-first read of a shell (SegmentCacheStore
|
|
2093
|
+
* .readShellDocument). Cache API first, then KV; both tiers hold one
|
|
2094
|
+
* cf-shell-frame body, so the prelude, postponed state, snapshot, versions,
|
|
2095
|
+
* and generation metadata cannot mix. Before it resolves it reads only the
|
|
2096
|
+
* head and the prelude's bytes, and the tag-marker read runs alongside the
|
|
2097
|
+
* prelude read (it starts as soon as the head is parsed, and is awaited
|
|
2098
|
+
* before this resolves, so an invalidated shell is never returned). The
|
|
2099
|
+
* snapshot is the rest of the same body, read on its own promise, bounded
|
|
2100
|
+
* by the document read's budget (`snapshotBudgets.document`; getShell uses
|
|
2101
|
+
* `snapshotBudgets.read`). SWR is a plain staleness flag; the capture
|
|
2102
|
+
* scheduler's in-flight set is the recapture stampede guard.
|
|
2103
|
+
*/
|
|
2104
|
+
async readShellDocument(
|
|
2105
|
+
key: string,
|
|
2106
|
+
options?: ShellDocumentReadOptions,
|
|
2107
|
+
): Promise<ShellDocumentRead | null> {
|
|
2108
|
+
return this.readShellDocumentWithin(
|
|
2109
|
+
key,
|
|
2110
|
+
options,
|
|
2111
|
+
this.snapshotBudgets.document,
|
|
2112
|
+
);
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
private async readShellDocumentWithin(
|
|
2116
|
+
key: string,
|
|
2117
|
+
options: ShellDocumentReadOptions | undefined,
|
|
2118
|
+
snapshotBudget: SnapshotReadBudget,
|
|
2119
|
+
): Promise<ShellDocumentRead | null> {
|
|
2120
|
+
const memoKey = this.shellMemoKey(key);
|
|
2121
|
+
const ctx = _getRequestContext();
|
|
2122
|
+
// A request after the same user's updateTag() reads past the isolate
|
|
2123
|
+
// memos (isolate-tag-memo.ts freshReadsRequired).
|
|
2124
|
+
const freshReads = freshReadsRequired(ctx);
|
|
2125
|
+
const memoized = freshReads
|
|
2126
|
+
? undefined
|
|
2127
|
+
: cfShellMemo.get(memoKey, this.memo.shellMs);
|
|
2128
|
+
if (memoized) {
|
|
2129
|
+
return this.readMemoizedShell(
|
|
2130
|
+
key,
|
|
2131
|
+
memoKey,
|
|
2132
|
+
memoized,
|
|
2133
|
+
options,
|
|
2134
|
+
snapshotBudget,
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
const stats = this.shellReadStats("l1");
|
|
2138
|
+
const l1StartedAt = stats ? performance.now() : 0;
|
|
2139
|
+
const kvFallback = (reason: string): Promise<ShellDocumentRead | null> =>
|
|
2140
|
+
this.kvReadShellDocument(key, snapshotBudget, stats, l1StartedAt, reason);
|
|
2141
|
+
if (stats && freshReads) stats.freshReads = true;
|
|
2142
|
+
this.prefetchShellMarkers(memoKey, options?.tagHints, stats);
|
|
2143
|
+
if (stats && this.memo.shellMs > 0) {
|
|
2144
|
+
stats.memo = { hit: false, bytes: cfShellMemo.size };
|
|
2145
|
+
}
|
|
1964
2146
|
try {
|
|
1965
2147
|
const cache = await this.getCache();
|
|
1966
|
-
const request = this.keyToRequest(
|
|
1967
|
-
const matchStartedAt =
|
|
2148
|
+
const request = this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`);
|
|
2149
|
+
const matchStartedAt = stats ? performance.now() : 0;
|
|
1968
2150
|
const {
|
|
1969
2151
|
response,
|
|
1970
2152
|
timedOut,
|
|
1971
2153
|
error: matchError,
|
|
1972
2154
|
} = await this.matchWithTimeout(cache, request);
|
|
1973
|
-
|
|
1974
|
-
? Date.now() - matchStartedAt
|
|
1975
|
-
: undefined;
|
|
2155
|
+
if (stats) stats.matchMs = performance.now() - matchStartedAt;
|
|
1976
2156
|
|
|
1977
2157
|
if (!response) {
|
|
1978
2158
|
if (matchError) {
|
|
@@ -1982,116 +2162,434 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
1982
2162
|
"[CFCacheStore] getShell L1 match",
|
|
1983
2163
|
);
|
|
1984
2164
|
}
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
});
|
|
1989
|
-
return this.kvGetShell(key);
|
|
2165
|
+
const reason = matchError ? "error" : timedOut ? "timeout" : "absent";
|
|
2166
|
+
this.debugShell(key, "l1-miss", { reason, stats });
|
|
2167
|
+
return kvFallback(reason);
|
|
1990
2168
|
}
|
|
1991
|
-
if (response.status !== 200) {
|
|
2169
|
+
if (response.status !== 200 || !response.body) {
|
|
1992
2170
|
this.debugShell(key, "l1-miss", {
|
|
1993
2171
|
reason: "non-200",
|
|
1994
2172
|
status: response.status,
|
|
1995
|
-
|
|
2173
|
+
stats,
|
|
1996
2174
|
});
|
|
1997
|
-
return
|
|
2175
|
+
return kvFallback("non-200");
|
|
1998
2176
|
}
|
|
1999
2177
|
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
this.
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
}
|
|
2019
|
-
if (!isShellEnvelope(value)) {
|
|
2178
|
+
// Unlike other L1 families, shells with KV always check the durable
|
|
2179
|
+
// generation marker — see the capture-start/purge race documented
|
|
2180
|
+
// above. Without KV there are no markers: L1-only shells adopt the
|
|
2181
|
+
// data families' purge-mode read semantics (a hit that survived the
|
|
2182
|
+
// purge is trusted; the per-request memo masks this request's own
|
|
2183
|
+
// updateTag() writes; entries a purge cannot reach fall back to the
|
|
2184
|
+
// marker check, which fails open KV-less).
|
|
2185
|
+
const opened = await this.openShellFrame(
|
|
2186
|
+
response.body,
|
|
2187
|
+
this.edgeReadTimeoutMs,
|
|
2188
|
+
"edge cache body read",
|
|
2189
|
+
(head) =>
|
|
2190
|
+
this.kv
|
|
2191
|
+
? this.isGloballyInvalidated(head.t, head.ta, true)
|
|
2192
|
+
: this.isL1Invalidated(head.t, head.ta, response.headers),
|
|
2193
|
+
stats,
|
|
2194
|
+
);
|
|
2195
|
+
if (opened.status === "corrupt") {
|
|
2020
2196
|
this.debugShell(key, "l1-miss", {
|
|
2021
2197
|
reason: "malformed",
|
|
2022
|
-
|
|
2023
|
-
bodyReadMs,
|
|
2198
|
+
stats,
|
|
2024
2199
|
});
|
|
2025
2200
|
return this.healCorruptL1(
|
|
2026
2201
|
cache,
|
|
2027
2202
|
request,
|
|
2028
|
-
|
|
2203
|
+
opened.error,
|
|
2029
2204
|
"getShell",
|
|
2030
|
-
() =>
|
|
2205
|
+
() => kvFallback("malformed"),
|
|
2031
2206
|
);
|
|
2032
2207
|
}
|
|
2033
|
-
|
|
2034
|
-
const now = Date.now();
|
|
2035
|
-
if (now > value.e) {
|
|
2208
|
+
if (opened.status === "timeout" || opened.status === "expired") {
|
|
2036
2209
|
this.debugShell(key, "l1-miss", {
|
|
2037
|
-
reason:
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
expiresAt: value.e,
|
|
2210
|
+
reason: opened.status,
|
|
2211
|
+
stats,
|
|
2212
|
+
expiresAt: opened.head?.e,
|
|
2041
2213
|
});
|
|
2042
|
-
return
|
|
2214
|
+
return kvFallback(opened.status);
|
|
2043
2215
|
}
|
|
2044
|
-
|
|
2045
|
-
// Unlike other L1 families, shells with KV always check the durable
|
|
2046
|
-
// generation marker — see the capture-start/purge race documented
|
|
2047
|
-
// above. Without KV there are no markers: L1-only shells adopt the
|
|
2048
|
-
// data families' purge-mode read semantics (a hit that survived the
|
|
2049
|
-
// purge is trusted; the per-request memo masks this request's own
|
|
2050
|
-
// updateTag() writes; entries a purge cannot reach fall back to the
|
|
2051
|
-
// marker check, which fails open KV-less).
|
|
2052
|
-
const markerStartedAt = INTERNAL_RANGO_DEBUG ? Date.now() : 0;
|
|
2053
|
-
const invalidated = this.kv
|
|
2054
|
-
? await this.isGloballyInvalidated(value.t, value.ta)
|
|
2055
|
-
: await this.isL1Invalidated(value.t, value.ta, response.headers);
|
|
2056
|
-
const markerMs = INTERNAL_RANGO_DEBUG
|
|
2057
|
-
? Date.now() - markerStartedAt
|
|
2058
|
-
: undefined;
|
|
2059
|
-
if (invalidated) {
|
|
2216
|
+
if (opened.status === "invalidated") {
|
|
2060
2217
|
this.debugShell(key, "marker-invalidated", {
|
|
2061
2218
|
tier: "l1",
|
|
2062
|
-
|
|
2063
|
-
bodyReadMs,
|
|
2064
|
-
markerMs,
|
|
2219
|
+
stats,
|
|
2065
2220
|
});
|
|
2066
2221
|
return null;
|
|
2067
2222
|
}
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2223
|
+
const read = this.shellDocumentRead(
|
|
2224
|
+
key,
|
|
2225
|
+
opened,
|
|
2226
|
+
snapshotBudget,
|
|
2227
|
+
stats,
|
|
2228
|
+
response.headers,
|
|
2229
|
+
);
|
|
2230
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
2231
|
+
this.debugShell(key, "l1-hit", {
|
|
2232
|
+
freshness: read.shouldRevalidate ? "stale" : "fresh",
|
|
2233
|
+
stats,
|
|
2234
|
+
expiresAt: opened.head.e,
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
return read;
|
|
2081
2238
|
} catch (error) {
|
|
2082
2239
|
reportCacheError(error, "cache-read", "[CFCacheStore] getShell");
|
|
2083
2240
|
this.debugShell(key, "l1-miss", { reason: "error" });
|
|
2084
|
-
return
|
|
2241
|
+
return kvFallback("error");
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/** @internal SegmentCacheStore.dropShellMemo */
|
|
2246
|
+
dropShellMemo(key: string): void {
|
|
2247
|
+
cfShellMemo.delete(this.shellMemoKey(key));
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
/**
|
|
2251
|
+
* Start the marker reads of the tags this shell key carried last time (and
|
|
2252
|
+
* the route's static tags) alongside the entry read (readShellTagMarker),
|
|
2253
|
+
* so the marker check after the head reuses them. With KV only: without it
|
|
2254
|
+
* there are no markers to read. A wrong hint costs a wasted read; the check
|
|
2255
|
+
* itself uses the head's tags.
|
|
2256
|
+
*/
|
|
2257
|
+
private prefetchShellMarkers(
|
|
2258
|
+
memoKey: string,
|
|
2259
|
+
routeTags: readonly string[] | undefined,
|
|
2260
|
+
stats: ShellReadStats | undefined,
|
|
2261
|
+
): void {
|
|
2262
|
+
if (!this.kv) return;
|
|
2263
|
+
const tags = hintedTags(cfTagHints.get(memoKey), routeTags);
|
|
2264
|
+
if (tags.length === 0) return;
|
|
2265
|
+
if (stats) {
|
|
2266
|
+
stats.markerHinted = tags;
|
|
2267
|
+
stats.markerHintStartedAt = performance.now();
|
|
2268
|
+
}
|
|
2269
|
+
for (const tag of tags) this.readShellTagMarker(tag).catch(() => {});
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
/**
|
|
2273
|
+
* A HIT: remember the entry's tags for the next read's hints, and fill the
|
|
2274
|
+
* marker row. The shell-read marker values stay in their own record: the
|
|
2275
|
+
* request's data reads (the tail's, or a partial navigation's matchPartial
|
|
2276
|
+
* on the same context) read their markers as before.
|
|
2277
|
+
*/
|
|
2278
|
+
private recordShellHit(
|
|
2279
|
+
key: string,
|
|
2280
|
+
tags: string[] | undefined,
|
|
2281
|
+
stats: ShellReadStats | undefined,
|
|
2282
|
+
): void {
|
|
2283
|
+
cfTagHints.remember(this.shellMemoKey(key), tags);
|
|
2284
|
+
if (!stats) return;
|
|
2285
|
+
const ctx = _getRequestContext();
|
|
2286
|
+
if (ctx) {
|
|
2287
|
+
recordMarkerRow(stats, tags, getShellMarkerReads(ctx, this).outcomes);
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
/** The isolate marker memo's key for a tag: markers are per namespace and version. */
|
|
2292
|
+
private markerMemoKey(tag: string): string {
|
|
2293
|
+
return `${this.namespace ?? ""}\u0000${this.version ?? ""}\u0000${tag}`;
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
/** The memo key: one per namespace, build version, base URL, and shell key. */
|
|
2297
|
+
private shellMemoKey(key: string): string {
|
|
2298
|
+
return `${this.namespace ?? ""}\u0000${this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`).url}`;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/**
|
|
2302
|
+
* Serve a memoized shell (shell-memo.ts). The tag-marker check is the only
|
|
2303
|
+
* store I/O left, so it is started first and awaited before anything is
|
|
2304
|
+
* returned: nothing of an invalidated shell reaches the response. A shell
|
|
2305
|
+
* that went stale since it was memoized, or that predates a tag this
|
|
2306
|
+
* isolate is invalidating or just invalidated (RecentTagInvalidations), is
|
|
2307
|
+
* dropped and read from the store (SWR recapture scheduling and each mode's
|
|
2308
|
+
* invalidation semantics stay with the store read). That read keeps the
|
|
2309
|
+
* caller's `tagHints`: cfTagHints may have evicted the key while the memo
|
|
2310
|
+
* still holds it.
|
|
2311
|
+
*/
|
|
2312
|
+
private async readMemoizedShell(
|
|
2313
|
+
key: string,
|
|
2314
|
+
memoKey: string,
|
|
2315
|
+
memoized: CFShellMemoValue,
|
|
2316
|
+
options: ShellDocumentReadOptions | undefined,
|
|
2317
|
+
snapshotBudget: SnapshotReadBudget,
|
|
2318
|
+
): Promise<ShellDocumentRead | null> {
|
|
2319
|
+
const { head } = memoized;
|
|
2320
|
+
if (!isMemoizableShell(head)) {
|
|
2321
|
+
cfShellMemo.delete(memoKey);
|
|
2322
|
+
return this.readShellDocumentWithin(key, options, snapshotBudget);
|
|
2323
|
+
}
|
|
2324
|
+
const stats = this.shellReadStats("memo");
|
|
2325
|
+
const markerStartedAt = stats ? performance.now() : 0;
|
|
2326
|
+
const invalidated = await (this.kv
|
|
2327
|
+
? this.isGloballyInvalidated(head.t, head.ta, true)
|
|
2328
|
+
: this.isL1Invalidated(
|
|
2329
|
+
head.t,
|
|
2330
|
+
head.ta,
|
|
2331
|
+
memoized.headers ?? new Headers(),
|
|
2332
|
+
));
|
|
2333
|
+
if (stats) {
|
|
2334
|
+
// Nothing else to wait for: the marker IS the memo hit's critical path.
|
|
2335
|
+
stats.markerMs = stats.markerWaitMs = performance.now() - markerStartedAt;
|
|
2336
|
+
stats.tags = head.t?.length ?? 0;
|
|
2337
|
+
stats.preludeBytes = memoized.prelude.length;
|
|
2338
|
+
}
|
|
2339
|
+
if (invalidated) {
|
|
2340
|
+
cfShellMemo.delete(memoKey);
|
|
2341
|
+
this.debugShell(key, "marker-invalidated", {
|
|
2342
|
+
tier: "memo",
|
|
2343
|
+
stats,
|
|
2344
|
+
});
|
|
2345
|
+
return null;
|
|
2346
|
+
}
|
|
2347
|
+
if (stats) stats.memo = { hit: true, bytes: cfShellMemo.size };
|
|
2348
|
+
this.recordShellHit(key, head.t, stats);
|
|
2349
|
+
this.debugShell(key, "memo-hit", {
|
|
2350
|
+
freshness: "fresh",
|
|
2351
|
+
stats,
|
|
2352
|
+
expiresAt: head.e,
|
|
2353
|
+
});
|
|
2354
|
+
return {
|
|
2355
|
+
entry: shellHeadToEntry(head),
|
|
2356
|
+
prelude: memoized.prelude,
|
|
2357
|
+
shouldRevalidate: false,
|
|
2358
|
+
snapshot: Promise.resolve(memoized.snapshot),
|
|
2359
|
+
...(stats ? { stats } : {}),
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
/**
|
|
2364
|
+
* Stats for one shell read, allocated only when the request collects
|
|
2365
|
+
* `debugPerformance` metrics or the build has the internal shell trace on,
|
|
2366
|
+
* so a normal HIT takes no timestamps for them.
|
|
2367
|
+
*/
|
|
2368
|
+
private shellReadStats(
|
|
2369
|
+
tier: ShellReadStats["tier"],
|
|
2370
|
+
): ShellReadStats | undefined {
|
|
2371
|
+
return INTERNAL_RANGO_DEBUG || _getRequestContext()?._metricsStore
|
|
2372
|
+
? { tier }
|
|
2373
|
+
: undefined;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
/**
|
|
2377
|
+
* Read a frame's head and prelude under one budget, starting the
|
|
2378
|
+
* tag-marker read (`isInvalidated`) the moment the head is parsed so it
|
|
2379
|
+
* overlaps the prelude read, then await it. Expired heads are rejected
|
|
2380
|
+
* before the marker read starts.
|
|
2381
|
+
*/
|
|
2382
|
+
private async openShellFrame(
|
|
2383
|
+
body: ReadableStream<Uint8Array>,
|
|
2384
|
+
budgetMs: number,
|
|
2385
|
+
label: string,
|
|
2386
|
+
isInvalidated: (head: ShellFrameHead) => Promise<boolean>,
|
|
2387
|
+
stats: ShellReadStats | undefined,
|
|
2388
|
+
): Promise<OpenedShellFrame> {
|
|
2389
|
+
const reader = new ShellFrameReader(body);
|
|
2390
|
+
let marker: Promise<boolean> | undefined;
|
|
2391
|
+
let markerSettledAt = 0;
|
|
2392
|
+
const headStartedAt = stats ? performance.now() : 0;
|
|
2393
|
+
const { value, timedOut } = await this.readWithTimeout(
|
|
2394
|
+
async (): Promise<OpenedShellFrame> => {
|
|
2395
|
+
const head = await reader.readHead();
|
|
2396
|
+
if (!head) {
|
|
2397
|
+
return {
|
|
2398
|
+
status: "corrupt",
|
|
2399
|
+
error: new Error("malformed/partial shell frame head"),
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
const markerStartedAt = stats ? performance.now() : 0;
|
|
2403
|
+
if (stats) {
|
|
2404
|
+
stats.headMs = markerStartedAt - headStartedAt;
|
|
2405
|
+
stats.headBytes = reader.headBytes;
|
|
2406
|
+
stats.tags = head.t?.length ?? 0;
|
|
2407
|
+
if (stats.markerHintStartedAt !== undefined) {
|
|
2408
|
+
stats.markerLeadMs = markerStartedAt - stats.markerHintStartedAt;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
if (Date.now() > head.e) return { status: "expired", head };
|
|
2412
|
+
marker = isInvalidated(head).then((invalidated) => {
|
|
2413
|
+
if (stats) {
|
|
2414
|
+
markerSettledAt = performance.now();
|
|
2415
|
+
stats.markerMs = markerSettledAt - markerStartedAt;
|
|
2416
|
+
}
|
|
2417
|
+
return invalidated;
|
|
2418
|
+
});
|
|
2419
|
+
marker.catch(() => {});
|
|
2420
|
+
const prelude = await reader.take(head.pl);
|
|
2421
|
+
if (!prelude) {
|
|
2422
|
+
return {
|
|
2423
|
+
status: "corrupt",
|
|
2424
|
+
error: new Error("shell frame ends inside the prelude"),
|
|
2425
|
+
};
|
|
2426
|
+
}
|
|
2427
|
+
if (stats) {
|
|
2428
|
+
stats.preludeMs = performance.now() - markerStartedAt;
|
|
2429
|
+
stats.preludeBytes = prelude.length;
|
|
2430
|
+
}
|
|
2431
|
+
return { status: "ok", head, prelude, reader };
|
|
2432
|
+
},
|
|
2433
|
+
budgetMs,
|
|
2434
|
+
label,
|
|
2435
|
+
);
|
|
2436
|
+
if (timedOut || !value) {
|
|
2437
|
+
reader.cancel();
|
|
2438
|
+
if (stats) {
|
|
2439
|
+
// The budget ran out: the whole elapsed read, split at the head.
|
|
2440
|
+
const elapsed = performance.now() - headStartedAt;
|
|
2441
|
+
if (stats.headMs === undefined) stats.headMs = elapsed;
|
|
2442
|
+
else stats.preludeMs = elapsed - stats.headMs;
|
|
2443
|
+
}
|
|
2444
|
+
return { status: "timeout" };
|
|
2085
2445
|
}
|
|
2446
|
+
if (value.status !== "ok") {
|
|
2447
|
+
reader.cancel();
|
|
2448
|
+
return value;
|
|
2449
|
+
}
|
|
2450
|
+
const preludeReadAt = stats ? performance.now() : 0;
|
|
2451
|
+
const invalidated = await marker!;
|
|
2452
|
+
if (stats) {
|
|
2453
|
+
stats.markerWaitMs = Math.max(0, markerSettledAt - preludeReadAt);
|
|
2454
|
+
}
|
|
2455
|
+
if (invalidated) {
|
|
2456
|
+
reader.cancel();
|
|
2457
|
+
return { status: "invalidated" };
|
|
2458
|
+
}
|
|
2459
|
+
return value;
|
|
2086
2460
|
}
|
|
2087
2461
|
|
|
2088
2462
|
/**
|
|
2089
|
-
*
|
|
2090
|
-
* the
|
|
2091
|
-
*
|
|
2092
|
-
*
|
|
2093
|
-
*
|
|
2094
|
-
*
|
|
2463
|
+
* The ShellDocumentRead for an opened frame. Its snapshot promise starts
|
|
2464
|
+
* reading the rest of the body now (off the commit path, not after it),
|
|
2465
|
+
* bounded by `budget` ({@link snapshotReadBudget}), and is registered with
|
|
2466
|
+
* waitUntil so the KV tier's L1 promotion and a corrupt entry's eviction
|
|
2467
|
+
* still run when nothing awaits it (a read that is not served). A snapshot
|
|
2468
|
+
* whose length differs from the head's `sl`, or that fails to parse, evicts
|
|
2469
|
+
* the entry from both tiers and reports cache-corrupt. Every failure
|
|
2470
|
+
* resolves undefined and names itself on `snapshotFailure` (`unavailable`
|
|
2471
|
+
* for a timeout or read error, `corrupt` otherwise): a document HIT then
|
|
2472
|
+
* degrades (rsc-rendering.ts serveShellHit), and getShell misses. Only
|
|
2473
|
+
* a read whose snapshot parsed goes into the per-isolate memo (when still
|
|
2474
|
+
* fresh), and `promote` receives its snapshot bytes (the KV tier's L1
|
|
2475
|
+
* promotion). `headers` are the L1 response's, kept for the KV-less
|
|
2476
|
+
* memo-hit marker check.
|
|
2477
|
+
*/
|
|
2478
|
+
private shellDocumentRead(
|
|
2479
|
+
key: string,
|
|
2480
|
+
opened: Extract<OpenedShellFrame, { status: "ok" }>,
|
|
2481
|
+
budget: SnapshotReadBudget,
|
|
2482
|
+
stats: ShellReadStats | undefined,
|
|
2483
|
+
headers?: Headers,
|
|
2484
|
+
promote?: (snapshotBytes: Uint8Array) => void,
|
|
2485
|
+
): ShellDocumentRead {
|
|
2486
|
+
const { head, prelude, reader } = opened;
|
|
2487
|
+
this.recordShellHit(key, head.t, stats);
|
|
2488
|
+
type SnapshotOutcome = {
|
|
2489
|
+
records?: ShellSnapshotRecord[];
|
|
2490
|
+
failure?: ShellSnapshotFailure;
|
|
2491
|
+
};
|
|
2492
|
+
const outcome = (async (): Promise<SnapshotOutcome> => {
|
|
2493
|
+
const readStartedAt = stats ? performance.now() : 0;
|
|
2494
|
+
const { value: rest, timedOut } = await this.readWithTimeout(
|
|
2495
|
+
() => reader.readRest(),
|
|
2496
|
+
budget.ms,
|
|
2497
|
+
"shell snapshot read",
|
|
2498
|
+
budget.consequence,
|
|
2499
|
+
);
|
|
2500
|
+
if (timedOut || !rest) {
|
|
2501
|
+
reader.cancel();
|
|
2502
|
+
return { failure: "unavailable" };
|
|
2503
|
+
}
|
|
2504
|
+
const parseStartedAt = stats ? performance.now() : 0;
|
|
2505
|
+
let records: ShellSnapshotRecord[] | undefined;
|
|
2506
|
+
try {
|
|
2507
|
+
if (rest.length !== head.sl) {
|
|
2508
|
+
throw new Error(
|
|
2509
|
+
`shell snapshot is ${rest.length} bytes, the head says ${head.sl}`,
|
|
2510
|
+
);
|
|
2511
|
+
}
|
|
2512
|
+
records = parseShellSnapshot(rest);
|
|
2513
|
+
} catch (error) {
|
|
2514
|
+
reportCacheError(
|
|
2515
|
+
error,
|
|
2516
|
+
"cache-corrupt",
|
|
2517
|
+
"[CFCacheStore] getShell: corrupt shell snapshot, evicting",
|
|
2518
|
+
);
|
|
2519
|
+
this.evictShell(key);
|
|
2520
|
+
return { failure: "corrupt" };
|
|
2521
|
+
}
|
|
2522
|
+
if (stats) {
|
|
2523
|
+
stats.snapshot = {
|
|
2524
|
+
readMs: parseStartedAt - readStartedAt,
|
|
2525
|
+
parseMs: performance.now() - parseStartedAt,
|
|
2526
|
+
bytes: rest.length,
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
if (isMemoizableShell(head)) {
|
|
2530
|
+
// A view into a larger body chunk would pin that chunk in the memo.
|
|
2531
|
+
const ownPrelude =
|
|
2532
|
+
prelude.byteLength === prelude.buffer.byteLength
|
|
2533
|
+
? prelude
|
|
2534
|
+
: prelude.slice();
|
|
2535
|
+
cfShellMemo.set(
|
|
2536
|
+
this.shellMemoKey(key),
|
|
2537
|
+
{ head, prelude: ownPrelude, snapshot: records, headers },
|
|
2538
|
+
ownPrelude.length + rest.length,
|
|
2539
|
+
this.memo.shellMs,
|
|
2540
|
+
this.memo.shellMaxBytes,
|
|
2541
|
+
);
|
|
2542
|
+
}
|
|
2543
|
+
promote?.(rest);
|
|
2544
|
+
return { records };
|
|
2545
|
+
})().catch((error: unknown): SnapshotOutcome => {
|
|
2546
|
+
reportCacheError(error, "cache-read", "[CFCacheStore] getShell snapshot");
|
|
2547
|
+
return { failure: "unavailable" };
|
|
2548
|
+
});
|
|
2549
|
+
if (this.waitUntil) this.waitUntil(() => outcome.then(() => {}));
|
|
2550
|
+
return {
|
|
2551
|
+
entry: shellHeadToEntry(head),
|
|
2552
|
+
prelude,
|
|
2553
|
+
shouldRevalidate: head.s > 0 && Date.now() > head.s,
|
|
2554
|
+
snapshot: outcome.then(({ records }) => records),
|
|
2555
|
+
snapshotFailure: outcome.then(({ failure }) => failure),
|
|
2556
|
+
...(stats && { stats }),
|
|
2557
|
+
};
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
/** Delete a shell from both tiers (non-blocking). */
|
|
2561
|
+
private evictShell(key: string): void {
|
|
2562
|
+
const evict = async (): Promise<void> => {
|
|
2563
|
+
const cache = await this.getCache();
|
|
2564
|
+
await reportingAsync(
|
|
2565
|
+
() => cache.delete(this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`)),
|
|
2566
|
+
"cache-delete",
|
|
2567
|
+
"[CFCacheStore] getShell: evict corrupt L1",
|
|
2568
|
+
);
|
|
2569
|
+
if (this.kv) {
|
|
2570
|
+
await this.evictKvKey(
|
|
2571
|
+
await this.toKVKey(`${SHELL_KEY_PREFIX}${key}`),
|
|
2572
|
+
"getShell",
|
|
2573
|
+
);
|
|
2574
|
+
}
|
|
2575
|
+
};
|
|
2576
|
+
if (this.waitUntil) this.waitUntil(evict);
|
|
2577
|
+
else void evict();
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
/**
|
|
2581
|
+
* Store a PPR shell in Cache API and, when KV is configured, KV — the same
|
|
2582
|
+
* cf-shell-frame bytes in both. The shared write is registered with
|
|
2583
|
+
* waitUntil and awaited so invalidation rejection can be acknowledged to
|
|
2584
|
+
* the capture scheduler. A KV-less store is L1-only by design (edge-only
|
|
2585
|
+
* ppr — see the section comment).
|
|
2586
|
+
*
|
|
2587
|
+
* KV rejects an expirationTtl below KV_MIN_EXPIRATION_TTL, so a shorter
|
|
2588
|
+
* retention (a `ppr` ttl + swr under 60, or a shell capped to a short or
|
|
2589
|
+
* aging route cache() entry) is written with that floor: the frame head's deadlines (`s`, `e`) are the
|
|
2590
|
+
* entry's, and every read checks `e` (openShellFrame), so the extra KV
|
|
2591
|
+
* lifetime is never served, and the shell is readable from every colo, not
|
|
2592
|
+
* only from the capturing colo's L1.
|
|
2095
2593
|
*/
|
|
2096
2594
|
async putShell(
|
|
2097
2595
|
key: string,
|
|
@@ -2111,6 +2609,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2111
2609
|
if (!this.kv && !this.tagPurge && Array.isArray(tags) && tags.length > 0) {
|
|
2112
2610
|
this.warnShellTagsNoEvictionOnce();
|
|
2113
2611
|
}
|
|
2612
|
+
// This isolate serves its own new capture from the next read on.
|
|
2613
|
+
cfShellMemo.delete(this.shellMemoKey(key));
|
|
2614
|
+
cfTagHints.remember(this.shellMemoKey(key), tags);
|
|
2114
2615
|
try {
|
|
2115
2616
|
const ttl = resolveTtl(ttlSeconds, this.defaults, DEFAULT_FUNCTION_TTL);
|
|
2116
2617
|
const swrWindow = resolveSwrWindow(swrSeconds, this.defaults);
|
|
@@ -2130,8 +2631,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2130
2631
|
const taggedAt =
|
|
2131
2632
|
Array.isArray(tags) && tags.length > 0 ? entry.createdAt : undefined;
|
|
2132
2633
|
|
|
2133
|
-
const writeKv = !!this.kv && retentionTtl
|
|
2134
|
-
const kvKey = writeKv
|
|
2634
|
+
const writeKv = !!this.kv && retentionTtl > 0;
|
|
2635
|
+
const kvKey = writeKv
|
|
2636
|
+
? await this.toKVKey(`${SHELL_KEY_PREFIX}${key}`)
|
|
2637
|
+
: null;
|
|
2135
2638
|
|
|
2136
2639
|
const write = (async (): Promise<"stored" | "invalidated" | void> => {
|
|
2137
2640
|
if (
|
|
@@ -2143,12 +2646,14 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2143
2646
|
return "invalidated";
|
|
2144
2647
|
}
|
|
2145
2648
|
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2649
|
+
// A navigationOnly entry stores no document half: no prelude bytes
|
|
2650
|
+
// and no postponed field (an omitted key, not an explicit undefined).
|
|
2651
|
+
const prelude =
|
|
2652
|
+
entry.prelude !== undefined
|
|
2653
|
+
? base64ToBytes(entry.prelude)
|
|
2654
|
+
: new Uint8Array(0);
|
|
2655
|
+
const snapshotBytes = encodeShellSnapshot(entry.snapshot);
|
|
2656
|
+
const head: ShellFrameHead = {
|
|
2152
2657
|
rv: entry.reactVersion,
|
|
2153
2658
|
bv: entry.buildVersion,
|
|
2154
2659
|
c: entry.createdAt,
|
|
@@ -2157,24 +2662,23 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2157
2662
|
t: tags,
|
|
2158
2663
|
ta: taggedAt,
|
|
2159
2664
|
i: entry.initialTheme,
|
|
2160
|
-
sn: entry.snapshot,
|
|
2161
2665
|
dk: entry.docKey,
|
|
2162
|
-
|
|
2163
|
-
tw: entry.transitionWhen,
|
|
2666
|
+
pr: entry.prunedRecords,
|
|
2164
2667
|
no: entry.navigationOnly,
|
|
2668
|
+
...(entry.postponed !== undefined ? { po: entry.postponed } : {}),
|
|
2669
|
+
pl: prelude.length,
|
|
2670
|
+
sl: snapshotBytes?.length ?? 0,
|
|
2165
2671
|
};
|
|
2166
|
-
const
|
|
2672
|
+
const frame = encodeShellFrame(head, prelude, snapshotBytes);
|
|
2167
2673
|
const writes: Promise<boolean>[] = [
|
|
2168
2674
|
(async () => {
|
|
2169
2675
|
try {
|
|
2170
2676
|
const cache = await this.getCache();
|
|
2171
2677
|
await cache.put(
|
|
2172
|
-
this.keyToRequest(
|
|
2173
|
-
this.
|
|
2678
|
+
this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`),
|
|
2679
|
+
this.shellFrameResponse(frame, head),
|
|
2174
2680
|
);
|
|
2175
|
-
this.debugShell(key, "l1-stored", {
|
|
2176
|
-
expiresAt: envelope.e,
|
|
2177
|
-
});
|
|
2681
|
+
this.debugShell(key, "l1-stored", { expiresAt: head.e });
|
|
2178
2682
|
return true;
|
|
2179
2683
|
} catch (error) {
|
|
2180
2684
|
reportCacheError(
|
|
@@ -2190,12 +2694,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2190
2694
|
writes.push(
|
|
2191
2695
|
(async () => {
|
|
2192
2696
|
try {
|
|
2193
|
-
await this.kv!.put(kvKey,
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2697
|
+
await this.kv!.put(kvKey, shellFrameToText(frame), {
|
|
2698
|
+
// Whole seconds, at least KV's floor: the frame head keeps
|
|
2699
|
+
// the exact deadlines the reads check.
|
|
2700
|
+
expirationTtl: Math.max(
|
|
2701
|
+
KV_MIN_EXPIRATION_TTL,
|
|
2702
|
+
Math.ceil(retentionTtl),
|
|
2703
|
+
),
|
|
2198
2704
|
});
|
|
2705
|
+
this.debugShell(key, "kv-stored", { expiresAt: head.e });
|
|
2199
2706
|
return true;
|
|
2200
2707
|
} catch (error) {
|
|
2201
2708
|
reportCacheError(
|
|
@@ -2219,107 +2726,134 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2219
2726
|
}
|
|
2220
2727
|
}
|
|
2221
2728
|
|
|
2222
|
-
/**
|
|
2223
|
-
private
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
postponed: envelope.po,
|
|
2227
|
-
reactVersion: envelope.rv,
|
|
2228
|
-
buildVersion: envelope.bv,
|
|
2229
|
-
initialTheme: envelope.i,
|
|
2230
|
-
snapshot: envelope.sn,
|
|
2231
|
-
docKey: envelope.dk,
|
|
2232
|
-
handlerLiveHoles: envelope.lh,
|
|
2233
|
-
transitionWhen: envelope.tw,
|
|
2234
|
-
navigationOnly: envelope.no,
|
|
2235
|
-
createdAt: envelope.c,
|
|
2236
|
-
};
|
|
2237
|
-
}
|
|
2238
|
-
|
|
2239
|
-
/** Build the Cache API representation of the coupled shell envelope. */
|
|
2240
|
-
private shellEnvelopeResponse(
|
|
2241
|
-
body: string,
|
|
2242
|
-
envelope: CFShellEnvelope,
|
|
2729
|
+
/** The Cache API representation of a shell frame. */
|
|
2730
|
+
private shellFrameResponse(
|
|
2731
|
+
frame: Uint8Array<ArrayBuffer>,
|
|
2732
|
+
head: ShellFrameHead,
|
|
2243
2733
|
): Response {
|
|
2244
|
-
const remainingTtl = Math.max(
|
|
2245
|
-
|
|
2246
|
-
Math.floor((envelope.e - Date.now()) / 1000),
|
|
2247
|
-
);
|
|
2248
|
-
return new Response(body, {
|
|
2734
|
+
const remainingTtl = Math.max(1, Math.floor((head.e - Date.now()) / 1000));
|
|
2735
|
+
return new Response(frame, {
|
|
2249
2736
|
headers: {
|
|
2250
|
-
"Content-Type": "application/
|
|
2737
|
+
"Content-Type": "application/octet-stream",
|
|
2251
2738
|
"Cache-Control": `public, max-age=${remainingTtl}`,
|
|
2252
|
-
[CACHE_STALE_AT_HEADER]: String(
|
|
2253
|
-
[CACHE_EXPIRES_AT_HEADER]: String(
|
|
2739
|
+
[CACHE_STALE_AT_HEADER]: String(head.s),
|
|
2740
|
+
[CACHE_EXPIRES_AT_HEADER]: String(head.e),
|
|
2254
2741
|
[CACHE_STATUS_HEADER]: "HIT",
|
|
2255
|
-
...this.tagHeaderEntries(
|
|
2742
|
+
...this.tagHeaderEntries(head.t, head.ta),
|
|
2256
2743
|
},
|
|
2257
2744
|
});
|
|
2258
2745
|
}
|
|
2259
2746
|
|
|
2260
|
-
/**
|
|
2261
|
-
|
|
2747
|
+
/**
|
|
2748
|
+
* KV shell fallback: the same prelude-first read, then L1 promotion. After
|
|
2749
|
+
* an L1 attempt, `stats` is that read's and gains the L1 attempt's time
|
|
2750
|
+
* (`l1MissMs`) before its per-tier fields restart for KV.
|
|
2751
|
+
*/
|
|
2752
|
+
private async kvReadShellDocument(
|
|
2262
2753
|
key: string,
|
|
2263
|
-
|
|
2754
|
+
snapshotBudget: SnapshotReadBudget,
|
|
2755
|
+
stats?: ShellReadStats,
|
|
2756
|
+
l1StartedAt = 0,
|
|
2757
|
+
l1MissReason?: string,
|
|
2758
|
+
): Promise<ShellDocumentRead | null> {
|
|
2264
2759
|
if (!this.kv) return null;
|
|
2760
|
+
if (stats) {
|
|
2761
|
+
stats.l1MissMs = performance.now() - l1StartedAt;
|
|
2762
|
+
stats.l1MissReason = l1MissReason;
|
|
2763
|
+
stats.tier = "kv";
|
|
2764
|
+
stats.matchMs = stats.headMs = stats.preludeMs = undefined;
|
|
2765
|
+
stats.markerMs = stats.markerWaitMs = undefined;
|
|
2766
|
+
stats.headBytes = stats.preludeBytes = stats.tags = undefined;
|
|
2767
|
+
} else {
|
|
2768
|
+
stats = this.shellReadStats("kv");
|
|
2769
|
+
}
|
|
2265
2770
|
try {
|
|
2266
|
-
const readStartedAt =
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
const
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2771
|
+
const readStartedAt = stats ? performance.now() : 0;
|
|
2772
|
+
// One kvReadTimeoutMs budget covers opening the value and reading its
|
|
2773
|
+
// head and prelude; the remainder after the open bounds the frame read.
|
|
2774
|
+
const deadline = Date.now() + this.kvReadTimeoutMs;
|
|
2775
|
+
const kvKey = await this.toKVKey(`${SHELL_KEY_PREFIX}${key}`);
|
|
2776
|
+
// A transient read REJECTION propagates to the catch below (reported
|
|
2777
|
+
// cache-read, the entry left intact); only a malformed frame on a body
|
|
2778
|
+
// that WAS read is corruption that evicts.
|
|
2779
|
+
const { value: raw, timedOut } = await this.readWithTimeout<unknown>(
|
|
2780
|
+
() => this.kv!.get(kvKey, { type: "stream" }),
|
|
2781
|
+
this.kvReadTimeoutMs,
|
|
2782
|
+
"KV read",
|
|
2783
|
+
);
|
|
2784
|
+
if (stats) stats.matchMs = performance.now() - readStartedAt;
|
|
2785
|
+
if (timedOut || raw == null) {
|
|
2278
2786
|
this.debugShell(key, "kv-miss", {
|
|
2279
2787
|
reason: timedOut ? "timeout" : "unavailable",
|
|
2280
|
-
|
|
2788
|
+
stats,
|
|
2281
2789
|
});
|
|
2282
2790
|
return null;
|
|
2283
2791
|
}
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2792
|
+
// A binding that ignores `type` (a test shim) hands back the stored
|
|
2793
|
+
// string itself.
|
|
2794
|
+
const body =
|
|
2795
|
+
raw instanceof ReadableStream
|
|
2796
|
+
? (raw as ReadableStream<Uint8Array>)
|
|
2797
|
+
: new Response(raw as BodyInit).body!;
|
|
2798
|
+
const opened = await this.openShellFrame(
|
|
2799
|
+
body,
|
|
2800
|
+
// readWithTimeout treats <= 0 as "no budget": keep a spent budget
|
|
2801
|
+
// spent (1 ms) rather than unbounded.
|
|
2802
|
+
this.kvReadTimeoutMs > 0 ? Math.max(1, deadline - Date.now()) : 0,
|
|
2803
|
+
"KV read",
|
|
2804
|
+
(head) => this.isGloballyInvalidated(head.t, head.ta, true),
|
|
2805
|
+
stats,
|
|
2806
|
+
);
|
|
2807
|
+
if (opened.status === "corrupt") {
|
|
2808
|
+
reportCacheError(
|
|
2809
|
+
opened.error,
|
|
2810
|
+
"cache-corrupt",
|
|
2811
|
+
"[CFCacheStore] getShell: malformed shell frame in KV, evicting",
|
|
2812
|
+
);
|
|
2813
|
+
this.scheduleKvEvict(kvKey, "getShell");
|
|
2287
2814
|
this.debugShell(key, "kv-miss", {
|
|
2288
|
-
reason: "
|
|
2289
|
-
|
|
2290
|
-
expiresAt: envelope.e,
|
|
2815
|
+
reason: "malformed",
|
|
2816
|
+
stats,
|
|
2291
2817
|
});
|
|
2292
2818
|
return null;
|
|
2293
2819
|
}
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
if (invalidated) {
|
|
2820
|
+
if (opened.status === "timeout" || opened.status === "expired") {
|
|
2821
|
+
this.debugShell(key, "kv-miss", {
|
|
2822
|
+
reason: opened.status,
|
|
2823
|
+
stats,
|
|
2824
|
+
expiresAt: opened.head?.e,
|
|
2825
|
+
});
|
|
2826
|
+
return null;
|
|
2827
|
+
}
|
|
2828
|
+
if (opened.status === "invalidated") {
|
|
2303
2829
|
this.debugShell(key, "marker-invalidated", {
|
|
2304
2830
|
tier: "kv",
|
|
2305
|
-
|
|
2306
|
-
markerMs,
|
|
2831
|
+
stats,
|
|
2307
2832
|
});
|
|
2308
2833
|
return null;
|
|
2309
2834
|
}
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2835
|
+
const read = this.shellDocumentRead(
|
|
2836
|
+
key,
|
|
2837
|
+
opened,
|
|
2838
|
+
snapshotBudget,
|
|
2839
|
+
stats,
|
|
2840
|
+
undefined,
|
|
2841
|
+
(snapshotBytes) =>
|
|
2842
|
+
this.promoteShellToL1(
|
|
2843
|
+
key,
|
|
2844
|
+
opened.head,
|
|
2845
|
+
opened.prelude,
|
|
2846
|
+
snapshotBytes,
|
|
2847
|
+
),
|
|
2848
|
+
);
|
|
2849
|
+
if (INTERNAL_RANGO_DEBUG) {
|
|
2850
|
+
this.debugShell(key, "kv-hit", {
|
|
2851
|
+
freshness: read.shouldRevalidate ? "stale" : "fresh",
|
|
2852
|
+
stats,
|
|
2853
|
+
expiresAt: opened.head.e,
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
return read;
|
|
2323
2857
|
} catch (error) {
|
|
2324
2858
|
reportCacheError(error, "cache-read", "[CFCacheStore] kvGetShell");
|
|
2325
2859
|
this.debugShell(key, "kv-miss", { reason: "error" });
|
|
@@ -2328,24 +2862,28 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2328
2862
|
}
|
|
2329
2863
|
|
|
2330
2864
|
/** Promote a valid KV shell into the per-colo Cache API tier. */
|
|
2331
|
-
private promoteShellToL1(
|
|
2865
|
+
private promoteShellToL1(
|
|
2866
|
+
key: string,
|
|
2867
|
+
head: ShellFrameHead,
|
|
2868
|
+
prelude: Uint8Array,
|
|
2869
|
+
snapshotBytes: Uint8Array,
|
|
2870
|
+
): void {
|
|
2332
2871
|
if (!this.waitUntil) return;
|
|
2333
2872
|
this.waitUntil(() =>
|
|
2334
2873
|
reportingAsync(
|
|
2335
2874
|
async () => {
|
|
2336
|
-
if (Date.now() >
|
|
2875
|
+
if (Date.now() > head.e) return;
|
|
2337
2876
|
const cache = await this.getCache();
|
|
2338
|
-
const body = JSON.stringify(envelope);
|
|
2339
2877
|
await cache.put(
|
|
2340
|
-
this.keyToRequest(
|
|
2341
|
-
this.
|
|
2878
|
+
this.keyToRequest(`${SHELL_KEY_PREFIX}${key}`),
|
|
2879
|
+
this.shellFrameResponse(
|
|
2880
|
+
encodeShellFrame(head, prelude, snapshotBytes),
|
|
2881
|
+
head,
|
|
2882
|
+
),
|
|
2342
2883
|
);
|
|
2343
2884
|
this.debugShell(key, "kv-promoted", {
|
|
2344
|
-
remainingTtl: Math.max(
|
|
2345
|
-
|
|
2346
|
-
Math.floor((envelope.e - Date.now()) / 1000),
|
|
2347
|
-
),
|
|
2348
|
-
expiresAt: envelope.e,
|
|
2885
|
+
remainingTtl: Math.max(1, Math.floor((head.e - Date.now()) / 1000)),
|
|
2886
|
+
expiresAt: head.e,
|
|
2349
2887
|
});
|
|
2350
2888
|
},
|
|
2351
2889
|
"cache-write",
|
|
@@ -2692,6 +3230,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2692
3230
|
private async isGloballyInvalidated(
|
|
2693
3231
|
tags: string[] | undefined,
|
|
2694
3232
|
taggedAt: number | undefined,
|
|
3233
|
+
isolateMemo = false,
|
|
2695
3234
|
): Promise<boolean> {
|
|
2696
3235
|
// Array.isArray (not just truthiness): a non-array tags value - direct store
|
|
2697
3236
|
// misuse like setItem(k, v, { tags: "products" }), or a skewed KV envelope -
|
|
@@ -2704,7 +3243,11 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2704
3243
|
const inflight = ctx ? getTagMarkerInflight(ctx, this) : undefined;
|
|
2705
3244
|
try {
|
|
2706
3245
|
const markers = await Promise.all(
|
|
2707
|
-
tags.map((tag) =>
|
|
3246
|
+
tags.map((tag) =>
|
|
3247
|
+
isolateMemo
|
|
3248
|
+
? this.readShellTagMarker(tag)
|
|
3249
|
+
: this.readTagMarker(tag, memo, inflight),
|
|
3250
|
+
),
|
|
2708
3251
|
);
|
|
2709
3252
|
for (const marker of markers) {
|
|
2710
3253
|
if (marker != null && marker >= taggedAt) return true;
|
|
@@ -2733,7 +3276,8 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2733
3276
|
* entry Cache-Tag tokens (`headers`): an entry a purge cannot reach — one
|
|
2734
3277
|
* written before the tokens existed, or whose tag set overflowed the
|
|
2735
3278
|
* Cache-Tag header limit — keeps the full marker check, or purge mode
|
|
2736
|
-
* would serve it stale until TTL with no eviction path.
|
|
3279
|
+
* would serve it stale until TTL with no eviction path. KV-less there are
|
|
3280
|
+
* no markers, so it gets the per-request memo check like any hit (#973).
|
|
2737
3281
|
*
|
|
2738
3282
|
* Without tagPurge this is the full marker cascade. KV-tier reads and
|
|
2739
3283
|
* shells always use isGloballyInvalidated directly: purge cannot reach KV,
|
|
@@ -2747,7 +3291,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2747
3291
|
): Promise<boolean> {
|
|
2748
3292
|
if (!this.tagPurge) return this.isGloballyInvalidated(tags, taggedAt);
|
|
2749
3293
|
if (!Array.isArray(tags) || tags.length === 0 || !taggedAt) return false;
|
|
2750
|
-
if (!this.hasEntryCacheTags(headers)) {
|
|
3294
|
+
if (this.kv && !this.hasEntryCacheTags(headers)) {
|
|
2751
3295
|
return this.isGloballyInvalidated(tags, taggedAt);
|
|
2752
3296
|
}
|
|
2753
3297
|
const ctx = _getRequestContext();
|
|
@@ -2803,6 +3347,55 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2803
3347
|
return this.fetchTagMarker(tag, memo);
|
|
2804
3348
|
}
|
|
2805
3349
|
|
|
3350
|
+
/**
|
|
3351
|
+
* A PPR shell read's marker for `tag`: this request's own value if it has
|
|
3352
|
+
* one (its invalidateTags() writes, or a store read), else through the
|
|
3353
|
+
* per-isolate marker memo (isolate-tag-memo.ts, stale-while-revalidate
|
|
3354
|
+
* under `markerFreshMs` / `markerMaxStaleMs`; a request carrying the
|
|
3355
|
+
* fresh-reads cookie reads L1/KV). The value is kept in the request's
|
|
3356
|
+
* shell-read record, never in the per-request memo the data families read,
|
|
3357
|
+
* HIT or MISS. A store read still lands in the per-request memo, as any
|
|
3358
|
+
* marker read does; it is not stale. A timed-out read fails open for this
|
|
3359
|
+
* request and is not memoized.
|
|
3360
|
+
*/
|
|
3361
|
+
private readShellTagMarker(tag: string): Promise<number | null> {
|
|
3362
|
+
const ctx = _getRequestContext();
|
|
3363
|
+
const memo = ctx ? getTagMarkerMemo(ctx, this) : undefined;
|
|
3364
|
+
if (memo?.has(tag)) return Promise.resolve(memo.get(tag) ?? null);
|
|
3365
|
+
const shell = ctx ? getShellMarkerReads(ctx, this) : undefined;
|
|
3366
|
+
const pending = shell?.reads.get(tag);
|
|
3367
|
+
if (pending) return pending;
|
|
3368
|
+
const read = cfMarkerMemo.readThrough(
|
|
3369
|
+
this.markerMemoKey(tag),
|
|
3370
|
+
async (background) => {
|
|
3371
|
+
const outcome = { timedOut: false, fromRequest: false };
|
|
3372
|
+
const value = await this.fetchTagMarker(
|
|
3373
|
+
tag,
|
|
3374
|
+
background ? undefined : memo,
|
|
3375
|
+
outcome,
|
|
3376
|
+
);
|
|
3377
|
+
return {
|
|
3378
|
+
value,
|
|
3379
|
+
memoize: !outcome.timedOut && !outcome.fromRequest,
|
|
3380
|
+
};
|
|
3381
|
+
},
|
|
3382
|
+
{
|
|
3383
|
+
freshMs: this.memo.markerFreshMs,
|
|
3384
|
+
maxStaleMs: this.memo.markerMaxStaleMs,
|
|
3385
|
+
bypass: freshReadsRequired(ctx),
|
|
3386
|
+
keepAlive: this.waitUntil
|
|
3387
|
+
? (refresh) => this.waitUntil!(() => refresh)
|
|
3388
|
+
: undefined,
|
|
3389
|
+
onOutcome:
|
|
3390
|
+
shell && (INTERNAL_RANGO_DEBUG || ctx?._metricsStore)
|
|
3391
|
+
? (outcome) => shell.outcomes.set(tag, outcome)
|
|
3392
|
+
: undefined,
|
|
3393
|
+
},
|
|
3394
|
+
);
|
|
3395
|
+
shell?.reads.set(tag, read);
|
|
3396
|
+
return read;
|
|
3397
|
+
}
|
|
3398
|
+
|
|
2806
3399
|
/**
|
|
2807
3400
|
* Uncached body of readTagMarker: L1 (per-colo Cache API, opt-in via
|
|
2808
3401
|
* tagCacheTtl) -> KV. Writes the resolved value back into the memo.
|
|
@@ -2811,19 +3404,33 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2811
3404
|
private async fetchTagMarker(
|
|
2812
3405
|
tag: string,
|
|
2813
3406
|
memo: Map<string, number | null> | undefined,
|
|
3407
|
+
read?: { timedOut: boolean; fromRequest?: boolean },
|
|
2814
3408
|
): Promise<number | null> {
|
|
2815
3409
|
// Write the resolved marker into the memo WITHOUT clobbering a value a
|
|
2816
3410
|
// concurrent invalidateTags() wrote during our await. The router resolves
|
|
2817
|
-
// sibling slots in parallel, so a slot's updateTag() can land
|
|
2818
|
-
//
|
|
2819
|
-
//
|
|
3411
|
+
// sibling slots in parallel, so a slot's updateTag() can land its
|
|
3412
|
+
// invalidatedAt into the memo while this read is still in flight;
|
|
3413
|
+
// overwriting it with our (pre-invalidation) read result would break
|
|
2820
3414
|
// read-your-own-writes for the rest of the request. If the tag was memoized
|
|
2821
3415
|
// mid-read, that value wins and is returned. Without a memo, the read result
|
|
2822
3416
|
// stands as-is.
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
3417
|
+
//
|
|
3418
|
+
// A mask memoized mid-read stays in this request: maskTagsForRequest
|
|
3419
|
+
// wrote it before the KV put was confirmed, and a failed put must not
|
|
3420
|
+
// reach other requests (#973). So it is not written to L1 here, and
|
|
3421
|
+
// `read.fromRequest` keeps the shell read from storing it in the isolate
|
|
3422
|
+
// marker memo; writeTagInvalidation writes both after the put. A value
|
|
3423
|
+
// another read memoized mid-read came from KV or L1 and is published.
|
|
3424
|
+
let fromRequest = false;
|
|
3425
|
+
const memoize = (value: number | null): number | null => {
|
|
3426
|
+
if (memo && memo.has(tag)) {
|
|
3427
|
+
const ctx = _getRequestContext();
|
|
3428
|
+
fromRequest = ctx !== undefined && isRequestMasked(ctx, this, tag);
|
|
3429
|
+
if (read) read.fromRequest = fromRequest;
|
|
3430
|
+
return memo.get(tag) ?? null;
|
|
3431
|
+
}
|
|
3432
|
+
memo?.set(tag, value);
|
|
3433
|
+
return value;
|
|
2827
3434
|
};
|
|
2828
3435
|
|
|
2829
3436
|
// L1 (per-colo) marker cache - opt-in via tagCacheTtl. Bounded by the same
|
|
@@ -2871,6 +3478,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2871
3478
|
"tag marker KV read",
|
|
2872
3479
|
);
|
|
2873
3480
|
if (timedOut) {
|
|
3481
|
+
if (read) read.timedOut = true;
|
|
2874
3482
|
// Memoize the fail-open result so the rest of this request is consistent
|
|
2875
3483
|
// (and does not re-pay the timeout per segment sharing the tag).
|
|
2876
3484
|
return memoize(null);
|
|
@@ -2878,10 +3486,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
2878
3486
|
const value = raw != null ? Number(raw) : null;
|
|
2879
3487
|
const resolved = memoize(value);
|
|
2880
3488
|
|
|
2881
|
-
// Populate L1 for subsequent reads in this colo (non-blocking)
|
|
2882
|
-
//
|
|
2883
|
-
//
|
|
2884
|
-
if (this.tagCacheTtl > 0) {
|
|
3489
|
+
// Populate L1 for subsequent reads in this colo (non-blocking), from KV's
|
|
3490
|
+
// answer only. A value memoized mid-read is skipped: KV's answer predates
|
|
3491
|
+
// it, and the memo's may be an unconfirmed mask (see memoize above).
|
|
3492
|
+
if (this.tagCacheTtl > 0 && !fromRequest) {
|
|
2885
3493
|
const put = () => this.putTagMarkerL1(tag, resolved);
|
|
2886
3494
|
if (this.waitUntil) this.waitUntil(put);
|
|
2887
3495
|
else void put();
|
|
@@ -3056,29 +3664,6 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
3056
3664
|
}
|
|
3057
3665
|
}
|
|
3058
3666
|
|
|
3059
|
-
/**
|
|
3060
|
-
* Invalidate every entry tagged with any of `tags`. Receives the whole batch
|
|
3061
|
-
* from one updateTag()/revalidateTag() call so the eager-purge hook fires
|
|
3062
|
-
* ONCE (one CDN purge request, not one per tag). For each tag: records the KV
|
|
3063
|
-
* marker (the durable cross-colo truth that reads compare taggedAt against),
|
|
3064
|
-
* writes the fresh marker straight into this colo's L1 (write-through, NOT
|
|
3065
|
-
* delete - a delete would let the next read re-read a not-yet-converged KV
|
|
3066
|
-
* value and re-arm the stale window), and memoizes it for same-request
|
|
3067
|
-
* read-your-own-writes. In purge mode (tagPurge) it then AWAITS the
|
|
3068
|
-
* consumer's purge-by-tag call with the entry Cache-Tags — the eviction the
|
|
3069
|
-
* per-read marker skip on L1 hits relies on. Finally fires onRevalidateTag
|
|
3070
|
-
* with the namespaced lookup Cache-Tags so a consumer purge evicts the
|
|
3071
|
-
* cached lookups in other colos promptly (otherwise they converge within
|
|
3072
|
-
* tagCacheTtl).
|
|
3073
|
-
*
|
|
3074
|
-
* Durable-write integrity: the in-memory write-through (memo + L1) for a tag
|
|
3075
|
-
* runs ONLY after that tag's KV marker write is confirmed. If any KV write
|
|
3076
|
-
* fails (transient error; over-limit keys are normalized by toKVKey rather
|
|
3077
|
-
* than rejected), this rejects with the
|
|
3078
|
-
* failed tags so an awaiting updateTag() surfaces the failure instead of
|
|
3079
|
-
* silently reporting success while other requests/colos serve stale data. The
|
|
3080
|
-
* eager purge still fires for the whole batch first (it is additive).
|
|
3081
|
-
*/
|
|
3082
3667
|
/**
|
|
3083
3668
|
* Shell tag-generation gate (SegmentCacheStore.isTagsInvalidatedSince): the
|
|
3084
3669
|
* SAME KV markers used by runtime envelopes also evict immutable build shells
|
|
@@ -3094,12 +3679,77 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
3094
3679
|
return this.isShellGenerationInvalidated(tags, sinceMs);
|
|
3095
3680
|
}
|
|
3096
3681
|
|
|
3682
|
+
/**
|
|
3683
|
+
* Invalidate every entry tagged with any of `tags`. Receives the whole batch
|
|
3684
|
+
* from one updateTag()/revalidateTag() call so the eager-purge hook fires
|
|
3685
|
+
* ONCE (one CDN purge request, not one per tag). Before its first await it
|
|
3686
|
+
* masks the tags for the rest of the request (maskTagsForRequest), which is
|
|
3687
|
+
* all revalidateTag() waits for. For each tag it then records the KV marker
|
|
3688
|
+
* (the durable cross-colo truth that reads compare taggedAt against) and
|
|
3689
|
+
* writes the fresh marker straight into this colo's L1 (write-through, NOT
|
|
3690
|
+
* delete - a delete would let the next read re-read a not-yet-converged KV
|
|
3691
|
+
* value and re-arm the stale window). In purge mode (tagPurge) it then
|
|
3692
|
+
* AWAITS the consumer's purge-by-tag call with the entry Cache-Tags — the
|
|
3693
|
+
* eviction the per-read marker skip on L1 hits relies on. Finally fires
|
|
3694
|
+
* onRevalidateTag with the namespaced lookup Cache-Tags so a consumer purge
|
|
3695
|
+
* evicts the cached lookups in other colos promptly (otherwise they converge
|
|
3696
|
+
* within tagCacheTtl).
|
|
3697
|
+
*
|
|
3698
|
+
* Durable-write integrity: the write-through other requests read (the
|
|
3699
|
+
* isolate marker memo and L1) for a tag runs ONLY after that tag's KV marker
|
|
3700
|
+
* write is confirmed. If any KV write fails (transient error; over-limit
|
|
3701
|
+
* keys are normalized by toKVKey rather than rejected), this rejects with
|
|
3702
|
+
* the failed tags so an awaiting updateTag() surfaces the failure instead of
|
|
3703
|
+
* silently reporting success while other requests/colos serve stale data.
|
|
3704
|
+
* The purge and onRevalidateTag still fire for the whole batch once the
|
|
3705
|
+
* puts settle, whether or not one failed (they are additive); the rejection
|
|
3706
|
+
* follows them.
|
|
3707
|
+
*/
|
|
3097
3708
|
async invalidateTags(tags: string[]): Promise<void> {
|
|
3098
3709
|
if (tags.length === 0) return;
|
|
3099
3710
|
const invalidatedAt = Date.now();
|
|
3711
|
+
this.maskTagsForRequest(tags, invalidatedAt);
|
|
3712
|
+
// The invalidating isolate drops its memoized shells for these tags at
|
|
3713
|
+
// once; with KV every isolate's per-read marker check rejects the rest.
|
|
3714
|
+
// Reads already in flight must not memoize them again while the markers
|
|
3715
|
+
// and the purge are written (KV-less purge mode has no marker to reject
|
|
3716
|
+
// that copy): the record holds them out until one window after this
|
|
3717
|
+
// settles, at least the default window so a store with the memo off still
|
|
3718
|
+
// covers another store's reads in this isolate.
|
|
3719
|
+
cfShellMemo.deleteWhere((shell) => shellHasAnyTag(shell.head.t, tags));
|
|
3720
|
+
recentShellInvalidations.begin(tags, invalidatedAt);
|
|
3721
|
+
try {
|
|
3722
|
+
await this.writeTagInvalidation(tags, invalidatedAt);
|
|
3723
|
+
} finally {
|
|
3724
|
+
recentShellInvalidations.settle(
|
|
3725
|
+
tags,
|
|
3726
|
+
Math.max(this.memo.shellMs, DEFAULT_SHELL_MEMO_MS),
|
|
3727
|
+
);
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
/**
|
|
3732
|
+
* The request-local half of invalidateTags(), run before its first await
|
|
3733
|
+
* (#973): this request's marker memo takes the new marker, so every later
|
|
3734
|
+
* read in the request (data families, purge-mode L1 hits, shell reads)
|
|
3735
|
+
* treats the tags as invalidated at `invalidatedAt` without waiting for the
|
|
3736
|
+
* KV put or the purge that revalidateTag() leaves to waitUntil. Written
|
|
3737
|
+
* before the durable write is confirmed: if that write fails, this request
|
|
3738
|
+
* pays extra misses, never a stale read, and later requests read the
|
|
3739
|
+
* durable state. Skipped where no read consults the memo (neither KV nor
|
|
3740
|
+
* tagPurge).
|
|
3741
|
+
*/
|
|
3742
|
+
private maskTagsForRequest(tags: string[], invalidatedAt: number): void {
|
|
3743
|
+
if (!this.kv && !this.tagPurge) return;
|
|
3100
3744
|
const ctx = _getRequestContext();
|
|
3101
|
-
|
|
3745
|
+
if (ctx) maskRequestTags(ctx, this, tags, invalidatedAt);
|
|
3746
|
+
}
|
|
3102
3747
|
|
|
3748
|
+
/** invalidateTags' marker writes, tag purge, and hooks. */
|
|
3749
|
+
private async writeTagInvalidation(
|
|
3750
|
+
tags: string[],
|
|
3751
|
+
invalidatedAt: number,
|
|
3752
|
+
): Promise<void> {
|
|
3103
3753
|
if (!this.kv && !this.onRevalidateTag && !this.tagPurge) {
|
|
3104
3754
|
console.warn(
|
|
3105
3755
|
`[CFCacheStore] invalidateTags had no effect: configure a KV namespace ` +
|
|
@@ -3110,6 +3760,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
3110
3760
|
|
|
3111
3761
|
const failedTags = new Set<string>();
|
|
3112
3762
|
const errors: unknown[] = [];
|
|
3763
|
+
const lookupMarkerCacheActive = Boolean(this.kv) && this.tagCacheTtl > 0;
|
|
3113
3764
|
if (this.kv) {
|
|
3114
3765
|
// Markers written with no expiry (tagInvalidationTtl unset) never expire,
|
|
3115
3766
|
// so high-cardinality tags accumulate KV keys unboundedly with no reaper.
|
|
@@ -3145,25 +3796,16 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
|
|
|
3145
3796
|
}
|
|
3146
3797
|
}),
|
|
3147
3798
|
);
|
|
3148
|
-
}
|
|
3149
3799
|
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
// path ever consults — EXCEPT the memo in purge mode: isL1Invalidated()
|
|
3155
|
-
// consults it (and only it) on every L1 hit, so a KV-less purge-mode store
|
|
3156
|
-
// still writes the memo for same-request read-your-own-writes. The
|
|
3157
|
-
// onRevalidateTag purge below still fires regardless (it is additive and
|
|
3158
|
-
// external to the marker cascade). The memo write is synchronous
|
|
3159
|
-
// (read-your-own-writes); the L1 Cache API writes are independent, so fan
|
|
3160
|
-
// them out in parallel rather than awaiting each.
|
|
3161
|
-
const lookupMarkerCacheActive = Boolean(this.kv) && this.tagCacheTtl > 0;
|
|
3162
|
-
if (this.kv || this.tagPurge) {
|
|
3800
|
+
// Write-through (isolate marker memo + L1) only for tags with a
|
|
3801
|
+
// confirmed durable marker; this request's own memo was written up
|
|
3802
|
+
// front (maskTagsForRequest). The L1 Cache API writes are independent,
|
|
3803
|
+
// so fan them out in parallel rather than awaiting each.
|
|
3163
3804
|
const l1Writes: Promise<void>[] = [];
|
|
3164
3805
|
for (const tag of tags) {
|
|
3165
3806
|
if (failedTags.has(tag)) continue;
|
|
3166
|
-
|
|
3807
|
+
// Same isolate: later requests here see the invalidation at once.
|
|
3808
|
+
cfMarkerMemo.store(this.markerMemoKey(tag), invalidatedAt);
|
|
3167
3809
|
if (lookupMarkerCacheActive) {
|
|
3168
3810
|
l1Writes.push(
|
|
3169
3811
|
this.putTagMarkerL1(tag, invalidatedAt, { critical: true }),
|