@ultimat3/cache 2.0.0 → 4.0.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 +19 -0
- package/README.md +12 -3
- package/package.json +2 -2
- package/src/cdn.ts +16 -0
- package/src/index.ts +2 -2
- package/src/invalidate.ts +8 -13
- package/src/purge-env.ts +0 -3
- package/src/redis.ts +20 -7
- package/src/tier-failures.ts +24 -3
package/CLAUDE.md
CHANGED
|
@@ -40,6 +40,16 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
40
40
|
`TierName` plus `'query-read'` — closed, and deliberately NOT a widening of `TierName`: a name
|
|
41
41
|
missing from `TIER_ORDER` sorts to `-1`, ahead of the request memo. A label is a log facet; a
|
|
42
42
|
`TierName` is a position on the ladder.
|
|
43
|
+
- **A refusal is rendered with `renderThrowable()`, never `error.message`** — the four sites that
|
|
44
|
+
absorb one (`bestEffort`'s log entry, and `fanOut`'s tier, ISR and broadcast catch blocks). A
|
|
45
|
+
tier, a revalidator and a broadcast are all app-supplied, so the value they reject with is too:
|
|
46
|
+
`instanceof` runs a `Proxy`'s `getPrototypeOf` trap and `String()` runs `Symbol.toPrimitive`, so
|
|
47
|
+
building the log line used to raise INSTEAD of absorbing the refusal — on the business write that
|
|
48
|
+
triggered the bust, which is the one caller both contracts promise to protect. The code field
|
|
49
|
+
keeps its own total probe (`ultimateCode` in `tier-failures.ts`) rather than core's `stringField`:
|
|
50
|
+
a driver error's `code` is a SQLSTATE and must never be reported as an `X_*` one. Consequence to
|
|
51
|
+
know: a recorded `message` carries the throwable's NAME (`Error: nats is down`, `"just a string"`),
|
|
52
|
+
which is what `renderThrowable` renders and what the tests here now pin.
|
|
43
53
|
- Tier failures go into `report.errors`. A cache tier may never fail a business read or write.
|
|
44
54
|
`createCacheStack` routes every `get`/`set`/`del` through `bestEffort()` for that reason — a
|
|
45
55
|
refusal becomes "that tier did not answer" and lands in `recentTierFailures()`, the read side's
|
|
@@ -147,6 +157,10 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
147
157
|
change: a cold shared tier, which the default build-id namespace already pays per deploy.
|
|
148
158
|
Pinned by `tier-parity.test.ts` (all three rungs, one test each) and `redis.live.test.ts` (the
|
|
149
159
|
same two busts against a real server, asserting the LRU's and Redis's survivors are EQUAL).
|
|
160
|
+
**A row bust does not read the index and still SREMs from it** (`sweepBucketsFor`) — reading it
|
|
161
|
+
over-reaches, removing a member cannot: only what the bust deleted leaves. Without that, a
|
|
162
|
+
deleted value key kept its membership in `e:{entity}` for ever while every write renewed that
|
|
163
|
+
index's lease — the unbounded `SMEMBERS` the lease was added to prevent, rebuilt out of corpses.
|
|
150
164
|
- **`CacheTier.set` REJECTS, never throws synchronously.** `createLruTier` and `createMemoTier` are
|
|
151
165
|
`async` for that reason alone — `LruCache.set` stays a sync API, but a `CacheTier` is one
|
|
152
166
|
interface with three implementations and `tier.set(...).catch(...)` has to mean the same thing on
|
|
@@ -202,6 +216,11 @@ Tier 1. Tagged caching + THE invalidation graph.
|
|
|
202
216
|
options and only `receiveInvalidationBroadcast` passes `false`. An inbound tag this process never
|
|
203
217
|
declared is dropped into `report.errors`, never thrown: a throw kills the subscriber loop and
|
|
204
218
|
silently ends cross-instance invalidation for the whole process.
|
|
219
|
+
- **A `cdn` tier holding `noopPurgeDriver()` reports `skipped`, never keys.** That is the default
|
|
220
|
+
state of every deployment with no CDN credentials (`selectPurgeDriver`), and the noop ECHOES the
|
|
221
|
+
keys it is handed — so the tier reported every tag as CLEARED and `busted` listed keys nothing
|
|
222
|
+
had purged, with `errors: []`. `isNoopPurgeDriver` lives in `cdn.ts`, beside the `name: 'noop'`
|
|
223
|
+
it tests for, because `createCdnTier` cannot import `purge-env.ts` without a cycle.
|
|
205
224
|
- **`report.cdn` is what depends on the tags; `report.tiers` is what cleared.** The `cdn` tier
|
|
206
225
|
purges `cdn-path` dependents itself, alongside the tags, so `busted` is built from `tiers` +
|
|
207
226
|
`isr` + `liveQueries` and never from `cdn` — folding in a list nothing purged is exactly the
|
package/README.md
CHANGED
|
@@ -170,9 +170,12 @@ partial bust while stale rows served until TTL. Value keys are still deleted cli
|
|
|
170
170
|
each, which is slot-local under every topology.
|
|
171
171
|
|
|
172
172
|
The script **deletes nothing at all** — not the value keys, and not the buckets either. The tier
|
|
173
|
-
`SREM`s exactly the members whose `DEL` succeeded,
|
|
174
|
-
|
|
175
|
-
|
|
173
|
+
`SREM`s exactly the members whose `DEL` succeeded, from every bucket that member joined —
|
|
174
|
+
including, for a row bust, the entity index it deliberately never *reads*. Reading `e:{entity}` for
|
|
175
|
+
a row bust would return every key of the entity; removing from it cannot over-reach, and a member
|
|
176
|
+
left there is a corpse in a set every later write renews the lease on. A refused delete keeps its
|
|
177
|
+
membership and the retry the error asks for still finds it; dropping the bucket inside the script
|
|
178
|
+
made that failure permanent. A `set` mirrors it: buckets are joined **before** the value is written and membership is
|
|
176
179
|
re-checked after, because a bust that landed in between would otherwise leave a row nothing can
|
|
177
180
|
reach by tag, serving until its own lease ran out.
|
|
178
181
|
|
|
@@ -319,6 +322,12 @@ nothing loads that file's contents at runtime:
|
|
|
319
322
|
| `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ZONE_ID` | Cloudflare |
|
|
320
323
|
| neither | nothing is purged, and `x dev` prints `cdn=none` |
|
|
321
324
|
|
|
325
|
+
A `cdn` tier holding the noop driver answers an invalidation with
|
|
326
|
+
`{ tier: 'cdn', keys: [], skipped: 'no purge driver configured' }` — never a list of keys. The noop
|
|
327
|
+
echoes what it is handed, so reporting its reply as accepted made every tag read as CLEARED in
|
|
328
|
+
`report.tiers` and in `recentInvalidations().busted`, with `errors: []`, in the default state of a
|
|
329
|
+
deployment that has no CDN at all. `isNoopPurgeDriver(driver)` is the same probe, exported.
|
|
330
|
+
|
|
322
331
|
Both pairs at once is `X_CONFIG_INVALID`: one process purges exactly one edge. Half a pair
|
|
323
332
|
is refused the same way — treating it as "no CDN" is how a deployment ships believing it
|
|
324
333
|
purges. Either refusal names the keys that are actually set, in `cause` and in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cache",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.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": "
|
|
34
|
+
"@ultimat3/core": "4.0.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/cdn.ts
CHANGED
|
@@ -66,6 +66,15 @@ export function noopPurgeDriver(): PurgeDriver {
|
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A driver that reaches no CDN, so a caller can report "purges nothing" without a name match.
|
|
71
|
+
*
|
|
72
|
+
* Lives here rather than beside `selectPurgeDriver`, which is where it was until 2026-08: the
|
|
73
|
+
* `name: 'noop'` it tests for is declared one function up, and `createCdnTier` — the caller that
|
|
74
|
+
* most needs it — cannot import from `purge-env.ts` without making a cycle of the two files.
|
|
75
|
+
*/
|
|
76
|
+
export const isNoopPurgeDriver = (driver: PurgeDriver): boolean => driver.name === 'noop';
|
|
77
|
+
|
|
69
78
|
export interface CdnTierOptions {
|
|
70
79
|
readonly purge?: PurgeDriver;
|
|
71
80
|
/**
|
|
@@ -108,6 +117,13 @@ export function createCdnTier(options: CdnTierOptions = {}): CacheTier {
|
|
|
108
117
|
* `cdn-path` dependent must tag that response with its own path.
|
|
109
118
|
*/
|
|
110
119
|
async invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
|
|
120
|
+
// The default state of any deployment with no CDN credentials, and it has to say so: the
|
|
121
|
+
// noop driver ECHOES its argument, so every tag came back as an accepted purge and
|
|
122
|
+
// `recentInvalidations().busted` listed keys nothing had cleared, `errors: []`. `skipped`
|
|
123
|
+
// is the field that already exists for this — a tier that did not run, named as one.
|
|
124
|
+
if (isNoopPurgeDriver(driver)) {
|
|
125
|
+
return { tier: 'cdn', keys: [], skipped: 'no purge driver configured' };
|
|
126
|
+
}
|
|
111
127
|
const keys = [...new Set([...serializeTags(tags), ...dependentsOfKind(tags, 'cdn-path')])];
|
|
112
128
|
if (keys.length === 0) return { tier: 'cdn', keys: [] };
|
|
113
129
|
const accepted = await driver.purge(keys);
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Public API of @ultimat3/cache. Explicit, no `export *`.
|
|
2
2
|
|
|
3
3
|
export type { CacheHeaderOptions, CdnTierOptions, PurgeDriver } from './cdn';
|
|
4
|
-
export { cacheHeaders, createCdnTier, noopPurgeDriver } from './cdn';
|
|
4
|
+
export { cacheHeaders, createCdnTier, isNoopPurgeDriver, noopPurgeDriver } from './cdn';
|
|
5
5
|
export type { CacheErrorCode } from './errors';
|
|
6
6
|
export {
|
|
7
7
|
CACHE_ERROR_CODES,
|
|
@@ -55,7 +55,7 @@ export {
|
|
|
55
55
|
cloudflarePurgeDriver,
|
|
56
56
|
} from './purge-cloudflare';
|
|
57
57
|
export type { PurgeEnvironment, PurgeSelection } from './purge-env';
|
|
58
|
-
export { CDN_PURGE_ENV_KEYS,
|
|
58
|
+
export { CDN_PURGE_ENV_KEYS, selectPurgeDriver } from './purge-env';
|
|
59
59
|
export type { FastlyPurgeOptions } from './purge-fastly';
|
|
60
60
|
export { FASTLY_API_URL, FASTLY_MAX_KEYS_PER_REQUEST, fastlyPurgeDriver } from './purge-fastly';
|
|
61
61
|
export type { PurgeFetch } from './purge-http';
|
package/src/invalidate.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// the returned report is what the `/_x` cache panel renders, so "did it actually clear?" is
|
|
5
5
|
// answerable without a log dive.
|
|
6
6
|
|
|
7
|
-
import { currentSpan, logger, systemClock, withSpan } from '@ultimat3/core';
|
|
7
|
+
import { currentSpan, logger, renderThrowable, systemClock, withSpan } from '@ultimat3/core';
|
|
8
8
|
import { markInvalidated } from './fence';
|
|
9
9
|
import { dependentsOfKind } from './graph';
|
|
10
10
|
import type { CacheTag } from './tags';
|
|
@@ -222,10 +222,11 @@ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<Inva
|
|
|
222
222
|
try {
|
|
223
223
|
tiers.push(await tier.invalidateTags(tags));
|
|
224
224
|
} catch (error) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
225
|
+
// `renderThrowable`, never `error.message`: a tier is app-supplied, so the value it
|
|
226
|
+
// rejects with is too, and both `instanceof` and `String()` RUN app code on it. A render
|
|
227
|
+
// that throws here rejects the whole fan-out — the failure the line above promises not to
|
|
228
|
+
// let reach the write that triggered the bust.
|
|
229
|
+
errors.push({ tier: tier.name, message: renderThrowable(error) });
|
|
229
230
|
}
|
|
230
231
|
}
|
|
231
232
|
// The report is read order, not clear order: it is what the `/_x` panel renders, and a ladder
|
|
@@ -240,10 +241,7 @@ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<Inva
|
|
|
240
241
|
try {
|
|
241
242
|
await revalidator?.(path);
|
|
242
243
|
} catch (error) {
|
|
243
|
-
errors.push({
|
|
244
|
-
tier: 'isr',
|
|
245
|
-
message: error instanceof Error ? error.message : String(error),
|
|
246
|
-
});
|
|
244
|
+
errors.push({ tier: 'isr', message: renderThrowable(error) });
|
|
247
245
|
}
|
|
248
246
|
}
|
|
249
247
|
|
|
@@ -255,10 +253,7 @@ function fanOut(tags: readonly CacheTag[], options: FanOutOptions): Promise<Inva
|
|
|
255
253
|
try {
|
|
256
254
|
await broadcast(wire);
|
|
257
255
|
} catch (error) {
|
|
258
|
-
errors.push({
|
|
259
|
-
tier: 'broadcast',
|
|
260
|
-
message: error instanceof Error ? error.message : String(error),
|
|
261
|
-
});
|
|
256
|
+
errors.push({ tier: 'broadcast', message: renderThrowable(error) });
|
|
262
257
|
}
|
|
263
258
|
}
|
|
264
259
|
|
package/src/purge-env.ts
CHANGED
|
@@ -41,9 +41,6 @@ const nonEmpty = (value: string | undefined): string | undefined =>
|
|
|
41
41
|
const configuredKeys = (env: PurgeEnvironment): readonly string[] =>
|
|
42
42
|
CDN_PURGE_ENV_KEYS.filter((key) => nonEmpty(env[key]) !== undefined);
|
|
43
43
|
|
|
44
|
-
/** A driver that reaches no CDN, so a caller can report "purges nothing" without a name match. */
|
|
45
|
-
export const isNoopPurgeDriver = (driver: PurgeDriver): boolean => driver.name === 'noop';
|
|
46
|
-
|
|
47
44
|
/**
|
|
48
45
|
* Either key selects its provider, and the other is then required: a `FASTLY_SERVICE_ID` with no
|
|
49
46
|
* token is a half-finished deploy, and treating it as "no CDN" is how an environment ships
|
package/src/redis.ts
CHANGED
|
@@ -239,6 +239,19 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
|
239
239
|
? [entityKey(owned.entity), tagKey(owned)]
|
|
240
240
|
: [tagKey(owned), tagKey({ entity: owned.entity })];
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The buckets a bust CLEANS UP, which is not the set it reads — and the asymmetry is the point.
|
|
244
|
+
*
|
|
245
|
+
* A row bust must not READ the entity index: it holds every key of the entity, so the bust would
|
|
246
|
+
* delete them all. It must still SREM from it. `set` joins the index on every write, so a row
|
|
247
|
+
* bust that deletes a value key and leaves its membership there leaves a corpse no later bust
|
|
248
|
+
* can reach — while `TAG_MEMBER_SCRIPT` renews that index's lease on every write, which is the
|
|
249
|
+
* unbounded `SMEMBERS` the lease exists to prevent, rebuilt out of dead keys. Removing a member
|
|
250
|
+
* cannot over-reach the way reading one can: only what this bust actually deleted leaves.
|
|
251
|
+
*/
|
|
252
|
+
const sweepBucketsFor = (owned: CacheTag): string[] =>
|
|
253
|
+
owned.id === undefined ? [] : [entityKey(owned.entity)];
|
|
254
|
+
|
|
242
255
|
return {
|
|
243
256
|
name: 'redis',
|
|
244
257
|
|
|
@@ -330,18 +343,18 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
|
330
343
|
*/
|
|
331
344
|
async invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
|
|
332
345
|
const claimed = new Set<string>();
|
|
333
|
-
const perTag: string[][] = [];
|
|
346
|
+
const perTag: { read: string[]; sweep: string[] }[] = [];
|
|
334
347
|
for (const owned of tags) {
|
|
335
348
|
// A bucket already claimed by an earlier tag is dropped rather than re-sent: a collection
|
|
336
349
|
// tag and one of its rows overlap, and the second call would read the same members.
|
|
337
|
-
const
|
|
338
|
-
for (const bucket of
|
|
339
|
-
if (
|
|
350
|
+
const read = bustBucketsFor(owned).filter((bucket) => !claimed.has(bucket));
|
|
351
|
+
for (const bucket of read) claimed.add(bucket);
|
|
352
|
+
if (read.length > 0) perTag.push({ read, sweep: [...read, ...sweepBucketsFor(owned)] });
|
|
340
353
|
}
|
|
341
354
|
if (perTag.length === 0) return { tier: 'redis', keys: [] };
|
|
342
355
|
const replies = await Promise.all(
|
|
343
|
-
perTag.map((
|
|
344
|
-
conn().send('EVAL', [INVALIDATE_SCRIPT, String(
|
|
356
|
+
perTag.map(({ read }) =>
|
|
357
|
+
conn().send('EVAL', [INVALIDATE_SCRIPT, String(read.length), ...read]),
|
|
345
358
|
),
|
|
346
359
|
);
|
|
347
360
|
// A member may sit in two tag sets; deleting it twice is harmless but reporting it twice
|
|
@@ -369,7 +382,7 @@ export function createRedisTier(options: RedisTierOptions = {}): CacheTier {
|
|
|
369
382
|
// drops the bucket, which is what made that failure permanent.
|
|
370
383
|
for (let i = 0; i < perTag.length; i += 1) {
|
|
371
384
|
const gone = [...new Set(toStrings(replies[i]))].filter((member) => deleted.has(member));
|
|
372
|
-
for (const bucket of perTag[i] ?? []) {
|
|
385
|
+
for (const bucket of perTag[i]?.sweep ?? []) {
|
|
373
386
|
for (let start = 0; start < gone.length; start += DELETE_BATCH) {
|
|
374
387
|
await conn().send('SREM', [bucket, ...gone.slice(start, start + DELETE_BATCH)]);
|
|
375
388
|
}
|
package/src/tier-failures.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// to return, so every swallowed refusal lands in one bounded log plus one `warn` — a stack
|
|
4
4
|
// running degraded stays answerable instead of merely looking slow.
|
|
5
5
|
|
|
6
|
-
import { logger, systemClock, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import { logger, renderThrowable, systemClock, UltimateError } from '@ultimat3/core';
|
|
7
7
|
import type { TierLabel } from './tiers';
|
|
8
8
|
|
|
9
9
|
/** The three tier calls a stack makes on the value path. `invalidateTags` reports its own. */
|
|
@@ -77,14 +77,35 @@ export async function bestEffort<T>(
|
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The `X_*` code when the tier threw an `UltimateError`, and `undefined` for every other answer —
|
|
82
|
+
* "the probe itself threw" included. `instanceof` RUNS a `Proxy`'s `getPrototypeOf` trap and the
|
|
83
|
+
* read past it is a getter call, both on a value this package did not build; the one place the
|
|
84
|
+
* question is asked is the catch block absorbing a refusal, which has nothing left to answer with
|
|
85
|
+
* if asking it raises. Core's `isThrownError` is this guard for `Error` and `stringField` is it for
|
|
86
|
+
* a loose field — neither fits here, because a driver error's `code` is a SQLSTATE and must never
|
|
87
|
+
* be reported as an `X_*` one.
|
|
88
|
+
*/
|
|
89
|
+
function ultimateCode(error: unknown): string | undefined {
|
|
90
|
+
try {
|
|
91
|
+
if (!(error instanceof UltimateError)) return undefined;
|
|
92
|
+
return typeof error.code === 'string' ? error.code : undefined;
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
80
98
|
function record(tier: TierLabel, op: TierOperation, key: string, error: unknown): void {
|
|
99
|
+
const code = ultimateCode(error);
|
|
81
100
|
const failure: TierFailure = {
|
|
82
101
|
at: systemClock.now().toISOString(),
|
|
83
102
|
tier,
|
|
84
103
|
op,
|
|
85
104
|
key,
|
|
86
|
-
...(
|
|
87
|
-
|
|
105
|
+
...(code === undefined ? {} : { code }),
|
|
106
|
+
// Never `error.message`: a rendering that throws replaces the absorbed refusal with a
|
|
107
|
+
// `TypeError` on the business read this function exists to keep alive.
|
|
108
|
+
message: renderThrowable(error),
|
|
88
109
|
};
|
|
89
110
|
failureLog.unshift(failure);
|
|
90
111
|
failureLog.length = Math.min(failureLog.length, MAX_TIER_FAILURES);
|