layercache 1.0.0 → 1.0.1

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/dist/index.cjs CHANGED
@@ -27,6 +27,7 @@ __export(index_exports, {
27
27
  PatternMatcher: () => PatternMatcher,
28
28
  RedisInvalidationBus: () => RedisInvalidationBus,
29
29
  RedisLayer: () => RedisLayer,
30
+ RedisSingleFlightCoordinator: () => RedisSingleFlightCoordinator,
30
31
  RedisTagIndex: () => RedisTagIndex,
31
32
  StampedeGuard: () => StampedeGuard,
32
33
  TagIndex: () => TagIndex
@@ -36,6 +37,81 @@ module.exports = __toCommonJS(index_exports);
36
37
  // src/CacheStack.ts
37
38
  var import_node_crypto = require("crypto");
38
39
 
40
+ // src/internal/StoredValue.ts
41
+ function isStoredValueEnvelope(value) {
42
+ return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
43
+ }
44
+ function createStoredValueEnvelope(options) {
45
+ const now = options.now ?? Date.now();
46
+ const freshTtlSeconds = normalizePositiveSeconds(options.freshTtlSeconds);
47
+ const staleWhileRevalidateSeconds = normalizePositiveSeconds(options.staleWhileRevalidateSeconds);
48
+ const staleIfErrorSeconds = normalizePositiveSeconds(options.staleIfErrorSeconds);
49
+ const freshUntil = freshTtlSeconds ? now + freshTtlSeconds * 1e3 : null;
50
+ const staleUntil = freshUntil && staleWhileRevalidateSeconds ? freshUntil + staleWhileRevalidateSeconds * 1e3 : null;
51
+ const errorUntil = freshUntil && staleIfErrorSeconds ? freshUntil + staleIfErrorSeconds * 1e3 : null;
52
+ return {
53
+ __layercache: 1,
54
+ kind: options.kind,
55
+ value: options.value,
56
+ freshUntil,
57
+ staleUntil,
58
+ errorUntil
59
+ };
60
+ }
61
+ function resolveStoredValue(stored, now = Date.now()) {
62
+ if (!isStoredValueEnvelope(stored)) {
63
+ return { state: "fresh", value: stored, stored };
64
+ }
65
+ if (stored.freshUntil === null || stored.freshUntil > now) {
66
+ return { state: "fresh", value: unwrapStoredValue(stored), stored, envelope: stored };
67
+ }
68
+ if (stored.staleUntil !== null && stored.staleUntil > now) {
69
+ return { state: "stale-while-revalidate", value: unwrapStoredValue(stored), stored, envelope: stored };
70
+ }
71
+ if (stored.errorUntil !== null && stored.errorUntil > now) {
72
+ return { state: "stale-if-error", value: unwrapStoredValue(stored), stored, envelope: stored };
73
+ }
74
+ return { state: "expired", value: null, stored, envelope: stored };
75
+ }
76
+ function unwrapStoredValue(stored) {
77
+ if (!isStoredValueEnvelope(stored)) {
78
+ return stored;
79
+ }
80
+ if (stored.kind === "empty") {
81
+ return null;
82
+ }
83
+ return stored.value ?? null;
84
+ }
85
+ function remainingStoredTtlSeconds(stored, now = Date.now()) {
86
+ if (!isStoredValueEnvelope(stored)) {
87
+ return void 0;
88
+ }
89
+ const expiry = maxExpiry(stored);
90
+ if (expiry === null) {
91
+ return void 0;
92
+ }
93
+ const remainingMs = expiry - now;
94
+ if (remainingMs <= 0) {
95
+ return 1;
96
+ }
97
+ return Math.max(1, Math.ceil(remainingMs / 1e3));
98
+ }
99
+ function maxExpiry(stored) {
100
+ const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
101
+ (value) => value !== null
102
+ );
103
+ if (values.length === 0) {
104
+ return null;
105
+ }
106
+ return Math.max(...values);
107
+ }
108
+ function normalizePositiveSeconds(value) {
109
+ if (!value || value <= 0) {
110
+ return void 0;
111
+ }
112
+ return value;
113
+ }
114
+
39
115
  // src/invalidation/PatternMatcher.ts
40
116
  var PatternMatcher = class {
41
117
  static matches(pattern, value) {
@@ -128,6 +204,10 @@ var StampedeGuard = class {
128
204
  };
129
205
 
130
206
  // src/CacheStack.ts
207
+ var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
208
+ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
209
+ var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
210
+ var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
131
211
  var EMPTY_METRICS = () => ({
132
212
  hits: 0,
133
213
  misses: 0,
@@ -135,7 +215,12 @@ var EMPTY_METRICS = () => ({
135
215
  sets: 0,
136
216
  deletes: 0,
137
217
  backfills: 0,
138
- invalidations: 0
218
+ invalidations: 0,
219
+ staleHits: 0,
220
+ refreshes: 0,
221
+ refreshErrors: 0,
222
+ writeFailures: 0,
223
+ singleFlightWaits: 0
139
224
  });
140
225
  var DebugLogger = class {
141
226
  enabled;
@@ -147,7 +232,7 @@ var DebugLogger = class {
147
232
  return;
148
233
  }
149
234
  const suffix = context ? ` ${JSON.stringify(context)}` : "";
150
- console.debug(`[cachestack] ${message}${suffix}`);
235
+ console.debug(`[layercache] ${message}${suffix}`);
151
236
  }
152
237
  };
153
238
  var CacheStack = class {
@@ -157,7 +242,7 @@ var CacheStack = class {
157
242
  if (layers.length === 0) {
158
243
  throw new Error("CacheStack requires at least one cache layer.");
159
244
  }
160
- const debugEnv = process.env.DEBUG?.split(",").includes("cachestack:debug") ?? false;
245
+ const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
161
246
  this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
162
247
  this.tagIndex = options.tagIndex ?? new TagIndex();
163
248
  this.startup = this.initialize();
@@ -171,49 +256,46 @@ var CacheStack = class {
171
256
  unsubscribeInvalidation;
172
257
  logger;
173
258
  tagIndex;
259
+ backgroundRefreshes = /* @__PURE__ */ new Map();
174
260
  async get(key, fetcher, options) {
175
261
  await this.startup;
176
- const hit = await this.getFromLayers(key, options);
262
+ const hit = await this.readFromLayers(key, options, "allow-stale");
177
263
  if (hit.found) {
178
- this.metrics.hits += 1;
179
- return hit.value;
264
+ if (hit.state === "fresh") {
265
+ this.metrics.hits += 1;
266
+ return hit.value;
267
+ }
268
+ if (hit.state === "stale-while-revalidate") {
269
+ this.metrics.hits += 1;
270
+ this.metrics.staleHits += 1;
271
+ if (fetcher) {
272
+ this.scheduleBackgroundRefresh(key, fetcher, options);
273
+ }
274
+ return hit.value;
275
+ }
276
+ if (!fetcher) {
277
+ this.metrics.hits += 1;
278
+ this.metrics.staleHits += 1;
279
+ return hit.value;
280
+ }
281
+ try {
282
+ return await this.fetchWithGuards(key, fetcher, options);
283
+ } catch (error) {
284
+ this.metrics.staleHits += 1;
285
+ this.metrics.refreshErrors += 1;
286
+ this.logger.debug("stale-if-error", { key, error: this.formatError(error) });
287
+ return hit.value;
288
+ }
180
289
  }
181
290
  this.metrics.misses += 1;
182
291
  if (!fetcher) {
183
292
  return null;
184
293
  }
185
- const runFetch = async () => {
186
- const secondHit = await this.getFromLayers(key, options);
187
- if (secondHit.found) {
188
- this.metrics.hits += 1;
189
- return secondHit.value;
190
- }
191
- this.metrics.fetches += 1;
192
- const fetched = await fetcher();
193
- if (fetched === null || fetched === void 0) {
194
- return null;
195
- }
196
- await this.set(key, fetched, options);
197
- return fetched;
198
- };
199
- if (this.options.stampedePrevention === false) {
200
- return runFetch();
201
- }
202
- return this.stampedeGuard.execute(key, runFetch);
294
+ return this.fetchWithGuards(key, fetcher, options);
203
295
  }
204
296
  async set(key, value, options) {
205
297
  await this.startup;
206
- await this.setAcrossLayers(key, value, options);
207
- if (options?.tags) {
208
- await this.tagIndex.track(key, options.tags);
209
- } else {
210
- await this.tagIndex.touch(key);
211
- }
212
- this.metrics.sets += 1;
213
- this.logger.debug("set", { key, tags: options?.tags });
214
- if (this.options.publishSetInvalidation !== false) {
215
- await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
216
- }
298
+ await this.storeEntry(key, "value", value, options);
217
299
  }
218
300
  async delete(key) {
219
301
  await this.startup;
@@ -229,7 +311,48 @@ var CacheStack = class {
229
311
  await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
230
312
  }
231
313
  async mget(entries) {
232
- return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
314
+ if (entries.length === 0) {
315
+ return [];
316
+ }
317
+ const canFastPath = entries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
318
+ if (!canFastPath) {
319
+ return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
320
+ }
321
+ await this.startup;
322
+ const pending = new Set(entries.map((_, index) => index));
323
+ const results = Array(entries.length).fill(null);
324
+ for (const layer of this.layers) {
325
+ const indexes = [...pending];
326
+ if (indexes.length === 0) {
327
+ break;
328
+ }
329
+ const keys = indexes.map((index) => entries[index].key);
330
+ const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
331
+ for (let offset = 0; offset < values.length; offset += 1) {
332
+ const index = indexes[offset];
333
+ const stored = values[offset];
334
+ if (stored === null) {
335
+ continue;
336
+ }
337
+ const resolved = resolveStoredValue(stored);
338
+ if (resolved.state === "expired") {
339
+ await layer.delete(entries[index].key);
340
+ continue;
341
+ }
342
+ await this.tagIndex.touch(entries[index].key);
343
+ await this.backfill(entries[index].key, stored, this.layers.indexOf(layer) - 1, entries[index].options);
344
+ results[index] = resolved.value;
345
+ pending.delete(index);
346
+ this.metrics.hits += 1;
347
+ }
348
+ }
349
+ if (pending.size > 0) {
350
+ for (const index of pending) {
351
+ await this.tagIndex.remove(entries[index].key);
352
+ this.metrics.misses += 1;
353
+ }
354
+ }
355
+ return results;
233
356
  }
234
357
  async mset(entries) {
235
358
  await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.options)));
@@ -255,6 +378,7 @@ var CacheStack = class {
255
378
  async disconnect() {
256
379
  await this.startup;
257
380
  await this.unsubscribeInvalidation?.();
381
+ await Promise.allSettled(this.backgroundRefreshes.values());
258
382
  }
259
383
  async initialize() {
260
384
  if (!this.options.invalidationBus) {
@@ -264,46 +388,225 @@ var CacheStack = class {
264
388
  await this.handleInvalidationMessage(message);
265
389
  });
266
390
  }
267
- async getFromLayers(key, options) {
391
+ async fetchWithGuards(key, fetcher, options) {
392
+ const fetchTask = async () => {
393
+ const secondHit = await this.readFromLayers(key, options, "fresh-only");
394
+ if (secondHit.found) {
395
+ this.metrics.hits += 1;
396
+ return secondHit.value;
397
+ }
398
+ return this.fetchAndPopulate(key, fetcher, options);
399
+ };
400
+ const singleFlightTask = async () => {
401
+ if (!this.options.singleFlightCoordinator) {
402
+ return fetchTask();
403
+ }
404
+ return this.options.singleFlightCoordinator.execute(
405
+ key,
406
+ this.resolveSingleFlightOptions(),
407
+ fetchTask,
408
+ () => this.waitForFreshValue(key, fetcher, options)
409
+ );
410
+ };
411
+ if (this.options.stampedePrevention === false) {
412
+ return singleFlightTask();
413
+ }
414
+ return this.stampedeGuard.execute(key, singleFlightTask);
415
+ }
416
+ async waitForFreshValue(key, fetcher, options) {
417
+ const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
418
+ const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
419
+ const deadline = Date.now() + timeoutMs;
420
+ this.metrics.singleFlightWaits += 1;
421
+ while (Date.now() < deadline) {
422
+ const hit = await this.readFromLayers(key, options, "fresh-only");
423
+ if (hit.found) {
424
+ this.metrics.hits += 1;
425
+ return hit.value;
426
+ }
427
+ await this.sleep(pollIntervalMs);
428
+ }
429
+ return this.fetchAndPopulate(key, fetcher, options);
430
+ }
431
+ async fetchAndPopulate(key, fetcher, options) {
432
+ this.metrics.fetches += 1;
433
+ const fetched = await fetcher();
434
+ if (fetched === null || fetched === void 0) {
435
+ if (!this.shouldNegativeCache(options)) {
436
+ return null;
437
+ }
438
+ await this.storeEntry(key, "empty", null, options);
439
+ return null;
440
+ }
441
+ await this.storeEntry(key, "value", fetched, options);
442
+ return fetched;
443
+ }
444
+ async storeEntry(key, kind, value, options) {
445
+ await this.writeAcrossLayers(key, kind, value, options);
446
+ if (options?.tags) {
447
+ await this.tagIndex.track(key, options.tags);
448
+ } else {
449
+ await this.tagIndex.touch(key);
450
+ }
451
+ this.metrics.sets += 1;
452
+ this.logger.debug("set", { key, kind, tags: options?.tags });
453
+ if (this.options.publishSetInvalidation !== false) {
454
+ await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
455
+ }
456
+ }
457
+ async readFromLayers(key, options, mode) {
458
+ let sawRetainableValue = false;
268
459
  for (let index = 0; index < this.layers.length; index += 1) {
269
460
  const layer = this.layers[index];
270
- const value = await layer.get(key);
271
- if (value === null) {
461
+ const stored = await this.readLayerEntry(layer, key);
462
+ if (stored === null) {
463
+ continue;
464
+ }
465
+ const resolved = resolveStoredValue(stored);
466
+ if (resolved.state === "expired") {
467
+ await layer.delete(key);
468
+ continue;
469
+ }
470
+ sawRetainableValue = true;
471
+ if (mode === "fresh-only" && resolved.state !== "fresh") {
272
472
  continue;
273
473
  }
274
474
  await this.tagIndex.touch(key);
275
- await this.backfill(key, value, index - 1, options);
276
- this.logger.debug("hit", { key, layer: layer.name });
277
- return { found: true, value };
475
+ await this.backfill(key, stored, index - 1, options);
476
+ this.logger.debug("hit", { key, layer: layer.name, state: resolved.state });
477
+ return { found: true, value: resolved.value, stored, state: resolved.state };
478
+ }
479
+ if (!sawRetainableValue) {
480
+ await this.tagIndex.remove(key);
481
+ }
482
+ this.logger.debug("miss", { key, mode });
483
+ return { found: false, value: null, stored: null, state: "miss" };
484
+ }
485
+ async readLayerEntry(layer, key) {
486
+ if (layer.getEntry) {
487
+ return layer.getEntry(key);
278
488
  }
279
- await this.tagIndex.remove(key);
280
- this.logger.debug("miss", { key });
281
- return { found: false, value: null };
489
+ return layer.get(key);
282
490
  }
283
- async backfill(key, value, upToIndex, options) {
491
+ async backfill(key, stored, upToIndex, options) {
284
492
  if (upToIndex < 0) {
285
493
  return;
286
494
  }
287
495
  for (let index = 0; index <= upToIndex; index += 1) {
288
496
  const layer = this.layers[index];
289
- await layer.set(key, value, this.resolveTtl(layer.name, layer.defaultTtl, options?.ttl));
497
+ const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
498
+ await layer.set(key, stored, ttl);
290
499
  this.metrics.backfills += 1;
291
500
  this.logger.debug("backfill", { key, layer: layer.name });
292
501
  }
293
502
  }
294
- async setAcrossLayers(key, value, options) {
295
- await Promise.all(
296
- this.layers.map((layer) => layer.set(key, value, this.resolveTtl(layer.name, layer.defaultTtl, options?.ttl)))
297
- );
503
+ async writeAcrossLayers(key, kind, value, options) {
504
+ const now = Date.now();
505
+ const operations = this.layers.map((layer) => async () => {
506
+ const freshTtl = this.resolveFreshTtl(layer.name, kind, options, layer.defaultTtl);
507
+ const staleWhileRevalidate = this.resolveLayerSeconds(
508
+ layer.name,
509
+ options?.staleWhileRevalidate,
510
+ this.options.staleWhileRevalidate
511
+ );
512
+ const staleIfError = this.resolveLayerSeconds(
513
+ layer.name,
514
+ options?.staleIfError,
515
+ this.options.staleIfError
516
+ );
517
+ const payload = createStoredValueEnvelope({
518
+ kind,
519
+ value,
520
+ freshTtlSeconds: freshTtl,
521
+ staleWhileRevalidateSeconds: staleWhileRevalidate,
522
+ staleIfErrorSeconds: staleIfError,
523
+ now
524
+ });
525
+ const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
526
+ await layer.set(key, payload, ttl);
527
+ });
528
+ await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
529
+ }
530
+ async executeLayerOperations(operations, context) {
531
+ if (this.options.writePolicy !== "best-effort") {
532
+ await Promise.all(operations.map((operation) => operation()));
533
+ return;
534
+ }
535
+ const results = await Promise.allSettled(operations.map((operation) => operation()));
536
+ const failures = results.filter((result) => result.status === "rejected");
537
+ if (failures.length === 0) {
538
+ return;
539
+ }
540
+ this.metrics.writeFailures += failures.length;
541
+ this.logger.debug("write-failure", {
542
+ ...context,
543
+ failures: failures.map((failure) => this.formatError(failure.reason))
544
+ });
545
+ if (failures.length === operations.length) {
546
+ throw new AggregateError(
547
+ failures.map((failure) => failure.reason),
548
+ `${context.action} failed for every cache layer`
549
+ );
550
+ }
551
+ }
552
+ resolveFreshTtl(layerName, kind, options, fallbackTtl) {
553
+ const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
554
+ layerName,
555
+ options?.negativeTtl,
556
+ this.options.negativeTtl,
557
+ this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
558
+ ) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
559
+ const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
560
+ return this.applyJitter(baseTtl, jitter);
561
+ }
562
+ resolveLayerSeconds(layerName, override, globalDefault, fallback) {
563
+ if (override !== void 0) {
564
+ return this.readLayerNumber(layerName, override) ?? fallback;
565
+ }
566
+ if (globalDefault !== void 0) {
567
+ return this.readLayerNumber(layerName, globalDefault) ?? fallback;
568
+ }
569
+ return fallback;
298
570
  }
299
- resolveTtl(layerName, fallbackTtl, ttlOverride) {
300
- if (ttlOverride === void 0) {
301
- return fallbackTtl;
571
+ readLayerNumber(layerName, value) {
572
+ if (typeof value === "number") {
573
+ return value;
302
574
  }
303
- if (typeof ttlOverride === "number") {
304
- return ttlOverride;
575
+ return value[layerName];
576
+ }
577
+ applyJitter(ttl, jitter) {
578
+ if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
579
+ return ttl;
305
580
  }
306
- return ttlOverride[layerName] ?? fallbackTtl;
581
+ const delta = (Math.random() * 2 - 1) * jitter;
582
+ return Math.max(1, Math.round(ttl + delta));
583
+ }
584
+ shouldNegativeCache(options) {
585
+ return options?.negativeCache ?? this.options.negativeCaching ?? false;
586
+ }
587
+ scheduleBackgroundRefresh(key, fetcher, options) {
588
+ if (this.backgroundRefreshes.has(key)) {
589
+ return;
590
+ }
591
+ const refresh = (async () => {
592
+ this.metrics.refreshes += 1;
593
+ try {
594
+ await this.fetchWithGuards(key, fetcher, options);
595
+ } catch (error) {
596
+ this.metrics.refreshErrors += 1;
597
+ this.logger.debug("refresh-error", { key, error: this.formatError(error) });
598
+ } finally {
599
+ this.backgroundRefreshes.delete(key);
600
+ }
601
+ })();
602
+ this.backgroundRefreshes.set(key, refresh);
603
+ }
604
+ resolveSingleFlightOptions() {
605
+ return {
606
+ leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
607
+ waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
608
+ pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
609
+ };
307
610
  }
308
611
  async deleteKeys(keys) {
309
612
  if (keys.length === 0) {
@@ -360,6 +663,15 @@ var CacheStack = class {
360
663
  }
361
664
  }
362
665
  }
666
+ formatError(error) {
667
+ if (error instanceof Error) {
668
+ return error.message;
669
+ }
670
+ return String(error);
671
+ }
672
+ sleep(ms) {
673
+ return new Promise((resolve) => setTimeout(resolve, ms));
674
+ }
363
675
  };
364
676
 
365
677
  // src/invalidation/RedisInvalidationBus.ts
@@ -370,7 +682,7 @@ var RedisInvalidationBus = class {
370
682
  constructor(options) {
371
683
  this.publisher = options.publisher;
372
684
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
373
- this.channel = options.channel ?? "cachestack:invalidation";
685
+ this.channel = options.channel ?? "layercache:invalidation";
374
686
  }
375
687
  async subscribe(handler) {
376
688
  const listener = async (_channel, payload) => {
@@ -396,7 +708,7 @@ var RedisTagIndex = class {
396
708
  scanCount;
397
709
  constructor(options) {
398
710
  this.client = options.client;
399
- this.prefix = options.prefix ?? "cachestack:tag-index";
711
+ this.prefix = options.prefix ?? "layercache:tag-index";
400
712
  this.scanCount = options.scanCount ?? 100;
401
713
  }
402
714
  async touch(key) {
@@ -492,6 +804,10 @@ var MemoryLayer = class {
492
804
  this.maxSize = options.maxSize ?? 1e3;
493
805
  }
494
806
  async get(key) {
807
+ const value = await this.getEntry(key);
808
+ return unwrapStoredValue(value);
809
+ }
810
+ async getEntry(key) {
495
811
  const entry = this.entries.get(key);
496
812
  if (!entry) {
497
813
  return null;
@@ -504,6 +820,13 @@ var MemoryLayer = class {
504
820
  this.entries.set(key, entry);
505
821
  return entry.value;
506
822
  }
823
+ async getMany(keys) {
824
+ const values = [];
825
+ for (const key of keys) {
826
+ values.push(await this.getEntry(key));
827
+ }
828
+ return values;
829
+ }
507
830
  async set(key, value, ttl = this.defaultTtl) {
508
831
  this.entries.delete(key);
509
832
  this.entries.set(key, {
@@ -576,12 +899,36 @@ var RedisLayer = class {
576
899
  this.scanCount = options.scanCount ?? 100;
577
900
  }
578
901
  async get(key) {
902
+ const payload = await this.getEntry(key);
903
+ return unwrapStoredValue(payload);
904
+ }
905
+ async getEntry(key) {
579
906
  const payload = await this.client.getBuffer(this.withPrefix(key));
580
907
  if (payload === null) {
581
908
  return null;
582
909
  }
583
910
  return this.serializer.deserialize(payload);
584
911
  }
912
+ async getMany(keys) {
913
+ if (keys.length === 0) {
914
+ return [];
915
+ }
916
+ const pipeline = this.client.pipeline();
917
+ for (const key of keys) {
918
+ pipeline.getBuffer(this.withPrefix(key));
919
+ }
920
+ const results = await pipeline.exec();
921
+ if (results === null) {
922
+ return keys.map(() => null);
923
+ }
924
+ return results.map((result) => {
925
+ const [, payload] = result;
926
+ if (payload === null) {
927
+ return null;
928
+ }
929
+ return this.serializer.deserialize(payload);
930
+ });
931
+ }
585
932
  async set(key, value, ttl = this.defaultTtl) {
586
933
  const payload = this.serializer.serialize(value);
587
934
  const normalizedKey = this.withPrefix(key);
@@ -643,6 +990,36 @@ var MsgpackSerializer = class {
643
990
  return (0, import_msgpack.decode)(normalized);
644
991
  }
645
992
  };
993
+
994
+ // src/singleflight/RedisSingleFlightCoordinator.ts
995
+ var import_node_crypto2 = require("crypto");
996
+ var RELEASE_SCRIPT = `
997
+ if redis.call("get", KEYS[1]) == ARGV[1] then
998
+ return redis.call("del", KEYS[1])
999
+ end
1000
+ return 0
1001
+ `;
1002
+ var RedisSingleFlightCoordinator = class {
1003
+ client;
1004
+ prefix;
1005
+ constructor(options) {
1006
+ this.client = options.client;
1007
+ this.prefix = options.prefix ?? "layercache:singleflight";
1008
+ }
1009
+ async execute(key, options, worker, waiter) {
1010
+ const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
1011
+ const token = (0, import_node_crypto2.randomUUID)();
1012
+ const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
1013
+ if (acquired === "OK") {
1014
+ try {
1015
+ return await worker();
1016
+ } finally {
1017
+ await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
1018
+ }
1019
+ }
1020
+ return waiter();
1021
+ }
1022
+ };
646
1023
  // Annotate the CommonJS export names for ESM import in node:
647
1024
  0 && (module.exports = {
648
1025
  CacheStack,
@@ -652,6 +1029,7 @@ var MsgpackSerializer = class {
652
1029
  PatternMatcher,
653
1030
  RedisInvalidationBus,
654
1031
  RedisLayer,
1032
+ RedisSingleFlightCoordinator,
655
1033
  RedisTagIndex,
656
1034
  StampedeGuard,
657
1035
  TagIndex