@decocms/blocks 7.63.0 → 7.64.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/package.json +3 -1
- package/src/cms/index.ts +3 -2
- package/src/cms/resolve.ts +33 -23
- package/src/cms/sectionLoaders.ts +56 -43
- package/src/middleware/observability.ts +25 -21
- package/src/sdk/cacheStorage.test.ts +242 -0
- package/src/sdk/cacheStorage.ts +287 -0
- package/src/sdk/cachedLoader.ts +82 -63
- package/src/sdk/fetchCache.ts +35 -32
- package/src/sdk/otelHttpMeter.test.ts +45 -115
- package/src/sdk/otelHttpMeter.ts +24 -138
- package/src/sdk/responseCache.ts +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/blocks",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.64.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"./sdk/abTesting": "./src/sdk/abTesting.ts",
|
|
44
44
|
"./sdk/analytics": "./src/sdk/analytics.ts",
|
|
45
45
|
"./sdk/cachedLoader": "./src/sdk/cachedLoader.ts",
|
|
46
|
+
"./sdk/responseCache": "./src/sdk/responseCache.ts",
|
|
47
|
+
"./sdk/cacheStorage": "./src/sdk/cacheStorage.ts",
|
|
46
48
|
"./sdk/cacheHeaders": "./src/sdk/cacheHeaders.ts",
|
|
47
49
|
"./sdk/clx": "./src/sdk/clx.ts",
|
|
48
50
|
"./sdk/cn": "./src/sdk/cn.ts",
|
package/src/cms/index.ts
CHANGED
|
@@ -84,8 +84,8 @@ export type {
|
|
|
84
84
|
CommerceLoader,
|
|
85
85
|
DanglingReferenceHandler,
|
|
86
86
|
DecoPageResult,
|
|
87
|
-
DeferredSection,
|
|
88
87
|
Deferred,
|
|
88
|
+
DeferredSection,
|
|
89
89
|
MatcherContext,
|
|
90
90
|
PageSeo,
|
|
91
91
|
ResolvedSection,
|
|
@@ -93,12 +93,12 @@ export type {
|
|
|
93
93
|
} from "./resolve";
|
|
94
94
|
export {
|
|
95
95
|
addSkipResolveType,
|
|
96
|
+
asResolved,
|
|
96
97
|
BOT_UA_SUBSTRINGS,
|
|
97
98
|
cacheDeferredRawProps,
|
|
98
99
|
clearCommerceLoaders,
|
|
99
100
|
evaluateMatcher,
|
|
100
101
|
extractSeoFromProps,
|
|
101
|
-
asResolved,
|
|
102
102
|
extractSeoFromSections,
|
|
103
103
|
getAsyncRenderingConfig,
|
|
104
104
|
getDeferredRawProps,
|
|
@@ -107,6 +107,7 @@ export {
|
|
|
107
107
|
isEagerRequest,
|
|
108
108
|
isSeoSection,
|
|
109
109
|
onBeforeResolve,
|
|
110
|
+
readDeferredRawProps,
|
|
110
111
|
reExtractRawProps,
|
|
111
112
|
registerBotPattern,
|
|
112
113
|
registerCommerceLoader,
|
package/src/cms/resolve.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override";
|
|
2
2
|
import { getMeter, MetricNames, withTracing } from "../middleware/observability";
|
|
3
|
+
import { createCacheStore } from "../sdk/cacheStorage";
|
|
4
|
+
import { detectDevice } from "../sdk/detectDevice";
|
|
3
5
|
import { djb2Hex } from "../sdk/djb2";
|
|
4
6
|
import { stickyDecide } from "../sdk/experiments";
|
|
5
7
|
import { parseSegmentCookie, SEGMENT_COOKIE, type StoredFlag, trafficToPct } from "../sdk/flags";
|
|
@@ -15,7 +17,6 @@ import {
|
|
|
15
17
|
registerActionSchemas,
|
|
16
18
|
registerLoaderSchemas,
|
|
17
19
|
} from "./schema";
|
|
18
|
-
import { detectDevice } from "../sdk/detectDevice";
|
|
19
20
|
import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders";
|
|
20
21
|
|
|
21
22
|
// globalThis-backed: share state across Vite server function split modules
|
|
@@ -273,10 +274,13 @@ function isAlwaysDeferSection(key: string): boolean {
|
|
|
273
274
|
// ---------------------------------------------------------------------------
|
|
274
275
|
|
|
275
276
|
const DEFERRED_PROPS_TTL = 120_000; // 2 minutes
|
|
276
|
-
const deferredRawPropsCache =
|
|
277
|
+
const deferredRawPropsCache = createCacheStore<{ rawProps: Record<string, unknown>; ts: number }>(
|
|
278
|
+
"deferred-props",
|
|
279
|
+
500,
|
|
280
|
+
);
|
|
277
281
|
|
|
278
282
|
function deferredPropsCacheKey(pagePath: string, component: string, index: number): string {
|
|
279
|
-
return `${pagePath}::${component}::${index}
|
|
283
|
+
return deferredRawPropsCache.key(`${pagePath}::${component}::${index}`);
|
|
280
284
|
}
|
|
281
285
|
|
|
282
286
|
export function cacheDeferredRawProps(
|
|
@@ -286,15 +290,17 @@ export function cacheDeferredRawProps(
|
|
|
286
290
|
rawProps: Record<string, unknown>,
|
|
287
291
|
): void {
|
|
288
292
|
const key = deferredPropsCacheKey(pagePath, component, index);
|
|
289
|
-
deferredRawPropsCache.set(key, { rawProps, ts: Date.now() });
|
|
293
|
+
deferredRawPropsCache.set(key, { rawProps, ts: Date.now() }, Date.now() + DEFERRED_PROPS_TTL);
|
|
294
|
+
}
|
|
290
295
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
296
|
+
/** Async form used when a deferred request may run in a different isolate. */
|
|
297
|
+
export async function readDeferredRawProps(
|
|
298
|
+
pagePath: string,
|
|
299
|
+
component: string,
|
|
300
|
+
index: number,
|
|
301
|
+
): Promise<Record<string, unknown> | null> {
|
|
302
|
+
const entry = await deferredRawPropsCache.read(deferredPropsCacheKey(pagePath, component, index));
|
|
303
|
+
return entry?.rawProps ?? null;
|
|
298
304
|
}
|
|
299
305
|
|
|
300
306
|
export function getDeferredRawProps(
|
|
@@ -1148,7 +1154,7 @@ interface ResolvedSectionsCache {
|
|
|
1148
1154
|
expiresAt: number;
|
|
1149
1155
|
}
|
|
1150
1156
|
|
|
1151
|
-
const resolvedLayoutCache =
|
|
1157
|
+
const resolvedLayoutCache = createCacheStore<ResolvedSectionsCache>("resolved-layouts");
|
|
1152
1158
|
const resolvedLayoutInflight = new Map<string, Promise<ResolvedSection[]>>();
|
|
1153
1159
|
|
|
1154
1160
|
/**
|
|
@@ -1178,8 +1184,8 @@ export function layoutCacheKey(blockKey: string, matcherCtx?: MatcherContext): s
|
|
|
1178
1184
|
return `${blockKey}::${detectDevice(matcherCtx?.userAgent ?? "")}`;
|
|
1179
1185
|
}
|
|
1180
1186
|
|
|
1181
|
-
function getCachedResolvedLayout(blockKey: string): ResolvedSection[] | null {
|
|
1182
|
-
const entry = resolvedLayoutCache.
|
|
1187
|
+
async function getCachedResolvedLayout(blockKey: string): Promise<ResolvedSection[] | null> {
|
|
1188
|
+
const entry = await resolvedLayoutCache.read(blockKey);
|
|
1183
1189
|
if (!entry) return null;
|
|
1184
1190
|
if (Date.now() > entry.expiresAt) {
|
|
1185
1191
|
resolvedLayoutCache.delete(blockKey);
|
|
@@ -1189,10 +1195,14 @@ function getCachedResolvedLayout(blockKey: string): ResolvedSection[] | null {
|
|
|
1189
1195
|
}
|
|
1190
1196
|
|
|
1191
1197
|
function setCachedResolvedLayout(blockKey: string, sections: ResolvedSection[]): void {
|
|
1192
|
-
resolvedLayoutCache.set(
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1198
|
+
resolvedLayoutCache.set(
|
|
1199
|
+
blockKey,
|
|
1200
|
+
{
|
|
1201
|
+
sections,
|
|
1202
|
+
expiresAt: Date.now() + RESOLVE_CACHE_TTL,
|
|
1203
|
+
},
|
|
1204
|
+
Date.now() + RESOLVE_CACHE_TTL,
|
|
1205
|
+
);
|
|
1196
1206
|
}
|
|
1197
1207
|
|
|
1198
1208
|
/**
|
|
@@ -2259,8 +2269,8 @@ async function resolveDecoPageImpl(
|
|
|
2259
2269
|
const layoutKey = isRawSectionLayout(section);
|
|
2260
2270
|
|
|
2261
2271
|
if (layoutKey) {
|
|
2262
|
-
const cacheKey = layoutCacheKey(layoutKey, rctx.matcherCtx);
|
|
2263
|
-
const cached = getCachedResolvedLayout(cacheKey);
|
|
2272
|
+
const cacheKey = resolvedLayoutCache.key(layoutCacheKey(layoutKey, rctx.matcherCtx));
|
|
2273
|
+
const cached = await getCachedResolvedLayout(cacheKey);
|
|
2264
2274
|
if (cached) return cached;
|
|
2265
2275
|
|
|
2266
2276
|
const inflight = resolvedLayoutInflight.get(cacheKey);
|
|
@@ -2357,8 +2367,8 @@ export async function resolvePageSections(
|
|
|
2357
2367
|
const layoutKey = isRawSectionLayout(section);
|
|
2358
2368
|
|
|
2359
2369
|
if (layoutKey) {
|
|
2360
|
-
const cacheKey = layoutCacheKey(layoutKey, rctx.matcherCtx);
|
|
2361
|
-
const cached = getCachedResolvedLayout(cacheKey);
|
|
2370
|
+
const cacheKey = resolvedLayoutCache.key(layoutCacheKey(layoutKey, rctx.matcherCtx));
|
|
2371
|
+
const cached = await getCachedResolvedLayout(cacheKey);
|
|
2362
2372
|
if (cached) return cached;
|
|
2363
2373
|
|
|
2364
2374
|
const inflight = resolvedLayoutInflight.get(cacheKey);
|
|
@@ -2449,7 +2459,7 @@ export async function resolveDeferredSectionFull(
|
|
|
2449
2459
|
// rawProps may be stripped from the client payload — resolve from cache or page
|
|
2450
2460
|
const rawProps =
|
|
2451
2461
|
ds.rawProps ??
|
|
2452
|
-
|
|
2462
|
+
(await readDeferredRawProps(pagePath, ds.component, ds.index)) ??
|
|
2453
2463
|
(await reExtractRawProps(pagePath, ds.component, ds.index, matcherCtx));
|
|
2454
2464
|
|
|
2455
2465
|
if (!rawProps) return null;
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
|
|
12
12
|
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
13
13
|
import { getCacheProfile } from "../sdk/cacheHeaders";
|
|
14
|
+
import { cacheBackground, createCacheStore, getCacheStorageContext } from "../sdk/cacheStorage";
|
|
14
15
|
import { detectDevice } from "../sdk/detectDevice";
|
|
15
|
-
import { djb2 } from "../sdk/djb2";
|
|
16
16
|
import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
17
17
|
import { withTracing } from "../sdk/observability";
|
|
18
18
|
import type { ResolvedSection } from "./resolve";
|
|
@@ -91,6 +91,8 @@ export function getDegradedSections(): string[] {
|
|
|
91
91
|
|
|
92
92
|
interface CacheableSectionConfig {
|
|
93
93
|
maxAge: number;
|
|
94
|
+
/** Bounded stale window in milliseconds. Default: 5 minutes. */
|
|
95
|
+
staleWhileRevalidate?: number;
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
export type CacheableSectionInput =
|
|
@@ -113,19 +115,10 @@ interface SectionCacheEntry {
|
|
|
113
115
|
refreshing: boolean;
|
|
114
116
|
}
|
|
115
117
|
|
|
116
|
-
const sectionLoaderCache =
|
|
118
|
+
const sectionLoaderCache = createCacheStore<SectionCacheEntry>("sections");
|
|
117
119
|
const sectionLoaderInflight = new Map<string, Promise<ResolvedSection>>();
|
|
118
|
-
const MAX_SECTION_CACHE_ENTRIES = 200;
|
|
119
|
-
|
|
120
|
-
function evictSectionCacheIfNeeded() {
|
|
121
|
-
if (sectionLoaderCache.size <= MAX_SECTION_CACHE_ENTRIES) return;
|
|
122
|
-
const oldest = [...sectionLoaderCache.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
123
|
-
const toDelete = oldest.slice(0, sectionLoaderCache.size - MAX_SECTION_CACHE_ENTRIES);
|
|
124
|
-
for (const [key] of toDelete) sectionLoaderCache.delete(key);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
120
|
function sectionCacheKey(component: string, props: Record<string, unknown>): string {
|
|
128
|
-
return `${component}::${
|
|
121
|
+
return `${component}::${JSON.stringify(props)}`;
|
|
129
122
|
}
|
|
130
123
|
|
|
131
124
|
/**
|
|
@@ -142,18 +135,24 @@ export function registerCacheableSections(configs: Record<string, CacheableSecti
|
|
|
142
135
|
}
|
|
143
136
|
}
|
|
144
137
|
|
|
145
|
-
function runCacheableSectionLoader(
|
|
138
|
+
async function runCacheableSectionLoader(
|
|
146
139
|
section: ResolvedSection,
|
|
147
140
|
loader: SectionLoaderFn,
|
|
148
141
|
request: Request,
|
|
149
142
|
config: CacheableSectionConfig,
|
|
150
143
|
): Promise<ResolvedSection> {
|
|
151
|
-
const key =
|
|
144
|
+
const key = sectionLoaderCache.key(
|
|
145
|
+
sectionCacheKey(section.component, section.props as Record<string, unknown>),
|
|
146
|
+
);
|
|
152
147
|
|
|
153
148
|
const existing = sectionLoaderInflight.get(key);
|
|
154
149
|
if (existing) return existing;
|
|
155
150
|
|
|
156
|
-
const entry =
|
|
151
|
+
const entry = getCacheStorageContext()?.storage
|
|
152
|
+
? await sectionLoaderCache.read(key)
|
|
153
|
+
: sectionLoaderCache.get(key);
|
|
154
|
+
const pending = sectionLoaderInflight.get(key);
|
|
155
|
+
if (pending) return pending;
|
|
157
156
|
const now = Date.now();
|
|
158
157
|
const isStale = entry ? now - entry.createdAt > config.maxAge : true;
|
|
159
158
|
|
|
@@ -163,18 +162,25 @@ function runCacheableSectionLoader(
|
|
|
163
162
|
|
|
164
163
|
if (entry && isStale && !entry.refreshing) {
|
|
165
164
|
entry.refreshing = true;
|
|
166
|
-
|
|
167
|
-
.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
section:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
165
|
+
cacheBackground(
|
|
166
|
+
Promise.resolve()
|
|
167
|
+
.then(() => loader(section.props as Record<string, unknown>, request))
|
|
168
|
+
.then((enrichedProps) => {
|
|
169
|
+
const enriched = { ...section, props: enrichedProps };
|
|
170
|
+
sectionLoaderCache.set(
|
|
171
|
+
key,
|
|
172
|
+
{
|
|
173
|
+
section: enriched,
|
|
174
|
+
createdAt: Date.now(),
|
|
175
|
+
refreshing: false,
|
|
176
|
+
},
|
|
177
|
+
Date.now() + config.maxAge + (config.staleWhileRevalidate ?? 300_000),
|
|
178
|
+
);
|
|
179
|
+
})
|
|
180
|
+
.catch(() => {
|
|
181
|
+
entry.refreshing = false;
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
178
184
|
return Promise.resolve(entry.section);
|
|
179
185
|
}
|
|
180
186
|
|
|
@@ -184,12 +190,15 @@ function runCacheableSectionLoader(
|
|
|
184
190
|
(async () => {
|
|
185
191
|
const enrichedProps = await loader(section.props as Record<string, unknown>, request);
|
|
186
192
|
const enriched = { ...section, props: enrichedProps };
|
|
187
|
-
sectionLoaderCache.set(
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
+
sectionLoaderCache.set(
|
|
194
|
+
key,
|
|
195
|
+
{
|
|
196
|
+
section: enriched,
|
|
197
|
+
createdAt: Date.now(),
|
|
198
|
+
refreshing: false,
|
|
199
|
+
},
|
|
200
|
+
Date.now() + config.maxAge + (config.staleWhileRevalidate ?? 300_000),
|
|
201
|
+
);
|
|
193
202
|
return enriched;
|
|
194
203
|
})(),
|
|
195
204
|
`sectionLoader ${key}`,
|
|
@@ -267,7 +276,7 @@ interface CachedSection {
|
|
|
267
276
|
expiresAt: number;
|
|
268
277
|
}
|
|
269
278
|
|
|
270
|
-
const layoutCache =
|
|
279
|
+
const layoutCache = createCacheStore<CachedSection>("layout-loaders");
|
|
271
280
|
const layoutInflight = new Map<string, Promise<ResolvedSection>>();
|
|
272
281
|
|
|
273
282
|
/**
|
|
@@ -336,8 +345,8 @@ export function layoutLoaderCacheKey(component: string, request?: Request): stri
|
|
|
336
345
|
return `${component}::${detectDevice(request?.headers?.get?.("user-agent") ?? "")}`;
|
|
337
346
|
}
|
|
338
347
|
|
|
339
|
-
function getCachedLayout(cacheKey: string): ResolvedSection | null {
|
|
340
|
-
const entry = layoutCache.
|
|
348
|
+
async function getCachedLayout(cacheKey: string): Promise<ResolvedSection | null> {
|
|
349
|
+
const entry = await layoutCache.read(cacheKey);
|
|
341
350
|
if (!entry) return null;
|
|
342
351
|
if (Date.now() > entry.expiresAt) {
|
|
343
352
|
layoutCache.delete(cacheKey);
|
|
@@ -347,21 +356,25 @@ function getCachedLayout(cacheKey: string): ResolvedSection | null {
|
|
|
347
356
|
}
|
|
348
357
|
|
|
349
358
|
function setCachedLayout(cacheKey: string, section: ResolvedSection): void {
|
|
350
|
-
layoutCache.set(
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
359
|
+
layoutCache.set(
|
|
360
|
+
cacheKey,
|
|
361
|
+
{
|
|
362
|
+
section,
|
|
363
|
+
expiresAt: Date.now() + LAYOUT_CACHE_TTL,
|
|
364
|
+
},
|
|
365
|
+
Date.now() + LAYOUT_CACHE_TTL,
|
|
366
|
+
);
|
|
354
367
|
}
|
|
355
368
|
|
|
356
369
|
/**
|
|
357
370
|
* Run a layout section's loader with in-flight dedup + TTL cache.
|
|
358
371
|
*/
|
|
359
|
-
function resolveLayoutSection(
|
|
372
|
+
async function resolveLayoutSection(
|
|
360
373
|
section: ResolvedSection,
|
|
361
374
|
loader: SectionLoaderFn,
|
|
362
375
|
request: Request,
|
|
363
376
|
): Promise<ResolvedSection> {
|
|
364
|
-
const key = layoutLoaderCacheKey(section.component, request);
|
|
377
|
+
const key = layoutCache.key(layoutLoaderCacheKey(section.component, request));
|
|
365
378
|
const { index } = section;
|
|
366
379
|
|
|
367
380
|
// Re-apply the caller's page-specific index onto a fresh object so the
|
|
@@ -369,7 +382,7 @@ function resolveLayoutSection(
|
|
|
369
382
|
const withIndex = (s: ResolvedSection): ResolvedSection =>
|
|
370
383
|
index !== undefined ? { ...s, index } : s;
|
|
371
384
|
|
|
372
|
-
const cached = getCachedLayout(key);
|
|
385
|
+
const cached = await getCachedLayout(key);
|
|
373
386
|
if (cached) return Promise.resolve(withIndex(cached));
|
|
374
387
|
|
|
375
388
|
const existing = layoutInflight.get(key);
|
|
@@ -283,8 +283,7 @@ export const MetricNames = {
|
|
|
283
283
|
// the metric name already provides the deco.cache.* namespace.
|
|
284
284
|
CACHE_REQUESTS: "deco.cache.requests",
|
|
285
285
|
// Size in bytes of the values moving through a cache, dimensioned by `op`.
|
|
286
|
-
//
|
|
287
|
-
// why an explicit-bounds histogram is the wrong shape here.
|
|
286
|
+
// Bucketed in decades of bytes — see CACHE_SIZE_BUCKET_BOUNDARIES_BYTES.
|
|
288
287
|
CACHE_SIZE: "deco.cache.size",
|
|
289
288
|
RESOLVE_DURATION: "deco.cms.resolve.duration",
|
|
290
289
|
LOADER_DURATION: "deco.loader.duration",
|
|
@@ -327,6 +326,29 @@ export const DURATION_BUCKET_BOUNDARIES_SECONDS: readonly number[] = [
|
|
|
327
326
|
10,
|
|
328
327
|
];
|
|
329
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Bucket boundaries, in BYTES, for {@link MetricNames.CACHE_SIZE}.
|
|
331
|
+
*
|
|
332
|
+
* Decades, matching the `http.server.response.body.size` histogram already
|
|
333
|
+
* flowing through the same ingestor — a size distribution is read in orders of
|
|
334
|
+
* magnitude, not in linear steps, and reusing the house convention means the
|
|
335
|
+
* two byte histograms are directly comparable.
|
|
336
|
+
*
|
|
337
|
+
* The range is bounded on both ends by the loader cache itself: entries are
|
|
338
|
+
* floored at `MIN_ENTRY_BYTES` (512 B) and the store is capped at 32 MB, which
|
|
339
|
+
* is why the last bound is the cap rather than another decade — the top bucket
|
|
340
|
+
* then reads as "single entry at/near the whole cache budget", which is the
|
|
341
|
+
* thing worth alerting on.
|
|
342
|
+
*/
|
|
343
|
+
export const CACHE_SIZE_BUCKET_BOUNDARIES_BYTES: readonly number[] = [
|
|
344
|
+
1_000,
|
|
345
|
+
10_000,
|
|
346
|
+
100_000,
|
|
347
|
+
1_000_000,
|
|
348
|
+
10_000_000,
|
|
349
|
+
33_554_432,
|
|
350
|
+
];
|
|
351
|
+
|
|
330
352
|
/**
|
|
331
353
|
* Per-metric metadata emitted in the OTLP payload's `description` and
|
|
332
354
|
* `unit` fields. Durations are normalized to **seconds at the source** (OTel
|
|
@@ -347,16 +369,6 @@ export const METRIC_METADATA: Record<
|
|
|
347
369
|
description: string;
|
|
348
370
|
unit: string;
|
|
349
371
|
boundaries?: readonly number[];
|
|
350
|
-
/**
|
|
351
|
-
* Histogram aggregation. Omitted means `"explicit"` — bucket into
|
|
352
|
-
* `boundaries`. `"exponential"` selects an OTel base-2 exponential
|
|
353
|
-
* histogram at {@link scale}, where bucket `i` covers
|
|
354
|
-
* `(2^(2^-scale))^i .. ^(i+1)]`. Adapters that don't implement it are free
|
|
355
|
-
* to keep treating the metric as an ordinary histogram.
|
|
356
|
-
*/
|
|
357
|
-
aggregation?: "explicit" | "exponential";
|
|
358
|
-
/** Exponential-histogram scale. Fixed per metric; adapters never rescale. */
|
|
359
|
-
scale?: number;
|
|
360
372
|
}
|
|
361
373
|
> = {
|
|
362
374
|
[MetricNames.HTTP_SERVER_REQUEST_DURATION]: {
|
|
@@ -376,15 +388,7 @@ export const METRIC_METADATA: Record<
|
|
|
376
388
|
[MetricNames.CACHE_SIZE]: {
|
|
377
389
|
description: "Size in bytes of values written to / read from a cache, dimensioned by op.",
|
|
378
390
|
unit: "By",
|
|
379
|
-
|
|
380
|
-
// cachedLoader floor) to the 32 MB cap — five decades. Any fixed bound set
|
|
381
|
-
// wide enough for the top is useless at the bottom.
|
|
382
|
-
aggregation: "exponential",
|
|
383
|
-
// scale -1 => base 4, so buckets land on 512 B · 2 K · 8 K · 32 K · 128 K ·
|
|
384
|
-
// 512 K · 2 M · 8 M · 32 M: ~9 buckets over the real range, ~1.7 per
|
|
385
|
-
// decade. scale 0 (base 2) would double the series count without saying
|
|
386
|
-
// anything new about a size distribution.
|
|
387
|
-
scale: -1,
|
|
391
|
+
boundaries: CACHE_SIZE_BUCKET_BOUNDARIES_BYTES,
|
|
388
392
|
},
|
|
389
393
|
[MetricNames.RESOLVE_DURATION]: {
|
|
390
394
|
description: "Duration of `deco.cms.resolvePage` — CMS route to block tree resolution.",
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { clearLoaderCache, createCachedLoader } from "./cachedLoader";
|
|
4
|
+
import {
|
|
5
|
+
bindCacheStorage,
|
|
6
|
+
type CacheStorage,
|
|
7
|
+
createCacheStore,
|
|
8
|
+
createKVCacheStorage,
|
|
9
|
+
createMemoryCacheStorage,
|
|
10
|
+
createWebCacheStorage,
|
|
11
|
+
} from "./cacheStorage";
|
|
12
|
+
import { RequestContext } from "./requestContext";
|
|
13
|
+
import { createResponseCache } from "./responseCache";
|
|
14
|
+
|
|
15
|
+
function fakeKV() {
|
|
16
|
+
const values = new Map<string, string>();
|
|
17
|
+
return {
|
|
18
|
+
values,
|
|
19
|
+
get: vi.fn(async (key: string) => values.get(key) ?? null),
|
|
20
|
+
put: vi.fn(async (key: string, value: string, _opts: { expirationTtl: number }) => {
|
|
21
|
+
values.set(key, value);
|
|
22
|
+
}),
|
|
23
|
+
delete: vi.fn(async (key: string) => {
|
|
24
|
+
values.delete(key);
|
|
25
|
+
}),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pending: Promise<unknown>[] = [];
|
|
30
|
+
function run<T>(storage: CacheStorage | null, scope: string, fn: () => T): T {
|
|
31
|
+
return RequestContext.run(new Request("https://site.test/"), () => {
|
|
32
|
+
bindCacheStorage({ storage, scope, waitUntil: (work) => pending.push(work) });
|
|
33
|
+
return fn();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
async function flush() {
|
|
37
|
+
await Promise.all(pending.splice(0));
|
|
38
|
+
}
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
await flush();
|
|
41
|
+
clearLoaderCache();
|
|
42
|
+
vi.useRealTimers();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function fakeWebCache(): Cache {
|
|
46
|
+
const values = new Map<string, Response>();
|
|
47
|
+
return {
|
|
48
|
+
async match(req: Request) {
|
|
49
|
+
return values.get(req.url)?.clone();
|
|
50
|
+
},
|
|
51
|
+
async put(req: Request, response: Response) {
|
|
52
|
+
values.set(req.url, response.clone());
|
|
53
|
+
},
|
|
54
|
+
async delete(req: Request) {
|
|
55
|
+
return values.delete(req.url);
|
|
56
|
+
},
|
|
57
|
+
} as Cache;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const [name, make] of [
|
|
61
|
+
["memory", () => createMemoryCacheStorage()],
|
|
62
|
+
["KV", () => createKVCacheStorage(fakeKV())],
|
|
63
|
+
["Web Cache API", () => createWebCacheStorage(fakeWebCache(), "https://site.test")],
|
|
64
|
+
] as const) {
|
|
65
|
+
describe(`${name} storage contract`, () => {
|
|
66
|
+
it("round-trips, overwrites and deletes values", async () => {
|
|
67
|
+
const storage = make();
|
|
68
|
+
expect(await storage.get("missing")).toBeNull();
|
|
69
|
+
await storage.set("key", "first", Date.now() + 60_000);
|
|
70
|
+
expect(await storage.get("key")).toBe("first");
|
|
71
|
+
await storage.set("key", "second", Date.now() + 60_000);
|
|
72
|
+
expect(await storage.get("key")).toBe("second");
|
|
73
|
+
await storage.delete("key");
|
|
74
|
+
expect(await storage.get("key")).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
it("enforces logical expiry even if the backend retains the record", async () => {
|
|
77
|
+
vi.useFakeTimers();
|
|
78
|
+
const storage = make();
|
|
79
|
+
await storage.set("short", "body", Date.now() + 500);
|
|
80
|
+
expect(await storage.get("short")).toBe("body");
|
|
81
|
+
vi.advanceTimersByTime(500);
|
|
82
|
+
expect(await storage.get("short")).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
it("uses KV's minimum physical TTL while enforcing shorter logical expiry", async () => {
|
|
88
|
+
const kv = fakeKV();
|
|
89
|
+
await createKVCacheStorage(kv).set("a".repeat(1000), "value", Date.now() + 2000);
|
|
90
|
+
expect(kv.put.mock.calls[0][2]).toEqual({ expirationTtl: 60 });
|
|
91
|
+
expect(kv.put.mock.calls[0][0].length).toBeLessThan(512);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("hydrates another store and isolates namespace, site and deployment", async () => {
|
|
95
|
+
const storage = createKVCacheStorage(fakeKV());
|
|
96
|
+
const first = createCacheStore<{ n: number }>("loaders");
|
|
97
|
+
await run(storage, "site:build-A", async () => {
|
|
98
|
+
first.set(first.key("item"), { n: 1 }, Date.now() + 60_000);
|
|
99
|
+
await flush();
|
|
100
|
+
});
|
|
101
|
+
const second = createCacheStore<{ n: number }>("loaders");
|
|
102
|
+
expect(await run(storage, "site:build-A", () => second.read(second.key("item")))).toEqual({
|
|
103
|
+
n: 1,
|
|
104
|
+
});
|
|
105
|
+
for (const scope of ["other-site:build-A", "site:build-B"]) {
|
|
106
|
+
expect(await run(storage, scope, () => second.read(second.key("item")))).toBeUndefined();
|
|
107
|
+
}
|
|
108
|
+
const sections = createCacheStore("sections");
|
|
109
|
+
expect(
|
|
110
|
+
await run(storage, "site:build-A", () => sections.read(sections.key("item"))),
|
|
111
|
+
).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does not switch bindings between concurrent requests", async () => {
|
|
115
|
+
const a = createMemoryCacheStorage();
|
|
116
|
+
const b = createMemoryCacheStorage();
|
|
117
|
+
const cache = createCacheStore<string>("concurrent");
|
|
118
|
+
await Promise.all(
|
|
119
|
+
[a, b].map((storage, i) =>
|
|
120
|
+
run(storage, `site-${i}`, async () => {
|
|
121
|
+
await Promise.resolve();
|
|
122
|
+
cache.set(cache.key("key"), String(i), Date.now() + 60_000);
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
await flush();
|
|
127
|
+
cache.clear();
|
|
128
|
+
expect(await run(a, "site-0", () => cache.read(cache.key("key")))).toBe("0");
|
|
129
|
+
expect(await run(b, "site-1", () => cache.read(cache.key("key")))).toBe("1");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("retains non-JSON values locally without silently corrupting them in KV", async () => {
|
|
133
|
+
const kv = fakeKV();
|
|
134
|
+
const cache = createCacheStore<{ date: Date }>("dates");
|
|
135
|
+
await run(createKVCacheStorage(kv), "site", async () => {
|
|
136
|
+
const value = { date: new Date() };
|
|
137
|
+
cache.set(cache.key("key"), value, Date.now() + 60_000);
|
|
138
|
+
expect(cache.get(cache.key("key"))).toBe(value);
|
|
139
|
+
await flush();
|
|
140
|
+
expect(kv.put).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("deduplicates a cold loader through slow storage and survives a memory wipe", async () => {
|
|
145
|
+
const storage = createKVCacheStorage(fakeKV());
|
|
146
|
+
const fn = vi.fn(async () => ({ product: 42 }));
|
|
147
|
+
const loader = createCachedLoader("test", fn, {
|
|
148
|
+
policy: "stale-while-revalidate",
|
|
149
|
+
maxAge: 60_000,
|
|
150
|
+
});
|
|
151
|
+
await run(storage, "site", async () => {
|
|
152
|
+
const values = await Promise.all([loader({}), loader({}), loader({})]);
|
|
153
|
+
expect(values).toEqual([{ product: 42 }, { product: 42 }, { product: 42 }]);
|
|
154
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
155
|
+
await flush();
|
|
156
|
+
clearLoaderCache();
|
|
157
|
+
expect(await loader({})).toEqual({ product: 42 });
|
|
158
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("falls back to the loader when storage reads and writes fail", async () => {
|
|
163
|
+
const failure = async () => {
|
|
164
|
+
throw new Error("KV unavailable");
|
|
165
|
+
};
|
|
166
|
+
const fn = vi.fn(async () => "origin");
|
|
167
|
+
const loader = createCachedLoader("failure", fn, { policy: "no-cache", maxAge: 1000 });
|
|
168
|
+
expect(await run({ get: failure, set: failure, delete: failure }, "site", () => loader({}))).toBe(
|
|
169
|
+
"origin",
|
|
170
|
+
);
|
|
171
|
+
await flush();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("round-trips binary responses, headers and status through KV", async () => {
|
|
175
|
+
const cache = createResponseCache(createKVCacheStorage(fakeKV()), "site:build");
|
|
176
|
+
const req = new Request("https://site.test/image");
|
|
177
|
+
const bytes = new Uint8Array([0, 255, 128, 1]);
|
|
178
|
+
await cache.put(
|
|
179
|
+
req,
|
|
180
|
+
new Response(bytes, {
|
|
181
|
+
status: 203,
|
|
182
|
+
headers: {
|
|
183
|
+
"cache-control": "public, max-age=60",
|
|
184
|
+
"content-type": "application/octet-stream",
|
|
185
|
+
"x-deco-stored-at": "123",
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
const response = await cache.match(req);
|
|
190
|
+
expect(response?.status).toBe(203);
|
|
191
|
+
expect(response?.headers.get("x-deco-stored-at")).toBe("123");
|
|
192
|
+
expect(new Uint8Array(await response!.arrayBuffer())).toEqual(bytes);
|
|
193
|
+
await cache.delete(req);
|
|
194
|
+
expect(await cache.match(req)).toBeUndefined();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("refuses to serialize private HTTP responses or cookies", async () => {
|
|
198
|
+
const storage = createMemoryCacheStorage();
|
|
199
|
+
const cache = createResponseCache(storage, "site");
|
|
200
|
+
const req = new Request("https://site.test/");
|
|
201
|
+
await cache.put(
|
|
202
|
+
req,
|
|
203
|
+
new Response("private", { headers: { "cache-control": "private, max-age=60" } }),
|
|
204
|
+
);
|
|
205
|
+
expect(await cache.match(req)).toBeUndefined();
|
|
206
|
+
await cache.put(
|
|
207
|
+
req,
|
|
208
|
+
new Response("cookie", {
|
|
209
|
+
headers: { "cache-control": "public, max-age=60", "set-cookie": "session=secret" },
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
expect(await cache.match(req)).toBeUndefined();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("bounds SWR separately from stale-if-error and stops serving at the final TTL", async () => {
|
|
216
|
+
vi.useFakeTimers();
|
|
217
|
+
const storage = createKVCacheStorage(fakeKV());
|
|
218
|
+
let fail = false;
|
|
219
|
+
const origin = vi.fn(async () => {
|
|
220
|
+
if (fail) throw new Error("origin down");
|
|
221
|
+
return "last good";
|
|
222
|
+
});
|
|
223
|
+
const loader = createCachedLoader("bounded-stale", origin, {
|
|
224
|
+
policy: "stale-while-revalidate",
|
|
225
|
+
maxAge: 100,
|
|
226
|
+
staleWhileRevalidate: 100,
|
|
227
|
+
staleIfError: 300,
|
|
228
|
+
});
|
|
229
|
+
await run(storage, "bounded", async () => {
|
|
230
|
+
expect(await loader({})).toBe("last good");
|
|
231
|
+
await flush();
|
|
232
|
+
fail = true;
|
|
233
|
+
vi.advanceTimersByTime(150);
|
|
234
|
+
expect(await loader({})).toBe("last good");
|
|
235
|
+
await flush();
|
|
236
|
+
vi.advanceTimersByTime(100);
|
|
237
|
+
expect(await loader({})).toBe("last good");
|
|
238
|
+
expect(origin).toHaveBeenCalledTimes(3); // foreground retry once SWR has elapsed
|
|
239
|
+
vi.advanceTimersByTime(150);
|
|
240
|
+
await expect(loader({})).rejects.toThrow("origin down");
|
|
241
|
+
});
|
|
242
|
+
});
|