@ultimat3/cache 23.0.0 → 25.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 +38 -20
- package/README.md +54 -17
- package/package.json +3 -3
- package/src/cdn.ts +59 -8
- package/src/errors.ts +1 -1
- package/src/index.ts +13 -9
- package/src/invalidate.ts +10 -3
- package/src/lru.ts +13 -2
- package/src/memo.ts +3 -3
- package/src/purge-cloudflare.ts +1 -1
- package/src/purge-env.ts +2 -2
- package/src/purge-fastly.ts +1 -1
- package/src/purge-http.ts +1 -3
- package/src/redis-fence.ts +109 -0
- package/src/redis.ts +10 -1
- package/src/semantic.ts +1 -1
- package/src/tags.ts +3 -4
- package/src/tier-fence.ts +57 -0
- package/src/tiers.ts +52 -15
- package/src/redis-fake.ts +0 -115
- package/src/single-flight.ts +0 -8
package/CLAUDE.md
CHANGED
|
@@ -18,21 +18,29 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
18
18
|
repair by the call's shape.
|
|
19
19
|
- `invalidateTags()` in `invalidate.ts` is the ONLY fan-out path, and the only writer of the log
|
|
20
20
|
`recentInvalidations()` reads. Never call `tier.invalidateTags()` from outside it.
|
|
21
|
-
- **The fan-out clears FARTHEST tier first, and reports in read order** (`
|
|
21
|
+
- **The fan-out clears FARTHEST tier first, and reports in read order** (core's `CACHE_TIERS`, what `/_x`
|
|
22
22
|
renders). Near-to-far lets a racing read promote the stale far value straight back up.
|
|
23
23
|
`CacheStack.drop` reverses for the same reason. `invalidation-race.test.ts`.
|
|
24
24
|
- **A fill is fenced: sample before `load()`, ask before the write** (`fence.ts`). `sampleFence({ key,
|
|
25
25
|
tags })` → `fence.isValid()`; `markInvalidated` is the write half (`fanOut`, `CacheStack.write`,
|
|
26
26
|
`CacheStack.drop`). Exported so a store outside this package reuses it rather than growing a
|
|
27
|
-
second mechanism. `cover()` widens a fence for joiners' tags (`
|
|
27
|
+
second mechanism. `cover()` widens a fence for joiners' tags (`cacheStack.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
|
-
- Tag order is `
|
|
39
|
+
- Tag order is core's `CACHE_TIERS`, never registration order. `sortTiers()` enforces it.
|
|
32
40
|
- **The rung NAMES are `@ultimat3/core`'s (`CACHE_TIERS`); the ladder is this package's.** `TierName`
|
|
33
|
-
aliases core's `CacheTierName
|
|
34
|
-
`
|
|
35
|
-
plus a factory here; `scripts/render-modes.ts` refuses a second declaration of the set. **`isr` is
|
|
41
|
+
aliases core's `CacheTierName`; the read order IS `CACHE_TIERS`, imported — 25.0.0 deleted the
|
|
42
|
+
`TIER_ORDER` alias (`X_HELPER_COPY` refuses a value of core's under a second name). Adding a
|
|
43
|
+
rung is an edit to `packages/core/src/cache-vocabulary.ts` plus a factory here; `scripts/render-modes.ts` refuses a second declaration of the set. **`isr` is
|
|
36
44
|
not a tier** — the `'isr'` in `invalidate.ts` is an ISR route (`DependentKind = 'isr-route'`).
|
|
37
45
|
- **`bestEffort()` is the only sanctioned way to swallow a cache refusal.** Its label is `TierLabel`
|
|
38
46
|
(`TierName` plus `'query-read'`), deliberately not a widening of `TierName`.
|
|
@@ -41,15 +49,15 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
41
49
|
values are too. The code field keeps its own total probe (`ultimateCode` in `tier-failures.ts`):
|
|
42
50
|
a driver `code` is a SQLSTATE, never an `X_*`.
|
|
43
51
|
- Tier failures go into `report.errors`; a cache tier may never fail a business read or write.
|
|
44
|
-
`
|
|
52
|
+
`cacheStack` routes every `get`/`set`/`del` through `bestEffort()` into
|
|
45
53
|
`recentTierFailures()`. `load()` is the one unguarded call (it IS the business read).
|
|
46
54
|
`LruCache.set` still throws `X_CACHE_TOO_LARGE` to a direct caller.
|
|
47
55
|
- **`serializeTags` is the wire form; `tagKeys` is the IDENTITY form** (sorted, de-duplicated —
|
|
48
56
|
`@ultimat3/query`'s `cacheKeyFor` builds keys from it). It lives here because `action` and `query`
|
|
49
|
-
are one tier and cannot share a copy.
|
|
50
|
-
`
|
|
57
|
+
are one tier and cannot share a copy. `@ultimat3/render`'s order-preserving route form is its
|
|
58
|
+
internal `routeTagKeys` (`render/src/route.ts`) — one public `tagKeys`, this one.
|
|
51
59
|
- **Two exports have no production caller and both are KEPT**: `invalidateWireTags` (the outbound
|
|
52
|
-
twin of `receiveInvalidationBroadcast`, which `packages/cli/src/
|
|
60
|
+
twin of `receiveInvalidationBroadcast`, which `packages/cli/src/runtime-cache.ts` calls) and
|
|
53
61
|
`recentTierFailures()` (no `/_x` panel reads it yet). Wire the panel or leave them.
|
|
54
62
|
- `tag.x` typing comes from the `CacheTagRegistry` augmentation, generated by `x manifest`.
|
|
55
63
|
- **`declareTags()` is additive and process-wide: a test undoes it with `isolateDeclaredTags()`,
|
|
@@ -60,16 +68,16 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
60
68
|
deletions. `isolateTierFailures`/`resetTierFailures` stay off `index.ts`.
|
|
61
69
|
- Clocks are injected (`LruOptions.clock`, `CacheStackOptions.clock`); read them through `nowMs()`.
|
|
62
70
|
- **`ttlMs` is positive and finite, and `assertTtl` (`tiers.ts`) is the one place that says so** —
|
|
63
|
-
every tier calls it before writing, the request memo and `
|
|
71
|
+
every tier calls it before writing, the request memo and `memorySemanticCache.remember`
|
|
64
72
|
included (scope `'semantic'`, `jitterFraction: 0`). `X_CACHE_TTL_INVALID`, never a resolution.
|
|
65
73
|
- **`assertTtl` also SPREADS the lease it validated.** `rng` is injected (`LruOptions.rng`,
|
|
66
74
|
`RedisTierOptions.rng`) — never `Math.random()` at a call site; `rng: () => 0` is the full lease.
|
|
67
75
|
`jitterFraction` outside `[0, 1)` is `X_CACHE_JITTER_INVALID`, refused, not clamped.
|
|
68
|
-
- **`
|
|
76
|
+
- **`cacheStack` is the production read path, and `@ultimat3/query`'s `readThrough` is its
|
|
69
77
|
caller** — it is what makes an action's `cache.invalidates` reach a `cache:` query.
|
|
70
|
-
- **`
|
|
71
|
-
|
|
72
|
-
`single-flight.
|
|
78
|
+
- **`cacheStack` shares one in-flight `load()` per key** through `@ultimat3/core`'s
|
|
79
|
+
`singleFlight`, imported by `tiers.ts` (`single-flight.test.ts` exercises it through the
|
|
80
|
+
stack); 25.0.0 deleted the `single-flight.ts` door and its re-export. A rejected load clears its entry. One `SingleFlight` per stack.
|
|
73
81
|
- **A wedged `load()` frees its key after `DEFAULT_LOAD_DEADLINE_MS` (30 s)** — http's
|
|
74
82
|
`requestTimeoutMs` default, written as a literal because http is tier 2. `loadDeadlineMs`
|
|
75
83
|
overrides it and `schedule` injects the timer. Eviction frees the KEY only; the load runs on. Not
|
|
@@ -87,7 +95,7 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
87
95
|
collection bust and `t:{entity}:<id>` + `t:{entity}` for a row one. A row bust still `SREM`s what
|
|
88
96
|
it deleted from the index (`sweepBucketsFor`). Pinned by `tier-parity.test.ts` and
|
|
89
97
|
`redis.live.test.ts`.
|
|
90
|
-
- **`CacheTier.set` REJECTS, never throws synchronously** — `
|
|
98
|
+
- **`CacheTier.set` REJECTS, never throws synchronously** — `lruTier`/`memoTier` are
|
|
91
99
|
`async` for that alone.
|
|
92
100
|
- **`redis.ts`'s script deletes NOTHING — it reads.** It returns the members; the tier `DEL`s them
|
|
93
101
|
client-side one key at a time (slot-local on Cluster/Dragonfly) and `SREM`s only what
|
|
@@ -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.
|
|
@@ -131,15 +148,16 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
131
148
|
|---|---|
|
|
132
149
|
| `tags.ts` | `tag` factory, wire form, match semantics, `tagKeys`, declared-tag registry |
|
|
133
150
|
| `graph.ts` | tag → dependents (cache keys, ISR routes, CDN paths, live queries) |
|
|
134
|
-
| `tiers.ts` | `CacheTier`, `
|
|
151
|
+
| `tiers.ts` | `CacheTier`, `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
|
-
| `
|
|
142
|
-
| `cdn.ts` | `Cache-Control`/`Surrogate-Key` emission, the `PurgeDriver` seam, `noopPurgeDriver` |
|
|
160
|
+
| `cdn.ts` | `Cache-Control`/`Surrogate-Key` emission, `surrogateKeys`, the purge key list, the `PurgeDriver` seam |
|
|
143
161
|
| `purge-http.ts` | the HTTP half both remote drivers share: one POST, batching, key guard, and core's retryable table re-exported |
|
|
144
162
|
| `purge-fastly.ts` | `fastlyPurgeDriver`: surrogate-key batch purge, `purge_all` |
|
|
145
163
|
| `purge-cloudflare.ts` | `cloudflarePurgeDriver`: cache-tag purge, `purge_everything` |
|
package/README.md
CHANGED
|
@@ -30,13 +30,14 @@ 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
|
|
34
|
-
|
|
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
|
|
35
|
+
without touching a call site — order comes from `@ultimat3/core`'s `CACHE_TIERS`, not registration order.
|
|
35
36
|
|
|
36
37
|
```ts
|
|
37
|
-
import {
|
|
38
|
+
import { cacheStack, lruTier, memoTier, registerTier } from '@ultimat3/cache';
|
|
38
39
|
|
|
39
|
-
const stack =
|
|
40
|
+
const stack = cacheStack([memoTier(), lruTier({ maxBytes: 64 * 1024 * 1024 })]);
|
|
40
41
|
for (const tier of stack.tiers) registerTier(tier);
|
|
41
42
|
|
|
42
43
|
const feed = await stack.read('feed:org-1', () => db.posts.recent(), {
|
|
@@ -57,9 +58,9 @@ called. 40,000 keys warmed by one rolling restart otherwise share one expiry ins
|
|
|
57
58
|
inside the same 30-second window. The roll is injected, never `Math.random()` at a call site:
|
|
58
59
|
|
|
59
60
|
```ts
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
lruTier({ rng: () => 0 }); // the full lease — what a test asserting an exact expiry wants
|
|
62
|
+
redisTier({ jitterFraction: 0 }); // off entirely
|
|
63
|
+
redisTier({ jitterFraction: 0.2 }); // a wider spread; outside [0, 1) is X_CACHE_JITTER_INVALID
|
|
63
64
|
```
|
|
64
65
|
|
|
65
66
|
**N concurrent misses are ONE origin load.** `stack.read` shares an in-flight `load()` per key, so
|
|
@@ -67,13 +68,13 @@ a reader arriving while another's load is running joins it instead of issuing it
|
|
|
67
68
|
ends as the load settles, rejection included, so one failure is never held as a permanent one. A
|
|
68
69
|
feed cached for 60s and read 8,000×/s otherwise sends ~1,600 identical queries to Postgres at every
|
|
69
70
|
TTL boundary, because the write only lands after `load()` resolves. The primitive is
|
|
70
|
-
`
|
|
71
|
-
the stack holds one per stack.
|
|
71
|
+
`singleFlight()` from `@ultimat3/core` if you need it elsewhere (25.0.0 dropped this
|
|
72
|
+
package's re-export); the stack holds one per stack.
|
|
72
73
|
A `load()` that never settles does **not** hold its key for ever: past `loadDeadlineMs`
|
|
73
74
|
(`DEFAULT_LOAD_DEADLINE_MS`, 30s — the point at which `http.requestTimeoutMs` already abandoned the
|
|
74
75
|
request that was waiting for it) the key is freed and the next reader loads for itself. Eviction
|
|
75
76
|
frees the key and never the work, so the readers already holding that load still get its answer,
|
|
76
|
-
and the cost is one duplicate fill — `
|
|
77
|
+
and the cost is one duplicate fill — `cacheStack(tiers, { loadDeadlineMs: 5_000 })` for a
|
|
77
78
|
tighter ceiling on a fast origin. A joiner shares the
|
|
78
79
|
leader's **write** as well as its load, so it contributes to it: tags union, TTLs take the shortest.
|
|
79
80
|
Without that the entry landed carrying only the leader's tags and the joiner's invalidation never
|
|
@@ -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. `redisTier` 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
|
|
@@ -200,7 +222,7 @@ Redis cannot read each other's payloads. Rename `PostView.author` to `PostView.a
|
|
|
200
222
|
deploy: `JSON.parse` does not validate, so the old pod reads the new shape back and hands it to a
|
|
201
223
|
renderer expecting the old one — an undefined author on every cached post, on half the fleet, for
|
|
202
224
|
the length of the rolling deploy. The cost of the default is a **cold shared tier per deploy**,
|
|
203
|
-
which is the cheaper of the two. Opt out with `
|
|
225
|
+
which is the cheaper of the two. Opt out with `redisTier({ buildId: null })` if you version
|
|
204
226
|
your own payloads.
|
|
205
227
|
|
|
206
228
|
## Invalidating
|
|
@@ -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
|
|
|
@@ -345,11 +382,11 @@ rather than failing the write that triggered it.
|
|
|
345
382
|
## Semantic cache
|
|
346
383
|
|
|
347
384
|
For LLM calls, where "list my orders" and "show me my orders" must hit the same entry.
|
|
348
|
-
`
|
|
385
|
+
`memorySemanticCache()` does cosine similarity at a 0.92 threshold (tight on
|
|
349
386
|
purpose — a false hit answers the wrong question, which is worse than a miss) and is the
|
|
350
387
|
only backing this package ships — it is O(n) and in-process. The interface (`SemanticCache`)
|
|
351
388
|
is a driver seam for that reason; a Postgres/pgvector-backed implementation does not exist
|
|
352
|
-
yet here (`@ultimat3/ai`'s `
|
|
389
|
+
yet here (`@ultimat3/ai`'s `postgresVectorStore()` is a separate store, for RAG retrieval, not this
|
|
353
390
|
cache).
|
|
354
391
|
|
|
355
392
|
## Errors
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cache",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.0.0",
|
|
4
4
|
"description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
28
|
+
"bun": ">=1.4.2"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
35
|
+
"@ultimat3/core": "25.0.0"
|
|
36
36
|
}
|
|
37
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.
|
|
@@ -74,7 +125,7 @@ export function noopPurgeDriver(): PurgeDriver {
|
|
|
74
125
|
* A driver that reaches no CDN, so a caller can report "purges nothing" without a name match.
|
|
75
126
|
*
|
|
76
127
|
* Lives here rather than beside `selectPurgeDriver`, which is where it was until 2026-08: the
|
|
77
|
-
* `name: 'noop'` it tests for is declared one function up, and `
|
|
128
|
+
* `name: 'noop'` it tests for is declared one function up, and `cdnTier` — the caller that
|
|
78
129
|
* most needs it — cannot import from `purge-env.ts` without making a cycle of the two files.
|
|
79
130
|
*/
|
|
80
131
|
export const isNoopPurgeDriver = (driver: PurgeDriver): boolean => driver.name === 'noop';
|
|
@@ -89,7 +140,7 @@ export interface CdnTierOptions {
|
|
|
89
140
|
readonly pathsForKey?: (key: string) => readonly string[];
|
|
90
141
|
}
|
|
91
142
|
|
|
92
|
-
export function
|
|
143
|
+
export function cdnTier(options: CdnTierOptions = {}): CacheTier {
|
|
93
144
|
const driver = options.purge ?? noopPurgeDriver();
|
|
94
145
|
|
|
95
146
|
return {
|
|
@@ -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/errors.ts
CHANGED
|
@@ -108,7 +108,7 @@ export class CacheLimitInvalidError extends UltimateError {
|
|
|
108
108
|
expected: string;
|
|
109
109
|
/**
|
|
110
110
|
* Where the value came FROM, when that is not an `app.config.ts` key. `loadDeadlineMs` and the
|
|
111
|
-
* similarity override arrive as call arguments (`
|
|
111
|
+
* similarity override arrive as call arguments (`cacheStack`, `lookup`), so telling their
|
|
112
112
|
* caller to edit `app.config.ts` names a key that does not exist.
|
|
113
113
|
*/
|
|
114
114
|
source?: string | undefined;
|
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
|
+
cdnTier,
|
|
7
|
+
isNoopPurgeDriver,
|
|
8
|
+
noopPurgeDriver,
|
|
9
|
+
surrogateKeys,
|
|
10
|
+
} from './cdn';
|
|
5
11
|
export type { CacheErrorCode } from './errors';
|
|
6
12
|
export {
|
|
7
13
|
CACHE_ERROR_CODES,
|
|
@@ -47,8 +53,8 @@ export {
|
|
|
47
53
|
} from './invalidate';
|
|
48
54
|
export type { LruOptions, LruStats } from './lru';
|
|
49
55
|
|
|
50
|
-
export {
|
|
51
|
-
export { clearMemo,
|
|
56
|
+
export { estimateBytes, LruCache, lruTier } from './lru';
|
|
57
|
+
export { clearMemo, memoSize, memoTier } from './memo';
|
|
52
58
|
export type { CloudflarePurgeOptions } from './purge-cloudflare';
|
|
53
59
|
export {
|
|
54
60
|
CLOUDFLARE_API_URL,
|
|
@@ -63,10 +69,10 @@ export type { PurgeFetch } from './purge-http';
|
|
|
63
69
|
export { DEFAULT_PURGE_TIMEOUT_MS } from './purge-http';
|
|
64
70
|
export type { RedisLike, RedisTierOptions } from './redis';
|
|
65
71
|
export {
|
|
66
|
-
createRedisTier,
|
|
67
72
|
namespaceFor,
|
|
68
73
|
REDIS_INVALIDATE_SCRIPT,
|
|
69
74
|
REDIS_TAG_MEMBER_SCRIPT,
|
|
75
|
+
redisTier,
|
|
70
76
|
} from './redis';
|
|
71
77
|
export type {
|
|
72
78
|
Embedding,
|
|
@@ -75,9 +81,7 @@ export type {
|
|
|
75
81
|
SemanticHit,
|
|
76
82
|
SemanticRememberOptions,
|
|
77
83
|
} from './semantic';
|
|
78
|
-
export { cosineSimilarity,
|
|
79
|
-
export type { FlightJoin, SingleFlight } from './single-flight';
|
|
80
|
-
export { createSingleFlight } from './single-flight';
|
|
84
|
+
export { cosineSimilarity, memorySemanticCache } from './semantic';
|
|
81
85
|
export type { CacheTag, CacheTagRegistry, TagFactory } from './tags';
|
|
82
86
|
export {
|
|
83
87
|
assertKnownTags,
|
|
@@ -96,6 +100,7 @@ export {
|
|
|
96
100
|
} from './tags';
|
|
97
101
|
export type { TierFailure, TierOperation } from './tier-failures';
|
|
98
102
|
export { bestEffort, recentTierFailures } from './tier-failures';
|
|
103
|
+
export type { FenceVerdict, TierFence } from './tier-fence';
|
|
99
104
|
export type {
|
|
100
105
|
CacheEntry,
|
|
101
106
|
CacheSetOptions,
|
|
@@ -114,11 +119,10 @@ export {
|
|
|
114
119
|
assertFiniteDurationMs,
|
|
115
120
|
assertFiniteSimilarityFloor,
|
|
116
121
|
assertTtl,
|
|
117
|
-
|
|
122
|
+
cacheStack,
|
|
118
123
|
DEFAULT_LOAD_DEADLINE_MS,
|
|
119
124
|
DEFAULT_TTL_JITTER_FRACTION,
|
|
120
125
|
isExpired,
|
|
121
126
|
nowMs,
|
|
122
127
|
sortTiers,
|
|
123
|
-
TIER_ORDER,
|
|
124
128
|
} from './tiers';
|
package/src/invalidate.ts
CHANGED
|
@@ -4,14 +4,21 @@
|
|
|
4
4
|
// the returned report is what the `/_x` cache panel renders, so "did it actually clear?" is
|
|
5
5
|
// answerable without a log dive.
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
CACHE_TIERS,
|
|
9
|
+
currentSpan,
|
|
10
|
+
logger,
|
|
11
|
+
renderThrowable,
|
|
12
|
+
systemClock,
|
|
13
|
+
withSpan,
|
|
14
|
+
} from '@ultimat3/core';
|
|
8
15
|
import { markInvalidated } from './fence';
|
|
9
16
|
import { dependentsOfKind } from './graph';
|
|
10
17
|
import type { CacheTag } from './tags';
|
|
11
18
|
import { assertKnownTags, knownTags, parseTag, serializeTags } from './tags';
|
|
12
19
|
import { isolateTierFailures, resetTierFailures } from './tier-failures';
|
|
13
20
|
import type { CacheTier, TierInvalidation } from './tiers';
|
|
14
|
-
import { sortTiers
|
|
21
|
+
import { sortTiers } from './tiers';
|
|
15
22
|
|
|
16
23
|
/** Revalidates one ISR route path. Provided by `@ultimat3/render`; absent on a worker. */
|
|
17
24
|
export type Revalidator = (path: string) => Promise<void> | void;
|
|
@@ -231,7 +238,7 @@ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<Inva
|
|
|
231
238
|
}
|
|
232
239
|
// The report is read order, not clear order: it is what the `/_x` panel renders, and a ladder
|
|
233
240
|
// printed upside down is a second thing for a reader to learn.
|
|
234
|
-
tiers.sort((a, b) =>
|
|
241
|
+
tiers.sort((a, b) => CACHE_TIERS.indexOf(a.tier) - CACHE_TIERS.indexOf(b.tier));
|
|
235
242
|
|
|
236
243
|
const isr = dependentsOfKind(tags, 'isr-route');
|
|
237
244
|
const cdn = dependentsOfKind(tags, 'cdn-path');
|
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
|
}
|
|
@@ -262,7 +273,7 @@ export class LruCache {
|
|
|
262
273
|
}
|
|
263
274
|
}
|
|
264
275
|
|
|
265
|
-
export function
|
|
276
|
+
export function lruTier(options: LruOptions = {}): CacheTier & { readonly cache: LruCache } {
|
|
266
277
|
const cache = new LruCache(options);
|
|
267
278
|
return {
|
|
268
279
|
name: 'lru',
|
package/src/memo.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function memoSize(): number {
|
|
|
46
46
|
return storeFor(false)?.size ?? 0;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
export function
|
|
49
|
+
export function memoTier(): CacheTier {
|
|
50
50
|
return {
|
|
51
51
|
name: 'request-memo',
|
|
52
52
|
|
|
@@ -61,14 +61,14 @@ export function createMemoTier(): CacheTier {
|
|
|
61
61
|
*
|
|
62
62
|
* This tier holds nothing past the request, so it stores no `expiresAt` — but it is still a
|
|
63
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: `
|
|
64
|
+
* it made `ttlMs: 0` a value the memo accepted and every other tier refused: `cacheStack`
|
|
65
65
|
* routes each rung through `bestEffort`, so the miswiring was swallowed as two tier failures
|
|
66
66
|
* and the read still hit — out of the one tier that never should have taken it. Exactly the
|
|
67
67
|
* "two tiers, two readings of `0`" the rule exists to close.
|
|
68
68
|
*
|
|
69
69
|
* Only a lease the caller SUPPLIED is checked: there is no default to fall back to, because a
|
|
70
70
|
* memo entry that outlives its request is not a thing that can happen. `jitterFraction: 0` for
|
|
71
|
-
* `
|
|
71
|
+
* `memorySemanticCache`'s reason — spreading a lease is a herd defence for a SHARED
|
|
72
72
|
* store, and this one dies with the request that made it.
|
|
73
73
|
*/
|
|
74
74
|
async set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
|
package/src/purge-cloudflare.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// tags ARE Ultimate's wire tags, so a `Cache-Tag` response header and an `invalidates: [tag.post]`
|
|
4
4
|
// name the same string — the alternative, purging by URL, would need a route list nobody keeps.
|
|
5
5
|
|
|
6
|
+
import { isRetryableStatus } from '@ultimat3/core';
|
|
6
7
|
import type { PurgeDriver } from './cdn';
|
|
7
8
|
import { CachePurgeFailedError } from './errors';
|
|
8
9
|
import type { PurgeFetch } from './purge-http';
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
defaultPurgeFetch,
|
|
14
15
|
detailFrom,
|
|
15
16
|
isRecord,
|
|
16
|
-
isRetryableStatus,
|
|
17
17
|
purgeBody,
|
|
18
18
|
purgePost,
|
|
19
19
|
requireCredential,
|
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';
|
package/src/purge-fastly.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// client. The keys are Ultimate's wire tags unchanged (`post`, `post:1`), which is the property
|
|
4
4
|
// that keeps an edge purge and an app-level invalidation from ever meaning different things.
|
|
5
5
|
|
|
6
|
+
import { isRetryableStatus } from '@ultimat3/core';
|
|
6
7
|
import type { PurgeDriver } from './cdn';
|
|
7
8
|
import { CachePurgeFailedError } from './errors';
|
|
8
9
|
import type { PurgeFetch } from './purge-http';
|
|
@@ -13,7 +14,6 @@ import {
|
|
|
13
14
|
defaultPurgeFetch,
|
|
14
15
|
detailFrom,
|
|
15
16
|
isRecord,
|
|
16
|
-
isRetryableStatus,
|
|
17
17
|
type PurgeBody,
|
|
18
18
|
purgeBody,
|
|
19
19
|
purgePost,
|
package/src/purge-http.ts
CHANGED
|
@@ -18,9 +18,7 @@ const MAX_DETAIL_LENGTH = 200;
|
|
|
18
18
|
// Every other 4xx is a credential or a plan, which no retry fixes. The table itself is
|
|
19
19
|
// `@ultimat3/core`'s — this line and `packages/mail/src/driver-resend.ts`'s were byte-identical in
|
|
20
20
|
// two packages that cannot import each other, so one of them was always going to be edited alone.
|
|
21
|
-
//
|
|
22
|
-
// off the shared HTTP half — the same door `@ultimat3/auth`'s `tokens.ts` gives `timingSafeEqual`.
|
|
23
|
-
export { isRetryableStatus } from '@ultimat3/core';
|
|
21
|
+
// Both purge drivers import it from core; it is not re-exported here (`X_HELPER_COPY`).
|
|
24
22
|
|
|
25
23
|
/**
|
|
26
24
|
* A bare reference to `globalThis.fetch` risks "Illegal invocation" on some hosts; closing over
|
|
@@ -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 {
|
|
@@ -189,7 +191,7 @@ function raiseSweepFailure(failures: readonly unknown[], attempted: number): nev
|
|
|
189
191
|
});
|
|
190
192
|
}
|
|
191
193
|
|
|
192
|
-
export function
|
|
194
|
+
export function redisTier(options: RedisTierOptions = {}): CacheTier {
|
|
193
195
|
const ns = namespaceFor(options.prefix ?? 'x', options.buildId);
|
|
194
196
|
const defaultTtlMs = assertFiniteDurationMs(
|
|
195
197
|
'redis',
|
|
@@ -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]),
|
package/src/semantic.ts
CHANGED
|
@@ -78,7 +78,7 @@ interface SemanticRecord {
|
|
|
78
78
|
readonly tags: readonly CacheTag[];
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
export function
|
|
81
|
+
export function memorySemanticCache(options: SemanticCacheOptions = {}): SemanticCache {
|
|
82
82
|
// Screened at construction, both of them: this tier's two knobs are the ones whose failure is
|
|
83
83
|
// silent — a floor of NaN matches everything, a ceiling of NaN evicts nothing.
|
|
84
84
|
const threshold = assertFiniteSimilarityFloor('semantic', 'threshold', options.threshold ?? 0.92);
|
package/src/tags.ts
CHANGED
|
@@ -65,10 +65,9 @@ export function serializeTags(tags: readonly CacheTag[]): string[] {
|
|
|
65
65
|
* both are tier 3, so neither can import the other and a copy in either is a second answer for the
|
|
66
66
|
* other. The same move `toBucket` made into `@ultimat3/http`.
|
|
67
67
|
*
|
|
68
|
-
* NOT
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* naming it here is where a reader can see both.
|
|
68
|
+
* NOT `@ultimat3/render`'s internal `routeTagKeys`, which is `serializeTags` over an optional list
|
|
69
|
+
* and deliberately preserves declaration order for a route descriptor (`dsl.test.ts` pins it). It
|
|
70
|
+
* was a second public `tagKeys` until 25.0.0: two behaviours under one name.
|
|
72
71
|
*/
|
|
73
72
|
export function tagKeys(tags: readonly CacheTag[]): readonly string[] {
|
|
74
73
|
return [...new Set(serializeTags(tags))].sort();
|
|
@@ -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
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
// sites. Order is data, not control flow.
|
|
5
5
|
|
|
6
6
|
import type { CacheTierName, Clock, Scheduler } from '@ultimat3/core';
|
|
7
|
-
import { CACHE_TIERS, systemClock } from '@ultimat3/core';
|
|
7
|
+
import { CACHE_TIERS, singleFlight, 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
|
-
import { createSingleFlight } from './single-flight';
|
|
13
12
|
import type { CacheTag } from './tags';
|
|
14
13
|
import { bestEffort } from './tier-failures';
|
|
14
|
+
import type { TierFence, TierFences } from './tier-fence';
|
|
15
|
+
import { sampleTierFences, verdictOf } from './tier-fence';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* The rungs, spelled in `@ultimat3/core` and nowhere else. Tier 0 owns the NAMES because
|
|
@@ -22,14 +23,14 @@ import { bestEffort } from './tier-failures';
|
|
|
22
23
|
*/
|
|
23
24
|
export type TierName = CacheTierName;
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
// Read order is core's `CACHE_TIERS`: index in that array is the tier's distance from the request.
|
|
27
|
+
// Imported, never aliased under a second name here (`X_HELPER_COPY`).
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Who a swallowed refusal is attributed to in `recentTierFailures()` and the `/_x` panel: every
|
|
30
31
|
* rung of the ladder, plus a store that degrades the same way without being on it.
|
|
31
32
|
*
|
|
32
|
-
* Closed rather than a free-form string, and NOT a widening of `TierName`: `
|
|
33
|
+
* Closed rather than a free-form string, and NOT a widening of `TierName`: `CACHE_TIERS` is the
|
|
33
34
|
* ladder and a name missing from it sorts to `-1`, ahead of the request memo. A label is a log
|
|
34
35
|
* facet; a `TierName` is a position. Two spellings of one store is a panel nobody can group.
|
|
35
36
|
*
|
|
@@ -188,6 +189,12 @@ export interface CacheTier {
|
|
|
188
189
|
set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void>;
|
|
189
190
|
del(key: string): Promise<void>;
|
|
190
191
|
invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation>;
|
|
192
|
+
/**
|
|
193
|
+
* A fence held in the tier's OWN store, for a tier other processes write too. Sampled before
|
|
194
|
+
* `load()` and asked after this tier's `set`, so a bust another replica ran — one this process
|
|
195
|
+
* may never be told about — still stops the fill (`tier-fence.ts`). Omitted by in-process tiers.
|
|
196
|
+
*/
|
|
197
|
+
fence?(scope: FenceScope): Promise<TierFence>;
|
|
191
198
|
}
|
|
192
199
|
|
|
193
200
|
export interface CacheStack {
|
|
@@ -212,9 +219,9 @@ export function isExpired<T>(entry: CacheEntry<T>, at: number): boolean {
|
|
|
212
219
|
return entry.expiresAt !== undefined && entry.expiresAt <= at;
|
|
213
220
|
}
|
|
214
221
|
|
|
215
|
-
/** Sorts tiers into `
|
|
222
|
+
/** Sorts tiers into `CACHE_TIERS` order so registration order cannot change read semantics. */
|
|
216
223
|
export function sortTiers(tiers: readonly CacheTier[]): readonly CacheTier[] {
|
|
217
|
-
return [...tiers].sort((a, b) =>
|
|
224
|
+
return [...tiers].sort((a, b) => CACHE_TIERS.indexOf(a.name) - CACHE_TIERS.indexOf(b.name));
|
|
218
225
|
}
|
|
219
226
|
|
|
220
227
|
/**
|
|
@@ -246,7 +253,7 @@ export interface CacheStackOptions {
|
|
|
246
253
|
readonly schedule?: Scheduler;
|
|
247
254
|
}
|
|
248
255
|
|
|
249
|
-
export function
|
|
256
|
+
export function cacheStack(
|
|
250
257
|
tiers: readonly CacheTier[],
|
|
251
258
|
options: CacheStackOptions = {},
|
|
252
259
|
): CacheStack {
|
|
@@ -259,14 +266,14 @@ export function createCacheStack(
|
|
|
259
266
|
// holding its promise still get whatever it eventually answers. What eviction buys is that the
|
|
260
267
|
// NEXT reader is allowed to try. So the worst case is one duplicate fill, which the ladder's
|
|
261
268
|
// last-write-wins `set` already tolerates, against a key pinned for the life of the process.
|
|
262
|
-
const flight =
|
|
269
|
+
const flight = singleFlight({
|
|
263
270
|
deadlineMs: assertFiniteDurationMs(
|
|
264
271
|
'ladder',
|
|
265
272
|
'loadDeadlineMs',
|
|
266
273
|
options.loadDeadlineMs ?? DEFAULT_LOAD_DEADLINE_MS,
|
|
267
|
-
// Caller-owned: it arrives as a `
|
|
274
|
+
// Caller-owned: it arrives as a `cacheStack({ loadDeadlineMs })` argument, so there is
|
|
268
275
|
// no `app.config.ts` key to send the reader to.
|
|
269
|
-
'the loadDeadlineMs argument to
|
|
276
|
+
'the loadDeadlineMs argument to cacheStack(...)',
|
|
270
277
|
),
|
|
271
278
|
schedule: options.schedule,
|
|
272
279
|
});
|
|
@@ -289,6 +296,7 @@ export function createCacheStack(
|
|
|
289
296
|
value: T,
|
|
290
297
|
setOptions?: CacheSetOptions,
|
|
291
298
|
fence?: CacheFence,
|
|
299
|
+
shared?: TierFences,
|
|
292
300
|
): Promise<void> => {
|
|
293
301
|
const resolved = ttlOptionsFor(value, setOptions);
|
|
294
302
|
const written: CacheTier[] = [];
|
|
@@ -297,8 +305,28 @@ export function createCacheStack(
|
|
|
297
305
|
await rollback(written, key);
|
|
298
306
|
return;
|
|
299
307
|
}
|
|
300
|
-
|
|
308
|
+
// A refused `set` leaves whatever the tier held before — the value this fill supersedes —
|
|
309
|
+
// and `bestEffort` swallows the refusal, so that tier would go on answering with it for its
|
|
310
|
+
// whole lease. The refusal is still the one recorded failure; the `del` is the repair.
|
|
311
|
+
let refused = false;
|
|
312
|
+
await bestEffort(tier.name, 'set', key, async () => {
|
|
313
|
+
try {
|
|
314
|
+
await tier.set(key, value, resolved);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
refused = true;
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
if (refused) await bestEffort(tier.name, 'del', key, () => tier.del(key));
|
|
301
321
|
written.push(tier);
|
|
322
|
+
// Asked AFTER the set: the other replica's bust either moved the generation by now, or
|
|
323
|
+
// read its buckets late enough to find what was just written.
|
|
324
|
+
const verdict = await verdictOf(tier, key, shared);
|
|
325
|
+
if (verdict === 'busted') {
|
|
326
|
+
await rollback(written, key);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (verdict === 'unprovable') await bestEffort(tier.name, 'del', key, () => tier.del(key));
|
|
302
330
|
}
|
|
303
331
|
};
|
|
304
332
|
|
|
@@ -372,12 +400,21 @@ export function createCacheStack(
|
|
|
372
400
|
key,
|
|
373
401
|
...(setOptions?.tags === undefined ? {} : { tags: setOptions.tags }),
|
|
374
402
|
});
|
|
403
|
+
// The same sample, taken in each tier other replicas also write. One round trip, and
|
|
404
|
+
// only when such a tier is on the ladder.
|
|
405
|
+
const fleet = await sampleTierFences(ordered, key, {
|
|
406
|
+
key,
|
|
407
|
+
...(setOptions?.tags === undefined ? {} : { tags: setOptions.tags }),
|
|
408
|
+
});
|
|
375
409
|
const value = await load();
|
|
376
410
|
// Joiners merged their own tags into the load they shared; covering is retroactive, so
|
|
377
411
|
// a tag that arrived mid-load is fenced back to the sample rather than from now.
|
|
378
412
|
const publish = async (options: CacheSetOptions | undefined): Promise<void> => {
|
|
379
|
-
if (options?.tags !== undefined)
|
|
380
|
-
|
|
413
|
+
if (options?.tags !== undefined) {
|
|
414
|
+
fence.cover({ tags: options.tags });
|
|
415
|
+
for (const tierFence of fleet.values()) tierFence.cover({ tags: options.tags });
|
|
416
|
+
}
|
|
417
|
+
await fill(key, value, options, fence, fleet);
|
|
381
418
|
};
|
|
382
419
|
const merged = shared() ?? setOptions;
|
|
383
420
|
await publish(merged);
|
package/src/redis-fake.ts
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
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/single-flight.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// N concurrent misses on one key are ONE origin load. The mechanism is `@ultimat3/core`'s — this
|
|
2
|
-
// file's shape verbatim, one tier down, plus identity-checked eviction and an optional injected
|
|
3
|
-
// deadline — because four packages each grew their own deduper and only copies can drift. This
|
|
4
|
-
// file stays as the door `@ultimat3/cache` has always published it through, so no caller moves.
|
|
5
|
-
// `@ultimat3/realtime`'s `entry.reading` is the same shape one tier UP, adopting the same door.
|
|
6
|
-
|
|
7
|
-
export type { FlightJoin, SingleFlight } from '@ultimat3/core';
|
|
8
|
-
export { createSingleFlight } from '@ultimat3/core';
|