@ultimat3/cache
Advanced tools
+14
-0
@@ -13,2 +13,16 @@ # @ultimat3/cache — agent notes | ||
| - **A tier's own LIMIT is screened where `app.config.ts` names it, `As of 2026-08-26`** — | ||
| `assertFiniteCapacity` for a byte budget or an entry ceiling, `assertFiniteDurationMs` for a | ||
| request budget or a `defaultTtlMs`, `assertFiniteSimilarityFloor` for the semantic tier's | ||
| threshold — all three beside `assertTtl` in `tiers.ts`, and all three raising one code, | ||
| `X_CACHE_LIMIT_INVALID`. Not a nicety: `Number(process.env.CACHE_MAX_BYTES)` on an unset variable | ||
| is `NaN`, `NaN` is not nullish so `??` keeps it, and every comparison against it is FALSE — | ||
| `while (bytes > maxBytes)` never evicted, so the bounded cache this package is built on had no | ||
| bound at all, and `similarity < floor` never skipped, so the semantic tier answered the nearest | ||
| thing it held to a question nobody asked. The failure is never a wrong number; it is the guard | ||
| switching itself off. Never `Math.max(1, x)` in front of one: that is a clamp, not a validator. | ||
| **The `Finite` in all three names is load-bearing** — `bun run finite-bounds` recognises a repair | ||
| by the shape of the CALL, so while they were spelled `assertCapacity` / `assertTimeoutMs` all | ||
| nine of this package's options read as unchecked to the ratchet while every one was screened. | ||
| - `invalidateTags()` in `invalidate.ts` is the ONLY fan-out path. Never call | ||
@@ -15,0 +29,0 @@ `tier.invalidateTags()` from outside it. It is also the only place the log is written: |
+2
-2
| { | ||
| "name": "@ultimat3/cache", | ||
| "version": "16.0.0", | ||
| "version": "17.0.0", | ||
| "description": "Tagged caching: request memo, LRU, Redis, CDN — one invalidation graph", | ||
@@ -34,4 +34,4 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "@ultimat3/core": "16.0.0" | ||
| "@ultimat3/core": "17.0.0" | ||
| } | ||
| } |
+52
-0
@@ -9,2 +9,3 @@ // The X_* codes owned by @ultimat3/cache. Each one names the exact config change or | ||
| 'X_CACHE_JITTER_INVALID', | ||
| 'X_CACHE_LIMIT_INVALID', | ||
| 'X_CACHE_PURGE_FAILED', | ||
@@ -25,2 +26,3 @@ 'X_CACHE_TAG_UNKNOWN', | ||
| X_CACHE_JITTER_INVALID: 'a TTL jitter fraction outside [0, 1)', | ||
| X_CACHE_LIMIT_INVALID: 'a cache ceiling, similarity floor or timeout that is not a usable number', | ||
| X_CACHE_PURGE_FAILED: 'the CDN refused a purge', | ||
@@ -77,2 +79,52 @@ X_CACHE_TAG_UNKNOWN: 'a tag no entity declared', | ||
| /** | ||
| * A LIMIT this package runs on that is not one — a tier's byte budget, its entry ceiling, the | ||
| * similarity floor the semantic tier decides "same question" with, or a purge driver's request | ||
| * timeout. Sibling of the TTL refusal below, over the knobs an `app.config.ts` carries and | ||
| * `CacheTooLargeError`'s `fix:` line already tells an operator to raise. | ||
| * | ||
| * `Number(process.env.CACHE_MAX_BYTES)` on an unset variable is `NaN`, `NaN` is not nullish so | ||
| * `??` passes it through, and every comparison it reaches then answers false. The failure is never | ||
| * a wrong number, it is the guard turning itself off — `while (bytes > NaN)` never evicts, so the | ||
| * cache has no ceiling at all, and `similarity < NaN` never skips, so a semantic lookup answers | ||
| * the nearest thing it holds to a question nobody asked. Refused where the value is still | ||
| * nameable, exactly as `assertTtl` does. | ||
| */ | ||
| /** | ||
| * The mechanism is stated ONLY for `NaN`, and that is deliberate. This cause used to say "a | ||
| * comparison against this one is false for every entry" for every rejected value, which is true of | ||
| * `NaN` and false of everything else it also receives: `Infinity`, `-Infinity`, `0`, a fraction, an | ||
| * unsafe integer. A ceiling of `Infinity` is never *exceeded* and a similarity floor of `Infinity` | ||
| * is never *met* — opposite outcomes, from one sentence claiming both. A cause that names the wrong | ||
| * failure sends the reader to the wrong place, which is the failure mode axiom 4 exists to prevent, | ||
| * so the shared half says only what is true of every value and the `NaN` half is added when it is. | ||
| */ | ||
| const limitEffect = (value: number): string => | ||
| Number.isNaN(value) | ||
| ? ', and every comparison against NaN is false, so the limit stops being enforced rather than being enforced wrongly — NaN is what Number(process.env.…) answers for an unset variable, and it is not nullish, so `??` does not catch it' | ||
| : ', and it is applied as given, so the tier is bounded at a value nobody chose'; | ||
| export class CacheLimitInvalidError extends UltimateError { | ||
| constructor(input: { | ||
| option: string; | ||
| value: number; | ||
| tier: string; | ||
| expected: string; | ||
| /** | ||
| * Where the value came FROM, when that is not an `app.config.ts` key. `loadDeadlineMs` and the | ||
| * similarity override arrive as call arguments (`createCacheStack`, `lookup`), so telling their | ||
| * caller to edit `app.config.ts` names a key that does not exist. | ||
| */ | ||
| source?: string | undefined; | ||
| }) { | ||
| const where = input.source ?? `${input.tier}.${input.option} in app.config.ts`; | ||
| super({ | ||
| code: 'X_CACHE_LIMIT_INVALID', | ||
| cause: `the ${input.tier} tier was given ${input.option}=${String(input.value)}; it must be ${input.expected}${limitEffect(input.value)}`, | ||
| fix: `set ${where} to ${input.expected} — and if it comes from the environment, parse it first: Number(process.env.CACHE_LIMIT) is NaN when the variable is unset`, | ||
| meta: { option: input.option, value: String(input.value), tier: input.tier }, | ||
| }); | ||
| } | ||
| } | ||
| /** | ||
| * A `ttlMs` that is not a positive, finite number of milliseconds. | ||
@@ -79,0 +131,0 @@ * |
+4
-0
@@ -11,2 +11,3 @@ // Public API of @ultimat3/cache. Explicit, no `export *`. | ||
| CacheJitterInvalidError, | ||
| CacheLimitInvalidError, | ||
| CachePurgeFailedError, | ||
@@ -113,2 +114,5 @@ CacheTagUnknownError, | ||
| export { | ||
| assertFiniteCapacity, | ||
| assertFiniteDurationMs, | ||
| assertFiniteSimilarityFloor, | ||
| assertTtl, | ||
@@ -115,0 +119,0 @@ createCacheStack, |
+14
-3
@@ -19,3 +19,3 @@ // Tier 1: in-process LRU bounded by BYTES, not entry count — an entry count budget is a | ||
| } from './tiers'; | ||
| import { assertTtl, nowMs } from './tiers'; | ||
| import { assertFiniteCapacity, assertFiniteDurationMs, assertTtl, nowMs } from './tiers'; | ||
@@ -85,2 +85,5 @@ export interface LruOptions { | ||
| /** 64 MiB. Named because the constructor now screens it, and a screen needs something to pass. */ | ||
| const DEFAULT_LRU_MAX_BYTES = 64 * 1024 * 1024; | ||
| export class LruCache { | ||
@@ -102,4 +105,12 @@ private readonly map = new Map<string, LruNode>(); | ||
| constructor(options: LruOptions = {}) { | ||
| this.maxBytes = options.maxBytes ?? 64 * 1024 * 1024; | ||
| this.defaultTtlMs = options.defaultTtlMs ?? 60_000; | ||
| this.maxBytes = assertFiniteCapacity( | ||
| 'lru', | ||
| 'maxBytes', | ||
| options.maxBytes ?? DEFAULT_LRU_MAX_BYTES, | ||
| ); | ||
| this.defaultTtlMs = assertFiniteDurationMs( | ||
| 'lru', | ||
| 'defaultTtlMs', | ||
| options.defaultTtlMs ?? 60_000, | ||
| ); | ||
| this.clock = options.clock ?? systemClock; | ||
@@ -106,0 +117,0 @@ this.jitter = { |
@@ -21,2 +21,3 @@ // Single responsibility: Cloudflare's cache-tag purge. One `POST /zones/<id>/purge_cache` per | ||
| } from './purge-http'; | ||
| import { assertFiniteDurationMs } from './tiers'; | ||
@@ -77,3 +78,7 @@ export const CLOUDFLARE_API_URL = 'https://api.cloudflare.com/client/v4'; | ||
| const baseUrl = options.baseUrl ?? CLOUDFLARE_API_URL; | ||
| const timeoutMs = options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS; | ||
| const timeoutMs = assertFiniteDurationMs( | ||
| 'cloudflare', | ||
| 'timeoutMs', | ||
| options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS, | ||
| ); | ||
| const doFetch = options.fetch ?? defaultPurgeFetch; | ||
@@ -80,0 +85,0 @@ const headers = { Authorization: `Bearer ${apiToken}` }; |
@@ -22,2 +22,3 @@ // Single responsibility: Fastly's surrogate-key purge. One `POST /service/<id>/purge` per batch | ||
| } from './purge-http'; | ||
| import { assertFiniteDurationMs } from './tiers'; | ||
@@ -76,3 +77,7 @@ export const FASTLY_API_URL = 'https://api.fastly.com'; | ||
| const baseUrl = options.baseUrl ?? FASTLY_API_URL; | ||
| const timeoutMs = options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS; | ||
| const timeoutMs = assertFiniteDurationMs( | ||
| 'fastly', | ||
| 'timeoutMs', | ||
| options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS, | ||
| ); | ||
| const doFetch = options.fetch ?? defaultPurgeFetch; | ||
@@ -79,0 +84,0 @@ const headers = { 'Fastly-Key': apiToken }; |
+37
-8
@@ -76,10 +76,39 @@ // Single responsibility: the HTTP half both remote purge drivers share — one POST with a | ||
| const UNSAFE_KEY = /[\s,]/; | ||
| const MAX_KEY_LENGTH = 1024; | ||
| const MAX_KEY_BYTES = 1024; | ||
| const keyProblem = (key: string): string | undefined => { | ||
| if (key === '') return 'is empty'; | ||
| // The CDN's limit is on the HEADER, so it is bytes — and `String.length` is UTF-16 code units, | ||
| // which for a non-ASCII tag is a third of the answer: 900 CJK characters measured 900 against a | ||
| // 1024 limit and put 2,700 bytes on the wire, where the provider refuses or truncates the key. | ||
| // That is the accepted purge that cleared nothing, arriving through the guard meant to stop it. | ||
| const keyBytes = (key: string): number => new TextEncoder().encode(key).byteLength; | ||
| /** | ||
| * The problem AND the repair, together — because there are three ways a key is unpurgeable and only | ||
| * one of them is fixed by renaming away a separator. A 900-character CJK tag carries no whitespace | ||
| * and no comma, so "rename it so it carries no space or comma" left the next purge failing exactly | ||
| * as the last one did: an instruction that does not repair the stated cause is the shape axiom 4 | ||
| * exists to forbid. | ||
| */ | ||
| interface KeyProblem { | ||
| readonly problem: string; | ||
| readonly fix: string; | ||
| } | ||
| const keyProblem = (key: string): KeyProblem | undefined => { | ||
| if (key === '') | ||
| return { | ||
| problem: 'is empty', | ||
| fix: 'give the tag a name in its declareTags(...) call — an empty surrogate key purges nothing', | ||
| }; | ||
| if (UNSAFE_KEY.test(key)) | ||
| return 'contains whitespace or a comma, which a CDN reads as a separator'; | ||
| if (key.length > MAX_KEY_LENGTH) | ||
| return `is ${key.length} characters, over the 1024-byte key limit`; | ||
| return { | ||
| problem: 'contains whitespace or a comma, which a CDN reads as a separator', | ||
| fix: 'rename the tag in its declareTags(...) call so the key carries no space or comma', | ||
| }; | ||
| const bytes = keyBytes(key); | ||
| if (bytes > MAX_KEY_BYTES) | ||
| return { | ||
| problem: `is ${bytes} bytes, over the ${MAX_KEY_BYTES}-byte key limit`, | ||
| fix: `shorten the tag in its declareTags(...) call to at most ${MAX_KEY_BYTES} UTF-8 bytes — the limit is on the header, so it is BYTES, and a non-ASCII character costs two or three of them each`, | ||
| }; | ||
| return undefined; | ||
@@ -98,5 +127,5 @@ }; | ||
| driver, | ||
| detail: `surrogate key ${JSON.stringify(key)} ${problem}`, | ||
| detail: `surrogate key ${JSON.stringify(key)} ${problem.problem}`, | ||
| retryable: false, | ||
| fix: 'rename the tag in its declareTags(...) call so the key carries no space or comma', | ||
| fix: problem.fix, | ||
| }); | ||
@@ -103,0 +132,0 @@ } |
+6
-2
@@ -19,3 +19,3 @@ // Tier 2: shared cache over `Bun.redis` (no client dependency — the runtime ships one). | ||
| } from './tiers'; | ||
| import { assertTtl, nowMs } from './tiers'; | ||
| import { assertFiniteDurationMs, assertTtl, nowMs } from './tiers'; | ||
@@ -195,3 +195,7 @@ /** The slice of Bun's Redis client this tier uses. Narrow on purpose: easy to fake in tests. */ | ||
| const ns = namespaceFor(options.prefix ?? 'x', options.buildId); | ||
| const defaultTtlMs = options.defaultTtlMs ?? 300_000; | ||
| const defaultTtlMs = assertFiniteDurationMs( | ||
| 'redis', | ||
| 'defaultTtlMs', | ||
| options.defaultTtlMs ?? 300_000, | ||
| ); | ||
| const clock = options.clock ?? systemClock; | ||
@@ -198,0 +202,0 @@ const jitter: TtlJitter = { |
+22
-5
@@ -11,3 +11,9 @@ // The semantic cache for LLM calls: a near-duplicate prompt should not pay for a second | ||
| import { tagsIntersect } from './tags'; | ||
| import { assertTtl, nowMs } from './tiers'; | ||
| import { | ||
| assertFiniteCapacity, | ||
| assertFiniteDurationMs, | ||
| assertFiniteSimilarityFloor, | ||
| assertTtl, | ||
| nowMs, | ||
| } from './tiers'; | ||
@@ -77,5 +83,11 @@ export type Embedding = readonly number[]; | ||
| export function createMemorySemanticCache(options: SemanticCacheOptions = {}): SemanticCache { | ||
| const threshold = options.threshold ?? 0.92; | ||
| const maxEntries = options.maxEntries ?? 1000; | ||
| const defaultTtlMs = options.defaultTtlMs ?? 3_600_000; | ||
| // Screened at construction, both of them: this tier's two knobs are the ones whose failure is | ||
| // silent — a floor of NaN matches everything, a ceiling of NaN evicts nothing. | ||
| const threshold = assertFiniteSimilarityFloor('semantic', 'threshold', options.threshold ?? 0.92); | ||
| const maxEntries = assertFiniteCapacity('semantic', 'maxEntries', options.maxEntries ?? 1000); | ||
| const defaultTtlMs = assertFiniteDurationMs( | ||
| 'semantic', | ||
| 'defaultTtlMs', | ||
| options.defaultTtlMs ?? 3_600_000, | ||
| ); | ||
| const clock = options.clock ?? systemClock; | ||
@@ -98,3 +110,8 @@ const records = new Map<string, SemanticRecord>(); | ||
| lookup<T>(embedding: Embedding, override?: number): Promise<SemanticHit<T> | undefined> { | ||
| const floor = override ?? threshold; | ||
| // The override is the same boundary, arriving per call — screened for the same reason, and | ||
| // this is the one that can carry a value straight off a request. | ||
| const floor = | ||
| override === undefined | ||
| ? threshold | ||
| : assertFiniteSimilarityFloor('semantic', 'threshold', override); | ||
| let best: SemanticHit<T> | undefined; | ||
@@ -101,0 +118,0 @@ for (const record of live()) { |
+64
-2
@@ -8,3 +8,3 @@ // The tier ladder: request-memo -> lru -> redis -> cdn. Reads walk DOWN until a hit, then | ||
| import { CACHE_TIERS, systemClock } from '@ultimat3/core'; | ||
| import { CacheJitterInvalidError, CacheTtlInvalidError } from './errors'; | ||
| import { CacheJitterInvalidError, CacheLimitInvalidError, CacheTtlInvalidError } from './errors'; | ||
| import type { CacheFence } from './fence'; | ||
@@ -103,2 +103,57 @@ import { markInvalidated, sampleFence } from './fence'; | ||
| /** | ||
| * A tier's ceiling — bytes or entries — screened where it is still nameable. The same rule as | ||
| * `assertTtl` beside it, over the other knob an `app.config.ts` carries. A SEPARATE refusal from | ||
| * `X_CACHE_TOO_LARGE`, which is one entry against a valid ceiling: this one is the ceiling itself, | ||
| * and it needs a screen because a bad ceiling refuses nothing and evicts nothing rather than | ||
| * refusing everything. `NaN > x` and `x > NaN` are both false. | ||
| */ | ||
| export function assertFiniteCapacity(tier: TtlScope, option: string, value: number): number { | ||
| if (Number.isSafeInteger(value) && value > 0) return value; | ||
| throw new CacheLimitInvalidError({ | ||
| tier, | ||
| option, | ||
| value, | ||
| expected: 'a whole number greater than zero', | ||
| }); | ||
| } | ||
| /** | ||
| * A millisecond duration: a driver's request budget (handed to `AbortSignal.timeout`, which THROWS | ||
| * on a non-finite one — inside `purgePost`'s try, so the config typo came back as a retryable | ||
| * transport failure that never happened) and a tier's own `defaultTtlMs`, which is otherwise only | ||
| * screened by `assertTtl` at the first `set`, one write into the process's life. | ||
| * | ||
| * The `Finite` in all three names here is load-bearing: `bun run finite-bounds` recognises a repair | ||
| * by the shape of the CALL, so `assertCapacity` and `assertTimeoutMs` left all nine of this | ||
| * package's options reading as unchecked while every one of them was screened. | ||
| */ | ||
| export function assertFiniteDurationMs( | ||
| tier: string, | ||
| option: string, | ||
| value: number, | ||
| /** Passed only where the value is a CALL argument rather than an `app.config.ts` key. */ | ||
| source?: string | undefined, | ||
| ): number { | ||
| if (Number.isSafeInteger(value) && value > 0) return value; | ||
| throw new CacheLimitInvalidError({ | ||
| tier, | ||
| option, | ||
| value, | ||
| expected: 'a whole number of milliseconds greater than zero', | ||
| source, | ||
| }); | ||
| } | ||
| /** | ||
| * The similarity floor the semantic tier decides "same question" with — a correctness boundary, | ||
| * not a tuning knob, so `NaN` here does not loosen it, it removes it: `similarity < NaN` is false | ||
| * for every record. Cosine similarity is in [-1, 1] and a floor of 0 already admits an orthogonal | ||
| * vector, so anything outside [0, 1] is a number nobody can have meant. | ||
| */ | ||
| export function assertFiniteSimilarityFloor(tier: TtlScope, option: string, value: number): number { | ||
| if (Number.isFinite(value) && value >= 0 && value <= 1) return value; | ||
| throw new CacheLimitInvalidError({ tier, option, value, expected: 'a number from 0 to 1' }); | ||
| } | ||
| export function assertTtl( | ||
@@ -209,3 +264,10 @@ key: string, | ||
| const flight = createSingleFlight({ | ||
| deadlineMs: options.loadDeadlineMs ?? DEFAULT_LOAD_DEADLINE_MS, | ||
| deadlineMs: assertFiniteDurationMs( | ||
| 'ladder', | ||
| 'loadDeadlineMs', | ||
| options.loadDeadlineMs ?? DEFAULT_LOAD_DEADLINE_MS, | ||
| // Caller-owned: it arrives as a `createCacheStack({ loadDeadlineMs })` argument, so there is | ||
| // no `app.config.ts` key to send the reader to. | ||
| 'the loadDeadlineMs argument to createCacheStack(...)', | ||
| ), | ||
| schedule: options.schedule, | ||
@@ -212,0 +274,0 @@ }); |
198469
5.39%3141
6.11%+ Added
- Removed
Updated