@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 +7 -0
- package/README.md +27 -2
- package/package.json +2 -2
- package/src/fence.ts +11 -0
- package/src/index.ts +5 -0
- package/src/invalidate.ts +191 -11
- package/src/lru.ts +3 -0
- package/src/partial-log.ts +54 -0
- package/src/tiers.ts +6 -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
|
|
@@ -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"`
|
|
308
|
-
|
|
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.
|
|
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/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
|
-
|
|
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,7 +335,139 @@ 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);
|
|
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 {
|