@ultimat3/cache 1.1.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
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// The fake `Bun.redis` both redis test files drive, and the two wire readers they assert with.
|
|
2
|
+
// It is a RECORDER, never an interpreter: it cannot run Lua, so it never pretends to, and every
|
|
3
|
+
// claim about what a script DOES lives in `redis.live.test.ts`. Not exported from `index.ts` —
|
|
4
|
+
// it is a test double shaped around this package's own assertions, not public API.
|
|
5
|
+
|
|
6
|
+
import type { RedisLike, RedisTierOptions } from './redis';
|
|
7
|
+
import { createRedisTier, REDIS_TAG_MEMBER_SCRIPT } from './redis';
|
|
8
|
+
import type { CacheTier } from './tiers';
|
|
9
|
+
|
|
10
|
+
export interface FakeRedis extends RedisLike {
|
|
11
|
+
readonly sent: string[][];
|
|
12
|
+
/**
|
|
13
|
+
* What the server's script answers for one `EVAL`. A test driving a path that READS the reply
|
|
14
|
+
* has to say what came back; there is no default, because `[]` is exactly what a gutted
|
|
15
|
+
* `INVALIDATE_SCRIPT` returns and a silent one would make "the bust cleared nothing" the
|
|
16
|
+
* baseline of both files.
|
|
17
|
+
*/
|
|
18
|
+
answerEval(script: string, reply: unknown): void;
|
|
19
|
+
/** Makes one value key's `DEL` refuse, which is the half of a bust that can fail alone. */
|
|
20
|
+
refuseDel(key: string): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function fakeRedis(): FakeRedis {
|
|
24
|
+
const values = new Map<string, string>();
|
|
25
|
+
// The lease `EX` bought, in ms. A fake that answered no `PTTL` could not catch a tier that
|
|
26
|
+
// stopped asking for one — and a hit read back without its remaining life is promoted on the
|
|
27
|
+
// caller's ttl, which is how a value one second from expiry gets a fresh five minutes.
|
|
28
|
+
const expiries = new Map<string, number>();
|
|
29
|
+
const sent: string[][] = [];
|
|
30
|
+
// A fake cannot run Lua. This one used to mirror both script bodies in TypeScript, which is why
|
|
31
|
+
// gutting either to `return 1` / `return {}` left all 517 tests in `cache` + `query` green — the
|
|
32
|
+
// assertions ran against the mirror and the script itself was executed by nothing, ever. What is
|
|
33
|
+
// left is a recorder: the wire traffic is what those files assert, the script body is opaque to
|
|
34
|
+
// them, and every claim about what a script DOES lives behind TEST_REDIS_URL.
|
|
35
|
+
const evalReplies = new Map<string, unknown>([
|
|
36
|
+
// Nothing reads the tag-join's reply, so a constant here asserts nothing about the script.
|
|
37
|
+
[REDIS_TAG_MEMBER_SCRIPT, 1],
|
|
38
|
+
]);
|
|
39
|
+
const refused = new Set<string>();
|
|
40
|
+
return {
|
|
41
|
+
sent,
|
|
42
|
+
answerEval(script, reply) {
|
|
43
|
+
evalReplies.set(script, reply);
|
|
44
|
+
},
|
|
45
|
+
refuseDel(key) {
|
|
46
|
+
refused.add(key);
|
|
47
|
+
},
|
|
48
|
+
get(key) {
|
|
49
|
+
return Promise.resolve(values.get(key) ?? null);
|
|
50
|
+
},
|
|
51
|
+
set(key, value) {
|
|
52
|
+
values.set(key, value);
|
|
53
|
+
return Promise.resolve('OK');
|
|
54
|
+
},
|
|
55
|
+
send(command, args) {
|
|
56
|
+
sent.push([command, ...args]);
|
|
57
|
+
if (command === 'SET') {
|
|
58
|
+
values.set(String(args[0]), String(args[1]));
|
|
59
|
+
if (args[2] === 'EX') expiries.set(String(args[0]), Number(args[3]) * 1_000);
|
|
60
|
+
if (args[2] === 'PX') expiries.set(String(args[0]), Number(args[3]));
|
|
61
|
+
return Promise.resolve('OK');
|
|
62
|
+
}
|
|
63
|
+
if (command === 'PTTL') {
|
|
64
|
+
const key = String(args[0]);
|
|
65
|
+
if (!values.has(key)) return Promise.resolve(-2);
|
|
66
|
+
return Promise.resolve(expiries.get(key) ?? -1);
|
|
67
|
+
}
|
|
68
|
+
if (command === 'DEL') {
|
|
69
|
+
if (refused.has(String(args[0]))) {
|
|
70
|
+
return Promise.reject(new Error(`redis refused DEL ${String(args[0])}`));
|
|
71
|
+
}
|
|
72
|
+
values.delete(String(args[0]));
|
|
73
|
+
expiries.delete(String(args[0]));
|
|
74
|
+
return Promise.resolve(1);
|
|
75
|
+
}
|
|
76
|
+
if (command === 'EVAL') {
|
|
77
|
+
const script = String(args[0]);
|
|
78
|
+
if (!evalReplies.has(script)) {
|
|
79
|
+
// Loud rather than `[]`: an empty member list is what the gutted script answers, so a
|
|
80
|
+
// default would report every bust in these files as clean and every one of them green.
|
|
81
|
+
throw new Error(
|
|
82
|
+
'fake redis cannot execute EVAL — call answerEval(script, reply) to state what the ' +
|
|
83
|
+
'server returned, or move the claim to redis.live.test.ts, which runs the script',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return Promise.resolve(evalReplies.get(script));
|
|
87
|
+
}
|
|
88
|
+
return Promise.resolve(null);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `buildId: null` and `rng: () => 0` are the two things a wire assertion needs pinned: the
|
|
95
|
+
* namespace carries the build id by default, and the lease is spread by default.
|
|
96
|
+
*/
|
|
97
|
+
export function tierFor(client: RedisLike, extra: RedisTierOptions = {}): CacheTier {
|
|
98
|
+
return createRedisTier({ client, buildId: null, rng: () => 0, ...extra });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The `{...}` hash tag of a key, which is what Redis Cluster hashes to a slot. */
|
|
102
|
+
export function slotTokenOf(key: string): string {
|
|
103
|
+
return /\{([^}]*)\}/.exec(key)?.[1] ?? key;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every key argument of one command — `EVAL script numkeys k1 .. kN`, `DEL key` and
|
|
108
|
+
* `SREM key member..`, whose members are values rather than keys and so hash to nothing.
|
|
109
|
+
*/
|
|
110
|
+
export function keysOf(command: readonly string[]): string[] {
|
|
111
|
+
if (command[0] === 'EVAL') return command.slice(3, 3 + Number(command[2]));
|
|
112
|
+
if (command[0] === 'DEL') return command.slice(1);
|
|
113
|
+
if (command[0] === 'SREM' || command[0] === 'SISMEMBER') return command.slice(1, 2);
|
|
114
|
+
return [];
|
|
115
|
+
}
|
package/src/redis.ts
CHANGED
|
@@ -3,11 +3,20 @@
|
|
|
3
3
|
// trip via a server-side script, not a KEYS scan. KEYS is O(n) and blocks the server; a
|
|
4
4
|
// framework that ships it as the invalidation path is shipping an outage.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import { appVersion, logger, systemClock } from '@ultimat3/core';
|
|
7
8
|
import { CacheDriverUnavailableError } from './errors';
|
|
8
9
|
import type { CacheTag } from './tags';
|
|
9
10
|
import { parseTag, serializeTag } from './tags';
|
|
10
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
CacheEntry,
|
|
13
|
+
CacheSetOptions,
|
|
14
|
+
CacheTier,
|
|
15
|
+
Rng,
|
|
16
|
+
TierInvalidation,
|
|
17
|
+
TtlJitter,
|
|
18
|
+
} from './tiers';
|
|
19
|
+
import { assertTtl, nowMs } from './tiers';
|
|
11
20
|
|
|
12
21
|
/** The slice of Bun's Redis client this tier uses. Narrow on purpose: easy to fake in tests. */
|
|
13
22
|
export interface RedisLike {
|
|
@@ -19,9 +28,37 @@ export interface RedisLike {
|
|
|
19
28
|
export interface RedisTierOptions {
|
|
20
29
|
/** Key namespace, so two apps can share one Redis without colliding. Default `x`. */
|
|
21
30
|
readonly prefix?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The build the keys belong to. Defaults to `appVersion()` (`APP_VERSION`, else `dev`), so two
|
|
33
|
+
* builds sharing one Redis never read each other's payloads. Pass `null` to opt out — see
|
|
34
|
+
* `namespaceFor`.
|
|
35
|
+
*/
|
|
36
|
+
readonly buildId?: string | null;
|
|
22
37
|
readonly defaultTtlMs?: number;
|
|
23
38
|
/** Injected in tests; production reads `Bun.redis`. */
|
|
24
39
|
readonly client?: RedisLike;
|
|
40
|
+
/** Turns `PTTL`'s remaining life into the absolute `expiresAt` a hit reports. */
|
|
41
|
+
readonly clock?: Clock;
|
|
42
|
+
/** TTL spread, in `[0, 1)`. Default `DEFAULT_TTL_JITTER_FRACTION`; `0` disables it. */
|
|
43
|
+
readonly jitterFraction?: number;
|
|
44
|
+
/** Injected so a jittered `EX` is deterministic; `() => 0` is the full lease. */
|
|
45
|
+
readonly rng?: Rng;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `<prefix>:<buildId>`, and `<prefix>` alone when the build id is opted out of.
|
|
50
|
+
*
|
|
51
|
+
* A shape change is why the build id is in the key by default. Rename `PostView.author` to
|
|
52
|
+
* `PostView.authorId` and deploy: old and new pods share one Redis, `JSON.parse` does not
|
|
53
|
+
* validate, and the old pod hands `parsed.v as T` to a renderer expecting `author` — an undefined
|
|
54
|
+
* author on every cached post, on half the fleet, for the length of the rolling deploy. The cost
|
|
55
|
+
* of the default is a cold shared tier per deploy, which is the cheaper of the two.
|
|
56
|
+
*
|
|
57
|
+
* `null` (or `''`) opts out, for a team that versions its own payloads.
|
|
58
|
+
*/
|
|
59
|
+
export function namespaceFor(prefix: string, buildId: string | null | undefined): string {
|
|
60
|
+
const resolved = buildId === undefined ? appVersion() : buildId;
|
|
61
|
+
return resolved === null || resolved === '' ? prefix : `${prefix}:${resolved}`;
|
|
25
62
|
}
|
|
26
63
|
|
|
27
64
|
interface StoredEntry {
|
|
@@ -30,22 +67,68 @@ interface StoredEntry {
|
|
|
30
67
|
}
|
|
31
68
|
|
|
32
69
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
70
|
+
* Read the tag sets out. It deletes nothing at all, and both halves of that are deliberate.
|
|
71
|
+
*
|
|
72
|
+
* A script may only touch keys it was handed in `KEYS`, and the members of a tag set are not
|
|
73
|
+
* among them: they are value keys hashing to slots this node may not even own. `DEL`ing them from
|
|
74
|
+
* inside the script therefore raised "attempted to access a non-local key in a cluster node" on
|
|
75
|
+
* Redis Cluster and in Dragonfly's strict mode — swallowed into `report.errors`, so a bust read
|
|
76
|
+
* as "partial", the write that triggered it still succeeded, and stale rows served until TTL.
|
|
77
|
+
*
|
|
78
|
+
* The buckets themselves used to go, atomically with the `SMEMBERS` that read them. That made one
|
|
79
|
+
* failure permanent: a refused `DEL` in the client-side batch left its member with no bucket to
|
|
80
|
+
* be found in again, so the retry the error asks for answered `keys: []` and those rows served
|
|
81
|
+
* until their own TTL. The tier now `SREM`s exactly the members it managed to delete, which is
|
|
82
|
+
* strictly more precise — a member added by a concurrent write between the two halves is not in
|
|
83
|
+
* that list, so it keeps its membership instead of being silently orphaned by the bust.
|
|
84
|
+
*
|
|
85
|
+
* That is HALF the cluster story. The other half is `KEYS` itself: one `EVAL` carrying every tag's
|
|
86
|
+
* buckets is rejected with `CROSSSLOT` before the script runs, because `<ns>:t:post` and
|
|
87
|
+
* `<ns>:t:user` hash to different slots. So the buckets carry a `{entity}` hash tag and the tier
|
|
88
|
+
* issues ONE call per tag — every key of a call then hashes on the same entity, by construction.
|
|
35
89
|
*/
|
|
36
90
|
const INVALIDATE_SCRIPT = `
|
|
37
91
|
local removed = {}
|
|
38
92
|
for i, tagKey in ipairs(KEYS) do
|
|
39
93
|
local members = redis.call('SMEMBERS', tagKey)
|
|
40
94
|
for _, key in ipairs(members) do
|
|
41
|
-
redis.call('DEL', key)
|
|
42
95
|
table.insert(removed, key)
|
|
43
96
|
end
|
|
44
|
-
redis.call('DEL', tagKey)
|
|
45
97
|
end
|
|
46
98
|
return removed
|
|
47
99
|
`.trim();
|
|
48
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Join a value key to one tag set, and never let that set outlive its members by more than the
|
|
103
|
+
* grace — one key in `KEYS`, so it is slot-local under every topology.
|
|
104
|
+
*
|
|
105
|
+
* A tag set with no expiry is unbounded. Value keys die after five minutes; their membership never
|
|
106
|
+
* did, so after a month `SMEMBERS <ns>:t:{post}` returned several million dead keys — hundreds of
|
|
107
|
+
* milliseconds of blocked event loop, a multi-megabyte reply, and millions of client-side `DEL`s.
|
|
108
|
+
* One publish became a Redis outage, and the set itself was unbounded memory in the shared store.
|
|
109
|
+
*
|
|
110
|
+
* The TTL only ever GROWS, which is the half that has to be atomic: a 60s member must not shorten
|
|
111
|
+
* a bucket a 1h member is in, or that value key becomes unreachable by tag and serves stale until
|
|
112
|
+
* its own lease runs out. `EXPIRE ... GT` says exactly this in one command but treats a key with
|
|
113
|
+
* no TTL as infinite — so a FRESH bucket would keep no expiry at all, which is the bug being
|
|
114
|
+
* fixed. Read-then-set inside the script covers both cases, and needs no Redis 7.
|
|
115
|
+
*/
|
|
116
|
+
const TAG_MEMBER_SCRIPT = `
|
|
117
|
+
redis.call('SADD', KEYS[1], ARGV[1])
|
|
118
|
+
local ttl = tonumber(ARGV[2])
|
|
119
|
+
local current = redis.call('TTL', KEYS[1])
|
|
120
|
+
if current < 0 or current < ttl then
|
|
121
|
+
redis.call('EXPIRE', KEYS[1], ttl)
|
|
122
|
+
end
|
|
123
|
+
return 1
|
|
124
|
+
`.trim();
|
|
125
|
+
|
|
126
|
+
/** Concurrent `DEL`s per flush. Bun pipelines them, so this bounds memory, not round trips. */
|
|
127
|
+
const DELETE_BATCH = 128;
|
|
128
|
+
|
|
129
|
+
/** Seconds a tag set outlives its newest member. See `TAG_MEMBER_SCRIPT`. */
|
|
130
|
+
const TAG_TTL_GRACE_SECONDS = 60;
|
|
131
|
+
|
|
49
132
|
function resolveClient(injected: RedisLike | undefined): RedisLike {
|
|
50
133
|
if (injected !== undefined) return injected;
|
|
51
134
|
const candidate = (Bun as unknown as { redis?: RedisLike }).redis;
|
|
@@ -62,9 +145,58 @@ function resolveClient(injected: RedisLike | undefined): RedisLike {
|
|
|
62
145
|
const toStrings = (value: unknown): string[] =>
|
|
63
146
|
Array.isArray(value) ? value.map((item) => String(item)) : [];
|
|
64
147
|
|
|
148
|
+
/**
|
|
149
|
+
* What `PTTL` said about a key the `GET` beside it just answered for. `-1` and `-2` are sentinels,
|
|
150
|
+
* not durations — and they mean different things here: `-1` is a key with no lease (one written
|
|
151
|
+
* outside this tier), `-2` is no key at all, which for a value the `GET` returned means it expired
|
|
152
|
+
* BETWEEN the two commands. A driver may hand either back as a string, so the parse is `Number`.
|
|
153
|
+
*/
|
|
154
|
+
type Lease = { readonly kind: 'reaped' } | { readonly kind: 'none' } | { readonly ms: number };
|
|
155
|
+
|
|
156
|
+
function leaseFrom(reply: unknown): Lease {
|
|
157
|
+
const pttl = Number(reply);
|
|
158
|
+
if (!Number.isFinite(pttl)) return { kind: 'none' };
|
|
159
|
+
if (pttl === -2) return { kind: 'reaped' };
|
|
160
|
+
return pttl > 0 ? { ms: pttl } : { kind: 'none' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `SISMEMBER` answered a literal `0`, and nothing else counts. A reply this cannot read is not
|
|
165
|
+
* evidence: treating one as "gone" deletes every value the tier writes, which is a cache that
|
|
166
|
+
* never caches.
|
|
167
|
+
*/
|
|
168
|
+
function saysAbsent(reply: unknown): boolean {
|
|
169
|
+
if (typeof reply === 'number') return reply === 0;
|
|
170
|
+
if (typeof reply === 'string') return Number(reply) === 0;
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The first refusal, verbatim when it is one — `fanOut` renders `message` into `report.errors`, so
|
|
176
|
+
* the operator sees which key the store refused rather than a count.
|
|
177
|
+
*
|
|
178
|
+
* The `fix:` is the call, not a command: there is no `x cache` in this build, and the retry is one
|
|
179
|
+
* line of the app's own code. It is safe to repeat because every key the store refused kept its
|
|
180
|
+
* tag membership — the bust `SREM`s only what it deleted.
|
|
181
|
+
*/
|
|
182
|
+
function raiseSweepFailure(failures: readonly unknown[], attempted: number): never {
|
|
183
|
+
const first = failures[0];
|
|
184
|
+
if (first instanceof Error) throw first;
|
|
185
|
+
throw new CacheDriverUnavailableError({
|
|
186
|
+
driver: 'redis',
|
|
187
|
+
cause: `${String(failures.length)} of ${String(attempted)} value keys could not be deleted`,
|
|
188
|
+
fix: "await invalidateTags(tags) again once redis answers — from '@ultimat3/cache', with the same tags; every key it refused kept its bucket membership, so the retry reaches it",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
65
192
|
export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
66
|
-
const
|
|
193
|
+
const ns = namespaceFor(options.prefix ?? 'x', options.buildId);
|
|
67
194
|
const defaultTtlMs = options.defaultTtlMs ?? 300_000;
|
|
195
|
+
const clock = options.clock ?? systemClock;
|
|
196
|
+
const jitter: TtlJitter = {
|
|
197
|
+
...(options.jitterFraction === undefined ? {} : { jitterFraction: options.jitterFraction }),
|
|
198
|
+
...(options.rng === undefined ? {} : { rng: options.rng }),
|
|
199
|
+
};
|
|
68
200
|
let client: RedisLike | undefined;
|
|
69
201
|
|
|
70
202
|
const conn = (): RedisLike => {
|
|
@@ -72,23 +204,74 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
|
72
204
|
return client;
|
|
73
205
|
};
|
|
74
206
|
|
|
75
|
-
const valueKey = (key: string): string => `${
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
const
|
|
207
|
+
const valueKey = (key: string): string => `${ns}:c:${key}`;
|
|
208
|
+
// `{entity}` is a Redis Cluster hash tag, not decoration: it is what makes a row's bucket and
|
|
209
|
+
// its collection's bucket hash to ONE slot, so a script may take both in `KEYS`.
|
|
210
|
+
const tagKey = (owned: CacheTag): string =>
|
|
211
|
+
owned.id === undefined ? `${ns}:t:{${owned.entity}}` : `${ns}:t:{${owned.entity}}:${owned.id}`;
|
|
212
|
+
// Every key carrying ANY tag of this entity — `LruCache`'s `entityIndex`, on the wire. A SECOND
|
|
213
|
+
// bucket rather than a reuse of `tagKey({ entity })`, and that is the whole fix: one key serving
|
|
214
|
+
// both roles is what made a row bust over-reach.
|
|
215
|
+
const entityKey = (entity: string): string => `${ns}:e:{${entity}}`;
|
|
216
|
+
|
|
217
|
+
// A write joins the bucket of the tag it declared, plus its entity's index — so a collection
|
|
218
|
+
// bust reaches a row-tagged key without that row's tag having to live in the collection's bucket.
|
|
219
|
+
const writeBucketsFor = (owned: CacheTag): string[] => [tagKey(owned), entityKey(owned.entity)];
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* `tagMatches` expressed in keys, which is the point: the LRU and the request memo both answer a
|
|
223
|
+
* bust through that predicate and this tier did not.
|
|
224
|
+
*
|
|
225
|
+
* A COLLECTION bust matches every tag of the entity, so it reads the entity index. A ROW bust
|
|
226
|
+
* matches its own tag and the bare collection tag ONLY — `post:2` survives a bust of `post:1` —
|
|
227
|
+
* so it reads the row's bucket and the collection tag's, never the index. Reusing the collection
|
|
228
|
+
* bucket as the index meant `invalidateTags([tag('post', '1')])` returned every post-tagged key
|
|
229
|
+
* in the store and deleted them: one row write emptied the shared tier for that whole entity,
|
|
230
|
+
* while the in-process tier one rung closer kept exactly the row that had changed.
|
|
231
|
+
*
|
|
232
|
+
* A collection bust also reads `tagKey(owned)`, a strict subset of the index in this layout. That
|
|
233
|
+
* extra `SMEMBERS` is bought deliberately: a deployment pinned to `buildId: null` upgrades into
|
|
234
|
+
* this layout with the old two-role buckets still leased, and reading them keeps a collection
|
|
235
|
+
* bust from MISSING those keys. Over-reading a subset costs a round trip; under-reading is stale.
|
|
236
|
+
*/
|
|
237
|
+
const bustBucketsFor = (owned: CacheTag): string[] =>
|
|
79
238
|
owned.id === undefined
|
|
80
|
-
? [
|
|
81
|
-
: [tagKey(
|
|
239
|
+
? [entityKey(owned.entity), tagKey(owned)]
|
|
240
|
+
: [tagKey(owned), tagKey({ entity: owned.entity })];
|
|
82
241
|
|
|
83
242
|
return {
|
|
84
243
|
name: 'redis',
|
|
85
244
|
|
|
245
|
+
/**
|
|
246
|
+
* The value AND what is left of its lease. `set` always applies a finite `EX`, so an entry
|
|
247
|
+
* read back without its remaining life is an entry the stack can only promote on the
|
|
248
|
+
* CALLER's ttl — re-leasing a row one second from expiry for a fresh five minutes into the
|
|
249
|
+
* LRU on every read, which is a hot key that never goes stale enough to be refetched.
|
|
250
|
+
*
|
|
251
|
+
* `PTTL` rather than an `expiresAt` written into the payload: the server owns the clock, so
|
|
252
|
+
* this survives skew between the node that wrote and the node that reads, and no stored
|
|
253
|
+
* shape changes under a running deployment. Issued alongside the `GET` rather than after
|
|
254
|
+
* it — Bun pipelines the pair, so the expiry costs no extra round trip.
|
|
255
|
+
*/
|
|
86
256
|
async get<T>(key: string): Promise<CacheEntry<T> | undefined> {
|
|
87
|
-
const
|
|
257
|
+
const stored = valueKey(key);
|
|
258
|
+
const [raw, pttl] = await Promise.all([
|
|
259
|
+
conn().get(stored),
|
|
260
|
+
conn().send('PTTL', [stored]) as Promise<unknown>,
|
|
261
|
+
]);
|
|
88
262
|
if (raw === null) return undefined;
|
|
263
|
+
const lease = leaseFrom(pttl);
|
|
264
|
+
// Expired between the two commands. Reported as a hit it would be a hit with no `expiresAt`,
|
|
265
|
+
// which the stack promotes into the LRU on the CALLER's ttl — a row one millisecond from
|
|
266
|
+
// death handed a fresh five minutes, one tier closer to the request.
|
|
267
|
+
if ('kind' in lease && lease.kind === 'reaped') return undefined;
|
|
89
268
|
try {
|
|
90
269
|
const parsed = JSON.parse(raw) as StoredEntry;
|
|
91
|
-
return {
|
|
270
|
+
return {
|
|
271
|
+
value: parsed.v as T,
|
|
272
|
+
tags: parsed.t.map(parseTag),
|
|
273
|
+
...('ms' in lease ? { expiresAt: nowMs(clock) + lease.ms } : {}),
|
|
274
|
+
};
|
|
92
275
|
} catch {
|
|
93
276
|
// A poisoned value is a miss, never a 500. Redis TTL will reap it.
|
|
94
277
|
logger.warn('cache.redis.corrupt-entry', { key });
|
|
@@ -96,36 +279,109 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
|
96
279
|
}
|
|
97
280
|
},
|
|
98
281
|
|
|
282
|
+
/**
|
|
283
|
+
* The value key gets a lease and so does every bucket it joins — see `TAG_MEMBER_SCRIPT`.
|
|
284
|
+
*
|
|
285
|
+
* The buckets are joined FIRST and the membership is re-checked LAST, because this write and
|
|
286
|
+
* a bust of the same tag are two clients with no lock between them. Writing the value first
|
|
287
|
+
* left a window where the bust's `SMEMBERS` found an empty bucket and the value it should
|
|
288
|
+
* have cleared survived its own invalidation for the full TTL. Joining first moves the window
|
|
289
|
+
* somewhere observable: `invalidateTags` removes a member only when it deleted that member's
|
|
290
|
+
* value key, so a membership gone by the time the `SET` lands means this write was busted
|
|
291
|
+
* while it was in the air — and the value goes with it, because a row nothing can reach by
|
|
292
|
+
* tag is one no later bust can clear either.
|
|
293
|
+
*/
|
|
99
294
|
async set<T>(key: string, value: T, setOptions?: CacheSetOptions): Promise<void> {
|
|
100
295
|
const tags = setOptions?.tags ?? [];
|
|
101
|
-
const ttlMs = setOptions?.ttlMs ?? defaultTtlMs;
|
|
296
|
+
const ttlMs = assertTtl(key, setOptions?.ttlMs ?? defaultTtlMs, 'redis', jitter);
|
|
102
297
|
const payload: StoredEntry = { v: value, t: tags.map(serializeTag) };
|
|
103
298
|
const stored = valueKey(key);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
299
|
+
// `PX`, not `EX`: the value's lease is spent in the milliseconds it was validated and
|
|
300
|
+
// jittered in. `Math.ceil(ttlMs / 1000)` honoured a 1,001ms lease as 2s — rounding toward
|
|
301
|
+
// STALENESS, the opposite of what the jitter beside it protects, and a disagreement with
|
|
302
|
+
// the LRU tier about when the same entry dies. The BUCKET keeps whole seconds and keeps
|
|
303
|
+
// rounding up: a tag set has to outlive every member it holds.
|
|
304
|
+
const bucketTtlSeconds = String(Math.max(1, Math.ceil(ttlMs / 1000)) + TAG_TTL_GRACE_SECONDS);
|
|
305
|
+
// Deduped: two tags of one entity share the entity index, and joining it twice is a round
|
|
306
|
+
// trip that changes nothing. Issued together — one key each, so still slot-local.
|
|
307
|
+
const buckets = [...new Set(tags.flatMap(writeBucketsFor))];
|
|
308
|
+
await Promise.all(
|
|
309
|
+
buckets.map((bucket) =>
|
|
310
|
+
conn().send('EVAL', [TAG_MEMBER_SCRIPT, '1', bucket, stored, bucketTtlSeconds]),
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
await conn().send('SET', [stored, JSON.stringify(payload), 'PX', String(Math.ceil(ttlMs))]);
|
|
314
|
+
if (buckets.length === 0) return;
|
|
315
|
+
const membership = await Promise.all(
|
|
316
|
+
buckets.map((bucket) => conn().send('SISMEMBER', [bucket, stored])),
|
|
317
|
+
);
|
|
318
|
+
if (membership.some(saysAbsent)) await conn().send('DEL', [stored]);
|
|
111
319
|
},
|
|
112
320
|
|
|
113
321
|
async del(key: string): Promise<void> {
|
|
114
322
|
await conn().send('DEL', [valueKey(key)]);
|
|
115
323
|
},
|
|
116
324
|
|
|
325
|
+
/**
|
|
326
|
+
* ONE script call per tag, never one for the batch. Every key a call is handed comes from a
|
|
327
|
+
* single tag and therefore carries the same `{entity}` hash tag, so it is one slot on Redis
|
|
328
|
+
* Cluster; the batched form was rejected with `CROSSSLOT` before the script ever ran, which
|
|
329
|
+
* landed in `report.errors` as a partial bust with stale rows serving until TTL.
|
|
330
|
+
*/
|
|
117
331
|
async invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
332
|
+
const claimed = new Set<string>();
|
|
333
|
+
const perTag: string[][] = [];
|
|
334
|
+
for (const owned of tags) {
|
|
335
|
+
// A bucket already claimed by an earlier tag is dropped rather than re-sent: a collection
|
|
336
|
+
// tag and one of its rows overlap, and the second call would read the same members.
|
|
337
|
+
const buckets = bustBucketsFor(owned).filter((bucket) => !claimed.has(bucket));
|
|
338
|
+
for (const bucket of buckets) claimed.add(bucket);
|
|
339
|
+
if (buckets.length > 0) perTag.push(buckets);
|
|
340
|
+
}
|
|
341
|
+
if (perTag.length === 0) return { tier: 'redis', keys: [] };
|
|
342
|
+
const replies = await Promise.all(
|
|
343
|
+
perTag.map((buckets) =>
|
|
344
|
+
conn().send('EVAL', [INVALIDATE_SCRIPT, String(buckets.length), ...buckets]),
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
// A member may sit in two tag sets; deleting it twice is harmless but reporting it twice
|
|
348
|
+
// makes the `/_x` panel overstate what cleared.
|
|
349
|
+
const members = [...new Set(replies.flatMap(toStrings))];
|
|
350
|
+
const deleted = new Set<string>();
|
|
351
|
+
const failures: unknown[] = [];
|
|
352
|
+
for (let start = 0; start < members.length; start += DELETE_BATCH) {
|
|
353
|
+
// One key per DEL — always slot-local. Issued together so the batch costs one round trip.
|
|
354
|
+
// `allSettled`, because which ones died decides what leaves the buckets below.
|
|
355
|
+
const batch = members.slice(start, start + DELETE_BATCH);
|
|
356
|
+
const settled = await Promise.allSettled(
|
|
357
|
+
batch.map((member) => conn().send('DEL', [member])),
|
|
358
|
+
);
|
|
359
|
+
settled.forEach((result, index) => {
|
|
360
|
+
const member = batch[index];
|
|
361
|
+
if (member === undefined) return;
|
|
362
|
+
if (result.status === 'fulfilled') deleted.add(member);
|
|
363
|
+
else failures.push(result.reason);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Only what actually died leaves its bucket. A member the store refused to delete keeps its
|
|
368
|
+
// membership, so the retry `report.errors` asks for still finds it; the script no longer
|
|
369
|
+
// drops the bucket, which is what made that failure permanent.
|
|
370
|
+
for (let i = 0; i < perTag.length; i += 1) {
|
|
371
|
+
const gone = [...new Set(toStrings(replies[i]))].filter((member) => deleted.has(member));
|
|
372
|
+
for (const bucket of perTag[i] ?? []) {
|
|
373
|
+
for (let start = 0; start < gone.length; start += DELETE_BATCH) {
|
|
374
|
+
await conn().send('SREM', [bucket, ...gone.slice(start, start + DELETE_BATCH)]);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (failures.length > 0) raiseSweepFailure(failures, members.length);
|
|
380
|
+
const stripped = [...deleted].map((key) => key.slice(`${ns}:c:`.length));
|
|
126
381
|
return { tier: 'redis', keys: stripped };
|
|
127
382
|
},
|
|
128
383
|
};
|
|
129
384
|
}
|
|
130
385
|
|
|
131
386
|
export const REDIS_INVALIDATE_SCRIPT = INVALIDATE_SCRIPT;
|
|
387
|
+
export const REDIS_TAG_MEMBER_SCRIPT = TAG_MEMBER_SCRIPT;
|
package/src/semantic.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Clock } from '@ultimat3/core';
|
|
|
8
8
|
import { systemClock } from '@ultimat3/core';
|
|
9
9
|
import type { CacheTag } from './tags';
|
|
10
10
|
import { tagsIntersect } from './tags';
|
|
11
|
-
import { nowMs } from './tiers';
|
|
11
|
+
import { assertTtl, nowMs } from './tiers';
|
|
12
12
|
|
|
13
13
|
export type Embedding = readonly number[];
|
|
14
14
|
|
|
@@ -111,12 +111,19 @@ export function createMemorySemanticCache(options: SemanticCacheOptions = {}): S
|
|
|
111
111
|
value: T,
|
|
112
112
|
rememberOptions?: SemanticRememberOptions,
|
|
113
113
|
): Promise<void> {
|
|
114
|
+
// The same TTL rule every tier writes under, and for the same reason: `0` here silently
|
|
115
|
+
// stored an entry that was already expired, so the cache answered every lookup with a miss
|
|
116
|
+
// and nothing said why. `jitterFraction: 0` — spreading a lease is a herd defence for a
|
|
117
|
+
// shared store, and this one is per process.
|
|
118
|
+
const ttlMs = assertTtl(key, rememberOptions?.ttlMs ?? defaultTtlMs, 'semantic', {
|
|
119
|
+
jitterFraction: 0,
|
|
120
|
+
});
|
|
114
121
|
records.delete(key);
|
|
115
122
|
records.set(key, {
|
|
116
123
|
key,
|
|
117
124
|
embedding,
|
|
118
125
|
value,
|
|
119
|
-
expiresAt: nowMs(clock) +
|
|
126
|
+
expiresAt: nowMs(clock) + ttlMs,
|
|
120
127
|
tags: rememberOptions?.tags ?? [],
|
|
121
128
|
});
|
|
122
129
|
// Insertion-ordered Map: the oldest key is the first one.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// How two callers' `CacheSetOptions` become one write, and how a `null` load picks its TTL. Both
|
|
2
|
+
// belong to the stack rather than to a tier — a tier sees one caller and one value, and neither
|
|
3
|
+
// decision is answerable from there.
|
|
4
|
+
|
|
5
|
+
import type { CacheTag } from './tags';
|
|
6
|
+
import { serializeTag } from './tags';
|
|
7
|
+
import type { CacheSetOptions } from './tiers';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `negativeTtlMs` selected when the value IS the absence of one. A lookup for a row that has not
|
|
11
|
+
* replicated yet answers `null` 40ms before it lands; holding that for the positive TTL serves
|
|
12
|
+
* "does not exist" for five minutes.
|
|
13
|
+
*/
|
|
14
|
+
export function ttlOptionsFor<T>(value: T, options?: CacheSetOptions): CacheSetOptions | undefined {
|
|
15
|
+
const negative = options?.negativeTtlMs;
|
|
16
|
+
if (negative === undefined) return options;
|
|
17
|
+
if (value !== null && value !== undefined) return options;
|
|
18
|
+
return { ...options, ttlMs: negative };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** First-seen order, deduped on the wire form — the same identity every tier indexes by. */
|
|
22
|
+
function mergeTags(
|
|
23
|
+
current: readonly CacheTag[] | undefined,
|
|
24
|
+
joining: readonly CacheTag[] | undefined,
|
|
25
|
+
): readonly CacheTag[] | undefined {
|
|
26
|
+
if (current === undefined) return joining;
|
|
27
|
+
if (joining === undefined) return current;
|
|
28
|
+
const seen = new Set(current.map(serializeTag));
|
|
29
|
+
const merged = [...current];
|
|
30
|
+
for (const owned of joining) {
|
|
31
|
+
const wire = serializeTag(owned);
|
|
32
|
+
if (seen.has(wire)) continue;
|
|
33
|
+
seen.add(wire);
|
|
34
|
+
merged.push(owned);
|
|
35
|
+
}
|
|
36
|
+
return merged;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The SHORTEST lease wins: an entry held longer than a caller asked for is that caller's bug. */
|
|
40
|
+
function shortest(current: number | undefined, joining: number | undefined): number | undefined {
|
|
41
|
+
if (current === undefined) return joining;
|
|
42
|
+
if (joining === undefined) return current;
|
|
43
|
+
return Math.min(current, joining);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fold a joiner's options into the single-flight leader's.
|
|
48
|
+
*
|
|
49
|
+
* A joiner that shares a load also shares its WRITE, so options it declared and the leader did not
|
|
50
|
+
* are silently dropped without this: the entry lands carrying only the leader's tags, and the
|
|
51
|
+
* joiner's invalidation — the whole point of declaring a tag — never reaches it again.
|
|
52
|
+
*/
|
|
53
|
+
export function mergeSetOptions(
|
|
54
|
+
current: CacheSetOptions,
|
|
55
|
+
joining: CacheSetOptions,
|
|
56
|
+
): CacheSetOptions {
|
|
57
|
+
const tags = mergeTags(current.tags, joining.tags);
|
|
58
|
+
const ttlMs = shortest(current.ttlMs, joining.ttlMs);
|
|
59
|
+
const negativeTtlMs = shortest(current.negativeTtlMs, joining.negativeTtlMs);
|
|
60
|
+
return {
|
|
61
|
+
...(tags === undefined ? {} : { tags }),
|
|
62
|
+
...(ttlMs === undefined ? {} : { ttlMs }),
|
|
63
|
+
...(negativeTtlMs === undefined ? {} : { negativeTtlMs }),
|
|
64
|
+
};
|
|
65
|
+
}
|