@checkstack/cache-utils 0.2.23 → 0.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/CHANGELOG.md +22 -0
- package/package.json +3 -3
- package/src/cached-scope.test.ts +90 -0
- package/src/cached-scope.ts +87 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# @checkstack/cache-utils
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bd41130: feat(cache-utils): add `CachedScope.wrapManyBatched` (epoch-guarded batched read-through)
|
|
8
|
+
|
|
9
|
+
`wrapManyBatched(ids, { keyFor, load })` serves cache hits and loads the MISSES
|
|
10
|
+
in ONE batched call (unlike `wrapMany`, which runs a loader per id), returning
|
|
11
|
+
values in input order. Crucially it carries the SAME per-key epoch guard as
|
|
12
|
+
`wrap`: a value is only written back if its key was not invalidated during the
|
|
13
|
+
load, so a concurrent mutation that invalidates a key mid-load truly wins the
|
|
14
|
+
race and cannot be clobbered by an in-flight loader's stale write. It also fails
|
|
15
|
+
open (a `provider.get` error is treated as a miss). This is the primitive the
|
|
16
|
+
auth `role -> access-rule ids` cache uses to keep its batched miss-load without
|
|
17
|
+
giving up the staleness guarantee the single-key `wrap` path already had.
|
|
18
|
+
|
|
19
|
+
## 0.2.24
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- @checkstack/cache-api@0.3.19
|
|
24
|
+
|
|
3
25
|
## 0.2.23
|
|
4
26
|
|
|
5
27
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/cache-utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "Elastic-2.0",
|
|
5
5
|
"checkstack": {
|
|
6
6
|
"type": "tooling"
|
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|
"type": "module",
|
|
9
9
|
"main": "src/index.ts",
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@checkstack/cache-api": "0.3.
|
|
11
|
+
"@checkstack/cache-api": "0.3.19"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
14
14
|
"@checkstack/tsconfig": "0.0.7",
|
|
15
|
-
"@checkstack/scripts": "0.7.
|
|
15
|
+
"@checkstack/scripts": "0.7.3",
|
|
16
16
|
"@types/bun": "^1.0.0",
|
|
17
17
|
"typescript": "^5.0.0"
|
|
18
18
|
},
|
package/src/cached-scope.test.ts
CHANGED
|
@@ -258,6 +258,96 @@ describe("createCachedScope", () => {
|
|
|
258
258
|
});
|
|
259
259
|
});
|
|
260
260
|
|
|
261
|
+
describe("wrapManyBatched", () => {
|
|
262
|
+
it("serves hits and loads misses in ONE batched call, in input order", async () => {
|
|
263
|
+
const provider = createMemoryProvider();
|
|
264
|
+
const scope = createCachedScope({
|
|
265
|
+
cacheManager: createManager(provider),
|
|
266
|
+
pluginId: "hc",
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Warm 'a' only.
|
|
270
|
+
await provider.set("hc:e:a", "cached:a");
|
|
271
|
+
|
|
272
|
+
const load = mock(async (miss: string[]) => miss.map((id) => `db:${id}`));
|
|
273
|
+
const result = await scope.wrapManyBatched(["a", "b", "c"], {
|
|
274
|
+
keyFor: (id) => `e:${id}`,
|
|
275
|
+
load,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
expect(result).toEqual(["cached:a", "db:b", "db:c"]);
|
|
279
|
+
// ONE batched load call, with exactly the miss ids (order preserved).
|
|
280
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
281
|
+
expect(load.mock.calls[0]![0]).toEqual(["b", "c"]);
|
|
282
|
+
// Misses were written back.
|
|
283
|
+
expect(await provider.get<string>("hc:e:b")).toBe("db:b");
|
|
284
|
+
expect(await provider.get<string>("hc:e:c")).toBe("db:c");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("returns [] for an empty id list without loading", async () => {
|
|
288
|
+
const provider = createMemoryProvider();
|
|
289
|
+
const scope = createCachedScope({
|
|
290
|
+
cacheManager: createManager(provider),
|
|
291
|
+
pluginId: "hc",
|
|
292
|
+
});
|
|
293
|
+
const load = mock(async () => []);
|
|
294
|
+
const result = await scope.wrapManyBatched<string, string>([], {
|
|
295
|
+
keyFor: (id) => id,
|
|
296
|
+
load,
|
|
297
|
+
});
|
|
298
|
+
expect(result).toEqual([]);
|
|
299
|
+
expect(load).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("fails open: a provider.get error treats the id as a miss", async () => {
|
|
303
|
+
const provider = createMemoryProvider();
|
|
304
|
+
provider.get = (async () => {
|
|
305
|
+
throw new Error("cache down");
|
|
306
|
+
}) as CacheProvider["get"];
|
|
307
|
+
const scope = createCachedScope({
|
|
308
|
+
cacheManager: createManager(provider),
|
|
309
|
+
pluginId: "hc",
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const load = mock(async (miss: string[]) => miss.map((id) => `db:${id}`));
|
|
313
|
+
const result = await scope.wrapManyBatched(["a", "b"], {
|
|
314
|
+
keyFor: (id) => `e:${id}`,
|
|
315
|
+
load,
|
|
316
|
+
});
|
|
317
|
+
expect(result).toEqual(["db:a", "db:b"]); // all loaded from DB
|
|
318
|
+
expect(load.mock.calls[0]![0]).toEqual(["a", "b"]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("epoch check: an invalidate during the batched load blocks the stale write-back", async () => {
|
|
322
|
+
const provider = createMemoryProvider();
|
|
323
|
+
const scope = createCachedScope({
|
|
324
|
+
cacheManager: createManager(provider),
|
|
325
|
+
pluginId: "p",
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const gate = deferred<string[]>();
|
|
329
|
+
const load = mock(() => gate.promise);
|
|
330
|
+
|
|
331
|
+
const reading = scope.wrapManyBatched(["k1", "k2"], {
|
|
332
|
+
keyFor: (id) => id,
|
|
333
|
+
load,
|
|
334
|
+
});
|
|
335
|
+
// Wait until the batched load has actually started (its epochs are now
|
|
336
|
+
// captured), THEN invalidate ONE of the two keys mid-load.
|
|
337
|
+
while (load.mock.calls.length === 0) await Promise.resolve();
|
|
338
|
+
await scope.invalidate("k1");
|
|
339
|
+
gate.resolve(["stale:k1", "fresh:k2"]);
|
|
340
|
+
const result = await reading;
|
|
341
|
+
|
|
342
|
+
// Caller still receives the loaded values (its read began pre-mutation)...
|
|
343
|
+
expect(result).toEqual(["stale:k1", "fresh:k2"]);
|
|
344
|
+
// ...but the invalidated key must NOT be repopulated with the stale value,
|
|
345
|
+
// while the untouched key IS written back.
|
|
346
|
+
expect(await provider.has("p:k1")).toBe(false);
|
|
347
|
+
expect(await provider.get<string>("p:k2")).toBe("fresh:k2");
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
261
351
|
describe("invalidate / invalidatePrefix", () => {
|
|
262
352
|
it("invalidate removes a single key so the next read re-loads", async () => {
|
|
263
353
|
const provider = createMemoryProvider();
|
package/src/cached-scope.ts
CHANGED
|
@@ -48,6 +48,30 @@ export interface CachedScope {
|
|
|
48
48
|
},
|
|
49
49
|
): Promise<T[]>;
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Per-entity caching for bulk reads whose MISSES are loaded in ONE batched
|
|
53
|
+
* call (not one loader per id like {@link wrapMany}). For each id, returns the
|
|
54
|
+
* cached value if present; the misses are collected and passed to
|
|
55
|
+
* {@link load} as a single array, and {@link load} returns their values in the
|
|
56
|
+
* SAME ORDER as the miss array.
|
|
57
|
+
*
|
|
58
|
+
* Unlike a hand-rolled `provider.get` + batched query + `provider.set`, this
|
|
59
|
+
* carries the SAME epoch guard as {@link wrap}: the epoch of each miss key is
|
|
60
|
+
* captured before {@link load} runs, and a value is only written back if the
|
|
61
|
+
* key was not invalidated during the load. So a mutation that invalidates a
|
|
62
|
+
* key mid-load truly wins the race and cannot be clobbered by an in-flight
|
|
63
|
+
* loader's stale write - matching {@link wrap}'s guarantee for a batched read.
|
|
64
|
+
*/
|
|
65
|
+
wrapManyBatched<Id, T>(
|
|
66
|
+
ids: readonly Id[],
|
|
67
|
+
options: {
|
|
68
|
+
keyFor: (id: Id) => string;
|
|
69
|
+
/** Load the miss ids in one call, returning values in the same order. */
|
|
70
|
+
load: (missIds: Id[]) => Promise<T[]>;
|
|
71
|
+
ttlMs?: number;
|
|
72
|
+
},
|
|
73
|
+
): Promise<T[]>;
|
|
74
|
+
|
|
51
75
|
/**
|
|
52
76
|
* Invalidate exactly one key. Safe to call from mutation paths.
|
|
53
77
|
* Prefer this over prefix invalidation when you know the affected entity.
|
|
@@ -180,6 +204,61 @@ export function createCachedScope({
|
|
|
180
204
|
);
|
|
181
205
|
}
|
|
182
206
|
|
|
207
|
+
async function wrapManyBatched<Id, T>(
|
|
208
|
+
ids: readonly Id[],
|
|
209
|
+
options: {
|
|
210
|
+
keyFor: (id: Id) => string;
|
|
211
|
+
load: (missIds: Id[]) => Promise<T[]>;
|
|
212
|
+
ttlMs?: number;
|
|
213
|
+
},
|
|
214
|
+
): Promise<T[]> {
|
|
215
|
+
if (ids.length === 0) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
const ttlMs = options.ttlMs ?? defaultTtlMs;
|
|
219
|
+
const keys = ids.map((id) => options.keyFor(id));
|
|
220
|
+
|
|
221
|
+
// Read every key in parallel; a get failure is treated as a MISS (fail open).
|
|
222
|
+
const cached = await Promise.all(
|
|
223
|
+
keys.map(async (key) => {
|
|
224
|
+
try {
|
|
225
|
+
return await provider.get<T>(key);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
reportError("get", error);
|
|
228
|
+
return; // treat as a miss (fail open)
|
|
229
|
+
}
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const result = Array.from<T>({ length: ids.length });
|
|
234
|
+
const missIndices: number[] = [];
|
|
235
|
+
for (const [i, value] of cached.entries()) {
|
|
236
|
+
if (value === undefined) missIndices.push(i);
|
|
237
|
+
else result[i] = value;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (missIndices.length > 0) {
|
|
241
|
+
// Capture each miss key's epoch BEFORE loading, so a concurrent
|
|
242
|
+
// invalidate during the load prevents its stale write (same guard as wrap).
|
|
243
|
+
const startEpochs = missIndices.map((i) => epochs.get(keys[i]!) ?? 0);
|
|
244
|
+
const loaded = await options.load(missIndices.map((i) => ids[i]!));
|
|
245
|
+
await Promise.all(
|
|
246
|
+
missIndices.map(async (i, j) => {
|
|
247
|
+
const value = loaded[j]!;
|
|
248
|
+
result[i] = value;
|
|
249
|
+
if ((epochs.get(keys[i]!) ?? 0) === startEpochs[j]) {
|
|
250
|
+
try {
|
|
251
|
+
await provider.set(keys[i]!, value, ttlMs);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
reportError("set", error);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
|
|
183
262
|
async function invalidate(key: string): Promise<void> {
|
|
184
263
|
bumpEpoch(key);
|
|
185
264
|
inflight.delete(key);
|
|
@@ -209,5 +288,12 @@ export function createCachedScope({
|
|
|
209
288
|
}
|
|
210
289
|
}
|
|
211
290
|
|
|
212
|
-
return {
|
|
291
|
+
return {
|
|
292
|
+
wrap,
|
|
293
|
+
wrapMany,
|
|
294
|
+
wrapManyBatched,
|
|
295
|
+
invalidate,
|
|
296
|
+
invalidatePrefix,
|
|
297
|
+
provider,
|
|
298
|
+
};
|
|
213
299
|
}
|