@ultimat3/cache 27.6.1 → 27.8.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 CHANGED
@@ -21,6 +21,10 @@ Tier 1. Tagged caching + THE invalidation graph.
21
21
  - **The fan-out clears FARTHEST tier first, and reports in read order** (core's `CACHE_TIERS`, what `/_x`
22
22
  renders). Near-to-far lets a racing read promote the stale far value straight back up.
23
23
  `CacheStack.drop` reverses for the same reason. `invalidation-race.test.ts`.
24
+ - **The EDGE is purged last**: read tiers → ISR revalidators → broadcast → `cdn`. Purged first, a
25
+ request before the origin's own delete was a public hit the purged edge cached again for a whole
26
+ `s-maxage`. A refused broadcast purges it again when the deferred one is published
27
+ (`purgeEdgeAgain`, called by cli); a flush purges it last too. `invalidate-order.test.ts`.
24
28
  - **A fill is fenced: sample before `load()`, ask before the write** (`fence.ts`). `sampleFence({ key,
25
29
  tags })` → `fence.isValid()`; `markInvalidated` is the write half (`fanOut`, `CacheStack.write`,
26
30
  `CacheStack.drop`). Exported so a store outside this package reuses it rather than growing a
@@ -42,6 +46,9 @@ Tier 1. Tagged caching + THE invalidation graph.
42
46
  `TIER_ORDER` alias (`X_HELPER_COPY` refuses a value of core's under a second name). Adding a
43
47
  rung is an edit to `packages/core/src/cache-vocabulary.ts` plus a factory here; `scripts/render-modes.ts` refuses a second declaration of the set. **`isr` is
44
48
  not a tier** — the `'isr'` in `invalidate.ts` is an ISR route (`DependentKind = 'isr-route'`).
49
+ - **`flushProcessTiers(source)` is for a process that cannot name what it missed** (its bus was
50
+ away): `CacheTier.clear()` on in-process tiers only, tagged ISR pages stale, fills fenced out
51
+ (`markAllInvalidated`). Never re-emits. A shared tier must NOT implement `clear`.
45
52
  - **`bestEffort()` is the only sanctioned way to swallow a cache refusal.** Its label is `TierLabel`
46
53
  (`TierName` plus `'query-read'`), deliberately not a widening of `TierName`.
47
54
  - **A refusal is rendered with `renderThrowable()`, never `error.message`** — at all five absorbing
package/README.md CHANGED
@@ -283,6 +283,22 @@ one naming the span that triggered it. That is the log the `/_x` cache panel ren
283
283
  actually clear?" is answerable without a log dive because the one fan-out path retained the
284
284
  answer, not because a second recorder was wired next to it.
285
285
 
286
+ ### The order of one bust
287
+
288
+ Read tiers, farthest first (`redis` → `lru` → `request-memo`) → the ISR revalidators → the
289
+ broadcast to peers → the `cdn` tier **last**. The edge holds responses, not values a render reads:
290
+ purged before the origin dropped its own page, it was handed that page again by the next request
291
+ and kept it for a whole `s-maxage`. `report.tiers` is still the ladder in read order.
292
+
293
+ ### The ISR holder is asked by tag
294
+
295
+ `registerRevalidator(byPath, byTags?)` takes both halves in one call (`@ultimat3/render`'s
296
+ controller is the caller). `byPath` is told each `isr-route` dependent the graph holds; `byTags`
297
+ (`TagRevalidator`) is handed the busted tags and answers the paths it revalidated — the pages its
298
+ STORE holds under them, which the graph cannot know when two controllers share a store or an entry
299
+ outlived the process that rendered it. Both lists are one `report.isr`; a rejection is a
300
+ `report.errors` row (`tier: 'isr'`), never a failed bust. Omitting `byTags` clears the previous one.
301
+
286
302
  ### Across instances
287
303
 
288
304
  `invalidateTags` clears the tiers of the process that called it. On a fleet that is one pod: a user
@@ -304,8 +320,17 @@ bus.subscribe('cache.invalidate', (wireTags) => receiveInvalidationBroadcast(wir
304
320
  The inbound half **cannot** re-emit, and that is structural rather than a flag: `emit` lives on the
305
321
  private fan-out options and `receiveInvalidationBroadcast` is the only caller that passes `false`.
306
322
  A receiver that re-broadcast would be a storm bounded by nothing. A failed send lands in
307
- `report.errors` under `tier: "broadcast"` — the other pods then clear on TTL, and the write that
308
- triggered the bust still succeeds. An inbound tag this process has not declared is dropped and
323
+ `report.errors` under `tier: "broadcast"` and the write that triggered the bust still succeeds.
324
+ What happens to that bust is the sender's (`@ultimat3/cli` keeps the refused tags, bounded, and
325
+ publishes them when the bus returns); with no such sender the other pods clear on TTL.
326
+
327
+ **`flushProcessTiers(source)` is the repair for a process that cannot know what it missed** — its
328
+ bus connection was down for a window, or a peer says it had more refused busts than it could keep.
329
+ It calls `CacheTier.clear()` on every tier that has one (only a tier whose store is this process's
330
+ heap implements it: `lruTier` does, `redisTier` and `cdnTier` do not), marks every ISR page that
331
+ depends on a tag stale through the registered revalidator, and fences out every fill in flight
332
+ (`markAllInvalidated`). It never re-emits and never throws; its report and its `/_x` event carry
333
+ `tags: ['*']` (`FLUSH_ALL_TAG`). An inbound tag this process has not declared is dropped and
309
334
  reported rather than thrown, because mid-deploy the new pods know an entity the old ones do not and
310
335
  a throw would kill the subscriber loop that delivered it.
311
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cache",
3
- "version": "27.6.1",
3
+ "version": "27.8.0",
4
4
  "description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,6 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "27.6.1"
35
+ "@ultimat3/core": "27.8.0"
36
36
  }
37
37
  }
package/src/fence.ts CHANGED
@@ -70,6 +70,17 @@ export function markInvalidated(scope: FenceScope): void {
70
70
  }
71
71
  }
72
72
 
73
+ /**
74
+ * Everything, invalidated: every fence sampled before this call answers `false`. For a process
75
+ * that cannot name what it missed (`flushProcessTiers`) — the same "older than the ring remembers:
76
+ * unprovable, so refused" rule, reached on purpose. One refetch per in-flight fill.
77
+ */
78
+ export function markAllInvalidated(): void {
79
+ generation += 1;
80
+ forgottenThrough = generation;
81
+ marks.length = 0;
82
+ }
83
+
73
84
  function hits(mark: Mark, scope: FenceScope): boolean {
74
85
  if (mark.key !== undefined) return scope.key !== undefined && mark.key === scope.key;
75
86
  const owned = mark.tag;
package/src/index.ts CHANGED
@@ -39,11 +39,16 @@ export type {
39
39
  InvalidationEvent,
40
40
  InvalidationReport,
41
41
  Revalidator,
42
+ TagRevalidator,
42
43
  } from './invalidate';
43
44
  export {
45
+ EVERY_TAG,
46
+ FLUSH_ALL_TAG,
47
+ flushProcessTiers,
44
48
  invalidateTags,
45
49
  invalidateWireTags,
46
50
  isolateTiers,
51
+ purgeEdgeAgain,
47
52
  receiveInvalidationBroadcast,
48
53
  recentInvalidations,
49
54
  registeredTiers,
package/src/invalidate.ts CHANGED
@@ -12,8 +12,9 @@ import {
12
12
  systemClock,
13
13
  withSpan,
14
14
  } from '@ultimat3/core';
15
- import { markInvalidated } from './fence';
16
- import { dependentsOfKind } from './graph';
15
+ import { markAllInvalidated, markInvalidated } from './fence';
16
+ import { dependentsOfKind, graphSnapshot } from './graph';
17
+ import { logPartial, resetPartialLog } from './partial-log';
17
18
  import type { CacheTag } from './tags';
18
19
  import { assertKnownTags, knownTags, parseTag, serializeTags } from './tags';
19
20
  import { isolateTierFailures, resetTierFailures } from './tier-failures';
@@ -23,6 +24,18 @@ import { sortTiers } from './tiers';
23
24
  /** Revalidates one ISR route path. Provided by `@ultimat3/render`; absent on a worker. */
24
25
  export type Revalidator = (path: string) => Promise<void> | void;
25
26
 
27
+ /**
28
+ * Revalidates every ISR page HELD under these tags, and answers their paths. The graph only knows
29
+ * the pages this process rendered; a store that outlives the process, or that two controllers
30
+ * share, holds pages with no edge here — so the holder is asked by tag as well as told by path.
31
+ */
32
+ export type TagRevalidator = (
33
+ tags: readonly CacheTag[] | typeof EVERY_TAG,
34
+ ) => Promise<readonly string[]> | readonly string[];
35
+
36
+ /** What a flush hands the `TagRevalidator` in place of tags: every page it holds under ANY tag. */
37
+ export const EVERY_TAG = 'every-tag';
38
+
26
39
  /**
27
40
  * Carries wire tags to every OTHER process. The seam, never the transport: `cache` is tier 1 and
28
41
  * may not reach `realtime` (tier 3) or NATS, so `@ultimat3/cli` registers the sender at boot the
@@ -94,6 +107,7 @@ export function recentInvalidations(): readonly InvalidationEvent[] {
94
107
 
95
108
  const registry: CacheTier[] = [];
96
109
  let revalidator: Revalidator | undefined;
110
+ let tagRevalidator: TagRevalidator | undefined;
97
111
  let broadcast: InvalidationBroadcast | undefined;
98
112
 
99
113
  /** Tiers register at boot from `app.config.ts`; order is normalised, not trusted. */
@@ -114,9 +128,11 @@ export function registeredTiers(): readonly CacheTier[] {
114
128
  export function resetTiers(): void {
115
129
  registry.length = 0;
116
130
  revalidator = undefined;
131
+ tagRevalidator = undefined;
117
132
  broadcast = undefined;
118
133
  invalidationLog.length = 0;
119
134
  resetTierFailures();
135
+ resetPartialLog();
120
136
  }
121
137
 
122
138
  /**
@@ -134,6 +150,7 @@ export function resetTiers(): void {
134
150
  export function isolateTiers(): () => void {
135
151
  const capturedTiers = [...registry];
136
152
  const capturedRevalidator = revalidator;
153
+ const capturedTagRevalidator = tagRevalidator;
137
154
  const capturedBroadcast = broadcast;
138
155
  const capturedLog = [...invalidationLog];
139
156
  const restoreFailures = isolateTierFailures();
@@ -142,14 +159,20 @@ export function isolateTiers(): () => void {
142
159
  resetTiers();
143
160
  registry.push(...capturedTiers);
144
161
  revalidator = capturedRevalidator;
162
+ tagRevalidator = capturedTagRevalidator;
145
163
  broadcast = capturedBroadcast;
146
164
  invalidationLog.push(...capturedLog);
147
165
  restoreFailures();
148
166
  };
149
167
  }
150
168
 
151
- export function registerRevalidator(next: Revalidator): void {
169
+ /**
170
+ * One registration for both halves, so they cannot belong to two controllers: `byTags` is replaced
171
+ * with `next` — omitted, it is cleared, never left pointing at the previous owner's store.
172
+ */
173
+ export function registerRevalidator(next: Revalidator, byTags?: TagRevalidator): void {
152
174
  revalidator = next;
175
+ tagRevalidator = byTags;
153
176
  }
154
177
 
155
178
  /**
@@ -221,13 +244,18 @@ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<Inva
221
244
 
222
245
  const tiers: TierInvalidation[] = [];
223
246
  const errors = options.errors;
247
+ /** Every tier that was asked and answered: what ends that tier's run of refusals. */
248
+ const cleared: string[] = [];
224
249
 
225
250
  // FARTHEST tier first. Near-to-far leaves the far tier holding the old value after the near
226
251
  // ones are clear, and a read racing the bust promotes it straight back up into them — the
227
252
  // report says every tier cleared, and the LRU is stale again before the call returns.
228
- for (const tier of [...sortTiers(registry)].reverse()) {
253
+ // The EDGE is not one of them here: it is purged last, below (`purgeEdge`).
254
+ const farthestFirst = [...sortTiers(registry)].reverse();
255
+ for (const tier of farthestFirst.filter((one) => !isEdgeTier(one))) {
229
256
  try {
230
257
  tiers.push(await tier.invalidateTags(tags));
258
+ cleared.push(tier.name);
231
259
  } catch (error) {
232
260
  // `renderThrowable`, never `error.message`: a tier is app-supplied, so the value it
233
261
  // rejects with is too, and both `instanceof` and `String()` RUN app code on it. A render
@@ -236,21 +264,32 @@ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<Inva
236
264
  errors.push({ tier: tier.name, message: renderThrowable(error) });
237
265
  }
238
266
  }
239
- // The report is read order, not clear order: it is what the `/_x` panel renders, and a ladder
240
- // printed upside down is a second thing for a reader to learn.
241
- tiers.sort((a, b) => CACHE_TIERS.indexOf(a.tier) - CACHE_TIERS.indexOf(b.tier));
242
-
243
- const isr = dependentsOfKind(tags, 'isr-route');
267
+ const isrPaths = new Set(dependentsOfKind(tags, 'isr-route'));
244
268
  const cdn = dependentsOfKind(tags, 'cdn-path');
245
269
  const liveQueries = dependentsOfKind(tags, 'live-query');
246
270
 
247
- for (const path of isr) {
271
+ let isrFailed = false;
272
+ for (const path of isrPaths) {
248
273
  try {
249
274
  await revalidator?.(path);
250
275
  } catch (error) {
276
+ isrFailed = true;
251
277
  errors.push({ tier: 'isr', message: renderThrowable(error) });
252
278
  }
253
279
  }
280
+ // After the graph's own paths, and reported with them: what the holder found under the tags
281
+ // that this process never registered.
282
+ try {
283
+ for (const path of (await tagRevalidator?.(tags)) ?? []) isrPaths.add(path);
284
+ } catch (error) {
285
+ isrFailed = true;
286
+ errors.push({ tier: 'isr', message: renderThrowable(error) });
287
+ }
288
+ const isr = [...isrPaths];
289
+ // Asked and answered: a path revalidated, or the holder read by tag — never a bust that
290
+ // reached no ISR holder at all, which has said nothing about an earlier refusal.
291
+ const isrAsked = (isr.length > 0 && revalidator !== undefined) || tagRevalidator !== undefined;
292
+ if (isrAsked && !isrFailed) cleared.push('isr');
254
293
 
255
294
  const wire = serializeTags(tags);
256
295
 
@@ -259,11 +298,20 @@ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<Inva
259
298
  if (emit && wire.length > 0 && broadcast !== undefined) {
260
299
  try {
261
300
  await broadcast(wire);
301
+ cleared.push('broadcast');
262
302
  } catch (error) {
263
303
  errors.push({ tier: 'broadcast', message: renderThrowable(error) });
264
304
  }
265
305
  }
266
306
 
307
+ // The edge LAST, after this origin dropped its own pages and told its peers to. Purged first,
308
+ // a request between the purge and the origin's delete was answered the old page as a public
309
+ // hit, and the edge — already purged — kept it for a whole `s-maxage` with no purge to come.
310
+ await purgeEdge(farthestFirst, tags, tiers, errors, cleared);
311
+ // The report is read order, not clear order: it is what the `/_x` panel renders, and a ladder
312
+ // printed upside down is a second thing for a reader to learn.
313
+ tiers.sort((a, b) => CACHE_TIERS.indexOf(a.tier) - CACHE_TIERS.indexOf(b.tier));
314
+
267
315
  const report: InvalidationReport = {
268
316
  tags: wire,
269
317
  tiers,
@@ -287,7 +335,139 @@ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<Inva
287
335
  errors: report.errors,
288
336
  });
289
337
 
290
- if (errors.length > 0) logger.warn('cache.invalidate.partial', { ...report });
338
+ // Thinned per failing tier (`partial-log.ts`): an outage is not one line per bust.
339
+ logPartial(report, cleared);
340
+ return report;
341
+ });
342
+ }
343
+
344
+ /** The one rung that holds RESPONSES rather than values a render reads: core's last `CACHE_TIERS` name. */
345
+ const isEdgeTier = (tier: CacheTier): boolean => tier.name === CACHE_TIERS[CACHE_TIERS.length - 1];
346
+
347
+ /** The edge's own half of the fan-out, with a read tier's isolation: a refusal is a report row. */
348
+ async function purgeEdge(
349
+ ordered: readonly CacheTier[],
350
+ tags: readonly CacheTag[],
351
+ tiers: TierInvalidation[],
352
+ errors: { tier: string; message: string }[],
353
+ /** Told each edge tier that was asked and answered — what ends its run of refusals in the log. */
354
+ cleared?: string[],
355
+ ): Promise<void> {
356
+ for (const tier of ordered.filter(isEdgeTier)) {
357
+ try {
358
+ tiers.push(await tier.invalidateTags(tags));
359
+ cleared?.push(tier.name);
360
+ } catch (error) {
361
+ errors.push({ tier: tier.name, message: renderThrowable(error) });
362
+ }
363
+ }
364
+ }
365
+
366
+ /**
367
+ * The edge, purged AGAIN for tags this process already busted — for whoever finally got a refused
368
+ * broadcast through (`@ultimat3/cli`'s deferred publish). The first purge ran with the bust, while
369
+ * the peers still held their copies: any request they answered since handed the edge the old page
370
+ * back. Only now, told, do they drop it, so only now does a purge of the edge hold. Never throws,
371
+ * and touches no other tier — the bust itself cleared those.
372
+ */
373
+ export async function purgeEdgeAgain(
374
+ wire: readonly string[],
375
+ ): Promise<readonly TierInvalidation[]> {
376
+ const tiers: TierInvalidation[] = [];
377
+ const errors: { tier: string; message: string }[] = [];
378
+ const cleared: string[] = [];
379
+ await purgeEdge(sortTiers(registry), wire.map(parseTag), tiers, errors, cleared);
380
+ // The same thinned line a bust writes, on the same per-tier count: an edge that refuses for a
381
+ // whole outage is one condition, whichever of the two purges met it.
382
+ logPartial({ tags: wire, tiers, errors }, cleared);
383
+ return tiers;
384
+ }
385
+
386
+ /** What a flush reports and records as its tags: not a wire tag, and never parsed as one. */
387
+ export const FLUSH_ALL_TAG = '*';
388
+
389
+ /**
390
+ * Drop everything this PROCESS holds, because it cannot know what it missed.
391
+ *
392
+ * A peer's bust reaches this process by the broadcast and by nothing else: its in-process tier and
393
+ * its tag-revalidated ISR pages are cleared by `receiveInvalidationBroadcast`, or they wait for
394
+ * their TTL — and a `revalidate: { tags }` page has none. So a process whose bus connection was
395
+ * down for a window, or a sender that had more refused busts than it could keep, has one correct
396
+ * answer left. Every in-process tier is cleared (`CacheTier.clear`), every ISR page that depends
397
+ * on a tag is revalidated as its route declared — marked stale, or DELETED under
398
+ * `onInvalidate: 'purge'`, the store's unregistered pages included — and every fill in flight is
399
+ * fenced out. Shared read tiers are untouched — the bust's sender cleared those. The edge is
400
+ * purged, last, for the tags of the ISR pages this process held: what it answered while deaf may
401
+ * be what the edge holds now. Nothing is re-emitted: this is local repair.
402
+ * Never throws; the cost is one cold cache.
403
+ */
404
+ export function flushProcessTiers(source: string): Promise<InvalidationReport> {
405
+ return withSpan('cache.flush', async (): Promise<InvalidationReport> => {
406
+ const startedAt = performance.now();
407
+ markAllInvalidated();
408
+ const tiers: TierInvalidation[] = [];
409
+ const errors: { tier: string; message: string }[] = [];
410
+ for (const tier of sortTiers(registry)) {
411
+ if (tier.clear === undefined) continue;
412
+ try {
413
+ await tier.clear();
414
+ tiers.push({ tier: tier.name, keys: [] });
415
+ } catch (error) {
416
+ errors.push({ tier: tier.name, message: renderThrowable(error) });
417
+ }
418
+ }
419
+ // Read BEFORE the pages are revalidated: a purged page leaves the graph, and its tags are
420
+ // what the edge is purged by below.
421
+ const tagged = graphSnapshot().filter((entry) =>
422
+ entry.dependents.some((dep) => dep.kind === 'isr-route'),
423
+ );
424
+ const isrPaths = new Set(
425
+ tagged.flatMap((entry) =>
426
+ entry.dependents.filter((dep) => dep.kind === 'isr-route').map((dep) => dep.id),
427
+ ),
428
+ );
429
+ for (const path of isrPaths) {
430
+ try {
431
+ await revalidator?.(path);
432
+ } catch (error) {
433
+ errors.push({ tier: 'isr', message: renderThrowable(error) });
434
+ }
435
+ }
436
+ // And every page the holder has that the graph never named — as a bust asks it, by tag. A
437
+ // flush is at least a bust of everything: under `onInvalidate: 'purge'` those pages are deleted.
438
+ try {
439
+ for (const path of (await tagRevalidator?.(EVERY_TAG)) ?? []) isrPaths.add(path);
440
+ } catch (error) {
441
+ errors.push({ tier: 'isr', message: renderThrowable(error) });
442
+ }
443
+ const isr = [...isrPaths];
444
+ // The edge LAST, as in a bust, and for the pages THIS process could have handed it while it
445
+ // was deaf: a hit it answered from a copy a peer had already purged went out public, and the
446
+ // peer's own edge purge had run by then. Shared read tiers stay untouched — the sender's.
447
+ await purgeEdge(
448
+ sortTiers(registry),
449
+ tagged.map((entry) => parseTag(entry.tag)),
450
+ tiers,
451
+ errors,
452
+ );
453
+ const report: InvalidationReport = {
454
+ tags: [FLUSH_ALL_TAG],
455
+ tiers,
456
+ isr,
457
+ cdn: [],
458
+ liveQueries: [],
459
+ durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
460
+ errors,
461
+ };
462
+ recordInvalidation({
463
+ at: systemClock.now().toISOString(),
464
+ tags: report.tags,
465
+ busted: [...isr],
466
+ source,
467
+ durationMs: report.durationMs,
468
+ errors,
469
+ });
470
+ if (errors.length > 0) logger.warn('cache.flush.partial', { ...report });
291
471
  return report;
292
472
  });
293
473
  }
package/src/lru.ts CHANGED
@@ -305,5 +305,8 @@ export function lruTier(options: LruOptions = {}): CacheTier & { readonly cache:
305
305
  invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
306
306
  return Promise.resolve({ tier: 'lru', keys: cache.invalidateTags(tags) });
307
307
  },
308
+ clear(): void {
309
+ cache.clear();
310
+ },
308
311
  };
309
312
  }
@@ -0,0 +1,54 @@
1
+ // Single responsibility: how often `cache.invalidate.partial` is said. A tier that keeps refusing
2
+ // — the broadcast, while the bus is away — is ONE condition on a process that is still serving:
3
+ // a warning on its 1st, 2nd, 4th, 8th, … refusal and one line when it clears again, never one
4
+ // line per bust per publisher for as long as an outage lasts. The report itself is unchanged:
5
+ // every caller still gets every error, and the `/_x` panel still records every bust.
6
+
7
+ import { isOutageMilestone, logger } from '@ultimat3/core';
8
+
9
+ /** The line a partial bust writes; `<event> recovered` when a failing tier clears again. */
10
+ export const INVALIDATE_PARTIAL = 'cache.invalidate.partial';
11
+
12
+ /**
13
+ * Tier names tracked at once. A name is a registered tier's, `isr` or `broadcast` — a handful —
14
+ * so this is never reached by a real app; past it a refusal is simply said every time.
15
+ */
16
+ const MAX_TRACKED_TIERS = 64;
17
+
18
+ /** Consecutive refusals per tier name, since that tier last cleared. */
19
+ const refusals = new Map<string, number>();
20
+
21
+ /**
22
+ * One finished bust. `errors` is what it could not clear; `cleared` names every tier that was
23
+ * ASKED and answered — a tier this bust never reached (a bust with nothing to broadcast) has
24
+ * said nothing about its own outage, so it ends none.
25
+ */
26
+ export function logPartial<R extends { readonly errors: readonly { readonly tier: string }[] }>(
27
+ report: R,
28
+ cleared: readonly string[],
29
+ ): void {
30
+ for (const tier of cleared) {
31
+ const after = refusals.get(tier);
32
+ if (after === undefined) continue;
33
+ refusals.delete(tier);
34
+ logger.info(`${INVALIDATE_PARTIAL} recovered`, { tier, after });
35
+ }
36
+ let said = false;
37
+ let failures = 0;
38
+ for (const tier of new Set(report.errors.map((error) => error.tier))) {
39
+ if (!refusals.has(tier) && refusals.size >= MAX_TRACKED_TIERS) {
40
+ said = true;
41
+ continue;
42
+ }
43
+ const count = (refusals.get(tier) ?? 0) + 1;
44
+ refusals.set(tier, count);
45
+ failures = Math.max(failures, count);
46
+ if (isOutageMilestone(count)) said = true;
47
+ }
48
+ if (said) logger.warn(INVALIDATE_PARTIAL, { ...report, failures });
49
+ }
50
+
51
+ /** Test seam, and `resetTiers()`'s: every tier starts unrefused. */
52
+ export function resetPartialLog(): void {
53
+ refusals.clear();
54
+ }
package/src/tiers.ts CHANGED
@@ -195,6 +195,12 @@ export interface CacheTier {
195
195
  * may never be told about — still stops the fill (`tier-fence.ts`). Omitted by in-process tiers.
196
196
  */
197
197
  fence?(scope: FenceScope): Promise<TierFence>;
198
+ /**
199
+ * Drop every entry — implemented ONLY by a tier whose store is this process's own heap (`lru`).
200
+ * `flushProcessTiers()` calls it when the process cannot know which busts it missed. A shared
201
+ * tier omits it: whoever ran a bust cleared that store for everyone.
202
+ */
203
+ clear?(): Promise<void> | void;
198
204
  }
199
205
 
200
206
  export interface CacheStack {