@ultimat3/cache
Advanced tools
+11
-4
@@ -43,5 +43,6 @@ # @ultimat3/cache — agent notes | ||
| `TierName` is a position on the ladder. | ||
| - **A refusal is rendered with `renderThrowable()`, never `error.message`** — the four sites that | ||
| absorb one (`bestEffort`'s log entry, and `fanOut`'s tier, ISR and broadcast catch blocks). A | ||
| tier, a revalidator and a broadcast are all app-supplied, so the value they reject with is too: | ||
| - **A refusal is rendered with `renderThrowable()`, never `error.message`** — the five sites that | ||
| absorb one (`bestEffort`'s log entry, `fanOut`'s tier, ISR and broadcast catch blocks, and | ||
| `purgePost`'s transport catch). A tier, a revalidator, a broadcast and the `fetch` a purge driver | ||
| is given are all app-supplied, so the value they reject with is too: | ||
| `instanceof` runs a `Proxy`'s `getPrototypeOf` trap and `String()` runs `Symbol.toPrimitive`, so | ||
@@ -135,3 +136,9 @@ building the log line used to raise INSTEAD of absorbing the refusal — on the business write that | ||
| stale to that caller. `work` reads the merge through `shared()` **after** the load, or it sees | ||
| only what the leader brought. | ||
| only what the leader brought — and **once more after the fill**, because the flight stays open | ||
| for the whole ladder and a joiner merging a tag mid-fill hit the identical hole one rung later. | ||
| The second read re-fills EVERY tier rather than the rungs still to come: re-reading per tier | ||
| would land the near tier — the one every later read hits first — with the FEWEST tags, so an | ||
| invalidation would clear the far rungs and leave the near one serving. `tagsAddedSince` in | ||
| `set-options.ts` is what makes the second pass conditional; `tiers.test.ts`'s | ||
| `a single-flight joiner that arrives during the FILL` is what notices. | ||
| - **`negativeTtlMs` is the stack's decision, not a tier's.** Only `createCacheStack` sees what | ||
@@ -138,0 +145,0 @@ `load()` answered, so the `null`/`undefined` branch lives in `ttlOptionsFor` there and reaches a |
+2
-2
| { | ||
| "name": "@ultimat3/cache", | ||
| "version": "7.0.0", | ||
| "version": "8.0.0", | ||
| "description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph", | ||
@@ -34,4 +34,4 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "7.0.0" | ||
| "@ultimat3/core": "8.0.0" | ||
| } | ||
| } |
@@ -6,2 +6,3 @@ // Single responsibility: the HTTP half both remote purge drivers share — one POST with a | ||
| import { renderThrowable } from '@ultimat3/core'; | ||
| import { CacheDriverUnavailableError, CachePurgeFailedError } from './errors'; | ||
@@ -156,3 +157,7 @@ | ||
| } catch (error) { | ||
| const reason = error instanceof Error ? error.message : 'the request failed before a response'; | ||
| // `renderThrowable`, never `error.message` behind an `instanceof`: `fetch` is INJECTED here, | ||
| // so the rejection is whatever a driver or a test double threw — and `instanceof` itself | ||
| // throws on a `Proxy` whose `getPrototypeOf` does, which would replace the coded refusal this | ||
| // catch exists to raise with a bare `TypeError` from inside it. Same rule as `invalidate.ts`. | ||
| const reason = renderThrowable(error); | ||
| throw new CachePurgeFailedError({ | ||
@@ -159,0 +164,0 @@ driver: input.driver, |
+17
-0
@@ -66,1 +66,18 @@ // How two callers' `CacheSetOptions` become one write, and how a `null` load picks its TTL. Both | ||
| } | ||
| /** | ||
| * Did `latest` gain a tag `written` does not carry? | ||
| * | ||
| * Compared on the wire form — the identity every tier indexes by and the one `mergeTags` above | ||
| * dedupes on — so "already written" means the same thing to both, and a re-fill is asked for | ||
| * exactly when a joiner brought something new. | ||
| */ | ||
| export function tagsAddedSince( | ||
| written: CacheSetOptions | undefined, | ||
| latest: CacheSetOptions | undefined, | ||
| ): boolean { | ||
| const added = latest?.tags; | ||
| if (added === undefined || added.length === 0) return false; | ||
| const seen = new Set((written?.tags ?? []).map(serializeTag)); | ||
| return added.some((owned) => !seen.has(serializeTag(owned))); | ||
| } |
+20
-3
@@ -11,3 +11,3 @@ // The tier ladder: request-memo -> lru -> redis -> cdn. Reads walk DOWN until a hit, then | ||
| import { markInvalidated, sampleFence } from './fence'; | ||
| import { mergeSetOptions, ttlOptionsFor } from './set-options'; | ||
| import { mergeSetOptions, tagsAddedSince, ttlOptionsFor } from './set-options'; | ||
| import { createSingleFlight } from './single-flight'; | ||
@@ -259,2 +259,6 @@ import type { CacheTag } from './tags'; | ||
| async read<T>(key: string, load: () => Promise<T>, setOptions?: CacheSetOptions): Promise<T> { | ||
| // Outside the flight on purpose, and the cost is known: N concurrent misses each walk the | ||
| // ladder before any of them joins, so a cold key pays N gets per rung. Moving it inside | ||
| // would serialise every HIT behind whichever caller happened to arrive first — the common | ||
| // case paying for the rare one. Carried as a Low; measure before changing it. | ||
| const hit = await lookup<T>(key, setOptions); | ||
@@ -278,5 +282,18 @@ if (hit !== undefined) return hit.value; | ||
| // a tag that arrived mid-load is fenced back to the sample rather than from now. | ||
| const publish = async (options: CacheSetOptions | undefined): Promise<void> => { | ||
| if (options?.tags !== undefined) fence.cover({ tags: options.tags }); | ||
| await fill(key, value, options, fence); | ||
| }; | ||
| const merged = shared() ?? setOptions; | ||
| if (merged?.tags !== undefined) fence.cover({ tags: merged.tags }); | ||
| await fill(key, value, merged, fence); | ||
| await publish(merged); | ||
| // The flight stays open until this whole `work` settles, and `fill` is one await per | ||
| // rung — so a joiner can still merge a tag after the read above, and the entry that | ||
| // landed would carry the leader's tags alone, which `invalidateTags` can never reach. | ||
| // Re-read once and re-fill EVERY tier: re-reading per tier instead would land the near | ||
| // tier — the one every later read hits first — with the FEWEST tags, so an invalidation | ||
| // would clear the far rungs and leave the near one serving. A joiner arriving inside | ||
| // the second pass is left where a plain cache hit already leaves one: reading a value | ||
| // that was published without its tag. | ||
| const late = shared() ?? setOptions; | ||
| if (tagsAddedSince(merged, late)) await publish(late); | ||
| return value; | ||
@@ -283,0 +300,0 @@ }, |
183699
1.71%2984
1.29%+ Added
- Removed
Updated