@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/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
- set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
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> {
@@ -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 { logger } from '@ultimat3/core';
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 { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } 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';
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
- * Drop the value keys, then drop the tag sets themselves. `SMEMBERS` + `DEL` in one EVAL is
34
- * atomic and single-trip; doing it client-side would race a concurrent write.
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 prefix = options.prefix ?? 'x';
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 => `${prefix}:c:${key}`;
76
- const tagKey = (wire: string): string => `${prefix}:t:${wire}`;
77
- // A row write must also appear in the collection's tag set, or list caches survive it.
78
- const tagKeysFor = (owned: CacheTag): string[] =>
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
- ? [tagKey(owned.entity)]
81
- : [tagKey(serializeTag(owned)), tagKey(owned.entity)];
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 raw = await conn().get(valueKey(key));
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 { value: parsed.v as T, tags: parsed.t.map(parseTag) };
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
- const ttlSeconds = Math.max(1, Math.ceil(ttlMs / 1000));
105
- await conn().send('SET', [stored, JSON.stringify(payload), 'EX', String(ttlSeconds)]);
106
- for (const owned of tags) {
107
- for (const bucket of tagKeysFor(owned)) {
108
- await conn().send('SADD', [bucket, stored]);
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 buckets = [...new Set(tags.flatMap(tagKeysFor))];
119
- if (buckets.length === 0) return { tier: 'redis', keys: [] };
120
- const result = await conn().send('EVAL', [
121
- INVALIDATE_SCRIPT,
122
- String(buckets.length),
123
- ...buckets,
124
- ]);
125
- const stripped = toStrings(result).map((key) => key.slice(`${prefix}:c:`.length));
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) + (rememberOptions?.ttlMs ?? defaultTtlMs),
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
+ }