@ultimat3/cache 27.6.1 → 27.7.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 +3 -0
- package/README.md +11 -2
- package/package.json +2 -2
- package/src/fence.ts +11 -0
- package/src/index.ts +2 -0
- package/src/invalidate.ts +66 -2
- package/src/lru.ts +3 -0
- package/src/tiers.ts +6 -0
package/CLAUDE.md
CHANGED
|
@@ -42,6 +42,9 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
42
42
|
`TIER_ORDER` alias (`X_HELPER_COPY` refuses a value of core's under a second name). Adding a
|
|
43
43
|
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
44
|
not a tier** — the `'isr'` in `invalidate.ts` is an ISR route (`DependentKind = 'isr-route'`).
|
|
45
|
+
- **`flushProcessTiers(source)` is for a process that cannot name what it missed** (its bus was
|
|
46
|
+
away): `CacheTier.clear()` on in-process tiers only, tagged ISR pages stale, fills fenced out
|
|
47
|
+
(`markAllInvalidated`). Never re-emits. A shared tier must NOT implement `clear`.
|
|
45
48
|
- **`bestEffort()` is the only sanctioned way to swallow a cache refusal.** Its label is `TierLabel`
|
|
46
49
|
(`TierName` plus `'query-read'`), deliberately not a widening of `TierName`.
|
|
47
50
|
- **A refusal is rendered with `renderThrowable()`, never `error.message`** — at all five absorbing
|
package/README.md
CHANGED
|
@@ -304,8 +304,17 @@ bus.subscribe('cache.invalidate', (wireTags) => receiveInvalidationBroadcast(wir
|
|
|
304
304
|
The inbound half **cannot** re-emit, and that is structural rather than a flag: `emit` lives on the
|
|
305
305
|
private fan-out options and `receiveInvalidationBroadcast` is the only caller that passes `false`.
|
|
306
306
|
A receiver that re-broadcast would be a storm bounded by nothing. A failed send lands in
|
|
307
|
-
`report.errors` under `tier: "broadcast"`
|
|
308
|
-
|
|
307
|
+
`report.errors` under `tier: "broadcast"` and the write that triggered the bust still succeeds.
|
|
308
|
+
What happens to that bust is the sender's (`@ultimat3/cli` keeps the refused tags, bounded, and
|
|
309
|
+
publishes them when the bus returns); with no such sender the other pods clear on TTL.
|
|
310
|
+
|
|
311
|
+
**`flushProcessTiers(source)` is the repair for a process that cannot know what it missed** — its
|
|
312
|
+
bus connection was down for a window, or a peer says it had more refused busts than it could keep.
|
|
313
|
+
It calls `CacheTier.clear()` on every tier that has one (only a tier whose store is this process's
|
|
314
|
+
heap implements it: `lruTier` does, `redisTier` and `cdnTier` do not), marks every ISR page that
|
|
315
|
+
depends on a tag stale through the registered revalidator, and fences out every fill in flight
|
|
316
|
+
(`markAllInvalidated`). It never re-emits and never throws; its report and its `/_x` event carry
|
|
317
|
+
`tags: ['*']` (`FLUSH_ALL_TAG`). An inbound tag this process has not declared is dropped and
|
|
309
318
|
reported rather than thrown, because mid-deploy the new pods know an entity the old ones do not and
|
|
310
319
|
a throw would kill the subscriber loop that delivered it.
|
|
311
320
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cache",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.7.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.7.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
package/src/invalidate.ts
CHANGED
|
@@ -12,8 +12,8 @@ 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
17
|
import type { CacheTag } from './tags';
|
|
18
18
|
import { assertKnownTags, knownTags, parseTag, serializeTags } from './tags';
|
|
19
19
|
import { isolateTierFailures, resetTierFailures } from './tier-failures';
|
|
@@ -292,6 +292,70 @@ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<Inva
|
|
|
292
292
|
});
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
/** What a flush reports and records as its tags: not a wire tag, and never parsed as one. */
|
|
296
|
+
export const FLUSH_ALL_TAG = '*';
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Drop everything this PROCESS holds, because it cannot know what it missed.
|
|
300
|
+
*
|
|
301
|
+
* A peer's bust reaches this process by the broadcast and by nothing else: its in-process tier and
|
|
302
|
+
* its tag-revalidated ISR pages are cleared by `receiveInvalidationBroadcast`, or they wait for
|
|
303
|
+
* their TTL — and a `revalidate: { tags }` page has none. So a process whose bus connection was
|
|
304
|
+
* down for a window, or a sender that had more refused busts than it could keep, has one correct
|
|
305
|
+
* 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.
|
|
308
|
+
* Never throws; the cost is one cold cache.
|
|
309
|
+
*/
|
|
310
|
+
export function flushProcessTiers(source: string): Promise<InvalidationReport> {
|
|
311
|
+
return withSpan('cache.flush', async (): Promise<InvalidationReport> => {
|
|
312
|
+
const startedAt = performance.now();
|
|
313
|
+
markAllInvalidated();
|
|
314
|
+
const tiers: TierInvalidation[] = [];
|
|
315
|
+
const errors: { tier: string; message: string }[] = [];
|
|
316
|
+
for (const tier of sortTiers(registry)) {
|
|
317
|
+
if (tier.clear === undefined) continue;
|
|
318
|
+
try {
|
|
319
|
+
await tier.clear();
|
|
320
|
+
tiers.push({ tier: tier.name, keys: [] });
|
|
321
|
+
} catch (error) {
|
|
322
|
+
errors.push({ tier: tier.name, message: renderThrowable(error) });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const isr = dedupe(
|
|
326
|
+
graphSnapshot().flatMap((entry) =>
|
|
327
|
+
entry.dependents.filter((dep) => dep.kind === 'isr-route').map((dep) => dep.id),
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
for (const path of isr) {
|
|
331
|
+
try {
|
|
332
|
+
await revalidator?.(path);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
errors.push({ tier: 'isr', message: renderThrowable(error) });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const report: InvalidationReport = {
|
|
338
|
+
tags: [FLUSH_ALL_TAG],
|
|
339
|
+
tiers,
|
|
340
|
+
isr,
|
|
341
|
+
cdn: [],
|
|
342
|
+
liveQueries: [],
|
|
343
|
+
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
|
344
|
+
errors,
|
|
345
|
+
};
|
|
346
|
+
recordInvalidation({
|
|
347
|
+
at: systemClock.now().toISOString(),
|
|
348
|
+
tags: report.tags,
|
|
349
|
+
busted: [...isr],
|
|
350
|
+
source,
|
|
351
|
+
durationMs: report.durationMs,
|
|
352
|
+
errors,
|
|
353
|
+
});
|
|
354
|
+
if (errors.length > 0) logger.warn('cache.flush.partial', { ...report });
|
|
355
|
+
return report;
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
295
359
|
/** First-seen order kept — a union of what actually changed, not a sorted report. */
|
|
296
360
|
function dedupe(values: readonly string[]): readonly string[] {
|
|
297
361
|
return [...new Set(values)];
|
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
|
}
|
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 {
|