@uniflowed/server 0.0.0-alpha.10

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.
@@ -0,0 +1,524 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/server`: the cache, with its contract written down.
4
+ //
5
+ // Every cache bug is one of five questions being unstated, so all five are
6
+ // answered here before any code, and the code below is only these paragraphs
7
+ // spelled in Flow.
8
+ //
9
+ // # 1. What is a key
10
+ //
11
+ // A list of strings, hashed by [`./cache-key.js`], which argues at length why
12
+ // it is narrower than `@uniflowed/query`'s and why it carries no build
13
+ // identity where the two disk caches do. Read that file first; the short
14
+ // version is that this store cannot outlive the process, so the identity of
15
+ // the code that filled an entry is a constant rather than an input.
16
+ //
17
+ // # 2. What is an entry
18
+ //
19
+ // A value, the instant it was stored, the instant it goes stale, the instant
20
+ // it may no longer be served at all, the tags it was filled under, and the
21
+ // path it belongs to. Nothing else — in particular no promise and no error. A
22
+ // failed fill stores nothing, because a cached failure is a failure served to
23
+ // people who would have succeeded.
24
+ //
25
+ // # 3. When is an entry stale
26
+ //
27
+ // Two different things, and conflating them is how a cache serves an answer it
28
+ // knows to be wrong:
29
+ //
30
+ // * **Time.** `revalidate` seconds after it was stored an entry is *stale*: it
31
+ // may be old. `expire` seconds after it was stored it is *expired*: it may
32
+ // not be served. `expire` defaults to `revalidate`, which means no
33
+ // stale-while-revalidate unless a caller asks for one — serving a stale
34
+ // answer is still serving a stale answer, and the caller is the only one who
35
+ // knows whether that is acceptable for this data. `expire > revalidate`
36
+ // opens the window, and inside it a reader gets the old value now and a
37
+ // refresh happens behind them.
38
+ // * **A statement.** `revalidateTag` and `revalidatePath` do not make an entry
39
+ // stale, they **expire** it. An entry a tag invalidated is not "possibly
40
+ // old", it is known wrong — somebody just changed the thing it describes —
41
+ // and stale-while-revalidate over a known-wrong answer is the failure mode
42
+ // this whole store exists not to have.
43
+ //
44
+ // An entry with no lifetime at all is not stored. There is no "cache forever"
45
+ // default and there will not be one: an in-memory entry with no expiry is an
46
+ // answer that is served until the process restarts, which is the stale answer
47
+ // nobody asked for wearing a config flag.
48
+ //
49
+ // # 4. Who evicts
50
+ //
51
+ // Three things, all of them synchronous and none of them a timer:
52
+ //
53
+ // * a read that finds an expired entry drops it;
54
+ // * an insertion that would exceed `maxEntries` drops the least recently used
55
+ // entry first;
56
+ // * `clear()`.
57
+ //
58
+ // No background sweep, deliberately. A cache with a timer in it is a process
59
+ // that will not exit, and the two costs it saves — memory held by entries
60
+ // nobody reads — are bounded by `maxEntries` anyway.
61
+ //
62
+ // # 5. What happens to a request that arrives while an entry is being filled
63
+ //
64
+ // It joins. One fill per key: the second caller awaits the first caller's
65
+ // promise rather than starting a second one. That is the property a cache in
66
+ // front of a slow loader is mostly *for* — ten simultaneous requests for a
67
+ // cold page are one render, not ten — and it is also the only one of the five
68
+ // that cannot be added later without changing every caller.
69
+ //
70
+ // A caller that finds a *stale but servable* entry does not join: it gets the
71
+ // old value immediately and a refresh runs behind it, alone. A refresh that
72
+ // fails leaves the stale entry where it is and reports through `onError`; the
73
+ // next reader tries again. A blocking fill that fails is the caller's failure
74
+ // and is not stored.
75
+ //
76
+ // # Where it lives, said plainly
77
+ //
78
+ // In memory, in one process. Four server processes behind a load balancer have
79
+ // four of these and they disagree; a restart empties it; `revalidateTag` in
80
+ // one process does not reach the other three. That is the honest state of it
81
+ // today and it is written here, in `docs/architecture.md`, and in the
82
+ // package's own documentation rather than implied away. What would fix it is a
83
+ // durable store behind this same seam, which is an adapter's job — and per the
84
+ // deployment rules a target that cannot provide one has to say so rather than
85
+ // quietly degrade. `resolve` is the whole seam: anything that can answer it
86
+ // can be the store.
87
+
88
+ import { AsyncLocalStorage } from "node:async_hooks";
89
+
90
+ import type { CacheKey } from "./cache-key.js";
91
+ import { hashCacheKey } from "./cache-key.js";
92
+
93
+ /** How long an entry stays fresh, and how long it may be served at all. */
94
+ export type CacheLifetime = {|
95
+ /** Seconds after which the entry is stale. */
96
+ readonly revalidate: number,
97
+ /**
98
+ * Seconds after which it may not be served.
99
+ *
100
+ * Defaults to `revalidate` — no stale-while-revalidate unless asked for.
101
+ */
102
+ readonly expire?: number,
103
+ |};
104
+
105
+ /** One stored answer. */
106
+ export type CacheEntry<T> = {|
107
+ readonly value: T,
108
+ readonly storedAt: number,
109
+ readonly revalidateAt: number,
110
+ readonly expiresAt: number,
111
+ readonly tags: $ReadOnlyArray<string>,
112
+ readonly path: string | null,
113
+ |};
114
+
115
+ /** What a caller says about the entry before it is filled. */
116
+ export type CacheRequest = {|
117
+ readonly key: CacheKey,
118
+ readonly lifetime?: CacheLifetime,
119
+ readonly tags?: $ReadOnlyArray<string>,
120
+ readonly path?: string,
121
+ |};
122
+
123
+ /** How a value was arrived at, for a caller that wants to report it. */
124
+ export type CacheOutcome = "hit" | "stale" | "miss" | "coalesced" | "uncacheable";
125
+
126
+ /** A value, and how it was arrived at. */
127
+ export type CacheResult<T> = {|
128
+ readonly value: T,
129
+ readonly outcome: CacheOutcome,
130
+ /** Whether this call left an entry behind. */
131
+ readonly stored: boolean,
132
+ |};
133
+
134
+ /** Running totals, for a benchmark or a report. */
135
+ export type CacheStats = {|
136
+ readonly hits: number,
137
+ readonly stale: number,
138
+ readonly misses: number,
139
+ readonly coalesced: number,
140
+ readonly fills: number,
141
+ readonly evictions: number,
142
+ readonly invalidations: number,
143
+ |};
144
+
145
+ /**
146
+ * What a host installed for one request, from `rendering.cache`.
147
+ *
148
+ * Declared here rather than in `../cache.js` so that `./context.js` can name
149
+ * it without importing the module that imports `./context.js`. A type-only
150
+ * cycle is harmless and an import cycle between two modules a request goes
151
+ * through is not worth finding out about later.
152
+ */
153
+ export type CacheOptions = {|
154
+ readonly store: CacheStore,
155
+ /** `rendering.cache.route`: whether a rendered document may be stored. */
156
+ readonly route?: boolean,
157
+ /** `rendering.cache.fetch`: whether a cached fetch client may use the store. */
158
+ readonly fetch?: boolean,
159
+ |};
160
+
161
+ /** How a store behaves. */
162
+ export type CacheStoreOptions = {|
163
+ /**
164
+ * The clock, in milliseconds.
165
+ *
166
+ * Injectable because staleness is the only thing in here that is a fact
167
+ * about time, and a test that drives it with a real clock is a test that
168
+ * sleeps — which is a test that flakes on a loaded machine. Defaults to
169
+ * `Date.now`.
170
+ */
171
+ readonly now?: () => number,
172
+ /** Most entries held at once. Defaults to 1024. */
173
+ readonly maxEntries?: number,
174
+ /**
175
+ * Where a background refresh's failure goes.
176
+ *
177
+ * It has nowhere else to go: nobody is awaiting it, so without this it is an
178
+ * unhandled rejection that takes the process down on a strict runtime.
179
+ */
180
+ readonly onError?: (error: mixed) => void,
181
+ |};
182
+
183
+ /**
184
+ * What a fill may declare about itself while it is running.
185
+ *
186
+ * The declarations arrive from *inside* `produce` — `cacheLife` and `cacheTag`
187
+ * are called by a loader or a component, six frames below the thing that
188
+ * started the fill, which is the same reason `cookies()` reads an
189
+ * `AsyncLocalStorage` rather than an argument.
190
+ */
191
+ export type CacheScope = {|
192
+ lifetime: CacheLifetime | null,
193
+ readonly tags: Array<string>,
194
+ /** Set when something decided this answer must not be stored, and why. */
195
+ denied: string | null,
196
+ |};
197
+
198
+ const scopes: AsyncLocalStorage<CacheScope> = new AsyncLocalStorage();
199
+
200
+ /**
201
+ * The fill this call is inside, or `null`.
202
+ *
203
+ * `null` rather than throwing, so each caller can name what *it* wanted the
204
+ * fill for — `cacheTag() was called outside a cached scope` is a better error
205
+ * than one generic message from here.
206
+ */
207
+ export function currentScope(): CacheScope | null {
208
+ return scopes.getStore() ?? null;
209
+ }
210
+
211
+ /** Run `body` as a fill, with `scope` collecting what it declares. */
212
+ export function runInScope<T>(scope: CacheScope, body: () => Promise<T>): Promise<T> {
213
+ return scopes.run(scope, body);
214
+ }
215
+
216
+ /** A fresh scope, declaring nothing yet. */
217
+ export function newScope(request: CacheRequest): CacheScope {
218
+ return {
219
+ lifetime: request.lifetime ?? null,
220
+ tags: request.tags == null ? [] : Array.from(request.tags),
221
+ denied: null,
222
+ };
223
+ }
224
+
225
+ /** Seconds to milliseconds, refusing anything that is not a real duration. */
226
+ function millis(seconds: number, name: string): number {
227
+ if (!Number.isFinite(seconds) || seconds < 0) {
228
+ throw new RangeError(
229
+ `@uniflowed/server: ${name} is ${String(seconds)} seconds, which is not a duration. ` +
230
+ "A cache entry with no stated end is one that is served until the process restarts.",
231
+ );
232
+ }
233
+ return seconds * 1000;
234
+ }
235
+
236
+ /**
237
+ * A cache: `resolve` a key, invalidate by tag or by path.
238
+ *
239
+ * Explicit rather than a module-level default, for the reason
240
+ * `@uniflowed/query`'s cache is: a singleton is shared with every other test in
241
+ * the process, and one test's cached answer then decides another's. On a
242
+ * server it is worse than a flake — two requests being answered at once must
243
+ * not be able to reach each other's data by accident — so a store is
244
+ * constructed by whoever owns the process and handed to whoever answers a
245
+ * request.
246
+ */
247
+ export class CacheStore {
248
+ readonly entries: Map<string, CacheEntry<mixed>> = new Map();
249
+ readonly filling: Map<string, Promise<mixed>> = new Map();
250
+ now: () => number;
251
+ maxEntries: number;
252
+ onError: (error: mixed) => void;
253
+ hits: number = 0;
254
+ staleServed: number = 0;
255
+ misses: number = 0;
256
+ coalesced: number = 0;
257
+ fills: number = 0;
258
+ evictions: number = 0;
259
+ invalidations: number = 0;
260
+
261
+ constructor(options?: CacheStoreOptions) {
262
+ this.now = options?.now ?? Date.now;
263
+ this.maxEntries = options?.maxEntries ?? 1024;
264
+ this.onError =
265
+ options?.onError ??
266
+ ((error: mixed) => {
267
+ // eslint-disable-next-line no-console
268
+ console.error("uf: a cache refresh failed", error);
269
+ });
270
+ if (!Number.isInteger(this.maxEntries) || this.maxEntries < 1) {
271
+ throw new RangeError(
272
+ `@uniflowed/server: maxEntries is ${String(this.maxEntries)}; a store that can hold ` +
273
+ "no entries is a store that only costs.",
274
+ );
275
+ }
276
+ }
277
+
278
+ /** How many entries are held. */
279
+ size(): number {
280
+ return this.entries.size;
281
+ }
282
+
283
+ /** The running totals. */
284
+ stats(): CacheStats {
285
+ return {
286
+ hits: this.hits,
287
+ stale: this.staleServed,
288
+ misses: this.misses,
289
+ coalesced: this.coalesced,
290
+ fills: this.fills,
291
+ evictions: this.evictions,
292
+ invalidations: this.invalidations,
293
+ };
294
+ }
295
+
296
+ /**
297
+ * The entry under `key` as it stands, without filling or evicting anything.
298
+ *
299
+ * For a test and for a report. Nothing on the answering path uses it: a read
300
+ * that finds an expired entry has to drop it, and a method that promises not
301
+ * to would be a second, subtly different read.
302
+ */
303
+ peek(key: CacheKey): CacheEntry<mixed> | null {
304
+ return this.entries.get(hashCacheKey(key)) ?? null;
305
+ }
306
+
307
+ /** Forget one entry. Answers whether there was one. */
308
+ forget(key: CacheKey): boolean {
309
+ return this.entries.delete(hashCacheKey(key));
310
+ }
311
+
312
+ /** Forget everything. Fills already in flight still finish, and are dropped. */
313
+ clear(): void {
314
+ this.entries.clear();
315
+ this.filling.clear();
316
+ }
317
+
318
+ /**
319
+ * The value for `request.key`, filling it with `produce` if it has to.
320
+ *
321
+ * The whole seam. Every one of the five answers above is in this method, and
322
+ * the order of the branches is the order the contract states them in.
323
+ */
324
+ async resolve<T>(request: CacheRequest, produce: () => Promise<T>): Promise<CacheResult<T>> {
325
+ const hash = hashCacheKey(request.key);
326
+ const at = this.now();
327
+ const existing = this.entries.get(hash);
328
+
329
+ if (existing != null) {
330
+ if (at >= existing.expiresAt) {
331
+ // Expired: dropped on the read that found it, which is one of the
332
+ // three things that evict. Falls through to a fill.
333
+ this.entries.delete(hash);
334
+ } else if (at < existing.revalidateAt) {
335
+ this.hits += 1;
336
+ this.touch(hash, existing);
337
+ return { value: (existing.value: $FlowFixMe), outcome: "hit", stored: false };
338
+ } else {
339
+ // Stale and inside the stale-while-revalidate window the caller asked
340
+ // for: the old value now, a refresh behind it. `void` and a `catch`
341
+ // rather than an await — nobody is waiting for this, and an unhandled
342
+ // rejection from a refresh nobody asked about would take the process
343
+ // down on a runtime that treats them as fatal.
344
+ this.staleServed += 1;
345
+ this.touch(hash, existing);
346
+ this.refresh(hash, request, produce);
347
+ return { value: (existing.value: $FlowFixMe), outcome: "stale", stored: false };
348
+ }
349
+ }
350
+
351
+ const inflight = this.filling.get(hash);
352
+ if (inflight != null) {
353
+ this.coalesced += 1;
354
+ const value: T = (await inflight: $FlowFixMe);
355
+ return { value, outcome: "coalesced", stored: false };
356
+ }
357
+
358
+ this.misses += 1;
359
+ const scope = newScope(request);
360
+ const filling = this.fill(hash, request, scope, produce);
361
+ this.filling.set(hash, filling);
362
+ let value: T;
363
+ try {
364
+ value = await filling;
365
+ } finally {
366
+ // Only if it is still ours: `clear()` may have run while this was in
367
+ // flight, and a later fill may already have claimed the key.
368
+ if (this.filling.get(hash) === filling) {
369
+ this.filling.delete(hash);
370
+ }
371
+ }
372
+ return {
373
+ value,
374
+ outcome: scope.denied == null && scope.lifetime != null ? "miss" : "uncacheable",
375
+ stored: this.entries.has(hash),
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Run `produce` as a fill and store what it produced, if it may be stored.
381
+ *
382
+ * Three ways it may not be, and each is a decision somebody made rather than
383
+ * a failure: the fill declared no lifetime, so there is no answer to "when
384
+ * does this stop being true"; something in it called `noStore`; or it threw,
385
+ * and a cached failure is a failure served to callers who would have
386
+ * succeeded.
387
+ */
388
+ async fill<T>(
389
+ hash: string,
390
+ request: CacheRequest,
391
+ scope: CacheScope,
392
+ produce: () => Promise<T>,
393
+ ): Promise<T> {
394
+ this.fills += 1;
395
+ const value = await runInScope(scope, produce);
396
+ const lifetime = scope.lifetime;
397
+ if (scope.denied != null || lifetime == null) {
398
+ return value;
399
+ }
400
+ const storedAt = this.now();
401
+ const revalidate = millis(lifetime.revalidate, "revalidate");
402
+ const expire = millis(lifetime.expire ?? lifetime.revalidate, "expire");
403
+ if (expire < revalidate) {
404
+ throw new RangeError(
405
+ `@uniflowed/server: expire (${String(lifetime.expire)}s) is before revalidate ` +
406
+ `(${String(lifetime.revalidate)}s), which asks for an entry that is unusable ` +
407
+ "before it is stale.",
408
+ );
409
+ }
410
+ this.store(hash, {
411
+ value,
412
+ storedAt,
413
+ revalidateAt: storedAt + revalidate,
414
+ expiresAt: storedAt + expire,
415
+ tags: Array.from(new Set(scope.tags)),
416
+ path: request.path ?? null,
417
+ });
418
+ return value;
419
+ }
420
+
421
+ /**
422
+ * Refill `hash` behind a reader that was served the stale value.
423
+ *
424
+ * Alone: a second stale read while this is running is served the stale value
425
+ * too and starts nothing, because the entry it would refresh is already
426
+ * being refreshed. A failure leaves the stale entry exactly where it is —
427
+ * the next reader inside the window tries again, and the one after the
428
+ * window blocks on a fill that can fail properly.
429
+ *
430
+ * # The one thing to know about it
431
+ *
432
+ * A refresh is started synchronously inside the reader that was served the
433
+ * stale value, so it inherits that reader's asynchronous context — including
434
+ * the request `../cache.js`'s bindings answer about. A refresh that reads
435
+ * `cookies()` therefore reads *that* reader's cookies, which would be a
436
+ * document about one person filed under a URL everybody asks for.
437
+ *
438
+ * It cannot become one, and the reason is worth stating rather than
439
+ * trusting: the same refusal that governs a cold fill governs this one.
440
+ * `../fetch.js`'s fill compares the request-state read count across its own
441
+ * run, so a refresh that read the request calls `noStore`, nothing is
442
+ * written, and the value is discarded — `refresh` does not return it to
443
+ * anybody. The stale entry stays until it expires and the reader after that
444
+ * blocks on a fill of its own.
445
+ *
446
+ * The cost of inheriting the context is a smaller one: an `after()` callback
447
+ * registered by a refresh lands on a request whose `settle` may already have
448
+ * run, and is then never called. Deferred work registered by a render nobody
449
+ * is waiting for is the least surprising thing to lose, and detaching the
450
+ * refresh into a context of its own would mean this module knowing what a
451
+ * request is, which it deliberately does not.
452
+ */
453
+ refresh<T>(hash: string, request: CacheRequest, produce: () => Promise<T>): void {
454
+ if (this.filling.has(hash)) {
455
+ return;
456
+ }
457
+ const scope = newScope(request);
458
+ const running = this.fill(hash, request, scope, produce).catch((error: mixed) => {
459
+ this.onError(error);
460
+ return undefined;
461
+ });
462
+ this.filling.set(hash, running);
463
+ void running.then(() => {
464
+ if (this.filling.get(hash) === running) {
465
+ this.filling.delete(hash);
466
+ }
467
+ });
468
+ }
469
+
470
+ /**
471
+ * Put an entry in, evicting the least recently used one if there is no room.
472
+ *
473
+ * `Map` keeps insertion order, and [`touch`] re-inserts on every read, so the
474
+ * first key the iterator yields is the one nothing has wanted for longest.
475
+ */
476
+ store(hash: string, entry: CacheEntry<mixed>): void {
477
+ this.entries.delete(hash);
478
+ while (this.entries.size >= this.maxEntries) {
479
+ const oldest = this.entries.keys().next();
480
+ if (oldest.done === true) {
481
+ break;
482
+ }
483
+ this.entries.delete(oldest.value);
484
+ this.evictions += 1;
485
+ }
486
+ this.entries.set(hash, entry);
487
+ }
488
+
489
+ /** Mark an entry as the most recently used one. */
490
+ touch(hash: string, entry: CacheEntry<mixed>): void {
491
+ this.entries.delete(hash);
492
+ this.entries.set(hash, entry);
493
+ }
494
+
495
+ /**
496
+ * Expire every entry filled under `tag`. Answers how many.
497
+ *
498
+ * Expired rather than marked stale, which is the distinction the module
499
+ * header argues: a tag is invalidated because somebody changed the thing it
500
+ * names, so the entry is known wrong rather than possibly old, and there is
501
+ * no window in which serving it is acceptable.
502
+ */
503
+ revalidateTag(tag: string): number {
504
+ return this.expireWhere((entry) => entry.tags.includes(tag));
505
+ }
506
+
507
+ /** Expire every entry filled for `path`. Answers how many. */
508
+ revalidatePath(path: string): number {
509
+ return this.expireWhere((entry) => entry.path === path);
510
+ }
511
+
512
+ /** Drop every entry `matches` describes, counting them. */
513
+ expireWhere(matches: (entry: CacheEntry<mixed>) => boolean): number {
514
+ let dropped = 0;
515
+ for (const [hash, entry] of Array.from(this.entries.entries())) {
516
+ if (matches(entry)) {
517
+ this.entries.delete(hash);
518
+ dropped += 1;
519
+ }
520
+ }
521
+ this.invalidations += dropped;
522
+ return dropped;
523
+ }
524
+ }