@ultimat3/cache
Advanced tools
+25
-3
@@ -141,2 +141,19 @@ # @ultimat3/cache — agent notes | ||
| stack, never a module-level map: two stacks are two ladders. | ||
| **The mechanism is `@ultimat3/core`'s since 2026-08-23** — this file's shape verbatim, one tier | ||
| down, because four packages each grew a deduper and only copies can drift. `single-flight.ts` | ||
| stays as the door, so `createSingleFlight`, `SingleFlight` and `FlightJoin` are still exported | ||
| from `@ultimat3/cache` unchanged; `single-flight.test.ts` pins the delegation by IDENTITY, since | ||
| behavioural parity is exactly what let the four copies drift in the first place. | ||
| - **A wedged `load()` no longer holds its key for ever** (`As of 2026-08-23`). It used to: every | ||
| later reader of that key joined a promise nothing would resolve, so a cache stopped damping an | ||
| outage and became one. `createCacheStack` passes `deadlineMs: DEFAULT_LOAD_DEADLINE_MS` (30s; | ||
| `loadDeadlineMs` overrides it, `schedule` injects the timer). The number is `@ultimat3/http`'s | ||
| `requestTimeoutMs` default written out — a `load()` still running at 30s has no reader left to | ||
| serve, because the request waiting on it was abandoned at the same instant — and it is a literal | ||
| because cache is tier 1 and http is tier 2. **Eviction frees the KEY and nothing else**: `load()` | ||
| is the app's function and this stack holds no signal to abort it, so the wedged load runs on and | ||
| its own readers still get its answer. The cost is one duplicate fill, which the ladder's | ||
| last-write-wins `set` already tolerates. Not an `app.config.ts` key on purpose — the ceiling | ||
| belongs to whoever wrote the `load()`, and `bun run scripts/config-readers.ts` refuses a leaf | ||
| key nothing reads. | ||
| - **A joiner shares the leader's WRITE, so it contributes to it** (`FlightJoin`, merged by | ||
@@ -255,3 +272,8 @@ `mergeSetOptions` in `set-options.ts`). Keyed on `key` alone and read late, the entry used to land | ||
| - `retryable` on `X_CACHE_PURGE_FAILED` is derived, never guessed: 408/409/425/429 and 5xx, plus | ||
| any request that never got a status. That table lives in `purge-http.ts` and is edited there. | ||
| any request that never got a status. The table is **`@ultimat3/core`'s** `isRetryableStatus` | ||
| (`retryable-status.ts`), `As of 2026-08-23`, and is edited there — `purge-http.ts` re-exports it | ||
| so both drivers still read "what a failure means" off the shared HTTP half, the same door | ||
| `@ultimat3/auth`'s `tokens.ts` gives `timingSafeEqual`. It was a private `RETRYABLE_STATUSES` | ||
| here that was byte-identical to `packages/mail/src/driver-resend.ts`'s, in two packages that | ||
| cannot import each other, so one copy was always going to be edited alone. | ||
| **It reaches `error.retry` too, `As of 2026-08-23`** — `CachePurgeFailedError` passes | ||
@@ -289,5 +311,5 @@ `retry: input.retryable ? 'retryable' : 'terminal'`. Without it the constructor fell back to | ||
| | `redis.ts` | `Bun.redis` tier, build-namespaced keys, hash-tagged buckets, one script call per tag | | ||
| | `single-flight.ts` | one in-flight `load()` per key, shared by every concurrent miss | | ||
| | `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 | | ||
| | `cdn.ts` | `Cache-Control`/`Surrogate-Key` emission, the `PurgeDriver` seam, `noopPurgeDriver` | | ||
| | `purge-http.ts` | the HTTP half both remote drivers share: one POST, retryable table, batching, key guard | | ||
| | `purge-http.ts` | the HTTP half both remote drivers share: one POST, batching, key guard, and core's retryable table re-exported | | ||
| | `purge-fastly.ts` | `fastlyPurgeDriver`: surrogate-key batch purge, `purge_all` | | ||
@@ -294,0 +316,0 @@ | `purge-cloudflare.ts` | `cloudflarePurgeDriver`: cache-tag purge, `purge_everything` | |
+2
-2
| { | ||
| "name": "@ultimat3/cache", | ||
| "version": "11.1.0", | ||
| "version": "11.2.0", | ||
| "description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph", | ||
@@ -34,4 +34,4 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "11.1.0" | ||
| "@ultimat3/core": "11.2.0" | ||
| } | ||
| } |
+8
-1
@@ -70,3 +70,10 @@ # @ultimat3/cache 🗂️ | ||
| TTL boundary, because the write only lands after `load()` resolves. The primitive is | ||
| `createSingleFlight()` if you need it elsewhere; the stack holds one per stack. A joiner shares the | ||
| `createSingleFlight()` if you need it elsewhere — `@ultimat3/core`'s, re-exported here unchanged; | ||
| the stack holds one per stack. | ||
| A `load()` that never settles does **not** hold its key for ever: past `loadDeadlineMs` | ||
| (`DEFAULT_LOAD_DEADLINE_MS`, 30s — the point at which `http.requestTimeoutMs` already abandoned the | ||
| request that was waiting for it) the key is freed and the next reader loads for itself. Eviction | ||
| frees the key and never the work, so the readers already holding that load still get its answer, | ||
| and the cost is one duplicate fill — `createCacheStack(tiers, { loadDeadlineMs: 5_000 })` for a | ||
| tighter ceiling on a fast origin. A joiner shares the | ||
| leader's **write** as well as its load, so it contributes to it: tags union, TTLs take the shortest. | ||
@@ -73,0 +80,0 @@ Without that the entry landed carrying only the leader's tags and the joiner's invalidation never |
+1
-0
@@ -114,2 +114,3 @@ // Public API of @ultimat3/cache. Explicit, no `export *`. | ||
| createCacheStack, | ||
| DEFAULT_LOAD_DEADLINE_MS, | ||
| DEFAULT_TTL_JITTER_FRACTION, | ||
@@ -116,0 +117,0 @@ isExpired, |
@@ -18,8 +18,9 @@ // Single responsibility: the HTTP half both remote purge drivers share — one POST with a | ||
| // A 4xx here means the same request, unchanged, might land: a throttle or a momentary conflict. | ||
| // Every other 4xx is a credential or a plan, which no retry fixes. | ||
| const RETRYABLE_STATUSES = new Set([408, 409, 425, 429]); | ||
| // Every other 4xx is a credential or a plan, which no retry fixes. The table itself is | ||
| // `@ultimat3/core`'s — this line and `packages/mail/src/driver-resend.ts`'s were byte-identical in | ||
| // two packages that cannot import each other, so one of them was always going to be edited alone. | ||
| // Re-exported rather than imported twice, so both purge drivers still read "what a failure means" | ||
| // off the shared HTTP half — the same door `@ultimat3/auth`'s `tokens.ts` gives `timingSafeEqual`. | ||
| export { isRetryableStatus } from '@ultimat3/core'; | ||
| export const isRetryableStatus = (status: number): boolean => | ||
| status >= 500 || RETRYABLE_STATUSES.has(status); | ||
| /** | ||
@@ -26,0 +27,0 @@ * A bare reference to `globalThis.fetch` risks "Illegal invocation" on some hosts; closing over |
+7
-77
@@ -1,78 +0,8 @@ | ||
| // N concurrent misses on one key are ONE origin load. Without this a cache is an outage | ||
| // amplifier: the write only lands after `load()` resolves, so every request that arrives inside | ||
| // that window misses too and every one of them queries the origin. The share is per load and | ||
| // never a second cache — the entry clears as it settles, rejection included. | ||
| // N concurrent misses on one key are ONE origin load. The mechanism is `@ultimat3/core`'s — this | ||
| // file's shape verbatim, one tier down, plus identity-checked eviction and an optional injected | ||
| // deadline — because four packages each grew their own deduper and only copies can drift. This | ||
| // file stays as the door `@ultimat3/cache` has always published it through, so no caller moves. | ||
| // `@ultimat3/realtime`'s `entry.reading` is the same shape one tier UP, adopting the same door. | ||
| /** | ||
| * What a joiner contributes to the load it joined. Without one a joiner is a free rider: it takes | ||
| * the leader's value AND the leader's write, so anything it declared about that write is dropped. | ||
| */ | ||
| export interface FlightJoin<C> { | ||
| readonly context: C; | ||
| /** Folds a joiner in. Called synchronously as it arrives, so the leader sees it before it writes. */ | ||
| readonly merge: (current: C, joining: C) => C; | ||
| } | ||
| /** Shares one in-flight `work()` per key. `@ultimat3/realtime`'s `entry.reading`, one tier down. */ | ||
| export interface SingleFlight { | ||
| /** | ||
| * `work` receives a reader for the merged context — read it LATE (after the load settles), or | ||
| * it answers with only what the leader brought. | ||
| */ | ||
| run<T, C = undefined>( | ||
| key: string, | ||
| work: (shared: () => C | undefined) => Promise<T>, | ||
| join?: FlightJoin<C>, | ||
| ): Promise<T>; | ||
| /** In-flight loads right now. A number that does not fall back to `0` is a leak. */ | ||
| readonly size: number; | ||
| } | ||
| /** The leader's promise, plus the box its merged context lives in — one identity for both. */ | ||
| interface Flight { | ||
| readonly running: Promise<unknown>; | ||
| readonly shared: { context: unknown }; | ||
| } | ||
| export function createSingleFlight(): SingleFlight { | ||
| const inflight = new Map<string, Flight>(); | ||
| return { | ||
| get size(): number { | ||
| return inflight.size; | ||
| }, | ||
| run<T, C = undefined>( | ||
| key: string, | ||
| work: (shared: () => C | undefined) => Promise<T>, | ||
| join?: FlightJoin<C>, | ||
| ): Promise<T> { | ||
| const joined = inflight.get(key); | ||
| // Two readers of one key asking for two different `T` is an app bug the cache cannot see; | ||
| // the value they share is the same object either way, so the cast is the honest one. | ||
| if (joined !== undefined) { | ||
| if (join !== undefined) { | ||
| joined.shared.context = join.merge(joined.shared.context as C, join.context); | ||
| } | ||
| return joined.running as Promise<T>; | ||
| } | ||
| const shared: { context: unknown } = { context: join?.context }; | ||
| // Wrapped so a `work()` that throws SYNCHRONOUSLY still rejects the joiners rather than | ||
| // escaping past the map and leaving no entry to clear. | ||
| const running: Promise<T> = (async () => await work(() => shared.context as C | undefined))(); | ||
| const entry: Flight = { running, shared }; | ||
| inflight.set(key, entry); | ||
| const settled = (): void => { | ||
| // Only the leader clears its own entry: a load started after this one settled must not be | ||
| // dropped by a late callback from the load it replaced. | ||
| if (inflight.get(key) === entry) inflight.delete(key); | ||
| }; | ||
| // A rejected load MUST clear too, or one failure is cached as a permanent rejection. | ||
| void running.then(settled, settled); | ||
| return running; | ||
| }, | ||
| }; | ||
| } | ||
| export type { FlightJoin, SingleFlight } from '@ultimat3/core'; | ||
| export { createSingleFlight } from '@ultimat3/core'; |
+30
-2
@@ -6,3 +6,3 @@ // The tier ladder: request-memo -> lru -> redis -> cdn. Reads walk DOWN until a hit, then | ||
| import type { CacheTierName, Clock } from '@ultimat3/core'; | ||
| import type { CacheTierName, Clock, Scheduler } from '@ultimat3/core'; | ||
| import { CACHE_TIERS, systemClock } from '@ultimat3/core'; | ||
@@ -166,2 +166,10 @@ import { CacheJitterInvalidError, CacheTtlInvalidError } from './errors'; | ||
| /** | ||
| * A `load()` still running at 30s has no reader left to serve: `stack.read` is on the request path, | ||
| * and `@ultimat3/http` abandons the request that is waiting for it at the same 30s | ||
| * (`requestTimeoutMs`). Stated as a literal because cache is tier 1 and http is tier 2, so the | ||
| * number cannot be imported — deliberately NOT a JWKS fetch's bound, which is a transport's. | ||
| */ | ||
| export const DEFAULT_LOAD_DEADLINE_MS = 30_000; | ||
| /** | ||
| * Every tier call here goes through `bestEffort`: a tier that refuses is a tier that did not | ||
@@ -174,2 +182,13 @@ * answer, never a failed business read. `load()` is the one call left unguarded — it *is* the | ||
| readonly clock?: Clock; | ||
| /** | ||
| * How long one `load()` may hold its key before a later reader is allowed to start its own, | ||
| * instead of joining a promise that may never resolve. Defaults to `DEFAULT_LOAD_DEADLINE_MS`. | ||
| * | ||
| * An option on the stack rather than an `app.config.ts` key on purpose: the ceiling belongs to | ||
| * whoever wrote the `load()`, not to the deployment, and a leaf key nothing reads is what | ||
| * `bun run scripts/config-readers.ts` exists to refuse. | ||
| */ | ||
| readonly loadDeadlineMs?: number; | ||
| /** Injected so the deadline is provable without a test waiting one out. */ | ||
| readonly schedule?: Scheduler; | ||
| } | ||
@@ -184,3 +203,12 @@ | ||
| // Per stack, not per module: two stacks are two ladders and must not join each other's loads. | ||
| const flight = createSingleFlight(); | ||
| // | ||
| // The deadline frees the KEY and nothing else — `load()` is the app's function and this stack | ||
| // holds no signal that could abort it, so the wedged load runs on and the readers already | ||
| // holding its promise still get whatever it eventually answers. What eviction buys is that the | ||
| // NEXT reader is allowed to try. So the worst case is one duplicate fill, which the ladder's | ||
| // last-write-wins `set` already tolerates, against a key pinned for the life of the process. | ||
| const flight = createSingleFlight({ | ||
| deadlineMs: options.loadDeadlineMs ?? DEFAULT_LOAD_DEADLINE_MS, | ||
| schedule: options.schedule, | ||
| }); | ||
@@ -187,0 +215,0 @@ /** Take back what a fence refused mid-ladder: half a stale ladder is still a stale read. */ |
188315
1.15%371
1.92%2960
-1.07%+ Added
- Removed
Updated