layercache 1.2.9 → 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
@@ -1811,29 +1811,35 @@ var JsonSerializer = class {
1811
1811
  };
1812
1812
 
1813
1813
  // src/stampede/StampedeGuard.ts
1814
- import { Mutex as Mutex2 } from "async-mutex";
1815
1814
  var StampedeGuard = class {
1816
- mutexes = /* @__PURE__ */ new Map();
1815
+ inFlight = /* @__PURE__ */ new Map();
1817
1816
  async execute(key, task) {
1818
- 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);
1819
1831
  try {
1820
- return await entry.mutex.runExclusive(task);
1832
+ return await entry.promise;
1821
1833
  } finally {
1822
- entry.references -= 1;
1823
- const current = this.mutexes.get(key);
1824
- if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
1825
- this.mutexes.delete(key);
1826
- }
1834
+ this.releaseEntry(key, entry);
1827
1835
  }
1828
1836
  }
1829
- getMutexEntry(key) {
1830
- let entry = this.mutexes.get(key);
1831
- if (!entry) {
1832
- entry = { mutex: new Mutex2(), references: 0 };
1833
- 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);
1834
1842
  }
1835
- entry.references += 1;
1836
- return entry;
1837
1843
  }
1838
1844
  };
1839
1845
 
@@ -2048,7 +2054,7 @@ var CacheStack = class extends EventEmitter {
2048
2054
  if (!fetcher) {
2049
2055
  return null;
2050
2056
  }
2051
- return this.fetchWithGuards(normalizedKey, fetcher, options);
2057
+ return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
2052
2058
  }
2053
2059
  /**
2054
2060
  * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
@@ -2539,12 +2545,15 @@ var CacheStack = class extends EventEmitter {
2539
2545
  await this.handleInvalidationMessage(message);
2540
2546
  });
2541
2547
  }
2542
- async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
2548
+ async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
2543
2549
  const fetchTask = async () => {
2544
- const secondHit = await this.readFromLayers(key, options, "fresh-only");
2545
- if (secondHit.found) {
2546
- this.metricsCollector.increment("hits");
2547
- 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
+ }
2548
2557
  }
2549
2558
  return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
2550
2559
  };
@@ -2552,12 +2561,22 @@ var CacheStack = class extends EventEmitter {
2552
2561
  if (!this.options.singleFlightCoordinator) {
2553
2562
  return fetchTask();
2554
2563
  }
2555
- return this.options.singleFlightCoordinator.execute(
2556
- key,
2557
- this.resolveSingleFlightOptions(),
2558
- fetchTask,
2559
- () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
2560
- );
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
+ }
2561
2580
  };
2562
2581
  if (this.options.stampedePrevention === false) {
2563
2582
  return singleFlightTask();
@@ -3485,6 +3504,7 @@ var RedisLayer = class {
3485
3504
  compression;
3486
3505
  compressionThreshold;
3487
3506
  decompressionMaxBytes;
3507
+ commandTimeoutMs;
3488
3508
  disconnectOnDispose;
3489
3509
  constructor(options) {
3490
3510
  this.client = options.client;
@@ -3497,6 +3517,7 @@ var RedisLayer = class {
3497
3517
  this.compression = options.compression;
3498
3518
  this.compressionThreshold = options.compressionThreshold ?? 1024;
3499
3519
  this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
3520
+ this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
3500
3521
  this.disconnectOnDispose = options.disconnectOnDispose ?? false;
3501
3522
  }
3502
3523
  async get(key) {
@@ -3504,7 +3525,7 @@ var RedisLayer = class {
3504
3525
  return unwrapStoredValue(payload);
3505
3526
  }
3506
3527
  async getEntry(key) {
3507
- const payload = await this.client.getBuffer(this.withPrefix(key));
3528
+ const payload = await this.runCommand(`get("${key}")`, () => this.client.getBuffer(this.withPrefix(key)));
3508
3529
  if (payload === null) {
3509
3530
  return null;
3510
3531
  }
@@ -3518,7 +3539,7 @@ var RedisLayer = class {
3518
3539
  for (const key of keys) {
3519
3540
  pipeline.getBuffer(this.withPrefix(key));
3520
3541
  }
3521
- const results = await pipeline.exec();
3542
+ const results = await this.runCommand(`mget(${keys.length})`, () => pipeline.exec());
3522
3543
  if (results === null) {
3523
3544
  return keys.map(() => null);
3524
3545
  }
@@ -3547,33 +3568,36 @@ var RedisLayer = class {
3547
3568
  pipeline.set(normalizedKey, payload);
3548
3569
  }
3549
3570
  }
3550
- await pipeline.exec();
3571
+ await this.runCommand(`mset(${entries.length})`, () => pipeline.exec());
3551
3572
  }
3552
3573
  async set(key, value, ttl = this.defaultTtl) {
3553
3574
  const serialized = this.primarySerializer().serialize(value);
3554
3575
  const payload = await this.encodePayload(serialized);
3555
3576
  const normalizedKey = this.withPrefix(key);
3556
3577
  if (ttl && ttl > 0) {
3557
- await this.client.set(normalizedKey, payload, "EX", ttl);
3578
+ await this.runCommand(`set("${key}")`, () => this.client.set(normalizedKey, payload, "EX", ttl));
3558
3579
  return;
3559
3580
  }
3560
- await this.client.set(normalizedKey, payload);
3581
+ await this.runCommand(`set("${key}")`, () => this.client.set(normalizedKey, payload));
3561
3582
  }
3562
3583
  async delete(key) {
3563
- await this.client.del(this.withPrefix(key));
3584
+ await this.runCommand(`delete("${key}")`, () => this.client.del(this.withPrefix(key)));
3564
3585
  }
3565
3586
  async deleteMany(keys) {
3566
3587
  if (keys.length === 0) {
3567
3588
  return;
3568
3589
  }
3569
- 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
+ );
3570
3594
  }
3571
3595
  async has(key) {
3572
- const exists = await this.client.exists(this.withPrefix(key));
3596
+ const exists = await this.runCommand(`has("${key}")`, () => this.client.exists(this.withPrefix(key)));
3573
3597
  return exists > 0;
3574
3598
  }
3575
3599
  async ttl(key) {
3576
- const remaining = await this.client.ttl(this.withPrefix(key));
3600
+ const remaining = await this.runCommand(`ttl("${key}")`, () => this.client.ttl(this.withPrefix(key)));
3577
3601
  if (remaining < 0) {
3578
3602
  return null;
3579
3603
  }
@@ -3581,13 +3605,16 @@ var RedisLayer = class {
3581
3605
  }
3582
3606
  async size() {
3583
3607
  if (!this.prefix) {
3584
- return this.client.dbsize();
3608
+ return this.runCommand("dbsize()", () => this.client.dbsize());
3585
3609
  }
3586
3610
  const pattern = `${this.prefix}*`;
3587
3611
  let cursor = "0";
3588
3612
  let count = 0;
3589
3613
  do {
3590
- 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
+ );
3591
3618
  cursor = nextCursor;
3592
3619
  count += keys.length;
3593
3620
  } while (cursor !== "0");
@@ -3595,7 +3622,7 @@ var RedisLayer = class {
3595
3622
  }
3596
3623
  async ping() {
3597
3624
  try {
3598
- return await this.client.ping() === "PONG";
3625
+ return await this.runCommand("ping()", () => this.client.ping()) === "PONG";
3599
3626
  } catch {
3600
3627
  return false;
3601
3628
  }
@@ -3618,14 +3645,17 @@ var RedisLayer = class {
3618
3645
  const pattern = `${this.prefix}*`;
3619
3646
  let cursor = "0";
3620
3647
  do {
3621
- 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
+ );
3622
3652
  cursor = nextCursor;
3623
3653
  if (keys.length === 0) {
3624
3654
  continue;
3625
3655
  }
3626
3656
  for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
3627
3657
  const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
3628
- await this.client.del(...batch);
3658
+ await this.runCommand(`clear-del(${batch.length})`, () => this.client.del(...batch));
3629
3659
  }
3630
3660
  } while (cursor !== "0");
3631
3661
  }
@@ -3641,7 +3671,10 @@ var RedisLayer = class {
3641
3671
  const pattern = `${this.prefix}*`;
3642
3672
  let cursor = "0";
3643
3673
  do {
3644
- 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
+ );
3645
3678
  cursor = nextCursor;
3646
3679
  for (const key of keys) {
3647
3680
  await visitor(this.prefix ? key.slice(this.prefix.length) : key);
@@ -3652,7 +3685,10 @@ var RedisLayer = class {
3652
3685
  const matches = [];
3653
3686
  let cursor = "0";
3654
3687
  do {
3655
- 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
+ );
3656
3692
  cursor = nextCursor;
3657
3693
  matches.push(...keys);
3658
3694
  } while (cursor !== "0");
@@ -3684,7 +3720,7 @@ var RedisLayer = class {
3684
3720
  }
3685
3721
  async deleteCorruptedKey(key) {
3686
3722
  try {
3687
- await this.client.del(this.withPrefix(key));
3723
+ await this.runCommand(`deleteCorrupted("${key}")`, () => this.client.del(this.withPrefix(key)));
3688
3724
  } catch (deleteError) {
3689
3725
  console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${key}"`, deleteError);
3690
3726
  }
@@ -3692,12 +3728,15 @@ var RedisLayer = class {
3692
3728
  async rewriteWithPrimarySerializer(key, value) {
3693
3729
  const serialized = this.primarySerializer().serialize(value);
3694
3730
  const payload = await this.encodePayload(serialized);
3695
- const ttl = await this.client.ttl(this.withPrefix(key));
3731
+ const ttl = await this.runCommand(`rewrite-ttl("${key}")`, () => this.client.ttl(this.withPrefix(key)));
3696
3732
  if (ttl > 0) {
3697
- 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
+ );
3698
3737
  return;
3699
3738
  }
3700
- await this.client.set(this.withPrefix(key), payload);
3739
+ await this.runCommand(`rewrite-set("${key}")`, () => this.client.set(this.withPrefix(key), payload));
3701
3740
  }
3702
3741
  primarySerializer() {
3703
3742
  const serializer = this.serializers[0];
@@ -3793,6 +3832,35 @@ var RedisLayer = class {
3793
3832
  source.pipe(decompressor);
3794
3833
  });
3795
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
+ }
3796
3864
  };
3797
3865
 
3798
3866
  // src/layers/DiskLayer.ts
@@ -4243,14 +4311,19 @@ return 0
4243
4311
  var RedisSingleFlightCoordinator = class {
4244
4312
  client;
4245
4313
  prefix;
4314
+ commandTimeoutMs;
4246
4315
  constructor(options) {
4247
4316
  this.client = options.client;
4248
4317
  this.prefix = options.prefix ?? "layercache:singleflight";
4318
+ this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
4249
4319
  }
4250
4320
  async execute(key, options, worker, waiter) {
4251
4321
  const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
4252
4322
  const token = randomUUID();
4253
- 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
+ );
4254
4327
  if (acquired === "OK") {
4255
4328
  const renewTimer = this.startLeaseRenewal(lockKey, token, options);
4256
4329
  try {
@@ -4259,7 +4332,7 @@ var RedisSingleFlightCoordinator = class {
4259
4332
  if (renewTimer) {
4260
4333
  clearInterval(renewTimer);
4261
4334
  }
4262
- await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
4335
+ await this.runCommand(`release("${key}")`, () => this.client.eval(RELEASE_SCRIPT, 1, lockKey, token));
4263
4336
  }
4264
4337
  }
4265
4338
  return waiter();
@@ -4270,11 +4343,45 @@ var RedisSingleFlightCoordinator = class {
4270
4343
  return void 0;
4271
4344
  }
4272
4345
  const timer = setInterval(() => {
4273
- 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);
4274
4350
  }, renewIntervalMs);
4275
4351
  timer.unref?.();
4276
4352
  return timer;
4277
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
+ }
4278
4385
  };
4279
4386
 
4280
4387
  // src/metrics/PrometheusExporter.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "1.2.9",
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",
@@ -2503,27 +2503,34 @@ var JsonSerializer = class {
2503
2503
 
2504
2504
  // ../../src/stampede/StampedeGuard.ts
2505
2505
  var StampedeGuard = class {
2506
- mutexes = /* @__PURE__ */ new Map();
2506
+ inFlight = /* @__PURE__ */ new Map();
2507
2507
  async execute(key, task) {
2508
- 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);
2509
2522
  try {
2510
- return await entry.mutex.runExclusive(task);
2523
+ return await entry.promise;
2511
2524
  } finally {
2512
- entry.references -= 1;
2513
- const current = this.mutexes.get(key);
2514
- if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
2515
- this.mutexes.delete(key);
2516
- }
2525
+ this.releaseEntry(key, entry);
2517
2526
  }
2518
2527
  }
2519
- getMutexEntry(key) {
2520
- let entry = this.mutexes.get(key);
2521
- if (!entry) {
2522
- entry = { mutex: new Mutex(), references: 0 };
2523
- 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);
2524
2533
  }
2525
- entry.references += 1;
2526
- return entry;
2527
2534
  }
2528
2535
  };
2529
2536
 
@@ -2738,7 +2745,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
2738
2745
  if (!fetcher) {
2739
2746
  return null;
2740
2747
  }
2741
- return this.fetchWithGuards(normalizedKey, fetcher, options);
2748
+ return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
2742
2749
  }
2743
2750
  /**
2744
2751
  * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
@@ -3229,12 +3236,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
3229
3236
  await this.handleInvalidationMessage(message);
3230
3237
  });
3231
3238
  }
3232
- async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
3239
+ async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
3233
3240
  const fetchTask = async () => {
3234
- const secondHit = await this.readFromLayers(key, options, "fresh-only");
3235
- if (secondHit.found) {
3236
- this.metricsCollector.increment("hits");
3237
- 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
+ }
3238
3248
  }
3239
3249
  return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
3240
3250
  };
@@ -3242,12 +3252,22 @@ var CacheStack = class extends import_node_events.EventEmitter {
3242
3252
  if (!this.options.singleFlightCoordinator) {
3243
3253
  return fetchTask();
3244
3254
  }
3245
- return this.options.singleFlightCoordinator.execute(
3246
- key,
3247
- this.resolveSingleFlightOptions(),
3248
- fetchTask,
3249
- () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3250
- );
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
+ }
3251
3271
  };
3252
3272
  if (this.options.stampedePrevention === false) {
3253
3273
  return singleFlightTask();
@@ -2467,27 +2467,34 @@ var JsonSerializer = class {
2467
2467
 
2468
2468
  // ../../src/stampede/StampedeGuard.ts
2469
2469
  var StampedeGuard = class {
2470
- mutexes = /* @__PURE__ */ new Map();
2470
+ inFlight = /* @__PURE__ */ new Map();
2471
2471
  async execute(key, task) {
2472
- const entry = this.getMutexEntry(key);
2472
+ const existing = this.inFlight.get(key);
2473
+ if (existing) {
2474
+ existing.references += 1;
2475
+ try {
2476
+ return await existing.promise;
2477
+ } finally {
2478
+ this.releaseEntry(key, existing);
2479
+ }
2480
+ }
2481
+ const entry = {
2482
+ promise: Promise.resolve().then(task),
2483
+ references: 1
2484
+ };
2485
+ this.inFlight.set(key, entry);
2473
2486
  try {
2474
- return await entry.mutex.runExclusive(task);
2487
+ return await entry.promise;
2475
2488
  } finally {
2476
- entry.references -= 1;
2477
- const current = this.mutexes.get(key);
2478
- if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
2479
- this.mutexes.delete(key);
2480
- }
2489
+ this.releaseEntry(key, entry);
2481
2490
  }
2482
2491
  }
2483
- getMutexEntry(key) {
2484
- let entry = this.mutexes.get(key);
2485
- if (!entry) {
2486
- entry = { mutex: new Mutex(), references: 0 };
2487
- this.mutexes.set(key, entry);
2492
+ releaseEntry(key, entry) {
2493
+ entry.references -= 1;
2494
+ const current = this.inFlight.get(key);
2495
+ if (current === entry && entry.references === 0) {
2496
+ this.inFlight.delete(key);
2488
2497
  }
2489
- entry.references += 1;
2490
- return entry;
2491
2498
  }
2492
2499
  };
2493
2500
 
@@ -2702,7 +2709,7 @@ var CacheStack = class extends EventEmitter {
2702
2709
  if (!fetcher) {
2703
2710
  return null;
2704
2711
  }
2705
- return this.fetchWithGuards(normalizedKey, fetcher, options);
2712
+ return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
2706
2713
  }
2707
2714
  /**
2708
2715
  * Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
@@ -3193,12 +3200,15 @@ var CacheStack = class extends EventEmitter {
3193
3200
  await this.handleInvalidationMessage(message);
3194
3201
  });
3195
3202
  }
3196
- async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
3203
+ async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
3197
3204
  const fetchTask = async () => {
3198
- const secondHit = await this.readFromLayers(key, options, "fresh-only");
3199
- if (secondHit.found) {
3200
- this.metricsCollector.increment("hits");
3201
- return secondHit.value;
3205
+ const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
3206
+ if (shouldRecheckFreshLayers) {
3207
+ const secondHit = await this.readFromLayers(key, options, "fresh-only");
3208
+ if (secondHit.found) {
3209
+ this.metricsCollector.increment("hits");
3210
+ return secondHit.value;
3211
+ }
3202
3212
  }
3203
3213
  return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
3204
3214
  };
@@ -3206,12 +3216,22 @@ var CacheStack = class extends EventEmitter {
3206
3216
  if (!this.options.singleFlightCoordinator) {
3207
3217
  return fetchTask();
3208
3218
  }
3209
- return this.options.singleFlightCoordinator.execute(
3210
- key,
3211
- this.resolveSingleFlightOptions(),
3212
- fetchTask,
3213
- () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3214
- );
3219
+ try {
3220
+ return await this.options.singleFlightCoordinator.execute(
3221
+ key,
3222
+ this.resolveSingleFlightOptions(),
3223
+ fetchTask,
3224
+ () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
3225
+ );
3226
+ } catch (error) {
3227
+ if (!this.isGracefulDegradationEnabled()) {
3228
+ throw error;
3229
+ }
3230
+ this.metricsCollector.increment("degradedOperations");
3231
+ this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
3232
+ this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
3233
+ return fetchTask();
3234
+ }
3215
3235
  };
3216
3236
  if (this.options.stampedePrevention === false) {
3217
3237
  return singleFlightTask();