@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 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. That table lives in `purge-http.ts` and is edited there.
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, retryable table, batching, key guard |
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; the stack holds one per stack. A joiner shares the
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.1.0",
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.1.0"
34
+ "@ultimat3/core": "11.3.0"
35
35
  }
36
36
  }
package/src/index.ts CHANGED
@@ -111,6 +111,7 @@ export type {
111
111
  export {
112
112
  assertTtl,
113
113
  createCacheStack,
114
+ DEFAULT_LOAD_DEADLINE_MS,
114
115
  DEFAULT_TTL_JITTER_FRACTION,
115
116
  isExpired,
116
117
  nowMs,
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
- const RETRYABLE_STATUSES = new Set([408, 409, 425, 429]);
20
-
21
- export const isRetryableStatus = (status: number): boolean =>
22
- status >= 500 || RETRYABLE_STATUSES.has(status);
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
@@ -1,78 +1,8 @@
1
- // N concurrent misses on one key are ONE origin load. Without this a cache is an outage
2
- // amplifier: the write only lands after `load()` resolves, so every request that arrives inside
3
- // that window misses too and every one of them queries the origin. The share is per load and
4
- // never a second cache the entry clears as it settles, rejection included.
5
-
6
- /**
7
- * What a joiner contributes to the load it joined. Without one a joiner is a free rider: it takes
8
- * the leader's value AND the leader's write, so anything it declared about that write is dropped.
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
- const flight = createSingleFlight();
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> => {