-
Notifications
You must be signed in to change notification settings - Fork 55
Expand file tree
/
Copy pathloader.ts
More file actions
407 lines (357 loc) · 13.1 KB
/
Copy pathloader.ts
File metadata and controls
407 lines (357 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
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
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
// deno-lint-ignore-file no-explicit-any
import JsonViewer from "../components/JsonViewer.tsx";
import { RequestContext } from "../deco.ts";
import { ValueType } from "../deps.ts";
import type { Block, BlockModule, InstanceOf } from "../engine/block.ts";
import { FieldResolver } from "../engine/core/resolver.ts";
import { singleFlight } from "../engine/core/utils.ts";
import type { DecofileProvider } from "../engine/decofile/provider.ts";
import { HttpError } from "../engine/errors.ts";
import type { ResolverMiddlewareContext } from "../engine/middleware.ts";
import type { State } from "../mod.ts";
import { logger } from "../observability/otel/config.ts";
import {
meter,
OTEL_ENABLE_EXTRA_METRICS,
} from "../observability/otel/metrics.ts";
import {
caches,
ENABLE_LOADER_CACHE,
revalidationLocker,
} from "../runtime/caches/mod.ts";
import { inFuture } from "../runtime/caches/utils.ts";
import type { DebugProperties } from "../utils/vary.ts";
import type { HttpContext } from "./handler.ts";
import {
applyProps,
type FnContext,
type FnProps,
gateKeeper,
type GateKeeperAccess,
type RequestState,
type SingleFlightKeyFunc,
} from "./utils.tsx";
export type Loader = InstanceOf<typeof loaderBlock, "#/root/loaders">;
type CacheMode = "no-store" | "no-cache" | "stale-while-revalidate";
export interface LoaderModule<
TProps = any,
TState = any,
> extends BlockModule<FnProps<TProps>>, GateKeeperAccess {
/**
* Specifies caching behavior for the loader and its dependencies.
*
* - **no-store**:
* - Completely bypasses the cache, ensuring that the loader always runs and fetches fresh data.
* - This setting also changes `ctx.vary.shouldCache` to `false`, which prevents other dependent sections from being cached.
* - The `vary` is not set, even if the loader has a cache key.
*
* - **no-cache**:
* - Ignores the cache for the current loader run, but does not affect the caching behavior of other dependent blocks.
* - This is useful for loaders that should always execute but whose results can still be cached.
*
* - **stale-while-revalidate**:
* - If no data exists for a cache key, the loader runs, and the fresh data is returned.
* - If stale data is available, it is returned immediately while the loader runs in the background to revalidate and update the cache if the data is outdated.
*
* @default "no-store"
*/
cache?: CacheMode | {
maxAge: number;
};
// a null value avoid cache
cacheKey?: (
props: TProps,
req: Request,
ctx: FnContext<TState>,
) => string | null;
/** @deprecated use cacheKey instead */
singleFlightKey?: SingleFlightKeyFunc<TProps, HttpContext>;
}
interface LoaderDebugData extends DebugProperties {
reason: {
cache: NonNullable<CacheMode>;
cacheKeyNull: boolean;
};
}
export interface WrappedError {
__isErr: true;
}
export const isWrappedError = (
err: any | WrappedError,
): err is WrappedError => {
return (err as WrappedError)?.__isErr;
};
export const isInvokeCtx = <TContext extends ResolverMiddlewareContext<any>>(
ctx: TContext | TContext & { isInvoke: true },
): ctx is TContext & { isInvoke: true } => {
return (ctx as TContext & { isInvoke: true })?.isInvoke;
};
export const wrapCaughtErrors = async <
TConfig = any,
TContext extends ResolverMiddlewareContext<any> = ResolverMiddlewareContext<
any
>,
>(_props: TConfig, ctx: TContext) => {
if (isInvokeCtx(ctx)) { // invoke should not wrap caught errors.
return ctx.next!();
}
try {
return await ctx.next!();
} catch (err) {
if (err instanceof HttpError) {
throw err;
}
return new Proxy({}, {
get: (_target, prop) => {
if (prop === "then") {
return undefined;
}
if (prop === "__isErr") {
return true;
}
/**
* This proxy may be used inside islands.
* Islands props are serialized by fresh's serializer.
* This code makes it behave well with fresh's serializer
*/
if (prop === "peek") {
return undefined;
}
if (prop === "toJSON") {
return () => null;
}
/**
* No special case found, throw and hope to be caught by the
* section's ErrorFallback
*/
throw err;
},
});
}
};
const stats = {
cache: meter.createCounter("loader_cache", {
unit: "1",
valueType: ValueType.DOUBLE,
}),
latency: meter.createHistogram("resolver_latency", {
description: "resolver latency",
unit: "ms",
valueType: ValueType.DOUBLE,
}),
};
let maybeCache: Cache | undefined;
caches?.open("loader")
.then((c) => maybeCache = c)
.catch(() => maybeCache = undefined);
const MAX_AGE_S = parseInt(Deno.env.get("CACHE_MAX_AGE_S") ?? "60"); // 60 seconds
// Reuse TextEncoder instance to avoid repeated instantiation
const textEncoder = new TextEncoder();
const isCache = (c: Cache | undefined): c is Cache => typeof c !== "undefined";
const noop = () => "";
/**
* Wraps the loader written by the user by adding support for:
* 1. Caching
* 2. Single Flight
* 3. Tracing
*
* Performance optimizations applied:
* - Reused TextEncoder instance to avoid repeated instantiation
* - Optimized cache key generation using string concatenation
* - Improved string concatenation for Content-Length header
*/
const wrapLoader = (
{
default: handler,
cache = "no-store",
cacheKey = noop,
singleFlightKey,
...rest
}: LoaderModule,
resolveChain: FieldResolver[],
release: DecofileProvider,
/**
* The block key — the loader's module path, e.g.
* `vtex/loaders/legacy/productListingPage.ts`. Used as the metric label
* instead of `ctx.resolverId`, which carries the full resolve chain and is
* unbounded. See the `loader` constant below.
*/
blockKey: string,
) => {
const [cacheMaxAge, mode] = typeof cache === "string"
? [MAX_AGE_S, cache]
: [cache?.maxAge, "stale-while-revalidate"];
const flights = singleFlight();
const bgFlights = singleFlight();
if (typeof singleFlightKey === "function") {
console.warn(
"singleFlightKey is deprecated and does not work anymore. Please use cacheKey instead",
);
}
return {
...rest,
default: async (
props: Parameters<typeof handler>[0],
req: Request,
ctx: FnContext<State, any>,
): Promise<ReturnType<typeof handler>> => {
// Metric label. Deliberately the block key and NOT `ctx.resolverId`:
// resolverId carries the full resolve chain, e.g.
// `Categories@sections.variants.1.value.5.sections.0.section.page`, so
// every section position of every variant of every page becomes its own
// time series. Measured in production that reached 3,684 distinct values
// on a single site and 21,849 across the fleet, against a documented
// budget of 1,000 per site and 100 fleet-wide — and `loader_cache` alone
// became 76.6% of all rows in otel_metrics_sum.
//
// The block key is bounded by the number of loader modules in the app,
// which is what the @decocms/start runtime already uses for the
// equivalent metric (13 distinct values fleet-wide). The chain is not
// lost: it still travels on error logs, where high cardinality is fine
// because they are read by point lookup rather than aggregated.
const loader = blockKey || ctx.resolverId || "unknown";
const start = performance.now();
let status: "bypass" | "miss" | "stale" | "hit" | undefined;
const isCacheEngineDefined = isCache(maybeCache);
const isCacheDisabled = !ENABLE_LOADER_CACHE ||
!isCacheEngineDefined;
const cacheKeyValue = cacheKey(props, req, ctx);
const isCacheKeyNull = cacheKeyValue === null;
const isCacheNoStore = mode === "no-store";
const isCacheNoCache = mode === "no-cache";
const bypassCache = isCacheNoStore || isCacheNoCache ||
isCacheKeyNull || isCacheDisabled;
try {
// Should skip cache
if (
bypassCache ||
// This code is unreachable, but the TS complains that cache is undefined because
// it doesn't get that isCache is inside the bypassCache variable
!isCache(maybeCache)
) {
const shouldNotCache = isCacheNoStore || isCacheKeyNull;
if (ctx.vary && shouldNotCache) {
ctx.vary.shouldCache = false;
if (ctx.debugEnabled) {
const resolver = resolveChain.at(-1);
resolver &&
ctx.vary.debug.push<LoaderDebugData>({
resolver,
reason: {
cache: mode as CacheMode,
cacheKeyNull: isCacheKeyNull,
},
});
}
}
!shouldNotCache && ctx.vary?.push(cacheKeyValue);
status = "bypass";
stats.cache.add(1, { status, loader });
RequestContext?.signal?.throwIfAborted();
return await handler(props, req, ctx);
}
ctx.vary?.push(loader, cacheKeyValue);
RequestContext?.signal?.throwIfAborted();
const cache = maybeCache;
const timing = ctx.monitoring?.timings.start("loader-hash");
// K_REVISION is preferred over the revisionID from the release
// because it does not change when only .decofile changes
const revisionID = Deno.env.get("K_REVISION") ??
(await release?.revision() ?? undefined);
if (!revisionID) {
logger.warn(`Could not get K_REVISION`);
timing?.end();
return await handler(props, req, ctx);
}
timing?.end();
const cacheKeyUrl = `https://localhost/?${new URLSearchParams({
resolver: loader,
revision: revisionID,
cacheKey: cacheKeyValue,
})}`;
const request = new Request(cacheKeyUrl);
const callHandlerAndCache = async () => {
const json = await handler(props, req, ctx);
// Serialize and encode once on the main thread.
const jsonStringEncoded = textEncoder.encode(JSON.stringify(json));
const expires = new Date(Date.now() + (cacheMaxAge * 1e3))
.toUTCString();
const headerPairs: [string, string][] = [
["expires", expires],
["Content-Type", "application/json"],
["Content-Length", "" + jsonStringEncoded.length],
];
// Cache write goes through the full chain (LRU → filesystem)
// so the LRU registers the key for fast match lookups.
// The filesystem layer offloads the actual I/O to a worker thread
// when DECO_CACHE_WRITE_WORKER=true.
cache.put(
request,
new Response(jsonStringEncoded, {
headers: Object.fromEntries(headerPairs),
}),
).catch((error) => logger.error(`loader error ${error}`));
return json;
};
const staleWhileRevalidate = async () => {
const matched = await cache.match(request).catch(() => null);
if (!matched) {
status = "miss";
stats.cache.add(1, { status, loader });
return await callHandlerAndCache();
}
const expires = matched.headers.get("expires");
const isStale = expires ? !inFuture(expires) : false;
if (isStale) {
status = "stale";
stats.cache.add(1, { status, loader });
revalidationLocker.tryAcquire(request)
.catch(() => true) // fail-open: locker error → allow revalidation
.then((acquired) => {
if (!acquired) return;
return bgFlights.do(request.url, callHandlerAndCache);
})
.catch((error) => logger.error(`loader error ${error}`));
} else {
status = "hit";
stats.cache.add(1, { status, loader });
}
return await matched.json();
};
return await flights.do(request.url, staleWhileRevalidate);
} finally {
const dimension = { loader, status };
if (OTEL_ENABLE_EXTRA_METRICS) {
stats.latency.record(performance.now() - start, dimension);
}
ctx.monitoring?.currentSpan?.setDesc(status);
}
},
};
};
const loaderBlock: Block<LoaderModule> = {
type: "loaders",
introspect: { includeReturn: true },
adapt: <TProps = any>(mod: LoaderModule<TProps>, key: string) => [
gateKeeper(mod.defaultVisibility, key),
wrapCaughtErrors,
(props: TProps, ctx: HttpContext<{ global: any } & RequestState>) =>
applyProps(
wrapLoader(mod, ctx.resolveChain, ctx.context.state.release, key),
)(
props,
ctx,
),
],
defaultPreview: (result) => {
return {
Component: JsonViewer,
props: { body: JSON.stringify(result, null, 2) },
};
},
};
/**
* <TResponse>(req:Request, ctx: HandlerContext<any, LiveConfig<TConfig>>) => Promise<TResponse> | TResponse
* Loaders are arbitrary functions that always run in a request context, it returns the response based on the config parameters and the request.
*/
export default loaderBlock;