@ultimat3/cache 11.1.0 → 11.3.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 +25 -3
- package/README.md +8 -1
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/purge-http.ts +6 -5
- package/src/single-flight.ts +8 -78
- package/src/tiers.ts +30 -2
package/CLAUDE.md
CHANGED
|
@@ -138,6 +138,23 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
138
138
|
`realtime`'s `entry.reading`). The share ends as the load settles — a REJECTED load must clear
|
|
139
139
|
its entry too, or one origin failure becomes a permanent cached rejection. One `SingleFlight` per
|
|
140
140
|
stack, never a module-level map: two stacks are two ladders.
|
|
141
|
+
**The mechanism is `@ultimat3/core`'s since 2026-08-23** — this file's shape verbatim, one tier
|
|
142
|
+
down, because four packages each grew a deduper and only copies can drift. `single-flight.ts`
|
|
143
|
+
stays as the door, so `createSingleFlight`, `SingleFlight` and `FlightJoin` are still exported
|
|
144
|
+
from `@ultimat3/cache` unchanged; `single-flight.test.ts` pins the delegation by IDENTITY, since
|
|
145
|
+
behavioural parity is exactly what let the four copies drift in the first place.
|
|
146
|
+
- **A wedged `load()` no longer holds its key for ever** (`As of 2026-08-23`). It used to: every
|
|
147
|
+
later reader of that key joined a promise nothing would resolve, so a cache stopped damping an
|
|
148
|
+
outage and became one. `createCacheStack` passes `deadlineMs: DEFAULT_LOAD_DEADLINE_MS` (30s;
|
|
149
|
+
`loadDeadlineMs` overrides it, `schedule` injects the timer). The number is `@ultimat3/http`'s
|
|
150
|
+
`requestTimeoutMs` default written out — a `load()` still running at 30s has no reader left to
|
|
151
|
+
serve, because the request waiting on it was abandoned at the same instant — and it is a literal
|
|
152
|
+
because cache is tier 1 and http is tier 2. **Eviction frees the KEY and nothing else**: `load()`
|
|
153
|
+
is the app's function and this stack holds no signal to abort it, so the wedged load runs on and
|
|
154
|
+
its own readers still get its answer. The cost is one duplicate fill, which the ladder's
|
|
155
|
+
last-write-wins `set` already tolerates. Not an `app.config.ts` key on purpose — the ceiling
|
|
156
|
+
belongs to whoever wrote the `load()`, and `bun run scripts/config-readers.ts` refuses a leaf
|
|
157
|
+
key nothing reads.
|
|
141
158
|
- **A joiner shares the leader's WRITE, so it contributes to it** (`FlightJoin`, merged by
|
|
142
159
|
`mergeSetOptions` in `set-options.ts`). Keyed on `key` alone and read late, the entry used to land
|
|
143
160
|
carrying only the leader's tags: the joiner's tag reached nothing, so the invalidation it declared
|
|
@@ -251,7 +268,12 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
251
268
|
`assertPurgeableKeys` refuses either **before** the request — a split key is purged successfully
|
|
252
269
|
and clears nothing, which is the one CDN failure no later read can catch.
|
|
253
270
|
- `retryable` on `X_CACHE_PURGE_FAILED` is derived, never guessed: 408/409/425/429 and 5xx, plus
|
|
254
|
-
any request that never got a status.
|
|
271
|
+
any request that never got a status. The table is **`@ultimat3/core`'s** `isRetryableStatus`
|
|
272
|
+
(`retryable-status.ts`), `As of 2026-08-23`, and is edited there — `purge-http.ts` re-exports it
|
|
273
|
+
so both drivers still read "what a failure means" off the shared HTTP half, the same door
|
|
274
|
+
`@ultimat3/auth`'s `tokens.ts` gives `timingSafeEqual`. It was a private `RETRYABLE_STATUSES`
|
|
275
|
+
here that was byte-identical to `packages/mail/src/driver-resend.ts`'s, in two packages that
|
|
276
|
+
cannot import each other, so one copy was always going to be edited alone.
|
|
255
277
|
**It reaches `error.retry` too, `As of 2026-08-23`** — `CachePurgeFailedError` passes
|
|
256
278
|
`retry: input.retryable ? 'retryable' : 'terminal'`. Without it the constructor fell back to
|
|
257
279
|
`retryFor('X_CACHE_PURGE_FAILED')`, this package registers no retry class, and the default is
|
|
@@ -284,9 +306,9 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
284
306
|
| `memo.ts` | request memo over the ALS ctx (WeakMap, no lifecycle) |
|
|
285
307
|
| `lru.ts` | byte-budgeted LRU (linked list + map + tag index) |
|
|
286
308
|
| `redis.ts` | `Bun.redis` tier, build-namespaced keys, hash-tagged buckets, one script call per tag |
|
|
287
|
-
| `single-flight.ts` | one in-flight `load()` per key, shared by every concurrent miss |
|
|
309
|
+
| `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 |
|
|
288
310
|
| `cdn.ts` | `Cache-Control`/`Surrogate-Key` emission, the `PurgeDriver` seam, `noopPurgeDriver` |
|
|
289
|
-
| `purge-http.ts` | the HTTP half both remote drivers share: one POST,
|
|
311
|
+
| `purge-http.ts` | the HTTP half both remote drivers share: one POST, batching, key guard, and core's retryable table re-exported |
|
|
290
312
|
| `purge-fastly.ts` | `fastlyPurgeDriver`: surrogate-key batch purge, `purge_all` |
|
|
291
313
|
| `purge-cloudflare.ts` | `cloudflarePurgeDriver`: cache-tag purge, `purge_everything` |
|
|
292
314
|
| `purge-env.ts` | `selectPurgeDriver`: which edge an environment purges, and nothing else |
|
package/README.md
CHANGED
|
@@ -67,7 +67,14 @@ a reader arriving while another's load is running joins it instead of issuing it
|
|
|
67
67
|
ends as the load settles, rejection included, so one failure is never held as a permanent one. A
|
|
68
68
|
feed cached for 60s and read 8,000×/s otherwise sends ~1,600 identical queries to Postgres at every
|
|
69
69
|
TTL boundary, because the write only lands after `load()` resolves. The primitive is
|
|
70
|
-
`createSingleFlight()` if you need it elsewhere
|
|
70
|
+
`createSingleFlight()` if you need it elsewhere — `@ultimat3/core`'s, re-exported here unchanged;
|
|
71
|
+
the stack holds one per stack.
|
|
72
|
+
A `load()` that never settles does **not** hold its key for ever: past `loadDeadlineMs`
|
|
73
|
+
(`DEFAULT_LOAD_DEADLINE_MS`, 30s — the point at which `http.requestTimeoutMs` already abandoned the
|
|
74
|
+
request that was waiting for it) the key is freed and the next reader loads for itself. Eviction
|
|
75
|
+
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 — `createCacheStack(tiers, { loadDeadlineMs: 5_000 })` for a
|
|
77
|
+
tighter ceiling on a fast origin. A joiner shares the
|
|
71
78
|
leader's **write** as well as its load, so it contributes to it: tags union, TTLs take the shortest.
|
|
72
79
|
Without that the entry landed carrying only the leader's tags and the joiner's invalidation never
|
|
73
80
|
fired.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cache",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.3.0",
|
|
4
4
|
"description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "11.
|
|
34
|
+
"@ultimat3/core": "11.3.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/index.ts
CHANGED
package/src/purge-http.ts
CHANGED
|
@@ -15,11 +15,12 @@ export const DEFAULT_PURGE_TIMEOUT_MS = 10_000;
|
|
|
15
15
|
const MAX_DETAIL_LENGTH = 200;
|
|
16
16
|
|
|
17
17
|
// A 4xx here means the same request, unchanged, might land: a throttle or a momentary conflict.
|
|
18
|
-
// Every other 4xx is a credential or a plan, which no retry fixes.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
// Every other 4xx is a credential or a plan, which no retry fixes. The table itself is
|
|
19
|
+
// `@ultimat3/core`'s — this line and `packages/mail/src/driver-resend.ts`'s were byte-identical in
|
|
20
|
+
// two packages that cannot import each other, so one of them was always going to be edited alone.
|
|
21
|
+
// Re-exported rather than imported twice, so both purge drivers still read "what a failure means"
|
|
22
|
+
// off the shared HTTP half — the same door `@ultimat3/auth`'s `tokens.ts` gives `timingSafeEqual`.
|
|
23
|
+
export { isRetryableStatus } from '@ultimat3/core';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* A bare reference to `globalThis.fetch` risks "Illegal invocation" on some hosts; closing over
|
package/src/single-flight.ts
CHANGED
|
@@ -1,78 +1,8 @@
|
|
|
1
|
-
// N concurrent misses on one key are ONE origin load.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
*/
|
|
10
|
-
export interface FlightJoin<C> {
|
|
11
|
-
readonly context: C;
|
|
12
|
-
/** Folds a joiner in. Called synchronously as it arrives, so the leader sees it before it writes. */
|
|
13
|
-
readonly merge: (current: C, joining: C) => C;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Shares one in-flight `work()` per key. `@ultimat3/realtime`'s `entry.reading`, one tier down. */
|
|
17
|
-
export interface SingleFlight {
|
|
18
|
-
/**
|
|
19
|
-
* `work` receives a reader for the merged context — read it LATE (after the load settles), or
|
|
20
|
-
* it answers with only what the leader brought.
|
|
21
|
-
*/
|
|
22
|
-
run<T, C = undefined>(
|
|
23
|
-
key: string,
|
|
24
|
-
work: (shared: () => C | undefined) => Promise<T>,
|
|
25
|
-
join?: FlightJoin<C>,
|
|
26
|
-
): Promise<T>;
|
|
27
|
-
/** In-flight loads right now. A number that does not fall back to `0` is a leak. */
|
|
28
|
-
readonly size: number;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** The leader's promise, plus the box its merged context lives in — one identity for both. */
|
|
32
|
-
interface Flight {
|
|
33
|
-
readonly running: Promise<unknown>;
|
|
34
|
-
readonly shared: { context: unknown };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function createSingleFlight(): SingleFlight {
|
|
38
|
-
const inflight = new Map<string, Flight>();
|
|
39
|
-
|
|
40
|
-
return {
|
|
41
|
-
get size(): number {
|
|
42
|
-
return inflight.size;
|
|
43
|
-
},
|
|
44
|
-
|
|
45
|
-
run<T, C = undefined>(
|
|
46
|
-
key: string,
|
|
47
|
-
work: (shared: () => C | undefined) => Promise<T>,
|
|
48
|
-
join?: FlightJoin<C>,
|
|
49
|
-
): Promise<T> {
|
|
50
|
-
const joined = inflight.get(key);
|
|
51
|
-
// Two readers of one key asking for two different `T` is an app bug the cache cannot see;
|
|
52
|
-
// the value they share is the same object either way, so the cast is the honest one.
|
|
53
|
-
if (joined !== undefined) {
|
|
54
|
-
if (join !== undefined) {
|
|
55
|
-
joined.shared.context = join.merge(joined.shared.context as C, join.context);
|
|
56
|
-
}
|
|
57
|
-
return joined.running as Promise<T>;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const shared: { context: unknown } = { context: join?.context };
|
|
61
|
-
// Wrapped so a `work()` that throws SYNCHRONOUSLY still rejects the joiners rather than
|
|
62
|
-
// escaping past the map and leaving no entry to clear.
|
|
63
|
-
const running: Promise<T> = (async () => await work(() => shared.context as C | undefined))();
|
|
64
|
-
const entry: Flight = { running, shared };
|
|
65
|
-
inflight.set(key, entry);
|
|
66
|
-
|
|
67
|
-
const settled = (): void => {
|
|
68
|
-
// Only the leader clears its own entry: a load started after this one settled must not be
|
|
69
|
-
// dropped by a late callback from the load it replaced.
|
|
70
|
-
if (inflight.get(key) === entry) inflight.delete(key);
|
|
71
|
-
};
|
|
72
|
-
// A rejected load MUST clear too, or one failure is cached as a permanent rejection.
|
|
73
|
-
void running.then(settled, settled);
|
|
74
|
-
|
|
75
|
-
return running;
|
|
76
|
-
},
|
|
77
|
-
};
|
|
78
|
-
}
|
|
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';
|
package/src/tiers.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// so a deployment can omit Redis (single node) or add the CDN tier without touching call
|
|
4
4
|
// sites. Order is data, not control flow.
|
|
5
5
|
|
|
6
|
-
import type { CacheTierName, Clock } from '@ultimat3/core';
|
|
6
|
+
import type { CacheTierName, Clock, Scheduler } from '@ultimat3/core';
|
|
7
7
|
import { CACHE_TIERS, systemClock } from '@ultimat3/core';
|
|
8
8
|
import { CacheJitterInvalidError, CacheTtlInvalidError } from './errors';
|
|
9
9
|
import type { CacheFence } from './fence';
|
|
@@ -161,6 +161,14 @@ export function sortTiers(tiers: readonly CacheTier[]): readonly CacheTier[] {
|
|
|
161
161
|
return [...tiers].sort((a, b) => TIER_ORDER.indexOf(a.name) - TIER_ORDER.indexOf(b.name));
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* A `load()` still running at 30s has no reader left to serve: `stack.read` is on the request path,
|
|
166
|
+
* and `@ultimat3/http` abandons the request that is waiting for it at the same 30s
|
|
167
|
+
* (`requestTimeoutMs`). Stated as a literal because cache is tier 1 and http is tier 2, so the
|
|
168
|
+
* number cannot be imported — deliberately NOT a JWKS fetch's bound, which is a transport's.
|
|
169
|
+
*/
|
|
170
|
+
export const DEFAULT_LOAD_DEADLINE_MS = 30_000;
|
|
171
|
+
|
|
164
172
|
/**
|
|
165
173
|
* Every tier call here goes through `bestEffort`: a tier that refuses is a tier that did not
|
|
166
174
|
* answer, never a failed business read. `load()` is the one call left unguarded — it *is* the
|
|
@@ -169,6 +177,17 @@ export function sortTiers(tiers: readonly CacheTier[]): readonly CacheTier[] {
|
|
|
169
177
|
export interface CacheStackOptions {
|
|
170
178
|
/** Read through `nowMs()`; the same clock a tier takes. Defaults to `systemClock`. */
|
|
171
179
|
readonly clock?: Clock;
|
|
180
|
+
/**
|
|
181
|
+
* How long one `load()` may hold its key before a later reader is allowed to start its own,
|
|
182
|
+
* instead of joining a promise that may never resolve. Defaults to `DEFAULT_LOAD_DEADLINE_MS`.
|
|
183
|
+
*
|
|
184
|
+
* An option on the stack rather than an `app.config.ts` key on purpose: the ceiling belongs to
|
|
185
|
+
* whoever wrote the `load()`, not to the deployment, and a leaf key nothing reads is what
|
|
186
|
+
* `bun run scripts/config-readers.ts` exists to refuse.
|
|
187
|
+
*/
|
|
188
|
+
readonly loadDeadlineMs?: number;
|
|
189
|
+
/** Injected so the deadline is provable without a test waiting one out. */
|
|
190
|
+
readonly schedule?: Scheduler;
|
|
172
191
|
}
|
|
173
192
|
|
|
174
193
|
export function createCacheStack(
|
|
@@ -178,7 +197,16 @@ export function createCacheStack(
|
|
|
178
197
|
const ordered = sortTiers(tiers);
|
|
179
198
|
const clock = options.clock ?? systemClock;
|
|
180
199
|
// Per stack, not per module: two stacks are two ladders and must not join each other's loads.
|
|
181
|
-
|
|
200
|
+
//
|
|
201
|
+
// The deadline frees the KEY and nothing else — `load()` is the app's function and this stack
|
|
202
|
+
// holds no signal that could abort it, so the wedged load runs on and the readers already
|
|
203
|
+
// holding its promise still get whatever it eventually answers. What eviction buys is that the
|
|
204
|
+
// NEXT reader is allowed to try. So the worst case is one duplicate fill, which the ladder's
|
|
205
|
+
// last-write-wins `set` already tolerates, against a key pinned for the life of the process.
|
|
206
|
+
const flight = createSingleFlight({
|
|
207
|
+
deadlineMs: options.loadDeadlineMs ?? DEFAULT_LOAD_DEADLINE_MS,
|
|
208
|
+
schedule: options.schedule,
|
|
209
|
+
});
|
|
182
210
|
|
|
183
211
|
/** Take back what a fence refused mid-ladder: half a stale ladder is still a stale read. */
|
|
184
212
|
const rollback = async (written: readonly CacheTier[], key: string): Promise<void> => {
|