@ultimat3/cache 1.2.0 → 3.0.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/CLAUDE.md +267 -0
- package/README.md +205 -4
- package/package.json +3 -2
- package/src/cdn.ts +12 -1
- package/src/errors.ts +50 -0
- package/src/fence.ts +110 -0
- package/src/graph.ts +0 -0
- package/src/index.ts +40 -3
- package/src/invalidate.ts +145 -19
- package/src/lru.ts +34 -12
- package/src/memo.ts +20 -2
- package/src/redis-fake.ts +115 -0
- package/src/redis.ts +287 -31
- package/src/semantic.ts +9 -2
- package/src/set-options.ts +65 -0
- package/src/single-flight.ts +78 -0
- package/src/tags.ts +45 -0
- package/src/tier-failures.ts +113 -0
- package/src/tiers.ts +221 -19
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// N concurrent misses on one key are ONE origin load. Without this a cache is an outage
|
|
2
|
+
// amplifier: the write only lands after `load()` resolves, so every request that arrives inside
|
|
3
|
+
// that window misses too and every one of them queries the origin. The share is per load and
|
|
4
|
+
// never a second cache — the entry clears as it settles, rejection included.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a joiner contributes to the load it joined. Without one a joiner is a free rider: it takes
|
|
8
|
+
* the leader's value AND the leader's write, so anything it declared about that write is dropped.
|
|
9
|
+
*/
|
|
10
|
+
export interface FlightJoin<C> {
|
|
11
|
+
readonly context: C;
|
|
12
|
+
/** Folds a joiner in. Called synchronously as it arrives, so the leader sees it before it writes. */
|
|
13
|
+
readonly merge: (current: C, joining: C) => C;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Shares one in-flight `work()` per key. `@ultimat3/realtime`'s `entry.reading`, one tier down. */
|
|
17
|
+
export interface SingleFlight {
|
|
18
|
+
/**
|
|
19
|
+
* `work` receives a reader for the merged context — read it LATE (after the load settles), or
|
|
20
|
+
* it answers with only what the leader brought.
|
|
21
|
+
*/
|
|
22
|
+
run<T, C = undefined>(
|
|
23
|
+
key: string,
|
|
24
|
+
work: (shared: () => C | undefined) => Promise<T>,
|
|
25
|
+
join?: FlightJoin<C>,
|
|
26
|
+
): Promise<T>;
|
|
27
|
+
/** In-flight loads right now. A number that does not fall back to `0` is a leak. */
|
|
28
|
+
readonly size: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The leader's promise, plus the box its merged context lives in — one identity for both. */
|
|
32
|
+
interface Flight {
|
|
33
|
+
readonly running: Promise<unknown>;
|
|
34
|
+
readonly shared: { context: unknown };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createSingleFlight(): SingleFlight {
|
|
38
|
+
const inflight = new Map<string, Flight>();
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
get size(): number {
|
|
42
|
+
return inflight.size;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
run<T, C = undefined>(
|
|
46
|
+
key: string,
|
|
47
|
+
work: (shared: () => C | undefined) => Promise<T>,
|
|
48
|
+
join?: FlightJoin<C>,
|
|
49
|
+
): Promise<T> {
|
|
50
|
+
const joined = inflight.get(key);
|
|
51
|
+
// Two readers of one key asking for two different `T` is an app bug the cache cannot see;
|
|
52
|
+
// the value they share is the same object either way, so the cast is the honest one.
|
|
53
|
+
if (joined !== undefined) {
|
|
54
|
+
if (join !== undefined) {
|
|
55
|
+
joined.shared.context = join.merge(joined.shared.context as C, join.context);
|
|
56
|
+
}
|
|
57
|
+
return joined.running as Promise<T>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const shared: { context: unknown } = { context: join?.context };
|
|
61
|
+
// Wrapped so a `work()` that throws SYNCHRONOUSLY still rejects the joiners rather than
|
|
62
|
+
// escaping past the map and leaving no entry to clear.
|
|
63
|
+
const running: Promise<T> = (async () => await work(() => shared.context as C | undefined))();
|
|
64
|
+
const entry: Flight = { running, shared };
|
|
65
|
+
inflight.set(key, entry);
|
|
66
|
+
|
|
67
|
+
const settled = (): void => {
|
|
68
|
+
// Only the leader clears its own entry: a load started after this one settled must not be
|
|
69
|
+
// dropped by a late callback from the load it replaced.
|
|
70
|
+
if (inflight.get(key) === entry) inflight.delete(key);
|
|
71
|
+
};
|
|
72
|
+
// A rejected load MUST clear too, or one failure is cached as a permanent rejection.
|
|
73
|
+
void running.then(settled, settled);
|
|
74
|
+
|
|
75
|
+
return running;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/tags.ts
CHANGED
|
@@ -54,6 +54,26 @@ export function serializeTags(tags: readonly CacheTag[]): string[] {
|
|
|
54
54
|
return tags.map(serializeTag);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The wire forms as an IDENTITY: sorted and de-duplicated, so two declaration orders of one tag
|
|
59
|
+
* set are one string. `@ultimat3/action`'s descriptor and OpenAPI `invalidates`, and
|
|
60
|
+
* `@ultimat3/query`'s `cacheKeyFor`, are built from it — a key that varied with declaration order
|
|
61
|
+
* would fill two entries for one read, and an action's `invalidates` would drop whichever of them
|
|
62
|
+
* it happened to name.
|
|
63
|
+
*
|
|
64
|
+
* It lives here rather than in either of those packages, which held byte-identical copies of it:
|
|
65
|
+
* both are tier 3, so neither can import the other and a copy in either is a second answer for the
|
|
66
|
+
* other. The same move `toBucket` made into `@ultimat3/http`.
|
|
67
|
+
*
|
|
68
|
+
* NOT the same function as `@ultimat3/render`'s same-named `tagKeys`, which is `serializeTags`
|
|
69
|
+
* over an optional list and deliberately preserves declaration order for a route descriptor
|
|
70
|
+
* (`dsl.test.ts` pins it). Two behaviours under one name, in two packages an app imports together:
|
|
71
|
+
* naming it here is where a reader can see both.
|
|
72
|
+
*/
|
|
73
|
+
export function tagKeys(tags: readonly CacheTag[]): readonly string[] {
|
|
74
|
+
return [...new Set(serializeTags(tags))].sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
57
77
|
/**
|
|
58
78
|
* Every tag a row participates in: its collection and its own identity. A row write
|
|
59
79
|
* therefore busts list caches and detail caches with one call.
|
|
@@ -95,6 +115,31 @@ export function resetDeclaredTags(): void {
|
|
|
95
115
|
declared.clear();
|
|
96
116
|
}
|
|
97
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Test seam, and the one `resetDeclaredTags()` cannot be: `declareTags` is additive and
|
|
120
|
+
* process-wide, so a suite that declares a fixture entity flips `assertKnownTags` on for every
|
|
121
|
+
* LATER file in the same `bun test` process — and those files fail with X_CACHE_TAG_UNKNOWN for a
|
|
122
|
+
* reason nothing in them explains. Resetting instead would drop what a neighbour declared, so this
|
|
123
|
+
* puts back exactly what it found:
|
|
124
|
+
*
|
|
125
|
+
* const restoreTags = isolateDeclaredTags();
|
|
126
|
+
* afterAll(restoreTags);
|
|
127
|
+
*
|
|
128
|
+
* This is the shape every process-global registry in this package is undone with — `isolateGraph()`
|
|
129
|
+
* in `graph.ts`, `isolateTiers()` in `invalidate.ts`, `isolateTierFailures()` in
|
|
130
|
+
* `tier-failures.ts` — and one reason covers all four: `@ultimat3/testing`'s leak guard compares
|
|
131
|
+
* its before/after samples for ADDITIONS only, so a file that DELETES what a neighbour registered
|
|
132
|
+
* is invisible to it and surfaces instead as a failure in an innocent file with nothing in it to
|
|
133
|
+
* explain the missing state. A reset in a test file is the one leak no mechanism catches for you.
|
|
134
|
+
*/
|
|
135
|
+
export function isolateDeclaredTags(): () => void {
|
|
136
|
+
const captured = knownTags();
|
|
137
|
+
return () => {
|
|
138
|
+
declared.clear();
|
|
139
|
+
for (const name of captured) declared.add(name);
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
98
143
|
/**
|
|
99
144
|
* Validation is skipped while nothing is declared — `x dev` boots before the manifest
|
|
100
145
|
* exists, and a hard failure there would be worse than a late one. Once any entity has
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// A cache tier is best-effort infrastructure: a refusal must never reach the caller of a
|
|
2
|
+
// business read or write, the same rule `invalidateTags` already keeps. `read()` has no report
|
|
3
|
+
// to return, so every swallowed refusal lands in one bounded log plus one `warn` — a stack
|
|
4
|
+
// running degraded stays answerable instead of merely looking slow.
|
|
5
|
+
|
|
6
|
+
import { logger, renderThrowable, systemClock, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { TierLabel } from './tiers';
|
|
8
|
+
|
|
9
|
+
/** The three tier calls a stack makes on the value path. `invalidateTags` reports its own. */
|
|
10
|
+
export type TierOperation = 'get' | 'set' | 'del';
|
|
11
|
+
|
|
12
|
+
export interface TierFailure {
|
|
13
|
+
/** ISO-8601, from core's `systemClock` — never `new Date()`. */
|
|
14
|
+
readonly at: string;
|
|
15
|
+
readonly tier: TierLabel;
|
|
16
|
+
readonly op: TierOperation;
|
|
17
|
+
readonly key: string;
|
|
18
|
+
/** The `X_*` code when the tier threw an `UltimateError`; absent for anything else. */
|
|
19
|
+
readonly code?: string;
|
|
20
|
+
readonly message: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A dev log, not an audit trail — capped so a long-lived process cannot grow it forever.
|
|
24
|
+
const MAX_TIER_FAILURES = 100;
|
|
25
|
+
|
|
26
|
+
/** Newest first. Module-private; read it through `recentTierFailures()`. */
|
|
27
|
+
const failureLog: TierFailure[] = [];
|
|
28
|
+
|
|
29
|
+
/** What a "is the cache degraded?" question reads: newest first, capped, a copy of the log. */
|
|
30
|
+
export function recentTierFailures(): readonly TierFailure[] {
|
|
31
|
+
return [...failureLog];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Test seam, and `resetTiers()`'s: drops every recorded failure. */
|
|
35
|
+
export function resetTierFailures(): void {
|
|
36
|
+
failureLog.length = 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `isolateDeclaredTags()`'s contract over this log — `tags.ts` carries the why. It lives here
|
|
41
|
+
* rather than in a test file because the log has a reader and no writer: from outside this module
|
|
42
|
+
* the entries `resetTierFailures()` drops cannot be put back at all.
|
|
43
|
+
*
|
|
44
|
+
* const restoreFailures = isolateTierFailures();
|
|
45
|
+
* afterAll(restoreFailures);
|
|
46
|
+
*/
|
|
47
|
+
export function isolateTierFailures(): () => void {
|
|
48
|
+
const captured = [...failureLog];
|
|
49
|
+
return () => {
|
|
50
|
+
failureLog.length = 0;
|
|
51
|
+
failureLog.push(...captured);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Runs one tier call and absorbs its refusal. `undefined` back means the tier declined, which a
|
|
57
|
+
* `get` already reads as a miss and a `set`/`del` as "that tier is unchanged" — so the caller
|
|
58
|
+
* needs no branch. The entry a tier refused to hold expires by TTL, exactly as one an
|
|
59
|
+
* `invalidateTags` failure left behind does.
|
|
60
|
+
*
|
|
61
|
+
* Public, and the only sanctioned way to swallow a cache refusal: a store outside this package
|
|
62
|
+
* (`@ultimat3/query`'s read cache) that wrapped its own `try/catch` would degrade invisibly, and
|
|
63
|
+
* a second failure log nobody reads is what this one exists to prevent. Pass the store's
|
|
64
|
+
* `TierLabel` — it is closed for that reason.
|
|
65
|
+
*/
|
|
66
|
+
export async function bestEffort<T>(
|
|
67
|
+
tier: TierLabel,
|
|
68
|
+
op: TierOperation,
|
|
69
|
+
key: string,
|
|
70
|
+
run: () => Promise<T>,
|
|
71
|
+
): Promise<T | undefined> {
|
|
72
|
+
try {
|
|
73
|
+
return await run();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
record(tier, op, key, error);
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The `X_*` code when the tier threw an `UltimateError`, and `undefined` for every other answer —
|
|
82
|
+
* "the probe itself threw" included. `instanceof` RUNS a `Proxy`'s `getPrototypeOf` trap and the
|
|
83
|
+
* read past it is a getter call, both on a value this package did not build; the one place the
|
|
84
|
+
* question is asked is the catch block absorbing a refusal, which has nothing left to answer with
|
|
85
|
+
* if asking it raises. Core's `isThrownError` is this guard for `Error` and `stringField` is it for
|
|
86
|
+
* a loose field — neither fits here, because a driver error's `code` is a SQLSTATE and must never
|
|
87
|
+
* be reported as an `X_*` one.
|
|
88
|
+
*/
|
|
89
|
+
function ultimateCode(error: unknown): string | undefined {
|
|
90
|
+
try {
|
|
91
|
+
if (!(error instanceof UltimateError)) return undefined;
|
|
92
|
+
return typeof error.code === 'string' ? error.code : undefined;
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function record(tier: TierLabel, op: TierOperation, key: string, error: unknown): void {
|
|
99
|
+
const code = ultimateCode(error);
|
|
100
|
+
const failure: TierFailure = {
|
|
101
|
+
at: systemClock.now().toISOString(),
|
|
102
|
+
tier,
|
|
103
|
+
op,
|
|
104
|
+
key,
|
|
105
|
+
...(code === undefined ? {} : { code }),
|
|
106
|
+
// Never `error.message`: a rendering that throws replaces the absorbed refusal with a
|
|
107
|
+
// `TypeError` on the business read this function exists to keep alive.
|
|
108
|
+
message: renderThrowable(error),
|
|
109
|
+
};
|
|
110
|
+
failureLog.unshift(failure);
|
|
111
|
+
failureLog.length = Math.min(failureLog.length, MAX_TIER_FAILURES);
|
|
112
|
+
logger.warn('cache.tier.failed', { ...failure });
|
|
113
|
+
}
|
package/src/tiers.ts
CHANGED
|
@@ -4,13 +4,54 @@
|
|
|
4
4
|
// sites. Order is data, not control flow.
|
|
5
5
|
|
|
6
6
|
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import { systemClock } from '@ultimat3/core';
|
|
8
|
+
import { CacheJitterInvalidError, CacheTtlInvalidError } from './errors';
|
|
9
|
+
import type { CacheFence } from './fence';
|
|
10
|
+
import { markInvalidated, sampleFence } from './fence';
|
|
11
|
+
import { mergeSetOptions, ttlOptionsFor } from './set-options';
|
|
12
|
+
import { createSingleFlight } from './single-flight';
|
|
7
13
|
import type { CacheTag } from './tags';
|
|
14
|
+
import { bestEffort } from './tier-failures';
|
|
8
15
|
|
|
9
16
|
export type TierName = 'request-memo' | 'lru' | 'redis' | 'cdn';
|
|
10
17
|
|
|
11
18
|
/** Read order. Index in this array is the tier's distance from the request. */
|
|
12
19
|
export const TIER_ORDER: readonly TierName[] = ['request-memo', 'lru', 'redis', 'cdn'];
|
|
13
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Who a swallowed refusal is attributed to in `recentTierFailures()` and the `/_x` panel: every
|
|
23
|
+
* rung of the ladder, plus a store that degrades the same way without being on it.
|
|
24
|
+
*
|
|
25
|
+
* Closed rather than a free-form string, and NOT a widening of `TierName`: `TIER_ORDER` is the
|
|
26
|
+
* ladder and a name missing from it sorts to `-1`, ahead of the request memo. A label is a log
|
|
27
|
+
* facet; a `TierName` is a position. Two spellings of one store is a panel nobody can group.
|
|
28
|
+
*
|
|
29
|
+
* **`'query-read'` emits nothing as of 2026-08** and is kept only because narrowing a shipped
|
|
30
|
+
* exported union breaks any caller that passes it to `bestEffort`. It named `@ultimat3/query`'s
|
|
31
|
+
* private read cache, which was a store in no registry — so `invalidateTags` could not reach it,
|
|
32
|
+
* which is exactly why that store is gone and a `cache:` read now fills these tiers. A refusal on
|
|
33
|
+
* that path is attributed to the tier that actually refused. Do not add a second such member: a
|
|
34
|
+
* cache worth a label is a cache worth registering.
|
|
35
|
+
*/
|
|
36
|
+
export type TierLabel = TierName | 'query-read';
|
|
37
|
+
|
|
38
|
+
/** Injected so a jittered TTL is deterministic in a test. Never `Math.random()` at a call site. */
|
|
39
|
+
export type Rng = () => number;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 5%: enough to smear a warm-up herd across the tail of its lease, small enough that a 60s cache
|
|
43
|
+
* is never mistaken for a 54s one. Higher is a correctness question for the app, not the tier.
|
|
44
|
+
*/
|
|
45
|
+
export const DEFAULT_TTL_JITTER_FRACTION = 0.05;
|
|
46
|
+
|
|
47
|
+
/** How a tier spreads its TTLs. Every tier takes it; `assertTtl` is the one place it is applied. */
|
|
48
|
+
export interface TtlJitter {
|
|
49
|
+
/** Fraction of the lease that may be shaved off, in `[0, 1)`. `0` disables it. */
|
|
50
|
+
readonly jitterFraction?: number;
|
|
51
|
+
/** `() => 0` is "the full lease, this write" — the deterministic setting a test wants. */
|
|
52
|
+
readonly rng?: Rng;
|
|
53
|
+
}
|
|
54
|
+
|
|
14
55
|
export interface CacheEntry<T> {
|
|
15
56
|
readonly value: T;
|
|
16
57
|
/** Epoch ms; `undefined` means no expiry. */
|
|
@@ -19,10 +60,58 @@ export interface CacheEntry<T> {
|
|
|
19
60
|
}
|
|
20
61
|
|
|
21
62
|
export interface CacheSetOptions {
|
|
63
|
+
/**
|
|
64
|
+
* Lifetime in milliseconds. **Positive and finite, always** — omit it for the tier's default.
|
|
65
|
+
* There is no "never expires" and no "do not cache": both used to be spellings of `0` that the
|
|
66
|
+
* LRU and Redis tiers read differently, so every tier now refuses it (`X_CACHE_TTL_INVALID`).
|
|
67
|
+
*/
|
|
22
68
|
readonly ttlMs?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Lifetime for a `null`/`undefined` load, when it should differ from `ttlMs`. A lookup for a
|
|
71
|
+
* row that has not replicated yet answers `null` 40ms before it lands; holding that for the
|
|
72
|
+
* positive TTL serves "does not exist" for five minutes. Omitted means "same as `ttlMs`",
|
|
73
|
+
* which is the accident this field makes a decision.
|
|
74
|
+
*/
|
|
75
|
+
readonly negativeTtlMs?: number;
|
|
23
76
|
readonly tags?: readonly CacheTag[];
|
|
24
77
|
}
|
|
25
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The one TTL rule, applied by every tier before it writes: validate, then spread. It lives here
|
|
81
|
+
* rather than in each tier because two tiers disagreeing about what `0` means is exactly the bug
|
|
82
|
+
* this replaced — and jitter belongs at the same choke point for the same reason.
|
|
83
|
+
*
|
|
84
|
+
* Jitter is not a nicety. A rolling restart warms 40,000 keys inside 30 seconds and hands every
|
|
85
|
+
* one of them the same 300s lease; five minutes later all 40,000 expire inside the same 30-second
|
|
86
|
+
* window, and with single-flight sharing only the loads that overlap that is still 40,000 origin
|
|
87
|
+
* reads. Shaving a random slice off each lease is what turns one cliff into a ramp.
|
|
88
|
+
*/
|
|
89
|
+
/**
|
|
90
|
+
* Where a lease is being spent. Every tier — plus `'semantic'`, which is not a tier and still may
|
|
91
|
+
* not invent its own reading of `ttlMs: 0`.
|
|
92
|
+
*/
|
|
93
|
+
export type TtlScope = TierName | 'semantic';
|
|
94
|
+
|
|
95
|
+
export function assertTtl(
|
|
96
|
+
key: string,
|
|
97
|
+
ttlMs: number,
|
|
98
|
+
tier: TtlScope,
|
|
99
|
+
jitter: TtlJitter = {},
|
|
100
|
+
): number {
|
|
101
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
102
|
+
throw new CacheTtlInvalidError({ key, ttlMs, tier });
|
|
103
|
+
}
|
|
104
|
+
const fraction = jitter.jitterFraction ?? DEFAULT_TTL_JITTER_FRACTION;
|
|
105
|
+
if (!Number.isFinite(fraction) || fraction < 0 || fraction >= 1) {
|
|
106
|
+
throw new CacheJitterInvalidError({ tier, jitterFraction: fraction });
|
|
107
|
+
}
|
|
108
|
+
if (fraction === 0) return ttlMs;
|
|
109
|
+
// Clamped rather than trusted: an `rng` outside [0, 1) would EXTEND the lease past what the
|
|
110
|
+
// caller asked for, which is a stale read no reader can explain.
|
|
111
|
+
const roll = Math.min(1, Math.max(0, (jitter.rng ?? Math.random)()));
|
|
112
|
+
return Math.max(1, Math.round(ttlMs * (1 - fraction * roll)));
|
|
113
|
+
}
|
|
114
|
+
|
|
26
115
|
/** Per-tier result of an invalidation, surfaced verbatim in the `/_x` cache panel. */
|
|
27
116
|
export interface TierInvalidation {
|
|
28
117
|
readonly tier: TierName;
|
|
@@ -65,36 +154,149 @@ export function sortTiers(tiers: readonly CacheTier[]): readonly CacheTier[] {
|
|
|
65
154
|
return [...tiers].sort((a, b) => TIER_ORDER.indexOf(a.name) - TIER_ORDER.indexOf(b.name));
|
|
66
155
|
}
|
|
67
156
|
|
|
68
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Every tier call here goes through `bestEffort`: a tier that refuses is a tier that did not
|
|
159
|
+
* answer, never a failed business read. `load()` is the one call left unguarded — it *is* the
|
|
160
|
+
* business read, and swallowing it would return `undefined` as if it were the value.
|
|
161
|
+
*/
|
|
162
|
+
export interface CacheStackOptions {
|
|
163
|
+
/** Read through `nowMs()`; the same clock a tier takes. Defaults to `systemClock`. */
|
|
164
|
+
readonly clock?: Clock;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function createCacheStack(
|
|
168
|
+
tiers: readonly CacheTier[],
|
|
169
|
+
options: CacheStackOptions = {},
|
|
170
|
+
): CacheStack {
|
|
69
171
|
const ordered = sortTiers(tiers);
|
|
172
|
+
const clock = options.clock ?? systemClock;
|
|
173
|
+
// Per stack, not per module: two stacks are two ladders and must not join each other's loads.
|
|
174
|
+
const flight = createSingleFlight();
|
|
70
175
|
|
|
71
|
-
|
|
72
|
-
|
|
176
|
+
/** Take back what a fence refused mid-ladder: half a stale ladder is still a stale read. */
|
|
177
|
+
const rollback = async (written: readonly CacheTier[], key: string): Promise<void> => {
|
|
178
|
+
for (const tier of [...written].reverse()) {
|
|
179
|
+
await bestEffort(tier.name, 'del', key, () => tier.del(key));
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `fence` is what stops a fill from republishing what an invalidation just cleared: the value
|
|
185
|
+
* was read by a `load()` that started before the bust, so writing it now hides that write from
|
|
186
|
+
* every reader for the whole TTL — and the invalidation reported `errors: []` while doing it.
|
|
187
|
+
* Re-checked per tier rather than once, because the ladder is several awaits long.
|
|
188
|
+
*/
|
|
189
|
+
const fill = async <T>(
|
|
190
|
+
key: string,
|
|
191
|
+
value: T,
|
|
192
|
+
setOptions?: CacheSetOptions,
|
|
193
|
+
fence?: CacheFence,
|
|
194
|
+
): Promise<void> => {
|
|
195
|
+
const resolved = ttlOptionsFor(value, setOptions);
|
|
196
|
+
const written: CacheTier[] = [];
|
|
197
|
+
for (const tier of ordered) {
|
|
198
|
+
if (fence !== undefined && !fence.isValid()) {
|
|
199
|
+
await rollback(written, key);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
await bestEffort(tier.name, 'set', key, () => tier.set(key, value, resolved));
|
|
203
|
+
written.push(tier);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
73
206
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
207
|
+
/** Walks down, promoting into every tier it passed. `undefined` means every tier missed. */
|
|
208
|
+
const lookup = async <T>(
|
|
209
|
+
key: string,
|
|
210
|
+
setOptions?: CacheSetOptions,
|
|
211
|
+
): Promise<CacheEntry<T> | undefined> => {
|
|
212
|
+
// A promotion is a write too. The ladder is one await per rung, so a bust can finish between
|
|
213
|
+
// the far `get` and the near `set` — which promotes a value out of a tier nothing has cleared
|
|
214
|
+
// yet into one that was cleared a millisecond ago. Fan-out order makes that window small; the
|
|
215
|
+
// fence is what makes crossing it not matter.
|
|
216
|
+
const fence = sampleFence({ key });
|
|
217
|
+
for (let i = 0; i < ordered.length; i += 1) {
|
|
218
|
+
const tier = ordered[i];
|
|
219
|
+
if (tier === undefined) continue;
|
|
220
|
+
const hit = await bestEffort(tier.name, 'get', key, () => tier.get<T>(key));
|
|
221
|
+
if (hit === undefined) continue;
|
|
222
|
+
const now = nowMs(clock);
|
|
223
|
+
// A tier may answer with an entry it has not reaped yet; expiry is decided here, once,
|
|
224
|
+
// by the predicate this module already exported and nothing had ever called.
|
|
225
|
+
if (isExpired(hit, now)) continue;
|
|
226
|
+
// Populate every tier we walked past, closest-first on the next read — carrying the
|
|
227
|
+
// entry's REMAINING life, never the caller's original ttlMs. Re-leasing a value one
|
|
228
|
+
// second from expiry for a fresh five minutes on every read is a hot key that never
|
|
229
|
+
// goes stale enough to be refetched.
|
|
230
|
+
const promoted: CacheSetOptions = {
|
|
231
|
+
...setOptions,
|
|
232
|
+
tags: hit.tags,
|
|
233
|
+
...(hit.expiresAt === undefined ? {} : { ttlMs: hit.expiresAt - now }),
|
|
234
|
+
};
|
|
235
|
+
fence.cover({ tags: hit.tags });
|
|
236
|
+
const promotedInto: CacheTier[] = [];
|
|
237
|
+
for (let up = 0; up < i; up += 1) {
|
|
238
|
+
const closer = ordered[up];
|
|
239
|
+
if (closer === undefined) continue;
|
|
240
|
+
if (!fence.isValid()) {
|
|
241
|
+
await rollback(promotedInto, key);
|
|
242
|
+
break;
|
|
83
243
|
}
|
|
84
|
-
|
|
244
|
+
await bestEffort(closer.name, 'set', key, () => closer.set(key, hit.value, promoted));
|
|
245
|
+
promotedInto.push(closer);
|
|
85
246
|
}
|
|
247
|
+
// Returned either way: this IS what a tier held when it was asked, and a fence never fails
|
|
248
|
+
// a business read — it only declines to publish.
|
|
249
|
+
return hit;
|
|
250
|
+
}
|
|
251
|
+
return undefined;
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
tiers: ordered,
|
|
86
256
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return value;
|
|
257
|
+
async read<T>(key: string, load: () => Promise<T>, setOptions?: CacheSetOptions): Promise<T> {
|
|
258
|
+
const hit = await lookup<T>(key, setOptions);
|
|
259
|
+
if (hit !== undefined) return hit.value;
|
|
260
|
+
|
|
261
|
+
// The stampede guard. The homepage feed read 8,000x/s with a 60s lease misses for the whole
|
|
262
|
+
// ~200ms `load()` takes, so ~1,600 identical queries reach Postgres at every TTL boundary
|
|
263
|
+
// unless the arrivals inside that window join the read already running.
|
|
264
|
+
return await flight.run<T, CacheSetOptions>(
|
|
265
|
+
key,
|
|
266
|
+
async (shared) => {
|
|
267
|
+
// Sampled BEFORE `load()` — everything after this instant is a write this value has
|
|
268
|
+
// not seen, and a fill that ignored it would hide that write for the whole TTL.
|
|
269
|
+
const fence = sampleFence({
|
|
270
|
+
key,
|
|
271
|
+
...(setOptions?.tags === undefined ? {} : { tags: setOptions.tags }),
|
|
272
|
+
});
|
|
273
|
+
const value = await load();
|
|
274
|
+
// Joiners merged their own tags into the load they shared; covering is retroactive, so
|
|
275
|
+
// a tag that arrived mid-load is fenced back to the sample rather than from now.
|
|
276
|
+
const merged = shared() ?? setOptions;
|
|
277
|
+
if (merged?.tags !== undefined) fence.cover({ tags: merged.tags });
|
|
278
|
+
await fill(key, value, merged, fence);
|
|
279
|
+
return value;
|
|
280
|
+
},
|
|
281
|
+
{ context: setOptions ?? {}, merge: mergeSetOptions },
|
|
282
|
+
);
|
|
90
283
|
},
|
|
91
284
|
|
|
92
|
-
|
|
93
|
-
|
|
285
|
+
write<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
|
|
286
|
+
// An explicit write is newer truth than any load already in flight for this key, so it
|
|
287
|
+
// fences those fills off before it starts rather than losing a race with one.
|
|
288
|
+
markInvalidated({ key });
|
|
289
|
+
return fill(key, value, options);
|
|
94
290
|
},
|
|
95
291
|
|
|
96
292
|
async drop(key: string): Promise<void> {
|
|
97
|
-
|
|
293
|
+
markInvalidated({ key });
|
|
294
|
+
// Farthest tier first, for the reason `invalidateTags` fans out that way: clearing the near
|
|
295
|
+
// tiers first leaves a window where a racing read finds the far tier still holding the old
|
|
296
|
+
// value and promotes it back up, into tiers this call has already cleared.
|
|
297
|
+
for (const tier of [...ordered].reverse()) {
|
|
298
|
+
await bestEffort(tier.name, 'del', key, () => tier.del(key));
|
|
299
|
+
}
|
|
98
300
|
},
|
|
99
301
|
};
|
|
100
302
|
}
|