@ultimat3/cache 22.15.0 → 24.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 CHANGED
@@ -27,6 +27,14 @@ Tier 1. Tagged caching + THE invalidation graph.
27
27
  second mechanism. `cover()` widens a fence for joiners' tags (`createCacheStack.read` only). A
28
28
  fence never fails a read — it declines to publish. The one process-global here with no `isolate*()`
29
29
  seam, structurally: a fence samples the current generation.
30
+ - **The Redis tier keeps a second fence, for the FLEET** (`CacheTier.fence`, `tier-fence.ts`,
31
+ `redis-fence.ts`): a bust `SET`s a leased generation BEFORE it reads its buckets; `stack.read`
32
+ samples before `load()` and asks AFTER the tier's `set`. `busted` withdraws the whole fill,
33
+ `unprovable` (Redis unreachable, past `FENCE_PROOF_WINDOW_MS`, an unsampled joiner tag) skips
34
+ that tier only. Plain `SET`/`GET`, never a script — so `redis-fence.test.ts` proves it on the
35
+ fake. Tags only; `drop`/`write` by key still fence one process.
36
+ - **A refused `set` in `fill` is followed by a `del` in that tier.** `LruCache.set` called
37
+ directly still leaves the entry it refused to replace.
30
38
  - One graph. `graph.ts` exports functions over module state and **no constructor**; no second registry.
31
39
  - Tag order is `TIER_ORDER`, never registration order. `sortTiers()` enforces it.
32
40
  - **The rung NAMES are `@ultimat3/core`'s (`CACHE_TIERS`); the ladder is this package's.** `TierName`
@@ -111,7 +119,16 @@ Tier 1. Tagged caching + THE invalidation graph.
111
119
  from `tiers` + `isr` + `liveQueries`, never `cdn`.
112
120
  - A purge driver is selected by `selectPurgeDriver` from the environment, never `app.config.ts`. Two
113
121
  CDN credentials, or half a pair, is refused. The token never reaches a printed string.
114
- - `assertPurgeableKeys` refuses whitespace or a comma in a key **before** the request.
122
+ - `assertPurgeableKeys` refuses whitespace or a comma in a key **before** the request — and at
123
+ EMISSION (`surrogateKeys`, `cacheHeaders`), where an unpurgeable key would otherwise be tagged.
124
+ - **The edge speaks `tagMatches` through two key lists in `cdn.ts`**: a response carries each wire
125
+ tag plus `e:<entity>`; a row bust purges `<entity>:<id>` + `<entity>`, a collection bust
126
+ `e:<entity>` + `<entity>`. Never purge `e:` for a row. `tag-parity.test.ts` runs one fixture
127
+ table through memo, LRU, Redis and CDN.
128
+ - **The one shipped emitter of surrogate keys is `toResult` in `packages/render/src/render-isr.ts`**
129
+ (`surrogateKeys(tags, 'isr')`, `As of 2026-10-02`; http strips them on a rewrite to private). `cacheHeaders` has no production caller, and
130
+ `@ultimat3/http`'s `applyCacheHeaders` joins `CacheHint.tags` verbatim — a caller hands it
131
+ `surrogateKeys` output. Render screens `revalidate.tags` at registration with this function.
115
132
  - `retryable` on `X_CACHE_PURGE_FAILED` is core's `isRetryableStatus` (re-exported by
116
133
  `purge-http.ts`), plus any request with no status, and `CachePurgeFailedError` passes it through
117
134
  as a per-instance `retry` — never `registerErrorRetry`, one code covers a 401 and a 429.
@@ -133,13 +150,15 @@ Tier 1. Tagged caching + THE invalidation graph.
133
150
  | `graph.ts` | tag → dependents (cache keys, ISR routes, CDN paths, live queries) |
134
151
  | `tiers.ts` | `CacheTier`, `TIER_ORDER`, `TierLabel`, `assertTtl`, read-through stack |
135
152
  | `fence.ts` | the invalidation fence a fill (here or in `query`) checks before it publishes |
153
+ | `tier-fence.ts` | `TierFence`/`FenceVerdict`, and how a fill samples and asks a shared tier's fence |
154
+ | `redis-fence.ts` | the leased generation keys a bust writes and a fill watches |
136
155
  | `set-options.ts` | how two callers' `CacheSetOptions` combine, and the `null`-load TTL |
137
156
  | `tier-failures.ts` | `bestEffort()`, and the bounded log of refusals it absorbs |
138
157
  | `memo.ts` | request memo over the ALS ctx (WeakMap, no lifecycle) |
139
158
  | `lru.ts` | byte-budgeted LRU (linked list + map + tag index) |
140
159
  | `redis.ts` | `Bun.redis` tier, build-namespaced keys, hash-tagged buckets, one script call per tag |
141
160
  | `single-flight.ts` | the door onto `@ultimat3/core`'s `createSingleFlight` — one in-flight `load()` per key, shared by every concurrent miss. No implementation of its own since 2026-08-23 |
142
- | `cdn.ts` | `Cache-Control`/`Surrogate-Key` emission, the `PurgeDriver` seam, `noopPurgeDriver` |
161
+ | `cdn.ts` | `Cache-Control`/`Surrogate-Key` emission, `surrogateKeys`, the purge key list, the `PurgeDriver` seam |
143
162
  | `purge-http.ts` | the HTTP half both remote drivers share: one POST, batching, key guard, and core's retryable table re-exported |
144
163
  | `purge-fastly.ts` | `fastlyPurgeDriver`: surrogate-key batch purge, `purge_all` |
145
164
  | `purge-cloudflare.ts` | `cloudflarePurgeDriver`: cache-tag purge, `purge_everything` |
package/README.md CHANGED
@@ -30,7 +30,8 @@ Reads walk down until a hit, then populate every tier they walked past. Writes p
30
30
  | 2 | `redis` | `Bun.redis` | tag→keys set, one `EVAL` **per tag** + slot-local `DEL`s | single node |
31
31
  | 3 | `cdn` | headers + purge driver | surrogate keys | no CDN |
32
32
 
33
- A tier is a `CacheTier` (`get`/`set`/`del`/`invalidateTags`). Swap or omit any of them
33
+ A tier is a `CacheTier` (`get`/`set`/`del`/`invalidateTags`, plus an optional `fence` for a tier
34
+ other processes also write). Swap or omit any of them
34
35
  without touching a call site — order comes from `TIER_ORDER`, not registration order.
35
36
 
36
37
  ```ts
@@ -92,6 +93,27 @@ const value = await run();
92
93
  if (fence.isValid()) await tier.set(key, value, { tags });
93
94
  ```
94
95
 
96
+ **That fence is this process's; the Redis tier keeps a second one for the fleet.** A bust another
97
+ replica ran reaches this process only when the broadcast does, and a lost broadcast means never —
98
+ so the fill would publish the pre-write rows into the *shared* tier and every replica would promote
99
+ them. `createRedisTier` implements `CacheTier.fence`: a bust writes a leased generation
100
+ (`<ns>:g:{entity}:r:<id>`, `:c`, `:a`) **before** it reads its buckets, `stack.read` samples the
101
+ generations its tags watch before `load()`, and re-reads them after the tier's `SET`.
102
+
103
+ | Verdict | Means | The fill |
104
+ |---|---|---|
105
+ | `valid` | nothing the tags match moved | stands |
106
+ | `busted` | a generation moved — proof the value is stale | withdrawn from every tier written so far |
107
+ | `unprovable` | Redis could not be asked, the load outlived the 60s proof window, or a joiner brought a tag nobody sampled | that tier alone is left unwritten; nearer tiers keep the value |
108
+
109
+ Cost, `As of 2026-10`: one pipelined round trip before each `load()` and one after the `SET` (one
110
+ `GET` for a collection tag, two for a row tag), two `SET`s per busted tag. Plain commands, no
111
+ script. Tag busts only — `stack.drop(key)` and `stack.write(key)` still fence this process alone.
112
+
113
+ **A tier that refuses a `set` during a fill has the key deleted in that tier.** The refusal is
114
+ absorbed (below), so without the `del` the tier keeps answering with the value the fill was
115
+ replacing — an entry that outgrew the LRU's budget served its previous version for a whole lease.
116
+
95
117
  **A `null` can carry its own TTL.** `negativeTtlMs` is used when the loaded value is `null` or
96
118
  `undefined`, so a lookup for a row that has not replicated yet is not held for the positive lease:
97
119
 
@@ -131,7 +153,7 @@ tagsFor(Post, row) // both, for a repo write
131
153
  ```
132
154
 
133
155
  Wire form is `post` / `post:<id>`, identical in Redis keys, CDN surrogate keys and
134
- `--json` reports. Invalidation is asymmetric-tolerant on purpose: busting a collection
156
+ `--json` reports — the edge adds one `e:<entity>` index key per entity ([CDN](#cdn)). Invalidation is asymmetric-tolerant on purpose: busting a collection
135
157
  kills its rows, busting a row kills the collections that held it.
136
158
 
137
159
  `tag.post` is typed via a registry that `x manifest` generates, so `tag.pots` is a build
@@ -306,11 +328,26 @@ covers — so a suite outside `packages/cache` isolates the tiers and gets the f
306
328
  ```ts
307
329
  cacheHeaders({ sMaxAge: 300, staleWhileRevalidate: 86_400, tags: [tag('post', id)] });
308
330
  // => { 'Cache-Control': 'public, max-age=0, s-maxage=300, stale-while-revalidate=86400',
309
- // 'Surrogate-Key': 'post:1' }
331
+ // 'Surrogate-Key': 'post:1 e:post', 'Cache-Tag': 'post:1,e:post' }
310
332
  ```
311
333
 
312
- The surrogate keys **are** the tags, byte for byte, so an edge purge and an app-level
313
- invalidation can never mean different things. A `cdn-path` dependent registered against a tag goes
334
+ An edge purges by exact key, so `tagMatches` is built from what a response **carries** and what a
335
+ bust **purges** — both from `cdn.ts`, so they cannot drift:
336
+
337
+ | | Row tag `post:1` | Collection tag `post` |
338
+ |---|---|---|
339
+ | a response carries (`surrogateKeys(tags)`) | `post:1` `e:post` | `post` `e:post` |
340
+ | a bust purges | `post:1` `post` | `e:post` `post` |
341
+
342
+ So a row bust clears that row's pages and the lists keyed `post`, never `post:2`'s; a collection
343
+ bust clears everything of the entity. `e:<entity>` is the edge twin of the Redis tier's entity
344
+ index. Carrying the wire tags alone left a detail page unreachable by a collection bust, and a
345
+ list unreachable by a row bust, with `errors: []`. Header growth, measured: `len(entity) + 3`
346
+ bytes per distinct entity in each of the two headers — 7 bytes each for `post`, 14 per response.
347
+
348
+ `surrogateKeys()` is exported for any emitter that does not go through `cacheHeaders()`. Both
349
+ refuse a key carrying whitespace or a comma **at emission** (`X_CACHE_PURGE_FAILED`): the CDN
350
+ would split it into keys no purge ever names. A `cdn-path` dependent registered against a tag goes
314
351
  out in the same purge — as a surrogate key, the one currency `PurgeDriver` has — so a host
315
352
  registering one must tag that response with its own path. Three `PurgeDriver`s ship:
316
353
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cache",
3
- "version": "22.15.0",
3
+ "version": "24.0.0",
4
4
  "description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "!src/**/*-fixture.ts",
22
23
  "CLAUDE.md",
23
24
  "README.md",
24
25
  "LICENSE"
@@ -31,6 +32,6 @@
31
32
  "test": "bun test"
32
33
  },
33
34
  "dependencies": {
34
- "@ultimat3/core": "22.15.0"
35
+ "@ultimat3/core": "24.0.0"
35
36
  }
36
37
  }
package/src/cdn.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  // Tier 3: the CDN. Ultimate does not read from the CDN (it sits in front of us), so this
2
2
  // tier's job is the other two thirds of caching: emitting the headers that let the CDN hold
3
- // the response, and purging by surrogate key when a tag changes. Surrogate keys ARE the
4
- // tags — same strings, so a CDN purge cannot drift from an app-level invalidation.
3
+ // the response, and purging by surrogate key when a tag changes. Surrogate keys are built from
4
+ // the tags in this one file, so what a purge sends cannot drift from what a response carries.
5
5
 
6
6
  import { dependentsOfKind } from './graph';
7
+ import { assertPurgeableKeys } from './purge-http';
7
8
  import type { CacheTag } from './tags';
8
- import { serializeTags } from './tags';
9
+ import { serializeTag } from './tags';
9
10
  import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers';
10
11
 
11
12
  export interface CacheHeaderOptions {
@@ -22,6 +23,56 @@ export interface CacheHeaderOptions {
22
23
  readonly tags?: readonly CacheTag[];
23
24
  }
24
25
 
26
+ /**
27
+ * The edge's entity index: every response tagged with ANY tag of `entity` carries it. The CDN twin
28
+ * of `redis.ts`'s `e:{entity}` bucket, and a separate key for the same reason — one key serving as
29
+ * both "the collection tag" and "everything of this entity" is what makes a row bust over-reach.
30
+ */
31
+ const entityIndexKey = (entity: string): string => `e:${entity}`;
32
+
33
+ const screened = (emitter: string, keys: readonly string[]): readonly string[] => {
34
+ const unique = [...new Set(keys)];
35
+ assertPurgeableKeys(emitter, unique);
36
+ return unique;
37
+ };
38
+
39
+ /**
40
+ * What a response CARRIES: each tag's wire form, plus its entity's index key.
41
+ *
42
+ * A CDN cannot ask "does this key match?" — it purges by exact key — so `tagMatches`, the two-way
43
+ * rule every other tier keeps, has to be built out of what is carried and what is purged. Carrying
44
+ * the wire tags alone meant a collection bust (`post`) never reached a detail page keyed `post:1`,
45
+ * with `errors: []`. `purgeKeysFor` below is the other half; they live together so they cannot drift.
46
+ *
47
+ * Screened HERE, at emission: a key carrying whitespace or a comma is split by the CDN into keys
48
+ * nothing ever purges, so the response would be tagged and could never be cleared.
49
+ */
50
+ export function surrogateKeys(
51
+ tags: readonly CacheTag[],
52
+ emitter = 'surrogateKeys',
53
+ ): readonly string[] {
54
+ return screened(
55
+ emitter,
56
+ tags.flatMap((owned) => [serializeTag(owned), entityIndexKey(owned.entity)]),
57
+ );
58
+ }
59
+
60
+ /**
61
+ * What a bust PURGES. A COLLECTION bust matches every tag of the entity, so it purges the index —
62
+ * and the bare collection key too, which responses cached before the index existed still carry.
63
+ * A ROW bust matches its own tag and the bare collection tag only: `post:2` survives `post:1`.
64
+ */
65
+ function purgeKeysFor(tags: readonly CacheTag[]): readonly string[] {
66
+ return screened(
67
+ 'cdn',
68
+ tags.flatMap((requested) =>
69
+ requested.id === undefined
70
+ ? [entityIndexKey(requested.entity), requested.entity]
71
+ : [serializeTag(requested), requested.entity],
72
+ ),
73
+ );
74
+ }
75
+
25
76
  /** `Cache-Control` + `Surrogate-Key` + `Cache-Tag`, ready to spread into a `Headers` init. */
26
77
  export function cacheHeaders(options: CacheHeaderOptions = {}): Record<string, string> {
27
78
  const visibility = options.visibility ?? 'public';
@@ -41,7 +92,7 @@ export function cacheHeaders(options: CacheHeaderOptions = {}): Record<string, s
41
92
  if (options.immutable === true) parts.push('immutable');
42
93
 
43
94
  const headers: Record<string, string> = { 'Cache-Control': parts.join(', ') };
44
- const keys = serializeTags(options.tags ?? []);
95
+ const keys = surrogateKeys(options.tags ?? [], 'cacheHeaders');
45
96
  if (keys.length > 0) {
46
97
  headers['Surrogate-Key'] = keys.join(' ');
47
98
  // Cloudflare's spelling of the same list; `@ultimat3/http`'s `applyCacheHeaders` writes both.
@@ -111,7 +162,7 @@ export function createCdnTier(options: CdnTierOptions = {}): CacheTier {
111
162
  },
112
163
 
113
164
  /**
114
- * The tags themselves plus every `cdn-path` the graph hangs off them — one purge, one list.
165
+ * `purgeKeysFor(tags)` plus every `cdn-path` the graph hangs off them — one purge, one list.
115
166
  *
116
167
  * Those paths were computed by `invalidate.ts` and reported as busted while nothing ever
117
168
  * purged them, so `x cache bust --json` named a path the edge still held for its whole
@@ -128,7 +179,7 @@ export function createCdnTier(options: CdnTierOptions = {}): CacheTier {
128
179
  if (isNoopPurgeDriver(driver)) {
129
180
  return { tier: 'cdn', keys: [], skipped: 'no purge driver configured' };
130
181
  }
131
- const keys = [...new Set([...serializeTags(tags), ...dependentsOfKind(tags, 'cdn-path')])];
182
+ const keys = [...new Set([...purgeKeysFor(tags), ...dependentsOfKind(tags, 'cdn-path')])];
132
183
  if (keys.length === 0) return { tier: 'cdn', keys: [] };
133
184
  const accepted = await driver.purge(keys);
134
185
  return { tier: 'cdn', keys: accepted };
package/src/index.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  // Public API of @ultimat3/cache. Explicit, no `export *`.
2
2
 
3
3
  export type { CacheHeaderOptions, CdnTierOptions, PurgeDriver } from './cdn';
4
- export { cacheHeaders, createCdnTier, isNoopPurgeDriver, noopPurgeDriver } from './cdn';
4
+ export {
5
+ cacheHeaders,
6
+ createCdnTier,
7
+ isNoopPurgeDriver,
8
+ noopPurgeDriver,
9
+ surrogateKeys,
10
+ } from './cdn';
5
11
  export type { CacheErrorCode } from './errors';
6
12
  export {
7
13
  CACHE_ERROR_CODES,
@@ -96,6 +102,7 @@ export {
96
102
  } from './tags';
97
103
  export type { TierFailure, TierOperation } from './tier-failures';
98
104
  export { bestEffort, recentTierFailures } from './tier-failures';
105
+ export type { FenceVerdict, TierFence } from './tier-fence';
99
106
  export type {
100
107
  CacheEntry,
101
108
  CacheSetOptions,
package/src/lru.ts CHANGED
@@ -43,6 +43,17 @@ interface LruNode {
43
43
 
44
44
  const encoder = new TextEncoder();
45
45
 
46
+ /**
47
+ * `JSON.stringify` renders a `Map` or a `Set` as `{}` — two bytes for a collection of any size, so
48
+ * a tier budgeted at 64 MiB held gigabytes of them without evicting once. Walked as their entries
49
+ * instead; a replacer is applied to what it returns, so a nested one is walked too.
50
+ */
51
+ const widenCollections = (_key: string, value: unknown): unknown => {
52
+ if (value instanceof Map) return [...value.entries()];
53
+ if (value instanceof Set) return [...value];
54
+ return value;
55
+ };
56
+
46
57
  /** Cheap, deterministic size estimate. Exact heap cost is unknowable; consistency matters. */
47
58
  export function estimateBytes(value: unknown): number {
48
59
  if (value === undefined) return 0;
@@ -50,7 +61,7 @@ export function estimateBytes(value: unknown): number {
50
61
  if (value instanceof ArrayBuffer) return value.byteLength;
51
62
  if (ArrayBuffer.isView(value)) return value.byteLength;
52
63
  try {
53
- return encoder.encode(JSON.stringify(value) ?? '').byteLength;
64
+ return encoder.encode(JSON.stringify(value, widenCollections) ?? '').byteLength;
54
65
  } catch {
55
66
  return 1024;
56
67
  }
package/src/purge-env.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Single responsibility: environment → purge driver. The one place that decides which CDN a boot
2
2
  // purges against, so `x dev`, a worker container and any custom host resolve it identically. Keyed
3
- // on env rather than an `app.config.ts` field because nothing loads that file's contents at
4
- // runtime — a `cache.cdn` block would be a setting no boot could read.
3
+ // on env, never an `app.config.ts` field the boot's one loader would read: the edge is a credential
4
+ // and a deployment's, and one image is purged against a different edge per environment.
5
5
 
6
6
  import { ConfigInvalidError } from '@ultimat3/core';
7
7
  import type { PurgeDriver } from './cdn';
@@ -0,0 +1,109 @@
1
+ // The fence, held in Redis. `fence.ts` is module state: it cannot see a bust another replica ran
2
+ // until the broadcast arrives, and a lost broadcast means never. A per-tag generation in the
3
+ // shared store is the one thing both replicas read. Plain `SET`/`GET` — no script — so the fake
4
+ // that cannot run Lua still proves every interleaving (`redis-fence.test.ts`).
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import type { FenceScope } from './fence';
8
+ import type { CacheTag } from './tags';
9
+ import type { FenceVerdict, TierFence } from './tier-fence';
10
+ import { nowMs } from './tiers';
11
+
12
+ /** The slice of the client a generation needs. `RedisLike` satisfies it. */
13
+ export interface GenerationClient {
14
+ get(key: string): Promise<string | null>;
15
+ send(command: string, args: string[]): Promise<unknown>;
16
+ }
17
+
18
+ /** How long a generation outlives the bust that wrote it. Bounds the keys a write-heavy app holds. */
19
+ export const GENERATION_LEASE_MS = 120_000;
20
+
21
+ /**
22
+ * How long a fence can vouch for its sample — half the lease. A generation EXPIRES: sampled
23
+ * absent, written by a bust, expired again reads as "nothing happened". A bust after the sample
24
+ * is still on the server for the whole window, so inside it an unchanged reading is a proof; past
25
+ * it nothing is, and the fence says so instead of guessing.
26
+ */
27
+ export const FENCE_PROOF_WINDOW_MS = GENERATION_LEASE_MS / 2;
28
+
29
+ /**
30
+ * `tagMatches` in keys, the way `redis.ts`'s buckets are. Three generations per entity:
31
+ * `r:<id>` moves on a bust of that row, `c` on a collection bust, `a` on ANY bust of the entity.
32
+ * A row fill is hit by its own row and by the collection; a collection fill by anything. One
33
+ * counter per entity would decline every row fill while any OTHER row was being written.
34
+ * `{entity}` is the Cluster hash tag the buckets already carry.
35
+ */
36
+ function generationKeys(ns: string) {
37
+ const row = (owned: CacheTag): string => `${ns}:g:{${owned.entity}}:r:${owned.id ?? ''}`;
38
+ const collection = (entity: string): string => `${ns}:g:{${entity}}:c`;
39
+ const any = (entity: string): string => `${ns}:g:{${entity}}:a`;
40
+ return {
41
+ bumpedBy: (bust: CacheTag): string[] =>
42
+ bust.id === undefined
43
+ ? [collection(bust.entity), any(bust.entity)]
44
+ : [row(bust), any(bust.entity)],
45
+ watchedBy: (owned: CacheTag): string[] =>
46
+ owned.id === undefined ? [any(owned.entity)] : [row(owned), collection(owned.entity)],
47
+ };
48
+ }
49
+
50
+ const unique = (keys: readonly string[]): string[] => [...new Set(keys)];
51
+
52
+ /**
53
+ * The write half, run by a bust BEFORE it reads its buckets. That order is the whole argument: a
54
+ * fill re-checks after its own `SET`, so either it sees this generation and withdraws, or it
55
+ * wrote early enough that the bucket read that follows finds its key and deletes it.
56
+ *
57
+ * A fresh token rather than `INCR`: a counter that expired restarts at 1 and can repeat a value
58
+ * a fence already sampled.
59
+ */
60
+ export async function bumpGenerations(
61
+ client: GenerationClient,
62
+ ns: string,
63
+ tags: readonly CacheTag[],
64
+ ): Promise<void> {
65
+ const keys = unique(tags.flatMap(generationKeys(ns).bumpedBy));
66
+ if (keys.length === 0) return;
67
+ const token = crypto.randomUUID();
68
+ const lease = String(GENERATION_LEASE_MS);
69
+ // One key per command, so each is slot-local; issued together, so the bust pays one round trip.
70
+ await Promise.all(keys.map((key) => client.send('SET', [key, token, 'PX', lease])));
71
+ }
72
+
73
+ /** The read half: sample now, and answer later whether anything covered moved in between. */
74
+ export async function sampleGenerations(
75
+ client: GenerationClient,
76
+ ns: string,
77
+ clock: Clock,
78
+ scope: FenceScope,
79
+ ): Promise<TierFence> {
80
+ const keysFor = (tags: readonly CacheTag[] | undefined): string[] =>
81
+ unique((tags ?? []).flatMap(generationKeys(ns).watchedBy));
82
+ const sampledAt = nowMs(clock);
83
+ const sampled = new Map<string, string | null>();
84
+ await Promise.all(
85
+ keysFor(scope.tags).map(async (key) => {
86
+ sampled.set(key, await client.get(key));
87
+ }),
88
+ );
89
+ let coveredUnsampled = false;
90
+
91
+ return {
92
+ cover(next: FenceScope): void {
93
+ // Unlike the in-process ring there is no history to reach back into: a tag that arrives
94
+ // after the sample was never read, so nothing can be said about it.
95
+ if (keysFor(next.tags).some((key) => !sampled.has(key))) coveredUnsampled = true;
96
+ },
97
+
98
+ async verdict(): Promise<FenceVerdict> {
99
+ if (coveredUnsampled) return 'unprovable';
100
+ const elapsed = nowMs(clock) - sampledAt;
101
+ // A clock that ran backwards measured nothing; that is not a proof either.
102
+ if (!(elapsed >= 0 && elapsed < FENCE_PROOF_WINDOW_MS)) return 'unprovable';
103
+ const current = await Promise.all(
104
+ [...sampled].map(async ([key, before]) => (await client.get(key)) === before),
105
+ );
106
+ return current.every(Boolean) ? 'valid' : 'busted';
107
+ },
108
+ };
109
+ }
package/src/redis.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  import type { Clock } from '@ultimat3/core';
7
7
  import { appVersion, logger, systemClock } from '@ultimat3/core';
8
8
  import { CacheDriverUnavailableError } from './errors';
9
+ import type { FenceScope } from './fence';
10
+ import { bumpGenerations, sampleGenerations } from './redis-fence';
9
11
  import type { CacheTag } from './tags';
10
12
  import { parseTag, serializeTag } from './tags';
11
13
  import type {
@@ -339,6 +341,9 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
339
341
  await conn().send('DEL', [valueKey(key)]);
340
342
  },
341
343
 
344
+ /** The fleet-wide half of the fill fence: generations other replicas' busts move. */
345
+ fence: (scope: FenceScope) => sampleGenerations(conn(), ns, clock, scope),
346
+
342
347
  /**
343
348
  * ONE script call per tag, never one for the batch. Every key a call is handed comes from a
344
349
  * single tag and therefore carries the same `{entity}` hash tag, so it is one slot on Redis
@@ -356,6 +361,10 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
356
361
  if (read.length > 0) perTag.push({ read, sweep: [...read, ...sweepBucketsFor(owned)] });
357
362
  }
358
363
  if (perTag.length === 0) return { tier: 'redis', keys: [] };
364
+ // Before the buckets are read, and awaited: see `bumpGenerations` for why the order is the
365
+ // guarantee. A refusal here rejects the bust into `report.errors` with nothing deleted, so
366
+ // the retry it asks for repeats the whole thing.
367
+ await bumpGenerations(conn(), ns, tags);
359
368
  const replies = await Promise.all(
360
369
  perTag.map(({ read }) =>
361
370
  conn().send('EVAL', [INVALIDATE_SCRIPT, String(read.length), ...read]),
@@ -0,0 +1,57 @@
1
+ // The fence a SHARED tier keeps in its own store, and how a fill consults it. `fence.ts` answers
2
+ // for this process; a tier that other processes also write (`redis.ts`) answers for the fleet.
3
+ // Optional on `CacheTier`: an in-process tier has no second writer to be fenced against.
4
+
5
+ import type { FenceScope } from './fence';
6
+ import { bestEffort } from './tier-failures';
7
+ import type { CacheTier } from './tiers';
8
+
9
+ /**
10
+ * `busted` is a PROOF the value is stale, so the whole fill is withdrawn — the near tiers too.
11
+ * `unprovable` is the absence of one: that tier alone is left unwritten, because a shared store
12
+ * that cannot vouch for a value must not take a process-local cache down with it.
13
+ */
14
+ export type FenceVerdict = 'valid' | 'busted' | 'unprovable';
15
+
16
+ export interface TierFence {
17
+ /** Asked AFTER the tier's own `set`, never before: see `bumpGenerations` for why. */
18
+ verdict(): Promise<FenceVerdict>;
19
+ /** Widen what the fence covers. A tag it never sampled makes it `unprovable`, not valid. */
20
+ cover(scope: FenceScope): void;
21
+ }
22
+
23
+ const UNPROVABLE: TierFence = {
24
+ verdict: () => Promise.resolve('unprovable'),
25
+ cover: () => undefined,
26
+ };
27
+
28
+ export type TierFences = ReadonlyMap<CacheTier, TierFence>;
29
+
30
+ /** One sample per fenced tier, taken before `load()`. A tier that could not answer is unprovable. */
31
+ export async function sampleTierFences(
32
+ tiers: readonly CacheTier[],
33
+ key: string,
34
+ scope: FenceScope,
35
+ ): Promise<TierFences> {
36
+ const fences = new Map<CacheTier, TierFence>();
37
+ await Promise.all(
38
+ tiers.map(async (tier) => {
39
+ if (tier.fence === undefined) return;
40
+ const sample = tier.fence.bind(tier);
41
+ const fence = await bestEffort(tier.name, 'get', key, () => sample(scope));
42
+ fences.set(tier, fence ?? UNPROVABLE);
43
+ }),
44
+ );
45
+ return fences;
46
+ }
47
+
48
+ /** `unprovable` when the tier could not be asked — a refusal is never read as a clean bill. */
49
+ export async function verdictOf(
50
+ tier: CacheTier,
51
+ key: string,
52
+ fences: TierFences | undefined,
53
+ ): Promise<FenceVerdict> {
54
+ const fence = fences?.get(tier);
55
+ if (fence === undefined) return 'valid';
56
+ return (await bestEffort(tier.name, 'get', key, () => fence.verdict())) ?? 'unprovable';
57
+ }
package/src/tiers.ts CHANGED
@@ -6,12 +6,14 @@
6
6
  import type { CacheTierName, Clock, Scheduler } from '@ultimat3/core';
7
7
  import { CACHE_TIERS, systemClock } from '@ultimat3/core';
8
8
  import { CacheJitterInvalidError, CacheLimitInvalidError, CacheTtlInvalidError } from './errors';
9
- import type { CacheFence } from './fence';
9
+ import type { CacheFence, FenceScope } from './fence';
10
10
  import { markInvalidated, sampleFence } from './fence';
11
11
  import { mergeSetOptions, tagsAddedSince, ttlOptionsFor } from './set-options';
12
12
  import { createSingleFlight } from './single-flight';
13
13
  import type { CacheTag } from './tags';
14
14
  import { bestEffort } from './tier-failures';
15
+ import type { TierFence, TierFences } from './tier-fence';
16
+ import { sampleTierFences, verdictOf } from './tier-fence';
15
17
 
16
18
  /**
17
19
  * The rungs, spelled in `@ultimat3/core` and nowhere else. Tier 0 owns the NAMES because
@@ -188,6 +190,12 @@ export interface CacheTier {
188
190
  set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void>;
189
191
  del(key: string): Promise<void>;
190
192
  invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation>;
193
+ /**
194
+ * A fence held in the tier's OWN store, for a tier other processes write too. Sampled before
195
+ * `load()` and asked after this tier's `set`, so a bust another replica ran — one this process
196
+ * may never be told about — still stops the fill (`tier-fence.ts`). Omitted by in-process tiers.
197
+ */
198
+ fence?(scope: FenceScope): Promise<TierFence>;
191
199
  }
192
200
 
193
201
  export interface CacheStack {
@@ -289,6 +297,7 @@ export function createCacheStack(
289
297
  value: T,
290
298
  setOptions?: CacheSetOptions,
291
299
  fence?: CacheFence,
300
+ shared?: TierFences,
292
301
  ): Promise<void> => {
293
302
  const resolved = ttlOptionsFor(value, setOptions);
294
303
  const written: CacheTier[] = [];
@@ -297,8 +306,28 @@ export function createCacheStack(
297
306
  await rollback(written, key);
298
307
  return;
299
308
  }
300
- await bestEffort(tier.name, 'set', key, () => tier.set(key, value, resolved));
309
+ // A refused `set` leaves whatever the tier held before — the value this fill supersedes —
310
+ // and `bestEffort` swallows the refusal, so that tier would go on answering with it for its
311
+ // whole lease. The refusal is still the one recorded failure; the `del` is the repair.
312
+ let refused = false;
313
+ await bestEffort(tier.name, 'set', key, async () => {
314
+ try {
315
+ await tier.set(key, value, resolved);
316
+ } catch (error) {
317
+ refused = true;
318
+ throw error;
319
+ }
320
+ });
321
+ if (refused) await bestEffort(tier.name, 'del', key, () => tier.del(key));
301
322
  written.push(tier);
323
+ // Asked AFTER the set: the other replica's bust either moved the generation by now, or
324
+ // read its buckets late enough to find what was just written.
325
+ const verdict = await verdictOf(tier, key, shared);
326
+ if (verdict === 'busted') {
327
+ await rollback(written, key);
328
+ return;
329
+ }
330
+ if (verdict === 'unprovable') await bestEffort(tier.name, 'del', key, () => tier.del(key));
302
331
  }
303
332
  };
304
333
 
@@ -372,12 +401,21 @@ export function createCacheStack(
372
401
  key,
373
402
  ...(setOptions?.tags === undefined ? {} : { tags: setOptions.tags }),
374
403
  });
404
+ // The same sample, taken in each tier other replicas also write. One round trip, and
405
+ // only when such a tier is on the ladder.
406
+ const fleet = await sampleTierFences(ordered, key, {
407
+ key,
408
+ ...(setOptions?.tags === undefined ? {} : { tags: setOptions.tags }),
409
+ });
375
410
  const value = await load();
376
411
  // Joiners merged their own tags into the load they shared; covering is retroactive, so
377
412
  // a tag that arrived mid-load is fenced back to the sample rather than from now.
378
413
  const publish = async (options: CacheSetOptions | undefined): Promise<void> => {
379
- if (options?.tags !== undefined) fence.cover({ tags: options.tags });
380
- await fill(key, value, options, fence);
414
+ if (options?.tags !== undefined) {
415
+ fence.cover({ tags: options.tags });
416
+ for (const tierFence of fleet.values()) tierFence.cover({ tags: options.tags });
417
+ }
418
+ await fill(key, value, options, fence, fleet);
381
419
  };
382
420
  const merged = shared() ?? setOptions;
383
421
  await publish(merged);