@ultimat3/cache 1.2.0 → 2.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/CLAUDE.md +257 -0
- package/README.md +205 -4
- package/package.json +3 -2
- package/src/cdn.ts +12 -1
- package/src/errors.ts +50 -0
- package/src/fence.ts +110 -0
- package/src/graph.ts +0 -0
- package/src/index.ts +40 -3
- package/src/invalidate.ts +141 -10
- package/src/lru.ts +34 -12
- package/src/memo.ts +20 -2
- package/src/redis-fake.ts +115 -0
- package/src/redis.ts +287 -31
- package/src/semantic.ts +9 -2
- package/src/set-options.ts +65 -0
- package/src/single-flight.ts +78 -0
- package/src/tags.ts +45 -0
- package/src/tier-failures.ts +92 -0
- package/src/tiers.ts +221 -19
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 {
|
|
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 {
|
|
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
|
@@ -5,20 +5,38 @@
|
|
|
5
5
|
// answerable without a log dive.
|
|
6
6
|
|
|
7
7
|
import { currentSpan, logger, 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
|
|
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
|
-
/**
|
|
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,15 +162,63 @@ 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
|
-
|
|
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
|
|
216
|
+
const errors = options.errors;
|
|
106
217
|
|
|
107
|
-
|
|
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) {
|
|
@@ -114,6 +228,9 @@ export function invalidateTags(tags: readonly CacheTag[]): Promise<InvalidationR
|
|
|
114
228
|
});
|
|
115
229
|
}
|
|
116
230
|
}
|
|
231
|
+
// The report is read order, not clear order: it is what the `/_x` panel renders, and a ladder
|
|
232
|
+
// printed upside down is a second thing for a reader to learn.
|
|
233
|
+
tiers.sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier));
|
|
117
234
|
|
|
118
235
|
const isr = dependentsOfKind(tags, 'isr-route');
|
|
119
236
|
const cdn = dependentsOfKind(tags, 'cdn-path');
|
|
@@ -130,8 +247,23 @@ export function invalidateTags(tags: readonly CacheTag[]): Promise<InvalidationR
|
|
|
130
247
|
}
|
|
131
248
|
}
|
|
132
249
|
|
|
250
|
+
const wire = serializeTags(tags);
|
|
251
|
+
|
|
252
|
+
// Last, and best-effort: every LOCAL tier has already cleared, so a dead transport degrades
|
|
253
|
+
// to "the other pods clear on TTL" rather than failing the write that triggered the bust.
|
|
254
|
+
if (emit && wire.length > 0 && broadcast !== undefined) {
|
|
255
|
+
try {
|
|
256
|
+
await broadcast(wire);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
errors.push({
|
|
259
|
+
tier: 'broadcast',
|
|
260
|
+
message: error instanceof Error ? error.message : String(error),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
133
265
|
const report: InvalidationReport = {
|
|
134
|
-
tags:
|
|
266
|
+
tags: wire,
|
|
135
267
|
tiers,
|
|
136
268
|
isr,
|
|
137
269
|
cdn,
|
|
@@ -146,7 +278,6 @@ export function invalidateTags(tags: readonly CacheTag[]): Promise<InvalidationR
|
|
|
146
278
|
busted: dedupe([
|
|
147
279
|
...report.tiers.flatMap((entry) => entry.keys),
|
|
148
280
|
...report.isr,
|
|
149
|
-
...report.cdn,
|
|
150
281
|
...report.liveQueries,
|
|
151
282
|
]),
|
|
152
283
|
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 {
|
|
12
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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);
|
package/src/memo.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { useContext } from '@ultimat3/core';
|
|
|
9
9
|
import type { CacheTag } from './tags';
|
|
10
10
|
import { tagsIntersect } from './tags';
|
|
11
11
|
import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers';
|
|
12
|
+
import { assertTtl } from './tiers';
|
|
12
13
|
|
|
13
14
|
type MemoStore = Map<string, CacheEntry<unknown>>;
|
|
14
15
|
|
|
@@ -55,9 +56,26 @@ export function createMemoTier(): CacheTier {
|
|
|
55
56
|
return Promise.resolve(entry as CacheEntry<T> | undefined);
|
|
56
57
|
},
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
/**
|
|
60
|
+
* The lease is VALIDATED and then discarded, which is not a contradiction.
|
|
61
|
+
*
|
|
62
|
+
* This tier holds nothing past the request, so it stores no `expiresAt` — but it is still a
|
|
63
|
+
* rung of one ladder, and `assertTtl` is the one place that says what `ttlMs` may be. Skipping
|
|
64
|
+
* it made `ttlMs: 0` a value the memo accepted and every other tier refused: `createCacheStack`
|
|
65
|
+
* routes each rung through `bestEffort`, so the miswiring was swallowed as two tier failures
|
|
66
|
+
* and the read still hit — out of the one tier that never should have taken it. Exactly the
|
|
67
|
+
* "two tiers, two readings of `0`" the rule exists to close.
|
|
68
|
+
*
|
|
69
|
+
* Only a lease the caller SUPPLIED is checked: there is no default to fall back to, because a
|
|
70
|
+
* memo entry that outlives its request is not a thing that can happen. `jitterFraction: 0` for
|
|
71
|
+
* `createMemorySemanticCache`'s reason — spreading a lease is a herd defence for a SHARED
|
|
72
|
+
* store, and this one dies with the request that made it.
|
|
73
|
+
*/
|
|
74
|
+
async set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
|
|
75
|
+
if (options?.ttlMs !== undefined) {
|
|
76
|
+
assertTtl(key, options.ttlMs, 'request-memo', { jitterFraction: 0 });
|
|
77
|
+
}
|
|
59
78
|
storeFor(true)?.set(key, { value, tags: options?.tags ?? [] });
|
|
60
|
-
return Promise.resolve();
|
|
61
79
|
},
|
|
62
80
|
|
|
63
81
|
del(key: string): Promise<void> {
|