@ultimat3/cache 27.7.0 → 27.8.1

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
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.7.0",
3
+ "version": "27.8.1",
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.7.0"
35
+ "@ultimat3/core": "27.8.1"
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
- 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,11 +335,54 @@ 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);
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 marked stale, and every fill in flight is fenced out. Shared tiers and the CDN are
307
- * untouched — the bust's sender cleared those — and nothing is re-emitted: this is local repair.
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
- const isr = dedupe(
326
- graphSnapshot().flatMap((entry) =>
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 isr) {
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
+ }