@checkstack/cache-utils 0.2.24 → 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 CHANGED
@@ -1,5 +1,21 @@
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
+
3
19
  ## 0.2.24
4
20
 
5
21
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/cache-utils",
3
- "version": "0.2.24",
3
+ "version": "0.3.0",
4
4
  "license": "Elastic-2.0",
5
5
  "checkstack": {
6
6
  "type": "tooling"
@@ -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();
@@ -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 { wrap, wrapMany, invalidate, invalidatePrefix, provider };
291
+ return {
292
+ wrap,
293
+ wrapMany,
294
+ wrapManyBatched,
295
+ invalidate,
296
+ invalidatePrefix,
297
+ provider,
298
+ };
213
299
  }