@ultimat3/cache 27.7.0 → 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 +4 -0
- package/README.md +16 -0
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/invalidate.ts +130 -14
- package/src/partial-log.ts +54 -0
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
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cache",
|
|
3
|
-
"version": "27.
|
|
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.
|
|
35
|
+
"@ultimat3/core": "27.8.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/index.ts
CHANGED
|
@@ -39,13 +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,
|
|
44
46
|
FLUSH_ALL_TAG,
|
|
45
47
|
flushProcessTiers,
|
|
46
48
|
invalidateTags,
|
|
47
49
|
invalidateWireTags,
|
|
48
50
|
isolateTiers,
|
|
51
|
+
purgeEdgeAgain,
|
|
49
52
|
receiveInvalidationBroadcast,
|
|
50
53
|
recentInvalidations,
|
|
51
54
|
registeredTiers,
|
package/src/invalidate.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from '@ultimat3/core';
|
|
15
15
|
import { markAllInvalidated, markInvalidated } from './fence';
|
|
16
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,11 +335,54 @@ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<Inva
|
|
|
287
335
|
errors: report.errors,
|
|
288
336
|
});
|
|
289
337
|
|
|
290
|
-
|
|
338
|
+
// Thinned per failing tier (`partial-log.ts`): an outage is not one line per bust.
|
|
339
|
+
logPartial(report, cleared);
|
|
291
340
|
return report;
|
|
292
341
|
});
|
|
293
342
|
}
|
|
294
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
|
+
|
|
295
386
|
/** What a flush reports and records as its tags: not a wire tag, and never parsed as one. */
|
|
296
387
|
export const FLUSH_ALL_TAG = '*';
|
|
297
388
|
|
|
@@ -303,8 +394,11 @@ export const FLUSH_ALL_TAG = '*';
|
|
|
303
394
|
* their TTL — and a `revalidate: { tags }` page has none. So a process whose bus connection was
|
|
304
395
|
* down for a window, or a sender that had more refused busts than it could keep, has one correct
|
|
305
396
|
* answer left. Every in-process tier is cleared (`CacheTier.clear`), every ISR page that depends
|
|
306
|
-
* on a tag is
|
|
307
|
-
*
|
|
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.
|
|
308
402
|
* Never throws; the cost is one cold cache.
|
|
309
403
|
*/
|
|
310
404
|
export function flushProcessTiers(source: string): Promise<InvalidationReport> {
|
|
@@ -322,18 +416,40 @@ export function flushProcessTiers(source: string): Promise<InvalidationReport> {
|
|
|
322
416
|
errors.push({ tier: tier.name, message: renderThrowable(error) });
|
|
323
417
|
}
|
|
324
418
|
}
|
|
325
|
-
|
|
326
|
-
|
|
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) =>
|
|
327
426
|
entry.dependents.filter((dep) => dep.kind === 'isr-route').map((dep) => dep.id),
|
|
328
427
|
),
|
|
329
428
|
);
|
|
330
|
-
for (const path of
|
|
429
|
+
for (const path of isrPaths) {
|
|
331
430
|
try {
|
|
332
431
|
await revalidator?.(path);
|
|
333
432
|
} catch (error) {
|
|
334
433
|
errors.push({ tier: 'isr', message: renderThrowable(error) });
|
|
335
434
|
}
|
|
336
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
|
+
);
|
|
337
453
|
const report: InvalidationReport = {
|
|
338
454
|
tags: [FLUSH_ALL_TAG],
|
|
339
455
|
tiers,
|
|
@@ -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
|
+
}
|