@decocms/blocks 7.20.10 → 7.21.1
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 +1 -1
- package/src/sdk/cachedLoader.test.ts +122 -0
- package/src/sdk/cachedLoader.ts +71 -10
package/package.json
CHANGED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { clearLoaderCache, createCachedLoader, getLoaderCacheStats } from "./cachedLoader";
|
|
3
|
+
|
|
4
|
+
describe("createCachedLoader", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
clearLoaderCache();
|
|
7
|
+
});
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
clearLoaderCache();
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("serves a fresh entry from cache (MISS then HIT) within maxAge", async () => {
|
|
14
|
+
const loaderFn = vi.fn(async (p: { id: number }) => ({ v: p.id }));
|
|
15
|
+
const cached = createCachedLoader("t/basic", loaderFn, {
|
|
16
|
+
policy: "stale-while-revalidate",
|
|
17
|
+
maxAge: 60_000,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
await cached({ id: 1 }); // MISS
|
|
21
|
+
await cached({ id: 1 }); // HIT — same key, within maxAge
|
|
22
|
+
|
|
23
|
+
expect(loaderFn).toHaveBeenCalledTimes(1);
|
|
24
|
+
expect(getLoaderCacheStats().entries).toBe(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("distinct props produce distinct entries", async () => {
|
|
28
|
+
const loaderFn = vi.fn(async (p: { id: number }) => ({ v: p.id }));
|
|
29
|
+
const cached = createCachedLoader("t/props", loaderFn, {
|
|
30
|
+
policy: "stale-while-revalidate",
|
|
31
|
+
maxAge: 60_000,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await cached({ id: 1 });
|
|
35
|
+
await cached({ id: 2 });
|
|
36
|
+
|
|
37
|
+
expect(loaderFn).toHaveBeenCalledTimes(2);
|
|
38
|
+
expect(getLoaderCacheStats().entries).toBe(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("clearLoaderCache() empties the cache so the next call is a MISS", async () => {
|
|
42
|
+
const loaderFn = vi.fn(async (p: { id: number }) => ({ v: p.id }));
|
|
43
|
+
const cached = createCachedLoader("t/bust", loaderFn, {
|
|
44
|
+
policy: "stale-while-revalidate",
|
|
45
|
+
maxAge: 60_000,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await cached({ id: 1 }); // MISS
|
|
49
|
+
await cached({ id: 1 }); // HIT
|
|
50
|
+
expect(loaderFn).toHaveBeenCalledTimes(1);
|
|
51
|
+
|
|
52
|
+
clearLoaderCache();
|
|
53
|
+
expect(getLoaderCacheStats().entries).toBe(0);
|
|
54
|
+
|
|
55
|
+
await cached({ id: 1 }); // MISS again — cache was cleared
|
|
56
|
+
expect(loaderFn).toHaveBeenCalledTimes(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("a purge during an in-flight loader does not repopulate the cleared entry", async () => {
|
|
60
|
+
// Loader we can resolve manually, to hold it "in flight" across the purge.
|
|
61
|
+
let release!: (v: { v: number }) => void;
|
|
62
|
+
const loaderFn = vi.fn(
|
|
63
|
+
() =>
|
|
64
|
+
new Promise<{ v: number }>((res) => {
|
|
65
|
+
release = res;
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
const cached = createCachedLoader("t/race", loaderFn, {
|
|
69
|
+
policy: "stale-while-revalidate",
|
|
70
|
+
maxAge: 60_000,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const inflight = cached({ id: 1 }); // MISS — loader now pending
|
|
74
|
+
clearLoaderCache(); // purge lands while the loader is in flight
|
|
75
|
+
release({ v: 99 }); // loader resolves with pre-purge data
|
|
76
|
+
await expect(inflight).resolves.toEqual({ v: 99 }); // caller still gets its value
|
|
77
|
+
|
|
78
|
+
// ...but the raced result must NOT have been written back to the cache.
|
|
79
|
+
expect(getLoaderCacheStats().entries).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("warns once per loader name when maxAge is very long", async () => {
|
|
83
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
84
|
+
const loaderFn = vi.fn(async () => ({}));
|
|
85
|
+
|
|
86
|
+
// > 10 min → warns
|
|
87
|
+
createCachedLoader("t/long-a", loaderFn, {
|
|
88
|
+
policy: "stale-while-revalidate",
|
|
89
|
+
maxAge: 3_600_000,
|
|
90
|
+
});
|
|
91
|
+
// same name again → deduped, no second warning
|
|
92
|
+
createCachedLoader("t/long-a", loaderFn, {
|
|
93
|
+
policy: "stale-while-revalidate",
|
|
94
|
+
maxAge: 3_600_000,
|
|
95
|
+
});
|
|
96
|
+
// short maxAge → no warning
|
|
97
|
+
createCachedLoader("t/short", loaderFn, {
|
|
98
|
+
policy: "stale-while-revalidate",
|
|
99
|
+
maxAge: 60_000,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const longWarnings = warn.mock.calls.filter(
|
|
103
|
+
(c) => typeof c[0] === "string" && c[0].includes("t/long-a"),
|
|
104
|
+
);
|
|
105
|
+
const shortWarnings = warn.mock.calls.filter(
|
|
106
|
+
(c) => typeof c[0] === "string" && c[0].includes("t/short"),
|
|
107
|
+
);
|
|
108
|
+
expect(longWarnings).toHaveLength(1);
|
|
109
|
+
expect(shortWarnings).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("no-store policy bypasses the cache entirely", async () => {
|
|
113
|
+
const loaderFn = vi.fn(async () => ({}));
|
|
114
|
+
const cached = createCachedLoader("t/nostore", loaderFn, { policy: "no-store" });
|
|
115
|
+
|
|
116
|
+
await cached({});
|
|
117
|
+
await cached({});
|
|
118
|
+
|
|
119
|
+
expect(loaderFn).toHaveBeenCalledTimes(2);
|
|
120
|
+
expect(getLoaderCacheStats().entries).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
});
|
package/src/sdk/cachedLoader.ts
CHANGED
|
@@ -21,6 +21,32 @@ import { type CacheProfileName, loaderCacheOptions } from "./cacheHeaders";
|
|
|
21
21
|
import { withInflightTimeout } from "./inflightTimeout";
|
|
22
22
|
import { RequestContext } from "./requestContext";
|
|
23
23
|
|
|
24
|
+
// Build-time constant injected by `decoVitePlugin()` (see @decocms/tanstack's
|
|
25
|
+
// vite plugin) — the same commit-SHA/deploy token the edge Cache API uses as
|
|
26
|
+
// its `__v` cache-key version. Declared here with a `typeof` guard so it's
|
|
27
|
+
// inert where the define is not applied (Node tests, non-plugin builds). Same
|
|
28
|
+
// pattern already used in `../cms/blockSource.ts`.
|
|
29
|
+
declare const __DECO_BUILD_HASH__: string | undefined;
|
|
30
|
+
|
|
31
|
+
// Prefixes every in-memory loader cache key with the build hash, mirroring the
|
|
32
|
+
// edge cache's `__v`. This is defense-in-depth, NOT the primary invalidation
|
|
33
|
+
// lever: the cache Map is per-isolate, so a fresh isolate spun for a new deploy
|
|
34
|
+
// already starts empty, and a warm isolate keeps running its old bundle (hence
|
|
35
|
+
// its old BUILD) until it is recycled — the prefix cannot flush that. It only
|
|
36
|
+
// matters if this store ever becomes shared across builds. Gated on truthiness
|
|
37
|
+
// (empty string ⇒ unversioned) to match getBuildHash()/getDeploymentId() in
|
|
38
|
+
// workerEntry.ts / blockSource.ts.
|
|
39
|
+
const BUILD =
|
|
40
|
+
typeof __DECO_BUILD_HASH__ !== "undefined" && __DECO_BUILD_HASH__ ? __DECO_BUILD_HASH__ : "";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `maxAge` above this (10 min) is almost always a mistake for a commerce loader:
|
|
44
|
+
* upstream (catalog/price/stock) changes then take that long to propagate and a
|
|
45
|
+
* redeploy is the only fast lever. Warned once per loader name.
|
|
46
|
+
*/
|
|
47
|
+
const LONG_MAXAGE_WARN = 600_000;
|
|
48
|
+
const warnedLongMaxAge = new Set<string>();
|
|
49
|
+
|
|
24
50
|
export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate";
|
|
25
51
|
|
|
26
52
|
export interface CachedLoaderOptions {
|
|
@@ -90,6 +116,13 @@ const MAX_CACHE_BYTES = resolveMaxBytes();
|
|
|
90
116
|
const cache = new Map<string, CacheEntry>();
|
|
91
117
|
let cacheBytes = 0;
|
|
92
118
|
|
|
119
|
+
// Bumped by clearLoaderCache(). A loader invocation captures this at entry and
|
|
120
|
+
// only writes its result back if the generation is unchanged when it settles —
|
|
121
|
+
// so a purge (e.g. POST /_cache/purge-loaders) that lands while a loader is
|
|
122
|
+
// in flight can't be silently undone by that in-flight loader repopulating a
|
|
123
|
+
// just-cleared entry with pre-purge data.
|
|
124
|
+
let cacheGeneration = 0;
|
|
125
|
+
|
|
93
126
|
// Floor each entry at 512 bytes so we never accumulate unbounded zero-cost
|
|
94
127
|
// entries when a loader legitimately returns `undefined` or an empty object
|
|
95
128
|
// — `JSON.stringify(undefined) === undefined` and the eviction loop would
|
|
@@ -180,10 +213,22 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
180
213
|
const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
|
|
181
214
|
const isDev = env?.DECO_CACHE_DISABLE === "true" || env?.NODE_ENV === "development";
|
|
182
215
|
|
|
216
|
+
if (policy !== "no-store" && maxAge > LONG_MAXAGE_WARN && !warnedLongMaxAge.has(name)) {
|
|
217
|
+
warnedLongMaxAge.add(name);
|
|
218
|
+
console.warn(
|
|
219
|
+
`[cachedLoader] ${name}: maxAge=${Math.round(maxAge / 1000)}s is very long — ` +
|
|
220
|
+
`upstream changes take that long to propagate. Prefer a short window and use ` +
|
|
221
|
+
`POST /_cache/purge-loaders (or a redeploy) for immediate invalidation.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
183
225
|
if (policy === "no-store") return loaderFn;
|
|
184
226
|
|
|
185
227
|
return async (props: TProps): Promise<TResult> => {
|
|
186
|
-
const cacheKey = `${name}::${keyFn(props)}`;
|
|
228
|
+
const cacheKey = `${BUILD}::${name}::${keyFn(props)}`;
|
|
229
|
+
// Snapshot the cache generation; a purge during this invocation bumps it,
|
|
230
|
+
// and the deferred writes below skip repopulating a just-cleared entry.
|
|
231
|
+
const gen = cacheGeneration;
|
|
187
232
|
|
|
188
233
|
const inflight = inflightRequests.get(cacheKey);
|
|
189
234
|
if (inflight) {
|
|
@@ -248,6 +293,9 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
248
293
|
entry.refreshing = true;
|
|
249
294
|
loaderFn(props)
|
|
250
295
|
.then((result) => {
|
|
296
|
+
// Skip the write if a purge cleared the cache mid-refresh — otherwise
|
|
297
|
+
// we'd re-insert pre-purge data the purge was meant to drop.
|
|
298
|
+
if (gen !== cacheGeneration) return;
|
|
251
299
|
setCacheEntry(cacheKey, {
|
|
252
300
|
value: result,
|
|
253
301
|
createdAt: Date.now(),
|
|
@@ -290,13 +338,17 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
290
338
|
)
|
|
291
339
|
.then((result) => {
|
|
292
340
|
recordLoaderMetric(name, performance.now() - loaderStart, "MISS");
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
341
|
+
// Skip caching if a purge landed while this loader was in flight — still
|
|
342
|
+
// return the fresh value to the caller, just don't persist a raced entry.
|
|
343
|
+
if (gen === cacheGeneration) {
|
|
344
|
+
setCacheEntry(cacheKey, {
|
|
345
|
+
value: result,
|
|
346
|
+
createdAt: Date.now(),
|
|
347
|
+
refreshing: false,
|
|
348
|
+
estimatedBytes: estimateBytes(result),
|
|
349
|
+
});
|
|
350
|
+
evictIfNeeded();
|
|
351
|
+
}
|
|
300
352
|
return result;
|
|
301
353
|
})
|
|
302
354
|
.catch((err) => {
|
|
@@ -380,7 +432,7 @@ export function createCachedLoaderFromModule<TProps, TResult>(
|
|
|
380
432
|
// Explicit null → the loader declared this call uncacheable: run fresh.
|
|
381
433
|
if (keyPart === null) return mod.default(props, req);
|
|
382
434
|
|
|
383
|
-
const key = `${name}::${keyPart}`;
|
|
435
|
+
const key = `${BUILD}::${name}::${keyPart}`;
|
|
384
436
|
const existing = moduleInflight.get(key) as Promise<TResult> | undefined;
|
|
385
437
|
if (existing) return existing;
|
|
386
438
|
|
|
@@ -423,12 +475,21 @@ export function createLoaderEntry<TProps = any, TResult = any>(
|
|
|
423
475
|
};
|
|
424
476
|
}
|
|
425
477
|
|
|
426
|
-
/**
|
|
478
|
+
/**
|
|
479
|
+
* Clear all cached entries in THIS isolate. Used both by decofile hot-reload
|
|
480
|
+
* (`@decocms/blocks-admin`) and by the `POST /_cache/purge-loaders` route — an
|
|
481
|
+
* escape hatch to invalidate immediately (e.g. after an out-of-band Magento/
|
|
482
|
+
* catalog sync) without waiting out the TTL. Per-isolate: the route must be hit
|
|
483
|
+
* for every isolate; a redeploy replaces isolates wholesale. Bumps the cache
|
|
484
|
+
* generation so any loader in flight at purge time won't repopulate a cleared
|
|
485
|
+
* entry with pre-purge data.
|
|
486
|
+
*/
|
|
427
487
|
export function clearLoaderCache() {
|
|
428
488
|
cache.clear();
|
|
429
489
|
cacheBytes = 0;
|
|
430
490
|
inflightRequests.clear();
|
|
431
491
|
moduleInflight.clear();
|
|
492
|
+
cacheGeneration++;
|
|
432
493
|
}
|
|
433
494
|
|
|
434
495
|
/** Get cache stats for diagnostics. */
|