@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 +21 -2
- package/README.md +42 -5
- package/package.json +3 -2
- package/src/cdn.ts +57 -6
- package/src/index.ts +8 -1
- package/src/lru.ts +12 -1
- package/src/purge-env.ts +2 -2
- package/src/redis-fence.ts +109 -0
- package/src/redis.ts +9 -0
- package/src/tier-fence.ts +57 -0
- package/src/tiers.ts +42 -4
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
|
|
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
|
|
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
|
-
|
|
313
|
-
|
|
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": "
|
|
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": "
|
|
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
|
|
4
|
-
// tags
|
|
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 {
|
|
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 =
|
|
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
|
-
*
|
|
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([...
|
|
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 {
|
|
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
|
|
4
|
-
//
|
|
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
|
-
|
|
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)
|
|
380
|
-
|
|
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);
|