@ultimat3/cache 1.2.0 → 3.0.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/src/errors.ts CHANGED
@@ -5,9 +5,11 @@ import { registerErrorCodes, UltimateError } from '@ultimat3/core';
5
5
  /** Codes this package declares and owns. */
6
6
  export const CACHE_OWNED_ERROR_CODES = [
7
7
  'X_CACHE_DRIVER_UNAVAILABLE',
8
+ 'X_CACHE_JITTER_INVALID',
8
9
  'X_CACHE_PURGE_FAILED',
9
10
  'X_CACHE_TAG_UNKNOWN',
10
11
  'X_CACHE_TOO_LARGE',
12
+ 'X_CACHE_TTL_INVALID',
11
13
  ] as const;
12
14
 
13
15
  /** Every code cache can throw. It borrows none: every remote driver here is implemented. */
@@ -18,9 +20,11 @@ export type CacheErrorCode = (typeof CACHE_ERROR_CODES)[number];
18
20
 
19
21
  export const CACHE_ERROR_TITLES: Readonly<Record<CacheOwnedErrorCode, string>> = {
20
22
  X_CACHE_DRIVER_UNAVAILABLE: "a tier's backing store is missing",
23
+ X_CACHE_JITTER_INVALID: 'a TTL jitter fraction outside [0, 1)',
21
24
  X_CACHE_PURGE_FAILED: 'the CDN refused a purge',
22
25
  X_CACHE_TAG_UNKNOWN: 'a tag no entity declared',
23
26
  X_CACHE_TOO_LARGE: "one entry exceeds the tier's byte budget",
27
+ X_CACHE_TTL_INVALID: 'a cache TTL that is not a positive number of milliseconds',
24
28
  };
25
29
 
26
30
  // One unconditional call, so a second package claiming one of cache's codes throws
@@ -72,6 +76,52 @@ export class CacheTooLargeError extends UltimateError {
72
76
  }
73
77
  }
74
78
 
79
+ /**
80
+ * A `ttlMs` that is not a positive, finite number of milliseconds.
81
+ *
82
+ * `0` used to mean two things: "never expires" in the LRU tier and `EX 1` — one second — in the
83
+ * Redis tier, so a stack holding both answered differently depending on which one hit. Neither is
84
+ * what a caller writing `0` intends, and the third reading ("do not cache") has its own spelling:
85
+ * do not call the cache. Refused rather than resolved, so the miswiring is a failure and not a
86
+ * behaviour that varies by deployment.
87
+ */
88
+ export class CacheTtlInvalidError extends UltimateError {
89
+ constructor(input: { key: string; ttlMs: number; tier: string }) {
90
+ super({
91
+ code: 'X_CACHE_TTL_INVALID',
92
+ cause: `entry "${input.key}" was written to the ${input.tier} tier with ttlMs=${String(
93
+ input.ttlMs,
94
+ )}; a TTL is a positive, finite number of milliseconds`,
95
+ fix: `cache.write('${input.key}', value, { ttlMs: 60_000 }) # or drop the option for the tier default; a value you do not want held is one you do not write`,
96
+ docs: docsFor('X_CACHE_TTL_INVALID'),
97
+ meta: { key: input.key, ttlMs: input.ttlMs, tier: input.tier },
98
+ });
99
+ }
100
+ }
101
+
102
+ /**
103
+ * A jitter fraction a tier cannot spread a TTL with.
104
+ *
105
+ * Jitter exists because 40,000 keys warmed by one rolling restart share one TTL and therefore one
106
+ * expiry instant; spreading them is the only thing that stops the herd. A fraction of `1` or more
107
+ * would shave a whole lease away and a negative one would EXTEND it past what the caller asked
108
+ * for, so both are miswiring rather than a preference — refused where the TTL rule already lives,
109
+ * for the same reason `0` is not silently reinterpreted as "never expires".
110
+ */
111
+ export class CacheJitterInvalidError extends UltimateError {
112
+ constructor(input: { tier: string; jitterFraction: number }) {
113
+ super({
114
+ code: 'X_CACHE_JITTER_INVALID',
115
+ cause: `the ${input.tier} tier was configured with jitterFraction=${String(
116
+ input.jitterFraction,
117
+ )}; a jitter fraction is a finite number in [0, 1)`,
118
+ fix: `set cache.${input.tier}.jitterFraction in app.config.ts to a value in [0, 1) — 0.05 is the default, 0 disables jitter`,
119
+ docs: docsFor('X_CACHE_JITTER_INVALID'),
120
+ meta: { tier: input.tier, jitterFraction: input.jitterFraction },
121
+ });
122
+ }
123
+ }
124
+
75
125
  /**
76
126
  * A remote purge did not happen. Never fatal on its own — `invalidateTags` collects it into
77
127
  * `report.errors` so a dead CDN cannot fail the write that triggered the bust — which is exactly
package/src/fence.ts ADDED
@@ -0,0 +1,110 @@
1
+ // A read-through fill writes what `load()` read, and `load()` read it in the past. An
2
+ // invalidation that lands in between finds nothing to clear and the fill then republishes the
3
+ // pre-write rows for a full TTL — invisibly, with `errors: []`. The fence is the identity check
4
+ // `single-flight.ts` does on a promise, done on time: sample before the load, ask before the write.
5
+
6
+ import type { CacheTag } from './tags';
7
+ import { tagMatches } from './tags';
8
+
9
+ /**
10
+ * What a fill is about to publish, in invalidation terms. Both halves are optional and both are
11
+ * checked: `key` catches a `drop`/`write` of that exact key, `tags` catch a tag bust.
12
+ */
13
+ export interface FenceScope {
14
+ readonly key?: string;
15
+ readonly tags?: readonly CacheTag[];
16
+ }
17
+
18
+ export interface CacheFence {
19
+ /** `false` once anything this fence covers was invalidated after the sample. Never throws. */
20
+ isValid(): boolean;
21
+ /**
22
+ * Widen what this fence covers — retroactively, back to the sample. A joiner arriving mid-load
23
+ * declares tags the leader never sampled, and those tags are unfenced for exactly the window
24
+ * they were absent unless covering reaches back.
25
+ */
26
+ cover(scope: FenceScope): void;
27
+ }
28
+
29
+ /**
30
+ * How many recent invalidations stay inspectable. A fill window is milliseconds and this is
31
+ * process-wide, so the ring is only ever short of an answer under a bust storm — where the
32
+ * conservative answer costs one refetch and the optimistic one serves a stale row until TTL.
33
+ */
34
+ export const FENCE_MEMORY = 1024;
35
+
36
+ interface Mark {
37
+ /** The generation this mark was recorded at; marks are pushed in generation order. */
38
+ readonly at: number;
39
+ readonly key?: string;
40
+ readonly tag?: CacheTag;
41
+ }
42
+
43
+ const marks: Mark[] = [];
44
+ let generation = 0;
45
+ /** The highest generation the ring has forgotten. A fence older than this cannot be proven. */
46
+ let forgottenThrough = 0;
47
+
48
+ /**
49
+ * Record an invalidation. `invalidateTags` already calls this for every fan-out, inbound
50
+ * broadcasts included, and `CacheStack` calls it for `drop`/`write` — a caller only needs it when
51
+ * it clears a cache key by some path of its own.
52
+ *
53
+ * Unlike every other process-global registry in this package this one needs no `isolate*()` seam
54
+ * and has no reset: a fence samples the CURRENT generation, which is always at or above
55
+ * `forgottenThrough`, so marks left behind by another test file can never invalidate a fence
56
+ * sampled after them.
57
+ */
58
+ export function markInvalidated(scope: FenceScope): void {
59
+ const key = scope.key;
60
+ const tags = scope.tags ?? [];
61
+ if (key === undefined && tags.length === 0) return;
62
+
63
+ generation += 1;
64
+ if (key !== undefined) marks.push({ at: generation, key });
65
+ for (const owned of tags) marks.push({ at: generation, tag: owned });
66
+
67
+ while (marks.length > FENCE_MEMORY) {
68
+ const dropped = marks.shift();
69
+ if (dropped !== undefined) forgottenThrough = dropped.at;
70
+ }
71
+ }
72
+
73
+ function hits(mark: Mark, scope: FenceScope): boolean {
74
+ if (mark.key !== undefined) return scope.key !== undefined && mark.key === scope.key;
75
+ const owned = mark.tag;
76
+ if (owned === undefined) return false;
77
+ // `tagMatches` is symmetric on the wildcard: a collection bust hits a row fence and a row bust
78
+ // hits a collection fence, which is the same asymmetry-tolerance every tier invalidates with.
79
+ return (scope.tags ?? []).some((wanted) => tagMatches(wanted, owned));
80
+ }
81
+
82
+ /**
83
+ * Take a fence before `load()`; ask it before the write:
84
+ *
85
+ * const fence = sampleFence({ key, tags });
86
+ * const value = await load();
87
+ * if (fence.isValid()) await tier.set(key, value, { tags });
88
+ */
89
+ export function sampleFence(scope: FenceScope): CacheFence {
90
+ const sampledAt = generation;
91
+ const covered: FenceScope[] = [scope];
92
+
93
+ return {
94
+ cover(next: FenceScope): void {
95
+ covered.push(next);
96
+ },
97
+
98
+ isValid(): boolean {
99
+ // Older than the ring remembers: unprovable, so refused. One refetch, not a stale TTL.
100
+ if (sampledAt < forgottenThrough) return false;
101
+ for (let i = marks.length - 1; i >= 0; i -= 1) {
102
+ const mark = marks[i];
103
+ // Marks are pushed in generation order, so the first one at or below the sample ends it.
104
+ if (mark === undefined || mark.at <= sampledAt) break;
105
+ if (covered.some((scoped) => hits(mark, scoped))) return false;
106
+ }
107
+ return true;
108
+ },
109
+ };
110
+ }
package/src/graph.ts CHANGED
Binary file
package/src/index.ts CHANGED
@@ -7,26 +7,39 @@ export {
7
7
  CACHE_ERROR_CODES,
8
8
  CACHE_ERROR_TITLES,
9
9
  CacheDriverUnavailableError,
10
+ CacheJitterInvalidError,
10
11
  CachePurgeFailedError,
11
12
  CacheTagUnknownError,
12
13
  CacheTooLargeError,
14
+ CacheTtlInvalidError,
13
15
  } from './errors';
16
+ export type { CacheFence, FenceScope } from './fence';
17
+ export { FENCE_MEMORY, markInvalidated, sampleFence } from './fence';
14
18
  export type { CacheDependent, DependentKind } from './graph';
15
19
  export {
16
20
  dependentsOf,
17
21
  dependentsOfKind,
18
22
  graphSize,
19
23
  graphSnapshot,
24
+ isolateGraph,
20
25
  registerDependent,
21
26
  resetGraph,
22
27
  unregisterDependent,
23
28
  } from './graph';
24
- export type { InvalidationEvent, InvalidationReport, Revalidator } from './invalidate';
29
+ export type {
30
+ InvalidationBroadcast,
31
+ InvalidationEvent,
32
+ InvalidationReport,
33
+ Revalidator,
34
+ } from './invalidate';
25
35
  export {
26
36
  invalidateTags,
27
37
  invalidateWireTags,
38
+ isolateTiers,
39
+ receiveInvalidationBroadcast,
28
40
  recentInvalidations,
29
41
  registeredTiers,
42
+ registerInvalidationBroadcast,
30
43
  registerRevalidator,
31
44
  registerTier,
32
45
  resetTiers,
@@ -48,7 +61,12 @@ export { FASTLY_API_URL, FASTLY_MAX_KEYS_PER_REQUEST, fastlyPurgeDriver } from '
48
61
  export type { PurgeFetch } from './purge-http';
49
62
  export { DEFAULT_PURGE_TIMEOUT_MS } from './purge-http';
50
63
  export type { RedisLike, RedisTierOptions } from './redis';
51
- export { createRedisTier, REDIS_INVALIDATE_SCRIPT } from './redis';
64
+ export {
65
+ createRedisTier,
66
+ namespaceFor,
67
+ REDIS_INVALIDATE_SCRIPT,
68
+ REDIS_TAG_MEMBER_SCRIPT,
69
+ } from './redis';
52
70
  export type {
53
71
  Embedding,
54
72
  SemanticCache,
@@ -57,26 +75,45 @@ export type {
57
75
  SemanticRememberOptions,
58
76
  } from './semantic';
59
77
  export { cosineSimilarity, createMemorySemanticCache } from './semantic';
78
+ export type { FlightJoin, SingleFlight } from './single-flight';
79
+ export { createSingleFlight } from './single-flight';
60
80
  export type { CacheTag, CacheTagRegistry, TagFactory } from './tags';
61
81
  export {
62
82
  assertKnownTags,
63
83
  declareTags,
84
+ isolateDeclaredTags,
64
85
  knownTags,
65
86
  parseTag,
66
87
  resetDeclaredTags,
67
88
  serializeTag,
68
89
  serializeTags,
69
90
  tag,
91
+ tagKeys,
70
92
  tagMatches,
71
93
  tagsFor,
72
94
  tagsIntersect,
73
95
  } from './tags';
96
+ export type { TierFailure, TierOperation } from './tier-failures';
97
+ export { bestEffort, recentTierFailures } from './tier-failures';
74
98
  export type {
75
99
  CacheEntry,
76
100
  CacheSetOptions,
77
101
  CacheStack,
102
+ CacheStackOptions,
78
103
  CacheTier,
104
+ Rng,
79
105
  TierInvalidation,
106
+ TierLabel,
80
107
  TierName,
108
+ TtlJitter,
109
+ TtlScope,
110
+ } from './tiers';
111
+ export {
112
+ assertTtl,
113
+ createCacheStack,
114
+ DEFAULT_TTL_JITTER_FRACTION,
115
+ isExpired,
116
+ nowMs,
117
+ sortTiers,
118
+ TIER_ORDER,
81
119
  } from './tiers';
82
- export { createCacheStack, isExpired, nowMs, sortTiers, TIER_ORDER } from './tiers';
package/src/invalidate.ts CHANGED
@@ -4,21 +4,39 @@
4
4
  // the returned report is what the `/_x` cache panel renders, so "did it actually clear?" is
5
5
  // answerable without a log dive.
6
6
 
7
- import { currentSpan, logger, systemClock, withSpan } from '@ultimat3/core';
7
+ import { currentSpan, logger, renderThrowable, systemClock, withSpan } from '@ultimat3/core';
8
+ import { markInvalidated } from './fence';
8
9
  import { dependentsOfKind } from './graph';
9
10
  import type { CacheTag } from './tags';
10
- import { assertKnownTags, parseTag, serializeTags } from './tags';
11
+ import { assertKnownTags, knownTags, parseTag, serializeTags } from './tags';
12
+ import { isolateTierFailures, resetTierFailures } from './tier-failures';
11
13
  import type { CacheTier, TierInvalidation } from './tiers';
12
- import { sortTiers } from './tiers';
14
+ import { sortTiers, TIER_ORDER } from './tiers';
13
15
 
14
16
  /** Revalidates one ISR route path. Provided by `@ultimat3/render`; absent on a worker. */
15
17
  export type Revalidator = (path: string) => Promise<void> | void;
16
18
 
19
+ /**
20
+ * Carries wire tags to every OTHER process. The seam, never the transport: `cache` is tier 1 and
21
+ * may not reach `realtime` (tier 3) or NATS, so `@ultimat3/cli` registers the sender at boot the
22
+ * same way `@ultimat3/render` registers the `Revalidator`.
23
+ *
24
+ * Without one, `invalidateTags` clears the LRU of exactly one process: a user edits their profile
25
+ * on pod 3, their next request lands on pod 7, and pod 7's in-process copy serves the pre-edit
26
+ * value for up to `defaultTtlMs`. The user watches their edit vanish and re-submits.
27
+ */
28
+ export type InvalidationBroadcast = (wireTags: readonly string[]) => Promise<void> | void;
29
+
17
30
  export interface InvalidationReport {
18
31
  readonly tags: readonly string[];
19
32
  readonly tiers: readonly TierInvalidation[];
20
33
  /** ISR route paths queued for regeneration. */
21
34
  readonly isr: readonly string[];
35
+ /**
36
+ * CDN paths the graph hangs off these tags — what *depends* on them, not what cleared. The
37
+ * `cdn` tier is what purges them (as surrogate keys, with the tags), so what actually cleared
38
+ * is that tier's row in `tiers`. With no `cdn` tier registered this list purges nowhere.
39
+ */
22
40
  readonly cdn: readonly string[];
23
41
  readonly liveQueries: readonly string[];
24
42
  readonly durationMs: number;
@@ -32,8 +50,13 @@ export interface InvalidationEvent {
32
50
  /** Wire-form tags, exactly `report.tags`. */
33
51
  readonly tags: readonly string[];
34
52
  /**
35
- * Everything the fan-out actually cleared: every tier key, plus the ISR paths, CDN paths
36
- * and live queries.
53
+ * Everything the fan-out actually cleared: every tier key the `cdn` tier's accepted purge
54
+ * keys included — plus the ISR paths and the live queries.
55
+ *
56
+ * Deliberately NOT `report.cdn`: that is the dependency graph's answer to "what depends on
57
+ * these tags", and folding it in here reported a path as busted when no `cdn` tier was
58
+ * registered to purge it. A partial bust that reads as a clean one is the failure this log
59
+ * exists to catch.
37
60
  */
38
61
  readonly busted: readonly string[];
39
62
  /**
@@ -64,6 +87,7 @@ export function recentInvalidations(): readonly InvalidationEvent[] {
64
87
 
65
88
  const registry: CacheTier[] = [];
66
89
  let revalidator: Revalidator | undefined;
90
+ let broadcast: InvalidationBroadcast | undefined;
67
91
 
68
92
  /** Tiers register at boot from `app.config.ts`; order is normalised, not trusted. */
69
93
  export function registerTier(tier: CacheTier): void {
@@ -76,17 +100,59 @@ export function registeredTiers(): readonly CacheTier[] {
76
100
  return sortTiers(registry);
77
101
  }
78
102
 
79
- /** Test seam: drops every registered tier, the revalidator, and the invalidation log. */
103
+ /**
104
+ * Test seam: drops every registered tier, the revalidator, the invalidation log and the
105
+ * swallowed-failure log. One reset, so a suite cannot clear half the recorded state.
106
+ */
80
107
  export function resetTiers(): void {
81
108
  registry.length = 0;
82
109
  revalidator = undefined;
110
+ broadcast = undefined;
83
111
  invalidationLog.length = 0;
112
+ resetTierFailures();
113
+ }
114
+
115
+ /**
116
+ * `isolateDeclaredTags()`'s contract over everything `resetTiers()` drops — `tags.ts` carries the
117
+ * why. It must live here because three of the four pieces are unreachable from a test file: the
118
+ * revalidator has no reader, and neither log has a writer, so a suite that reset them could put
119
+ * back only the tier registry:
120
+ *
121
+ * const restoreTiers = isolateTiers();
122
+ * afterAll(restoreTiers);
123
+ *
124
+ * Registration order is kept, not `sortTiers()`'s: `registeredTiers()` normalises on read, so
125
+ * restoring the sorted list would hand the process back a registry it never had.
126
+ */
127
+ export function isolateTiers(): () => void {
128
+ const capturedTiers = [...registry];
129
+ const capturedRevalidator = revalidator;
130
+ const capturedBroadcast = broadcast;
131
+ const capturedLog = [...invalidationLog];
132
+ const restoreFailures = isolateTierFailures();
133
+
134
+ return () => {
135
+ resetTiers();
136
+ registry.push(...capturedTiers);
137
+ revalidator = capturedRevalidator;
138
+ broadcast = capturedBroadcast;
139
+ invalidationLog.push(...capturedLog);
140
+ restoreFailures();
141
+ };
84
142
  }
85
143
 
86
144
  export function registerRevalidator(next: Revalidator): void {
87
145
  revalidator = next;
88
146
  }
89
147
 
148
+ /**
149
+ * Registers the outbound half of cross-instance invalidation. Called once at boot by whatever
150
+ * owns the transport — `@ultimat3/cli`, not this package.
151
+ */
152
+ export function registerInvalidationBroadcast(next: InvalidationBroadcast): void {
153
+ broadcast = next;
154
+ }
155
+
90
156
  /**
91
157
  * Fan out `tags` across every registered tier plus the dependency graph. Never throws for a
92
158
  * tier failure: a dead Redis must not fail the write that triggered the bust — the failure
@@ -96,24 +162,76 @@ export function invalidateTags(tags: readonly CacheTag[]): Promise<InvalidationR
96
162
  // Captured before `withSpan` opens `cache.invalidate` below: inside that callback the active
97
163
  // span is already this call's own, which would make every event's source the same string.
98
164
  const source = currentSpan()?.name ?? 'invalidateTags';
165
+ return fanOut(tags, { source, emit: true, validate: true, errors: [] });
166
+ }
167
+
168
+ /**
169
+ * The inbound half: another instance's fan-out, applied here.
170
+ *
171
+ * It cannot re-emit, and that is structural rather than a flag a caller could set wrong — `emit`
172
+ * lives on `fanOut`'s private options and this is the only entry point that passes `false`. A
173
+ * broadcast that re-broadcast would be a storm bounded by nothing.
174
+ *
175
+ * A tag this process has not declared is DROPPED and reported, never thrown: mid-deploy the new
176
+ * pods know an entity the old ones do not, and a throw here kills the subscriber loop that
177
+ * delivered it — which would silently end cross-instance invalidation for the whole process.
178
+ */
179
+ export function receiveInvalidationBroadcast(wire: readonly string[]): Promise<InvalidationReport> {
180
+ const declared = new Set(knownTags());
181
+ const errors: { tier: string; message: string }[] = [];
182
+ const accepted: CacheTag[] = [];
183
+ for (const value of wire) {
184
+ const parsed = parseTag(value);
185
+ // An empty registry is `assertKnownTags`'s "validation is off" state; honour the same rule.
186
+ if (declared.size === 0 || declared.has(parsed.entity)) accepted.push(parsed);
187
+ else errors.push({ tier: 'broadcast', message: `ignored undeclared tag "${value}"` });
188
+ }
189
+ return fanOut(accepted, { source: 'cache.broadcast', emit: false, validate: false, errors });
190
+ }
191
+
192
+ interface FanOutOptions {
193
+ readonly source: string;
194
+ /** Never widened to a public parameter: see `receiveInvalidationBroadcast`. */
195
+ readonly emit: boolean;
196
+ /** The local path throws on a typo; the inbound one has already filtered instead. */
197
+ readonly validate: boolean;
198
+ readonly errors: { tier: string; message: string }[];
199
+ }
200
+
201
+ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<InvalidationReport> {
202
+ const { source, emit } = options;
99
203
 
100
204
  return withSpan('cache.invalidate', async (): Promise<InvalidationReport> => {
101
205
  const startedAt = performance.now();
102
- assertKnownTags(tags);
206
+ // Inside the span, so a typo is a REJECTED promise and not a synchronous throw past every
207
+ // caller that only ever awaited this function.
208
+ if (options.validate) assertKnownTags(tags);
209
+
210
+ // Before the first tier is touched, so a read-through fill whose `load()` started earlier
211
+ // cannot republish what this call is about to clear — the bust would otherwise land on a key
212
+ // that is not there yet, report `errors: []`, and be overwritten milliseconds later.
213
+ markInvalidated({ tags });
103
214
 
104
215
  const tiers: TierInvalidation[] = [];
105
- const errors: { tier: string; message: string }[] = [];
216
+ const errors = options.errors;
106
217
 
107
- for (const tier of sortTiers(registry)) {
218
+ // FARTHEST tier first. Near-to-far leaves the far tier holding the old value after the near
219
+ // ones are clear, and a read racing the bust promotes it straight back up into them — the
220
+ // report says every tier cleared, and the LRU is stale again before the call returns.
221
+ for (const tier of [...sortTiers(registry)].reverse()) {
108
222
  try {
109
223
  tiers.push(await tier.invalidateTags(tags));
110
224
  } catch (error) {
111
- errors.push({
112
- tier: tier.name,
113
- message: error instanceof Error ? error.message : String(error),
114
- });
225
+ // `renderThrowable`, never `error.message`: a tier is app-supplied, so the value it
226
+ // rejects with is too, and both `instanceof` and `String()` RUN app code on it. A render
227
+ // that throws here rejects the whole fan-out — the failure the line above promises not to
228
+ // let reach the write that triggered the bust.
229
+ errors.push({ tier: tier.name, message: renderThrowable(error) });
115
230
  }
116
231
  }
232
+ // The report is read order, not clear order: it is what the `/_x` panel renders, and a ladder
233
+ // printed upside down is a second thing for a reader to learn.
234
+ tiers.sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier));
117
235
 
118
236
  const isr = dependentsOfKind(tags, 'isr-route');
119
237
  const cdn = dependentsOfKind(tags, 'cdn-path');
@@ -123,15 +241,24 @@ export function invalidateTags(tags: readonly CacheTag[]): Promise<InvalidationR
123
241
  try {
124
242
  await revalidator?.(path);
125
243
  } catch (error) {
126
- errors.push({
127
- tier: 'isr',
128
- message: error instanceof Error ? error.message : String(error),
129
- });
244
+ errors.push({ tier: 'isr', message: renderThrowable(error) });
245
+ }
246
+ }
247
+
248
+ const wire = serializeTags(tags);
249
+
250
+ // Last, and best-effort: every LOCAL tier has already cleared, so a dead transport degrades
251
+ // to "the other pods clear on TTL" rather than failing the write that triggered the bust.
252
+ if (emit && wire.length > 0 && broadcast !== undefined) {
253
+ try {
254
+ await broadcast(wire);
255
+ } catch (error) {
256
+ errors.push({ tier: 'broadcast', message: renderThrowable(error) });
130
257
  }
131
258
  }
132
259
 
133
260
  const report: InvalidationReport = {
134
- tags: serializeTags(tags),
261
+ tags: wire,
135
262
  tiers,
136
263
  isr,
137
264
  cdn,
@@ -146,7 +273,6 @@ export function invalidateTags(tags: readonly CacheTag[]): Promise<InvalidationR
146
273
  busted: dedupe([
147
274
  ...report.tiers.flatMap((entry) => entry.keys),
148
275
  ...report.isr,
149
- ...report.cdn,
150
276
  ...report.liveQueries,
151
277
  ]),
152
278
  source,
package/src/lru.ts CHANGED
@@ -8,8 +8,15 @@ import { systemClock } from '@ultimat3/core';
8
8
  import { CacheTooLargeError } from './errors';
9
9
  import type { CacheTag } from './tags';
10
10
  import { serializeTag } from './tags';
11
- import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers';
12
- import { nowMs } from './tiers';
11
+ import type {
12
+ CacheEntry,
13
+ CacheSetOptions,
14
+ CacheTier,
15
+ Rng,
16
+ TierInvalidation,
17
+ TtlJitter,
18
+ } from './tiers';
19
+ import { assertTtl, nowMs } from './tiers';
13
20
 
14
21
  export interface LruOptions {
15
22
  /** Byte budget for the whole tier. Default 64 MiB. */
@@ -17,13 +24,17 @@ export interface LruOptions {
17
24
  /** Applied when a `set` omits `ttlMs`. Default 60s — stale-by-default is safer here. */
18
25
  readonly defaultTtlMs?: number;
19
26
  readonly clock?: Clock;
27
+ /** TTL spread, in `[0, 1)`. Default `DEFAULT_TTL_JITTER_FRACTION`; `0` disables it. */
28
+ readonly jitterFraction?: number;
29
+ /** Injected so a jittered expiry is deterministic; `() => 0` is the full lease. */
30
+ readonly rng?: Rng;
20
31
  }
21
32
 
22
33
  interface LruNode {
23
34
  key: string;
24
35
  value: unknown;
25
36
  bytes: number;
26
- /** Epoch ms, or `Number.POSITIVE_INFINITY` for no expiry. */
37
+ /** Epoch ms. Always finite: `assertTtl` refuses the `0` that used to mean "never expires". */
27
38
  expiresAt: number;
28
39
  tags: readonly CacheTag[];
29
40
  prev: LruNode | undefined;
@@ -77,6 +88,7 @@ export class LruCache {
77
88
  private readonly maxBytes: number;
78
89
  private readonly defaultTtlMs: number;
79
90
  private readonly clock: Clock;
91
+ private readonly jitter: TtlJitter;
80
92
  private head: LruNode | undefined;
81
93
  private tail: LruNode | undefined;
82
94
  private bytes = 0;
@@ -88,6 +100,10 @@ export class LruCache {
88
100
  this.maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
89
101
  this.defaultTtlMs = options.defaultTtlMs ?? 60_000;
90
102
  this.clock = options.clock ?? systemClock;
103
+ this.jitter = {
104
+ ...(options.jitterFraction === undefined ? {} : { jitterFraction: options.jitterFraction }),
105
+ ...(options.rng === undefined ? {} : { rng: options.rng }),
106
+ };
91
107
  }
92
108
 
93
109
  get<T>(key: string): CacheEntry<T> | undefined {
@@ -103,11 +119,7 @@ export class LruCache {
103
119
  }
104
120
  this.touch(node);
105
121
  this.hits += 1;
106
- return {
107
- value: node.value as T,
108
- tags: node.tags,
109
- ...(node.expiresAt === Number.POSITIVE_INFINITY ? {} : { expiresAt: node.expiresAt }),
110
- };
122
+ return { value: node.value as T, tags: node.tags, expiresAt: node.expiresAt };
111
123
  }
112
124
 
113
125
  set<T>(key: string, value: T, options: CacheSetOptions = {}): void {
@@ -116,15 +128,17 @@ export class LruCache {
116
128
  throw new CacheTooLargeError({ key, bytes, maxBytes: this.maxBytes, tier: 'lru' });
117
129
  }
118
130
 
131
+ // Validate BEFORE evicting the entry being replaced: a rejected write must leave the cache
132
+ // exactly as it found it, or `X_CACHE_TTL_INVALID` also silently drops a live, valid value.
133
+ const ttl = assertTtl(key, options.ttlMs ?? this.defaultTtlMs, 'lru', this.jitter);
119
134
  const existing = this.map.get(key);
120
135
  if (existing !== undefined) this.unlink(existing);
121
136
 
122
- const ttl = options.ttlMs ?? this.defaultTtlMs;
123
137
  const node: LruNode = {
124
138
  key,
125
139
  value,
126
140
  bytes,
127
- expiresAt: ttl <= 0 ? Number.POSITIVE_INFINITY : nowMs(this.clock) + ttl,
141
+ expiresAt: nowMs(this.clock) + ttl,
128
142
  tags: options.tags ?? [],
129
143
  prev: undefined,
130
144
  next: undefined,
@@ -185,6 +199,11 @@ export class LruCache {
185
199
  this.head = undefined;
186
200
  this.tail = undefined;
187
201
  this.bytes = 0;
202
+ // Stats describe THIS cache's lifetime, not the process's — a cleared cache is a fresh one,
203
+ // so `stats()` after `clear()` must not still show hits/evictions from what is now gone.
204
+ this.hits = 0;
205
+ this.misses = 0;
206
+ this.evictions = 0;
188
207
  }
189
208
 
190
209
  stats(): LruStats {
@@ -240,9 +259,12 @@ export function createLruTier(options: LruOptions = {}): CacheTier & { readonly
240
259
  get<T>(key: string) {
241
260
  return Promise.resolve(cache.get<T>(key));
242
261
  },
243
- set<T>(key: string, value: T, setOptions?: CacheSetOptions) {
262
+ // `async`, so a refusal is a REJECTION and not a synchronous throw out of a function typed
263
+ // `Promise<void>`. `LruCache.set` stays synchronous — it is a sync API — but a `CacheTier` is
264
+ // one interface with two implementations, and `tier.set(...).catch(...)` has to mean the same
265
+ // thing on the in-process rung as on the shared one, where the throw is already a rejection.
266
+ async set<T>(key: string, value: T, setOptions?: CacheSetOptions) {
244
267
  cache.set(key, value, setOptions ?? {});
245
- return Promise.resolve();
246
268
  },
247
269
  del(key: string) {
248
270
  cache.del(key);