layercache 1.2.8 → 1.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/dist/index.js CHANGED
@@ -440,7 +440,7 @@ function normalizeForSerialization(value) {
440
440
  }
441
441
  function serializeKeyPart(value) {
442
442
  if (typeof value === "string") {
443
- return `s:${value}`;
443
+ return `s:${value.replace(/%/g, "%25").replace(/:/g, "%3A")}`;
444
444
  }
445
445
  if (typeof value === "number") {
446
446
  return `n:${value}`;
@@ -671,6 +671,7 @@ var CacheStackLayerWriter = class {
671
671
  }
672
672
  const results = await Promise.allSettled(operations.map((operation) => operation()));
673
673
  const failures = results.filter((result) => result.status === "rejected");
674
+ const degraded = results.filter((result) => result.status === "fulfilled");
674
675
  if (failures.length === 0) {
675
676
  return;
676
677
  }
@@ -849,6 +850,7 @@ function planFreshReadPolicies({
849
850
  }
850
851
 
851
852
  // src/internal/CacheStackSnapshotManager.ts
853
+ import { randomBytes } from "crypto";
852
854
  import { constants, promises as fs } from "fs";
853
855
  import path from "path";
854
856
 
@@ -948,6 +950,42 @@ async function readUtf8HandleWithLimit(handle, byteLimit) {
948
950
  return Buffer.concat(chunks).toString("utf8");
949
951
  }
950
952
 
953
+ // src/internal/StructuredDataSanitizer.ts
954
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
955
+ function sanitizeStructuredData(value, options) {
956
+ return sanitizeValue(value, 0, { count: 0 }, options);
957
+ }
958
+ function sanitizeValue(value, depth, state, options) {
959
+ state.count += 1;
960
+ if (state.count > options.maxNodes) {
961
+ throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
962
+ }
963
+ if (depth > options.maxDepth) {
964
+ throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
965
+ }
966
+ if (Array.isArray(value)) {
967
+ const sanitized2 = [];
968
+ for (const entry of value) {
969
+ sanitized2.push(sanitizeValue(entry, depth + 1, state, options));
970
+ }
971
+ return sanitized2;
972
+ }
973
+ if (!isPlainObject(value)) {
974
+ return value;
975
+ }
976
+ const sanitized = options.createObject?.() ?? {};
977
+ for (const [key, entry] of Object.entries(value)) {
978
+ if (DANGEROUS_KEYS.has(key)) {
979
+ continue;
980
+ }
981
+ sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
982
+ }
983
+ return sanitized;
984
+ }
985
+ function isPlainObject(value) {
986
+ return Object.prototype.toString.call(value) === "[object Object]";
987
+ }
988
+
951
989
  // src/internal/CacheStackSnapshotManager.ts
952
990
  var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
953
991
  var CacheStackSnapshotManager = class {
@@ -972,7 +1010,16 @@ var CacheStackSnapshotManager = class {
972
1010
  const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
973
1011
  await Promise.all(
974
1012
  batch.map(async (entry) => {
975
- await Promise.all(this.options.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1013
+ await Promise.all(
1014
+ this.options.layers.map(async (layer) => {
1015
+ if (this.options.shouldSkipLayer(layer)) return;
1016
+ try {
1017
+ await layer.set(entry.key, entry.value, entry.ttl);
1018
+ } catch (error) {
1019
+ await this.options.handleLayerFailure(layer, "write", error);
1020
+ }
1021
+ })
1022
+ );
976
1023
  await this.options.tagIndex.touch(entry.key);
977
1024
  })
978
1025
  );
@@ -982,7 +1029,7 @@ var CacheStackSnapshotManager = class {
982
1029
  const targetPath = await validateSnapshotFilePath(filePath, "write", snapshotBaseDir);
983
1030
  const tempPath = path.join(
984
1031
  path.dirname(targetPath),
985
- `.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
1032
+ `.layercache-${process.pid}-${Date.now()}-${randomBytes(8).toString("hex")}.tmp`
986
1033
  );
987
1034
  let handle;
988
1035
  try {
@@ -1082,7 +1129,13 @@ var CacheStackSnapshotManager = class {
1082
1129
  });
1083
1130
  }
1084
1131
  sanitizeSnapshotValue(value) {
1085
- return this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
1132
+ const roundTripped = this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
1133
+ return sanitizeStructuredData(roundTripped, {
1134
+ label: "Snapshot value",
1135
+ maxDepth: 64,
1136
+ maxNodes: 1e4,
1137
+ createObject: () => /* @__PURE__ */ Object.create(null)
1138
+ });
1086
1139
  }
1087
1140
  };
1088
1141
 
@@ -1462,7 +1515,13 @@ var FetchRateLimiter = class {
1462
1515
  this.pendingBuckets.add(next.bucketKey);
1463
1516
  }
1464
1517
  this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
1465
- this.drain();
1518
+ if (!this.drainTimer) {
1519
+ this.drainTimer = setTimeout(() => {
1520
+ this.drainTimer = void 0;
1521
+ this.drain();
1522
+ }, 0);
1523
+ this.drainTimer.unref?.();
1524
+ }
1466
1525
  });
1467
1526
  }
1468
1527
  }
@@ -1504,6 +1563,9 @@ var FetchRateLimiter = class {
1504
1563
  }
1505
1564
  if (this.buckets.size >= MAX_BUCKETS) {
1506
1565
  this.evictIdleBuckets();
1566
+ if (this.buckets.size >= MAX_BUCKETS) {
1567
+ throw new Error(`FetchRateLimiter bucket limit (${MAX_BUCKETS}) exceeded.`);
1568
+ }
1507
1569
  }
1508
1570
  const bucket = { active: 0, startedAt: [] };
1509
1571
  this.buckets.set(bucketKey, bucket);
@@ -1733,38 +1795,6 @@ var TtlResolver = class {
1733
1795
  }
1734
1796
  };
1735
1797
 
1736
- // src/internal/StructuredDataSanitizer.ts
1737
- var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1738
- function sanitizeStructuredData(value, options) {
1739
- return sanitizeValue(value, 0, { count: 0 }, options);
1740
- }
1741
- function sanitizeValue(value, depth, state, options) {
1742
- state.count += 1;
1743
- if (state.count > options.maxNodes) {
1744
- throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
1745
- }
1746
- if (depth > options.maxDepth) {
1747
- throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
1748
- }
1749
- if (Array.isArray(value)) {
1750
- return value.map((entry) => sanitizeValue(entry, depth + 1, state, options));
1751
- }
1752
- if (!isPlainObject(value)) {
1753
- return value;
1754
- }
1755
- const sanitized = options.createObject?.() ?? {};
1756
- for (const [key, entry] of Object.entries(value)) {
1757
- if (DANGEROUS_KEYS.has(key)) {
1758
- continue;
1759
- }
1760
- sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
1761
- }
1762
- return sanitized;
1763
- }
1764
- function isPlainObject(value) {
1765
- return Object.prototype.toString.call(value) === "[object Object]";
1766
- }
1767
-
1768
1798
  // src/serialization/JsonSerializer.ts
1769
1799
  var JsonSerializer = class {
1770
1800
  serialize(value) {
@@ -1781,29 +1811,35 @@ var JsonSerializer = class {
1781
1811
  };
1782
1812
 
1783
1813
  // src/stampede/StampedeGuard.ts
1784
- import { Mutex as Mutex2 } from "async-mutex";
1785
1814
  var StampedeGuard = class {
1786
- mutexes = /* @__PURE__ */ new Map();
1815
+ inFlight = /* @__PURE__ */ new Map();
1787
1816
  async execute(key, task) {
1788
- const entry = this.getMutexEntry(key);
1817
+ const existing = this.inFlight.get(key);
1818
+ if (existing) {
1819
+ existing.references += 1;
1820
+ try {
1821
+ return await existing.promise;
1822
+ } finally {
1823
+ this.releaseEntry(key, existing);
1824
+ }
1825
+ }
1826
+ const entry = {
1827
+ promise: Promise.resolve().then(task),
1828
+ references: 1
1829
+ };
1830
+ this.inFlight.set(key, entry);
1789
1831
  try {
1790
- return await entry.mutex.runExclusive(task);
1832
+ return await entry.promise;
1791
1833
  } finally {
1792
- entry.references -= 1;
1793
- const current = this.mutexes.get(key);
1794
- if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
1795
- this.mutexes.delete(key);
1796
- }
1834
+ this.releaseEntry(key, entry);
1797
1835
  }
1798
1836
  }
1799
- getMutexEntry(key) {
1800
- let entry = this.mutexes.get(key);
1801
- if (!entry) {
1802
- entry = { mutex: new Mutex2(), references: 0 };
1803
- this.mutexes.set(key, entry);
1837
+ releaseEntry(key, entry) {
1838
+ entry.references -= 1;
1839
+ const current = this.inFlight.get(key);
1840
+ if (current === entry && entry.references === 0) {
1841
+ this.inFlight.delete(key);
1804
1842
  }
1805
- entry.references += 1;
1806
- return entry;
1807
1843
  }
1808
1844
  };
1809
1845
 
@@ -1929,6 +1965,8 @@ var CacheStack = class extends EventEmitter {
1929
1965
  tagIndex: this.tagIndex,
1930
1966
  snapshotSerializer: this.snapshotSerializer,
1931
1967
  readLayerEntry: this.readLayerEntry.bind(this),
1968
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
1969
+ handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
1932
1970
  qualifyKey: this.qualifyKey.bind(this),
1933
1971
  stripQualifiedKey: this.stripQualifiedKey.bind(this),
1934
1972
  validateCacheKey,
@@ -1953,6 +1991,7 @@ var CacheStack = class extends EventEmitter {
1953
1991
  layerWriter;
1954
1992
  snapshots;
1955
1993
  backgroundRefreshes = /* @__PURE__ */ new Map();
1994
+ backgroundRefreshAbort = /* @__PURE__ */ new Map();
1956
1995
  layerDegradedUntil = /* @__PURE__ */ new Map();
1957
1996
  maintenance = new CacheStackMaintenance();
1958
1997
  ttlResolver;
@@ -2015,7 +2054,7 @@ var CacheStack = class extends EventEmitter {
2015
2054
  if (!fetcher) {
2016
2055
  return null;
2017
2056
  }
2018
- return this.fetchWithGuards(normalizedKey, fetcher, options);
2057
+ return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
2019
2058
  }
2020
2059
  /**
2021
2060
  * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
@@ -2197,7 +2236,7 @@ var CacheStack = class extends EventEmitter {
2197
2236
  }
2198
2237
  for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
2199
2238
  const layer = this.layers[layerIndex];
2200
- if (!layer) continue;
2239
+ if (!layer || this.shouldSkipLayer(layer)) continue;
2201
2240
  const keys = [...pending];
2202
2241
  if (keys.length === 0) {
2203
2242
  break;
@@ -2214,6 +2253,9 @@ var CacheStack = class extends EventEmitter {
2214
2253
  await layer.delete(key);
2215
2254
  continue;
2216
2255
  }
2256
+ if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
2257
+ this.metricsCollector.increment("staleHits", indexesByKey.get(key)?.length ?? 1);
2258
+ }
2217
2259
  await this.tagIndex.touch(key);
2218
2260
  await this.backfill(key, stored, layerIndex - 1);
2219
2261
  resultsByKey.set(key, resolved.value);
@@ -2469,7 +2511,25 @@ var CacheStack = class extends EventEmitter {
2469
2511
  await this.unsubscribeInvalidation?.();
2470
2512
  await this.flushWriteBehindQueue();
2471
2513
  await this.maintenance.waitForGenerationCleanup();
2472
- await Promise.allSettled([...this.backgroundRefreshes.values()]);
2514
+ for (const key of this.backgroundRefreshAbort.keys()) {
2515
+ this.backgroundRefreshAbort.set(key, true);
2516
+ }
2517
+ await Promise.allSettled(
2518
+ [...this.backgroundRefreshes.values()].map((promise) => {
2519
+ let timer;
2520
+ return Promise.race([
2521
+ promise,
2522
+ new Promise((resolve2) => {
2523
+ timer = setTimeout(resolve2, 5e3);
2524
+ timer.unref?.();
2525
+ })
2526
+ ]).finally(() => {
2527
+ if (timer) clearTimeout(timer);
2528
+ });
2529
+ })
2530
+ );
2531
+ this.backgroundRefreshes.clear();
2532
+ this.backgroundRefreshAbort.clear();
2473
2533
  this.maintenance.disposeWriteBehindTimer();
2474
2534
  this.fetchRateLimiter.dispose();
2475
2535
  await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()));
@@ -2485,12 +2545,15 @@ var CacheStack = class extends EventEmitter {
2485
2545
  await this.handleInvalidationMessage(message);
2486
2546
  });
2487
2547
  }
2488
- async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
2548
+ async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
2489
2549
  const fetchTask = async () => {
2490
- const secondHit = await this.readFromLayers(key, options, "fresh-only");
2491
- if (secondHit.found) {
2492
- this.metricsCollector.increment("hits");
2493
- return secondHit.value;
2550
+ const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
2551
+ if (shouldRecheckFreshLayers) {
2552
+ const secondHit = await this.readFromLayers(key, options, "fresh-only");
2553
+ if (secondHit.found) {
2554
+ this.metricsCollector.increment("hits");
2555
+ return secondHit.value;
2556
+ }
2494
2557
  }
2495
2558
  return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
2496
2559
  };
@@ -2498,12 +2561,22 @@ var CacheStack = class extends EventEmitter {
2498
2561
  if (!this.options.singleFlightCoordinator) {
2499
2562
  return fetchTask();
2500
2563
  }
2501
- return this.options.singleFlightCoordinator.execute(
2502
- key,
2503
- this.resolveSingleFlightOptions(),
2504
- fetchTask,
2505
- () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
2506
- );
2564
+ try {
2565
+ return await this.options.singleFlightCoordinator.execute(
2566
+ key,
2567
+ this.resolveSingleFlightOptions(),
2568
+ fetchTask,
2569
+ () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
2570
+ );
2571
+ } catch (error) {
2572
+ if (!this.isGracefulDegradationEnabled()) {
2573
+ throw error;
2574
+ }
2575
+ this.metricsCollector.increment("degradedOperations");
2576
+ this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
2577
+ this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
2578
+ return fetchTask();
2579
+ }
2507
2580
  };
2508
2581
  if (this.options.stampedePrevention === false) {
2509
2582
  return singleFlightTask();
@@ -2736,15 +2809,19 @@ var CacheStack = class extends EventEmitter {
2736
2809
  }
2737
2810
  const clearEpoch = this.maintenance.currentClearEpoch();
2738
2811
  const keyEpoch = this.maintenance.currentKeyEpoch(key);
2812
+ this.backgroundRefreshAbort.set(key, false);
2739
2813
  const refresh = (async () => {
2740
2814
  this.metricsCollector.increment("refreshes");
2741
2815
  try {
2816
+ if (this.backgroundRefreshAbort.get(key)) return;
2742
2817
  await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch);
2743
2818
  } catch (error) {
2819
+ if (this.backgroundRefreshAbort.get(key)) return;
2744
2820
  this.metricsCollector.increment("refreshErrors");
2745
2821
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
2746
2822
  } finally {
2747
2823
  this.backgroundRefreshes.delete(key);
2824
+ this.backgroundRefreshAbort.delete(key);
2748
2825
  }
2749
2826
  })();
2750
2827
  this.backgroundRefreshes.set(key, refresh);
@@ -2847,7 +2924,7 @@ var CacheStack = class extends EventEmitter {
2847
2924
  timer.unref?.();
2848
2925
  })
2849
2926
  ]);
2850
- if (result && typeof result === "object" && "kind" in result) {
2927
+ if (result !== null && result !== void 0 && typeof result === "object" && "kind" in result) {
2851
2928
  if (result.kind === "error") {
2852
2929
  throw result.error;
2853
2930
  }
@@ -2865,7 +2942,7 @@ var CacheStack = class extends EventEmitter {
2865
2942
  }
2866
2943
  async observeOperation(name, attributes, execute) {
2867
2944
  const id = this.nextOperationId;
2868
- this.nextOperationId += 1;
2945
+ this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER;
2869
2946
  this.emit("operation-start", { id, name, attributes });
2870
2947
  try {
2871
2948
  const result = await execute();
@@ -3101,6 +3178,7 @@ var RedisInvalidationBus = class {
3101
3178
  logger;
3102
3179
  handlers = /* @__PURE__ */ new Set();
3103
3180
  sharedListener;
3181
+ subscribePromise;
3104
3182
  constructor(options) {
3105
3183
  this.publisher = options.publisher;
3106
3184
  this.subscriber = options.subscriber ?? options.publisher.duplicate();
@@ -3108,15 +3186,27 @@ var RedisInvalidationBus = class {
3108
3186
  this.logger = options.logger;
3109
3187
  }
3110
3188
  async subscribe(handler) {
3111
- if (this.handlers.size === 0) {
3112
- const listener = (_channel, payload) => {
3113
- void this.dispatchToHandlers(payload);
3114
- };
3115
- this.sharedListener = listener;
3116
- this.subscriber.on("message", listener);
3117
- await this.subscriber.subscribe(this.channel);
3189
+ const previousPromise = this.subscribePromise;
3190
+ let resolveThis;
3191
+ this.subscribePromise = new Promise((resolve2) => {
3192
+ resolveThis = resolve2;
3193
+ });
3194
+ if (previousPromise) {
3195
+ await previousPromise;
3196
+ }
3197
+ try {
3198
+ if (this.handlers.size === 0) {
3199
+ const listener = (_channel, payload) => {
3200
+ void this.dispatchToHandlers(payload);
3201
+ };
3202
+ this.sharedListener = listener;
3203
+ this.subscriber.on("message", listener);
3204
+ await this.subscriber.subscribe(this.channel);
3205
+ }
3206
+ this.handlers.add(handler);
3207
+ } finally {
3208
+ resolveThis();
3118
3209
  }
3119
- this.handlers.add(handler);
3120
3210
  return async () => {
3121
3211
  this.handlers.delete(handler);
3122
3212
  if (this.handlers.size === 0 && this.sharedListener) {
@@ -3317,10 +3407,21 @@ function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
3317
3407
  }
3318
3408
 
3319
3409
  // src/integrations/opentelemetry.ts
3410
+ var MAX_SPANS = 1e4;
3320
3411
  function createOpenTelemetryPlugin(cache, tracer) {
3321
3412
  const spans = /* @__PURE__ */ new Map();
3322
3413
  const onStart = (event) => {
3323
- spans.set(event.id, tracer.startSpan(event.name, { attributes: event.attributes }));
3414
+ try {
3415
+ if (spans.size >= MAX_SPANS) {
3416
+ const oldest = spans.keys().next().value;
3417
+ if (oldest !== void 0) {
3418
+ spans.get(oldest)?.end();
3419
+ spans.delete(oldest);
3420
+ }
3421
+ }
3422
+ spans.set(event.id, tracer.startSpan(event.name, { attributes: event.attributes }));
3423
+ } catch {
3424
+ }
3324
3425
  };
3325
3426
  const onEnd = (event) => {
3326
3427
  const span = spans.get(event.id);
@@ -3328,12 +3429,15 @@ function createOpenTelemetryPlugin(cache, tracer) {
3328
3429
  return;
3329
3430
  }
3330
3431
  spans.delete(event.id);
3331
- span.setAttribute?.("layercache.success", event.success);
3332
- if (event.result) {
3333
- span.setAttribute?.("layercache.result", event.result);
3334
- }
3335
- if (event.error !== void 0) {
3336
- span.recordException?.(event.error);
3432
+ try {
3433
+ span.setAttribute?.("layercache.success", event.success);
3434
+ if (event.result) {
3435
+ span.setAttribute?.("layercache.result", event.result);
3436
+ }
3437
+ if (event.error !== void 0) {
3438
+ span.recordException?.(event.error);
3439
+ }
3440
+ } catch {
3337
3441
  }
3338
3442
  span.end();
3339
3443
  };
@@ -3400,6 +3504,7 @@ var RedisLayer = class {
3400
3504
  compression;
3401
3505
  compressionThreshold;
3402
3506
  decompressionMaxBytes;
3507
+ commandTimeoutMs;
3403
3508
  disconnectOnDispose;
3404
3509
  constructor(options) {
3405
3510
  this.client = options.client;
@@ -3412,6 +3517,7 @@ var RedisLayer = class {
3412
3517
  this.compression = options.compression;
3413
3518
  this.compressionThreshold = options.compressionThreshold ?? 1024;
3414
3519
  this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
3520
+ this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
3415
3521
  this.disconnectOnDispose = options.disconnectOnDispose ?? false;
3416
3522
  }
3417
3523
  async get(key) {
@@ -3419,7 +3525,7 @@ var RedisLayer = class {
3419
3525
  return unwrapStoredValue(payload);
3420
3526
  }
3421
3527
  async getEntry(key) {
3422
- const payload = await this.client.getBuffer(this.withPrefix(key));
3528
+ const payload = await this.runCommand(`get("${key}")`, () => this.client.getBuffer(this.withPrefix(key)));
3423
3529
  if (payload === null) {
3424
3530
  return null;
3425
3531
  }
@@ -3433,7 +3539,7 @@ var RedisLayer = class {
3433
3539
  for (const key of keys) {
3434
3540
  pipeline.getBuffer(this.withPrefix(key));
3435
3541
  }
3436
- const results = await pipeline.exec();
3542
+ const results = await this.runCommand(`mget(${keys.length})`, () => pipeline.exec());
3437
3543
  if (results === null) {
3438
3544
  return keys.map(() => null);
3439
3545
  }
@@ -3462,33 +3568,36 @@ var RedisLayer = class {
3462
3568
  pipeline.set(normalizedKey, payload);
3463
3569
  }
3464
3570
  }
3465
- await pipeline.exec();
3571
+ await this.runCommand(`mset(${entries.length})`, () => pipeline.exec());
3466
3572
  }
3467
3573
  async set(key, value, ttl = this.defaultTtl) {
3468
3574
  const serialized = this.primarySerializer().serialize(value);
3469
3575
  const payload = await this.encodePayload(serialized);
3470
3576
  const normalizedKey = this.withPrefix(key);
3471
3577
  if (ttl && ttl > 0) {
3472
- await this.client.set(normalizedKey, payload, "EX", ttl);
3578
+ await this.runCommand(`set("${key}")`, () => this.client.set(normalizedKey, payload, "EX", ttl));
3473
3579
  return;
3474
3580
  }
3475
- await this.client.set(normalizedKey, payload);
3581
+ await this.runCommand(`set("${key}")`, () => this.client.set(normalizedKey, payload));
3476
3582
  }
3477
3583
  async delete(key) {
3478
- await this.client.del(this.withPrefix(key));
3584
+ await this.runCommand(`delete("${key}")`, () => this.client.del(this.withPrefix(key)));
3479
3585
  }
3480
3586
  async deleteMany(keys) {
3481
3587
  if (keys.length === 0) {
3482
3588
  return;
3483
3589
  }
3484
- await this.client.del(...keys.map((key) => this.withPrefix(key)));
3590
+ await this.runCommand(
3591
+ `deleteMany(${keys.length})`,
3592
+ () => this.client.del(...keys.map((key) => this.withPrefix(key)))
3593
+ );
3485
3594
  }
3486
3595
  async has(key) {
3487
- const exists = await this.client.exists(this.withPrefix(key));
3596
+ const exists = await this.runCommand(`has("${key}")`, () => this.client.exists(this.withPrefix(key)));
3488
3597
  return exists > 0;
3489
3598
  }
3490
3599
  async ttl(key) {
3491
- const remaining = await this.client.ttl(this.withPrefix(key));
3600
+ const remaining = await this.runCommand(`ttl("${key}")`, () => this.client.ttl(this.withPrefix(key)));
3492
3601
  if (remaining < 0) {
3493
3602
  return null;
3494
3603
  }
@@ -3496,13 +3605,16 @@ var RedisLayer = class {
3496
3605
  }
3497
3606
  async size() {
3498
3607
  if (!this.prefix) {
3499
- return this.client.dbsize();
3608
+ return this.runCommand("dbsize()", () => this.client.dbsize());
3500
3609
  }
3501
3610
  const pattern = `${this.prefix}*`;
3502
3611
  let cursor = "0";
3503
3612
  let count = 0;
3504
3613
  do {
3505
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
3614
+ const [nextCursor, keys] = await this.runCommand(
3615
+ `scan("${pattern}")`,
3616
+ () => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
3617
+ );
3506
3618
  cursor = nextCursor;
3507
3619
  count += keys.length;
3508
3620
  } while (cursor !== "0");
@@ -3510,7 +3622,7 @@ var RedisLayer = class {
3510
3622
  }
3511
3623
  async ping() {
3512
3624
  try {
3513
- return await this.client.ping() === "PONG";
3625
+ return await this.runCommand("ping()", () => this.client.ping()) === "PONG";
3514
3626
  } catch {
3515
3627
  return false;
3516
3628
  }
@@ -3533,14 +3645,17 @@ var RedisLayer = class {
3533
3645
  const pattern = `${this.prefix}*`;
3534
3646
  let cursor = "0";
3535
3647
  do {
3536
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
3648
+ const [nextCursor, keys] = await this.runCommand(
3649
+ `scan("${pattern}")`,
3650
+ () => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
3651
+ );
3537
3652
  cursor = nextCursor;
3538
3653
  if (keys.length === 0) {
3539
3654
  continue;
3540
3655
  }
3541
3656
  for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
3542
3657
  const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
3543
- await this.client.del(...batch);
3658
+ await this.runCommand(`clear-del(${batch.length})`, () => this.client.del(...batch));
3544
3659
  }
3545
3660
  } while (cursor !== "0");
3546
3661
  }
@@ -3556,7 +3671,10 @@ var RedisLayer = class {
3556
3671
  const pattern = `${this.prefix}*`;
3557
3672
  let cursor = "0";
3558
3673
  do {
3559
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
3674
+ const [nextCursor, keys] = await this.runCommand(
3675
+ `scan("${pattern}")`,
3676
+ () => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
3677
+ );
3560
3678
  cursor = nextCursor;
3561
3679
  for (const key of keys) {
3562
3680
  await visitor(this.prefix ? key.slice(this.prefix.length) : key);
@@ -3567,7 +3685,10 @@ var RedisLayer = class {
3567
3685
  const matches = [];
3568
3686
  let cursor = "0";
3569
3687
  do {
3570
- const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
3688
+ const [nextCursor, keys] = await this.runCommand(
3689
+ `scan("${pattern}")`,
3690
+ () => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
3691
+ );
3571
3692
  cursor = nextCursor;
3572
3693
  matches.push(...keys);
3573
3694
  } while (cursor !== "0");
@@ -3599,7 +3720,7 @@ var RedisLayer = class {
3599
3720
  }
3600
3721
  async deleteCorruptedKey(key) {
3601
3722
  try {
3602
- await this.client.del(this.withPrefix(key));
3723
+ await this.runCommand(`deleteCorrupted("${key}")`, () => this.client.del(this.withPrefix(key)));
3603
3724
  } catch (deleteError) {
3604
3725
  console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${key}"`, deleteError);
3605
3726
  }
@@ -3607,12 +3728,15 @@ var RedisLayer = class {
3607
3728
  async rewriteWithPrimarySerializer(key, value) {
3608
3729
  const serialized = this.primarySerializer().serialize(value);
3609
3730
  const payload = await this.encodePayload(serialized);
3610
- const ttl = await this.client.ttl(this.withPrefix(key));
3731
+ const ttl = await this.runCommand(`rewrite-ttl("${key}")`, () => this.client.ttl(this.withPrefix(key)));
3611
3732
  if (ttl > 0) {
3612
- await this.client.set(this.withPrefix(key), payload, "EX", ttl);
3733
+ await this.runCommand(
3734
+ `rewrite-set("${key}")`,
3735
+ () => this.client.set(this.withPrefix(key), payload, "EX", ttl)
3736
+ );
3613
3737
  return;
3614
3738
  }
3615
- await this.client.set(this.withPrefix(key), payload);
3739
+ await this.runCommand(`rewrite-set("${key}")`, () => this.client.set(this.withPrefix(key), payload));
3616
3740
  }
3617
3741
  primarySerializer() {
3618
3742
  const serializer = this.serializers[0];
@@ -3708,10 +3832,39 @@ var RedisLayer = class {
3708
3832
  source.pipe(decompressor);
3709
3833
  });
3710
3834
  }
3835
+ normalizeCommandTimeoutMs(value) {
3836
+ if (value === void 0) {
3837
+ return void 0;
3838
+ }
3839
+ if (!Number.isFinite(value) || value <= 0) {
3840
+ throw new Error("RedisLayer.commandTimeoutMs must be a positive number.");
3841
+ }
3842
+ return value;
3843
+ }
3844
+ async runCommand(operation, command) {
3845
+ const promise = command();
3846
+ if (!this.commandTimeoutMs) {
3847
+ return promise;
3848
+ }
3849
+ let timer;
3850
+ return Promise.race([
3851
+ promise,
3852
+ new Promise((_, reject) => {
3853
+ timer = setTimeout(() => {
3854
+ reject(new Error(`RedisLayer command ${operation} timed out after ${this.commandTimeoutMs}ms.`));
3855
+ }, this.commandTimeoutMs);
3856
+ timer.unref?.();
3857
+ })
3858
+ ]).finally(() => {
3859
+ if (timer) {
3860
+ clearTimeout(timer);
3861
+ }
3862
+ });
3863
+ }
3711
3864
  };
3712
3865
 
3713
3866
  // src/layers/DiskLayer.ts
3714
- import { createHash } from "crypto";
3867
+ import { createHash, randomBytes as randomBytes2 } from "crypto";
3715
3868
  import { promises as fs2 } from "fs";
3716
3869
  import { join, resolve } from "path";
3717
3870
  var FILE_SCAN_CONCURRENCY = 32;
@@ -3764,7 +3917,7 @@ var DiskLayer = class {
3764
3917
  };
3765
3918
  const payload = this.serializer.serialize(entry);
3766
3919
  const targetPath = this.keyToPath(key);
3767
- const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
3920
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes2(8).toString("hex")}.tmp`;
3768
3921
  try {
3769
3922
  await fs2.writeFile(tempPath, payload);
3770
3923
  await fs2.rename(tempPath, targetPath);
@@ -4158,14 +4311,19 @@ return 0
4158
4311
  var RedisSingleFlightCoordinator = class {
4159
4312
  client;
4160
4313
  prefix;
4314
+ commandTimeoutMs;
4161
4315
  constructor(options) {
4162
4316
  this.client = options.client;
4163
4317
  this.prefix = options.prefix ?? "layercache:singleflight";
4318
+ this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
4164
4319
  }
4165
4320
  async execute(key, options, worker, waiter) {
4166
4321
  const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
4167
4322
  const token = randomUUID();
4168
- const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
4323
+ const acquired = await this.runCommand(
4324
+ `acquire("${key}")`,
4325
+ () => this.client.set(lockKey, token, "PX", options.leaseMs, "NX")
4326
+ );
4169
4327
  if (acquired === "OK") {
4170
4328
  const renewTimer = this.startLeaseRenewal(lockKey, token, options);
4171
4329
  try {
@@ -4174,7 +4332,7 @@ var RedisSingleFlightCoordinator = class {
4174
4332
  if (renewTimer) {
4175
4333
  clearInterval(renewTimer);
4176
4334
  }
4177
- await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
4335
+ await this.runCommand(`release("${key}")`, () => this.client.eval(RELEASE_SCRIPT, 1, lockKey, token));
4178
4336
  }
4179
4337
  }
4180
4338
  return waiter();
@@ -4185,11 +4343,45 @@ var RedisSingleFlightCoordinator = class {
4185
4343
  return void 0;
4186
4344
  }
4187
4345
  const timer = setInterval(() => {
4188
- void this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs)).catch(() => void 0);
4346
+ void this.runCommand(
4347
+ `renew("${lockKey}")`,
4348
+ () => this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs))
4349
+ ).catch(() => void 0);
4189
4350
  }, renewIntervalMs);
4190
4351
  timer.unref?.();
4191
4352
  return timer;
4192
4353
  }
4354
+ normalizeCommandTimeoutMs(value) {
4355
+ if (value === void 0) {
4356
+ return void 0;
4357
+ }
4358
+ if (!Number.isFinite(value) || value <= 0) {
4359
+ throw new Error("RedisSingleFlightCoordinator.commandTimeoutMs must be a positive number.");
4360
+ }
4361
+ return value;
4362
+ }
4363
+ async runCommand(operation, command) {
4364
+ const promise = command();
4365
+ if (!this.commandTimeoutMs) {
4366
+ return promise;
4367
+ }
4368
+ let timer;
4369
+ return Promise.race([
4370
+ promise,
4371
+ new Promise((_, reject) => {
4372
+ timer = setTimeout(() => {
4373
+ reject(
4374
+ new Error(`RedisSingleFlightCoordinator command ${operation} timed out after ${this.commandTimeoutMs}ms.`)
4375
+ );
4376
+ }, this.commandTimeoutMs);
4377
+ timer.unref?.();
4378
+ })
4379
+ ]).finally(() => {
4380
+ if (timer) {
4381
+ clearTimeout(timer);
4382
+ }
4383
+ });
4384
+ }
4193
4385
  };
4194
4386
 
4195
4387
  // src/metrics/PrometheusExporter.ts