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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "1.2.8",
3
+ "version": "1.3.0",
4
4
  "description": "Production-ready multi-layer caching for Node.js. Stack memory + Redis + disk behind one API with stampede prevention, tag invalidation, stale serving, and full observability.",
5
5
  "keywords": [
6
6
  "cache",
@@ -74,7 +74,15 @@
74
74
  "lint": "biome check .",
75
75
  "lint:fix": "biome check --write .",
76
76
  "bench:latency": "tsx benchmarks/latency.ts",
77
- "bench:stampede": "tsx benchmarks/stampede.ts"
77
+ "bench:stampede": "tsx benchmarks/stampede.ts",
78
+ "bench:direct": "tsx benchmarks/direct.ts",
79
+ "bench:http": "tsx benchmarks/http.ts",
80
+ "bench:edge": "tsx benchmarks/edge.ts",
81
+ "bench:slow-redis": "tsx benchmarks/slow-redis.ts",
82
+ "bench:memory-pressure": "tsx benchmarks/memory-pressure.ts",
83
+ "bench:queue-amplification": "tsx benchmarks/queue-amplification.ts",
84
+ "bench:multi-process-fanout": "tsx benchmarks/multi-process-fanout.ts",
85
+ "bench:all": "npm run bench:direct && npm run bench:edge && npm run bench:slow-redis && npm run bench:queue-amplification && npm run bench:http && npm run bench:multi-process-fanout"
78
86
  },
79
87
  "dependencies": {
80
88
  "@msgpack/msgpack": "^3.0.0",
@@ -90,8 +98,10 @@
90
98
  "@biomejs/biome": "^1.9.4",
91
99
  "@nestjs/common": "^11.1.0",
92
100
  "@nestjs/core": "^11.1.0",
101
+ "@types/autocannon": "^7.12.7",
93
102
  "@types/node": "^22.15.2",
94
103
  "@vitest/coverage-v8": "^4.1.2",
104
+ "autocannon": "^8.0.0",
95
105
  "ioredis": "^5.6.1",
96
106
  "ioredis-mock": "^8.13.0",
97
107
  "reflect-metadata": "^0.2.2",
@@ -724,7 +724,7 @@ function normalizeForSerialization(value) {
724
724
  }
725
725
  function serializeKeyPart(value) {
726
726
  if (typeof value === "string") {
727
- return `s:${value}`;
727
+ return `s:${value.replace(/%/g, "%25").replace(/:/g, "%3A")}`;
728
728
  }
729
729
  if (typeof value === "number") {
730
730
  return `n:${value}`;
@@ -1113,6 +1113,7 @@ var CacheStackLayerWriter = class {
1113
1113
  }
1114
1114
  const results = await Promise.allSettled(operations.map((operation) => operation()));
1115
1115
  const failures = results.filter((result) => result.status === "rejected");
1116
+ const degraded = results.filter((result) => result.status === "fulfilled");
1116
1117
  if (failures.length === 0) {
1117
1118
  return;
1118
1119
  }
@@ -1291,6 +1292,7 @@ function planFreshReadPolicies({
1291
1292
  }
1292
1293
 
1293
1294
  // ../../src/internal/CacheStackSnapshotManager.ts
1295
+ var import_node_crypto = require("crypto");
1294
1296
  var import_node_fs = require("fs");
1295
1297
  var import_node_path = __toESM(require("path"), 1);
1296
1298
 
@@ -1390,6 +1392,42 @@ async function readUtf8HandleWithLimit(handle, byteLimit) {
1390
1392
  return Buffer.concat(chunks).toString("utf8");
1391
1393
  }
1392
1394
 
1395
+ // ../../src/internal/StructuredDataSanitizer.ts
1396
+ var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
1397
+ function sanitizeStructuredData(value, options) {
1398
+ return sanitizeValue(value, 0, { count: 0 }, options);
1399
+ }
1400
+ function sanitizeValue(value, depth, state, options) {
1401
+ state.count += 1;
1402
+ if (state.count > options.maxNodes) {
1403
+ throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
1404
+ }
1405
+ if (depth > options.maxDepth) {
1406
+ throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
1407
+ }
1408
+ if (Array.isArray(value)) {
1409
+ const sanitized2 = [];
1410
+ for (const entry of value) {
1411
+ sanitized2.push(sanitizeValue(entry, depth + 1, state, options));
1412
+ }
1413
+ return sanitized2;
1414
+ }
1415
+ if (!isPlainObject(value)) {
1416
+ return value;
1417
+ }
1418
+ const sanitized = options.createObject?.() ?? {};
1419
+ for (const [key, entry] of Object.entries(value)) {
1420
+ if (DANGEROUS_KEYS.has(key)) {
1421
+ continue;
1422
+ }
1423
+ sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
1424
+ }
1425
+ return sanitized;
1426
+ }
1427
+ function isPlainObject(value) {
1428
+ return Object.prototype.toString.call(value) === "[object Object]";
1429
+ }
1430
+
1393
1431
  // ../../src/internal/CacheStackSnapshotManager.ts
1394
1432
  var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
1395
1433
  var CacheStackSnapshotManager = class {
@@ -1414,7 +1452,16 @@ var CacheStackSnapshotManager = class {
1414
1452
  const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
1415
1453
  await Promise.all(
1416
1454
  batch.map(async (entry) => {
1417
- await Promise.all(this.options.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
1455
+ await Promise.all(
1456
+ this.options.layers.map(async (layer) => {
1457
+ if (this.options.shouldSkipLayer(layer)) return;
1458
+ try {
1459
+ await layer.set(entry.key, entry.value, entry.ttl);
1460
+ } catch (error) {
1461
+ await this.options.handleLayerFailure(layer, "write", error);
1462
+ }
1463
+ })
1464
+ );
1418
1465
  await this.options.tagIndex.touch(entry.key);
1419
1466
  })
1420
1467
  );
@@ -1424,7 +1471,7 @@ var CacheStackSnapshotManager = class {
1424
1471
  const targetPath = await validateSnapshotFilePath(filePath, "write", snapshotBaseDir);
1425
1472
  const tempPath = import_node_path.default.join(
1426
1473
  import_node_path.default.dirname(targetPath),
1427
- `.layercache-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`
1474
+ `.layercache-${process.pid}-${Date.now()}-${(0, import_node_crypto.randomBytes)(8).toString("hex")}.tmp`
1428
1475
  );
1429
1476
  let handle;
1430
1477
  try {
@@ -1524,7 +1571,13 @@ var CacheStackSnapshotManager = class {
1524
1571
  });
1525
1572
  }
1526
1573
  sanitizeSnapshotValue(value) {
1527
- return this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
1574
+ const roundTripped = this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
1575
+ return sanitizeStructuredData(roundTripped, {
1576
+ label: "Snapshot value",
1577
+ maxDepth: 64,
1578
+ maxNodes: 1e4,
1579
+ createObject: () => /* @__PURE__ */ Object.create(null)
1580
+ });
1528
1581
  }
1529
1582
  };
1530
1583
 
@@ -1904,7 +1957,13 @@ var FetchRateLimiter = class {
1904
1957
  this.pendingBuckets.add(next.bucketKey);
1905
1958
  }
1906
1959
  this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
1907
- this.drain();
1960
+ if (!this.drainTimer) {
1961
+ this.drainTimer = setTimeout(() => {
1962
+ this.drainTimer = void 0;
1963
+ this.drain();
1964
+ }, 0);
1965
+ this.drainTimer.unref?.();
1966
+ }
1908
1967
  });
1909
1968
  }
1910
1969
  }
@@ -1946,6 +2005,9 @@ var FetchRateLimiter = class {
1946
2005
  }
1947
2006
  if (this.buckets.size >= MAX_BUCKETS) {
1948
2007
  this.evictIdleBuckets();
2008
+ if (this.buckets.size >= MAX_BUCKETS) {
2009
+ throw new Error(`FetchRateLimiter bucket limit (${MAX_BUCKETS}) exceeded.`);
2010
+ }
1949
2011
  }
1950
2012
  const bucket = { active: 0, startedAt: [] };
1951
2013
  this.buckets.set(bucketKey, bucket);
@@ -2424,38 +2486,6 @@ var TagIndex = class {
2424
2486
  }
2425
2487
  };
2426
2488
 
2427
- // ../../src/internal/StructuredDataSanitizer.ts
2428
- var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
2429
- function sanitizeStructuredData(value, options) {
2430
- return sanitizeValue(value, 0, { count: 0 }, options);
2431
- }
2432
- function sanitizeValue(value, depth, state, options) {
2433
- state.count += 1;
2434
- if (state.count > options.maxNodes) {
2435
- throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
2436
- }
2437
- if (depth > options.maxDepth) {
2438
- throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
2439
- }
2440
- if (Array.isArray(value)) {
2441
- return value.map((entry) => sanitizeValue(entry, depth + 1, state, options));
2442
- }
2443
- if (!isPlainObject(value)) {
2444
- return value;
2445
- }
2446
- const sanitized = options.createObject?.() ?? {};
2447
- for (const [key, entry] of Object.entries(value)) {
2448
- if (DANGEROUS_KEYS.has(key)) {
2449
- continue;
2450
- }
2451
- sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
2452
- }
2453
- return sanitized;
2454
- }
2455
- function isPlainObject(value) {
2456
- return Object.prototype.toString.call(value) === "[object Object]";
2457
- }
2458
-
2459
2489
  // ../../src/serialization/JsonSerializer.ts
2460
2490
  var JsonSerializer = class {
2461
2491
  serialize(value) {
@@ -2473,27 +2503,34 @@ var JsonSerializer = class {
2473
2503
 
2474
2504
  // ../../src/stampede/StampedeGuard.ts
2475
2505
  var StampedeGuard = class {
2476
- mutexes = /* @__PURE__ */ new Map();
2506
+ inFlight = /* @__PURE__ */ new Map();
2477
2507
  async execute(key, task) {
2478
- const entry = this.getMutexEntry(key);
2508
+ const existing = this.inFlight.get(key);
2509
+ if (existing) {
2510
+ existing.references += 1;
2511
+ try {
2512
+ return await existing.promise;
2513
+ } finally {
2514
+ this.releaseEntry(key, existing);
2515
+ }
2516
+ }
2517
+ const entry = {
2518
+ promise: Promise.resolve().then(task),
2519
+ references: 1
2520
+ };
2521
+ this.inFlight.set(key, entry);
2479
2522
  try {
2480
- return await entry.mutex.runExclusive(task);
2523
+ return await entry.promise;
2481
2524
  } finally {
2482
- entry.references -= 1;
2483
- const current = this.mutexes.get(key);
2484
- if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
2485
- this.mutexes.delete(key);
2486
- }
2525
+ this.releaseEntry(key, entry);
2487
2526
  }
2488
2527
  }
2489
- getMutexEntry(key) {
2490
- let entry = this.mutexes.get(key);
2491
- if (!entry) {
2492
- entry = { mutex: new Mutex(), references: 0 };
2493
- this.mutexes.set(key, entry);
2528
+ releaseEntry(key, entry) {
2529
+ entry.references -= 1;
2530
+ const current = this.inFlight.get(key);
2531
+ if (current === entry && entry.references === 0) {
2532
+ this.inFlight.delete(key);
2494
2533
  }
2495
- entry.references += 1;
2496
- return entry;
2497
2534
  }
2498
2535
  };
2499
2536
 
@@ -2619,6 +2656,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
2619
2656
  tagIndex: this.tagIndex,
2620
2657
  snapshotSerializer: this.snapshotSerializer,
2621
2658
  readLayerEntry: this.readLayerEntry.bind(this),
2659
+ shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
2660
+ handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
2622
2661
  qualifyKey: this.qualifyKey.bind(this),
2623
2662
  stripQualifiedKey: this.stripQualifiedKey.bind(this),
2624
2663
  validateCacheKey,
@@ -2643,6 +2682,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2643
2682
  layerWriter;
2644
2683
  snapshots;
2645
2684
  backgroundRefreshes = /* @__PURE__ */ new Map();
2685
+ backgroundRefreshAbort = /* @__PURE__ */ new Map();
2646
2686
  layerDegradedUntil = /* @__PURE__ */ new Map();
2647
2687
  maintenance = new CacheStackMaintenance();
2648
2688
  ttlResolver;
@@ -2705,7 +2745,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2705
2745
  if (!fetcher) {
2706
2746
  return null;
2707
2747
  }
2708
- return this.fetchWithGuards(normalizedKey, fetcher, options);
2748
+ return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
2709
2749
  }
2710
2750
  /**
2711
2751
  * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
@@ -2887,7 +2927,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2887
2927
  }
2888
2928
  for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
2889
2929
  const layer = this.layers[layerIndex];
2890
- if (!layer) continue;
2930
+ if (!layer || this.shouldSkipLayer(layer)) continue;
2891
2931
  const keys = [...pending];
2892
2932
  if (keys.length === 0) {
2893
2933
  break;
@@ -2904,6 +2944,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
2904
2944
  await layer.delete(key);
2905
2945
  continue;
2906
2946
  }
2947
+ if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
2948
+ this.metricsCollector.increment("staleHits", indexesByKey.get(key)?.length ?? 1);
2949
+ }
2907
2950
  await this.tagIndex.touch(key);
2908
2951
  await this.backfill(key, stored, layerIndex - 1);
2909
2952
  resultsByKey.set(key, resolved.value);
@@ -3159,7 +3202,25 @@ var CacheStack = class extends import_node_events.EventEmitter {
3159
3202
  await this.unsubscribeInvalidation?.();
3160
3203
  await this.flushWriteBehindQueue();
3161
3204
  await this.maintenance.waitForGenerationCleanup();
3162
- await Promise.allSettled([...this.backgroundRefreshes.values()]);
3205
+ for (const key of this.backgroundRefreshAbort.keys()) {
3206
+ this.backgroundRefreshAbort.set(key, true);
3207
+ }
3208
+ await Promise.allSettled(
3209
+ [...this.backgroundRefreshes.values()].map((promise) => {
3210
+ let timer;
3211
+ return Promise.race([
3212
+ promise,
3213
+ new Promise((resolve) => {
3214
+ timer = setTimeout(resolve, 5e3);
3215
+ timer.unref?.();
3216
+ })
3217
+ ]).finally(() => {
3218
+ if (timer) clearTimeout(timer);
3219
+ });
3220
+ })
3221
+ );
3222
+ this.backgroundRefreshes.clear();
3223
+ this.backgroundRefreshAbort.clear();
3163
3224
  this.maintenance.disposeWriteBehindTimer();
3164
3225
  this.fetchRateLimiter.dispose();
3165
3226
  await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()));
@@ -3175,12 +3236,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
3175
3236
  await this.handleInvalidationMessage(message);
3176
3237
  });
3177
3238
  }
3178
- async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
3239
+ async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
3179
3240
  const fetchTask = async () => {
3180
- const secondHit = await this.readFromLayers(key, options, "fresh-only");
3181
- if (secondHit.found) {
3182
- this.metricsCollector.increment("hits");
3183
- return secondHit.value;
3241
+ const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
3242
+ if (shouldRecheckFreshLayers) {
3243
+ const secondHit = await this.readFromLayers(key, options, "fresh-only");
3244
+ if (secondHit.found) {
3245
+ this.metricsCollector.increment("hits");
3246
+ return secondHit.value;
3247
+ }
3184
3248
  }
3185
3249
  return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
3186
3250
  };
@@ -3188,12 +3252,22 @@ var CacheStack = class extends import_node_events.EventEmitter {
3188
3252
  if (!this.options.singleFlightCoordinator) {
3189
3253
  return fetchTask();
3190
3254
  }
3191
- return this.options.singleFlightCoordinator.execute(
3192
- key,
3193
- this.resolveSingleFlightOptions(),
3194
- fetchTask,
3195
- () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3196
- );
3255
+ try {
3256
+ return await this.options.singleFlightCoordinator.execute(
3257
+ key,
3258
+ this.resolveSingleFlightOptions(),
3259
+ fetchTask,
3260
+ () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3261
+ );
3262
+ } catch (error) {
3263
+ if (!this.isGracefulDegradationEnabled()) {
3264
+ throw error;
3265
+ }
3266
+ this.metricsCollector.increment("degradedOperations");
3267
+ this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
3268
+ this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
3269
+ return fetchTask();
3270
+ }
3197
3271
  };
3198
3272
  if (this.options.stampedePrevention === false) {
3199
3273
  return singleFlightTask();
@@ -3426,15 +3500,19 @@ var CacheStack = class extends import_node_events.EventEmitter {
3426
3500
  }
3427
3501
  const clearEpoch = this.maintenance.currentClearEpoch();
3428
3502
  const keyEpoch = this.maintenance.currentKeyEpoch(key);
3503
+ this.backgroundRefreshAbort.set(key, false);
3429
3504
  const refresh = (async () => {
3430
3505
  this.metricsCollector.increment("refreshes");
3431
3506
  try {
3507
+ if (this.backgroundRefreshAbort.get(key)) return;
3432
3508
  await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch);
3433
3509
  } catch (error) {
3510
+ if (this.backgroundRefreshAbort.get(key)) return;
3434
3511
  this.metricsCollector.increment("refreshErrors");
3435
3512
  this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
3436
3513
  } finally {
3437
3514
  this.backgroundRefreshes.delete(key);
3515
+ this.backgroundRefreshAbort.delete(key);
3438
3516
  }
3439
3517
  })();
3440
3518
  this.backgroundRefreshes.set(key, refresh);
@@ -3537,7 +3615,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
3537
3615
  timer.unref?.();
3538
3616
  })
3539
3617
  ]);
3540
- if (result && typeof result === "object" && "kind" in result) {
3618
+ if (result !== null && result !== void 0 && typeof result === "object" && "kind" in result) {
3541
3619
  if (result.kind === "error") {
3542
3620
  throw result.error;
3543
3621
  }
@@ -3555,7 +3633,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
3555
3633
  }
3556
3634
  async observeOperation(name, attributes, execute) {
3557
3635
  const id = this.nextOperationId;
3558
- this.nextOperationId += 1;
3636
+ this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER;
3559
3637
  this.emit("operation-start", { id, name, attributes });
3560
3638
  try {
3561
3639
  const result = await execute();
@@ -443,6 +443,7 @@ declare class CacheStack extends EventEmitter {
443
443
  private readonly layerWriter;
444
444
  private readonly snapshots;
445
445
  private readonly backgroundRefreshes;
446
+ private readonly backgroundRefreshAbort;
446
447
  private readonly layerDegradedUntil;
447
448
  private readonly maintenance;
448
449
  private readonly ttlResolver;
@@ -443,6 +443,7 @@ declare class CacheStack extends EventEmitter {
443
443
  private readonly layerWriter;
444
444
  private readonly snapshots;
445
445
  private readonly backgroundRefreshes;
446
+ private readonly backgroundRefreshAbort;
446
447
  private readonly layerDegradedUntil;
447
448
  private readonly maintenance;
448
449
  private readonly ttlResolver;