@ultimat3/cache 1.1.0 → 2.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.
@@ -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,92 @@
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, 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
+ function record(tier: TierLabel, op: TierOperation, key: string, error: unknown): void {
81
+ const failure: TierFailure = {
82
+ at: systemClock.now().toISOString(),
83
+ tier,
84
+ op,
85
+ key,
86
+ ...(error instanceof UltimateError ? { code: error.code } : {}),
87
+ message: error instanceof Error ? error.message : String(error),
88
+ };
89
+ failureLog.unshift(failure);
90
+ failureLog.length = Math.min(failureLog.length, MAX_TIER_FAILURES);
91
+ logger.warn('cache.tier.failed', { ...failure });
92
+ }
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
- export function createCacheStack(tiers: readonly CacheTier[]): CacheStack {
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
- return {
72
- tiers: ordered,
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
- async read<T>(key: string, load: () => Promise<T>, options?: CacheSetOptions): Promise<T> {
75
- for (let i = 0; i < ordered.length; i += 1) {
76
- const tier = ordered[i];
77
- if (tier === undefined) continue;
78
- const hit = await tier.get<T>(key);
79
- if (hit === undefined) continue;
80
- // Populate every tier we walked past, closest-first on the next read.
81
- for (let up = 0; up < i; up += 1) {
82
- await ordered[up]?.set(key, hit.value, { ...options, tags: hit.tags });
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
- return hit.value;
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
- const value = await load();
88
- for (const tier of ordered) await tier.set(key, value, options);
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
- async write<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
93
- for (const tier of ordered) await tier.set(key, value, options);
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
- for (const tier of ordered) await tier.del(key);
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
  }