layercache 3.0.0 → 3.1.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.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-BDyuPmIq.cjs';
2
- export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheContextOptionsContext, n as CacheDegradationOptions, o as CacheEntryWriteKind, p as CacheEntryWriteOptions, q as CacheFetcher, r as CacheFetcherContext, s as CacheHealthCheckResult, t as CacheHitRateSnapshot, u as CacheInspectResult, v as CacheLayerLatency, w as CacheMGetEntry, x as CacheMSetEntry, y as CacheMetricsSnapshot, z as CacheMissError, A as CacheNamespace, B as CacheRateLimitOptions, D as CacheSnapshotEntry, E as CacheStackEvents, F as CacheStackOptions, G as CacheStatsSnapshot, H as CacheTtlPolicy, J as CacheTtlPolicyContext, K as CacheWarmEntry, L as CacheWarmOptions, M as CacheWarmProgress, N as CacheWriteBehindOptions, O as CacheWriteOptions, P as EvictionPolicy, Q as LayerTtlMap, R as MemoryLayer, S as MemoryLayerOptions, T as MemoryLayerSnapshotEntry, U as PatternMatcher, V as TagIndex, W as createHonoCacheMiddleware } from './edge-BDyuPmIq.cjs';
1
+ import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-LBUuZAdr.cjs';
2
+ export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheContextOptionsContext, n as CacheDegradationOptions, o as CacheEntryResult, p as CacheEntryWriteKind, q as CacheEntryWriteOptions, r as CacheFetcher, s as CacheFetcherContext, t as CacheHealthCheckResult, u as CacheHitRateSnapshot, v as CacheInspectResult, w as CacheLayerLatency, x as CacheMGetEntry, y as CacheMSetEntry, z as CacheMetricsSnapshot, A as CacheMissError, B as CacheNamespace, D as CacheRateLimitOptions, E as CacheSnapshotEntry, F as CacheStackEvents, G as CacheStackOptions, H as CacheStatsSnapshot, J as CacheTtlPolicy, K as CacheTtlPolicyContext, L as CacheWarmEntry, M as CacheWarmOptions, N as CacheWarmProgress, O as CacheWriteBehindOptions, P as CacheWriteOptions, Q as EvictionPolicy, R as LayerTtlMap, S as MemoryLayer, T as MemoryLayerOptions, U as MemoryLayerSnapshotEntry, V as PatternMatcher, W as TagIndex, X as createHonoCacheMiddleware } from './edge-LBUuZAdr.cjs';
3
3
  import Redis from 'ioredis';
4
4
  import 'node:events';
5
5
 
@@ -480,6 +480,11 @@ interface DiskLayerOptions {
480
480
  * Set to `false` to disable the limit.
481
481
  */
482
482
  maxEntryBytes?: number | false;
483
+ /**
484
+ * Maximum pending write operations allowed in the serialized write queue.
485
+ * Defaults to 10,000. Set to `false` to disable the guard.
486
+ */
487
+ maxWriteQueueDepth?: number | false;
483
488
  /**
484
489
  * Encrypt cached data at rest using AES-256-GCM. Accepts a string or Buffer.
485
490
  * The key material is hashed with SHA-256 to derive the actual cipher key.
@@ -513,8 +518,10 @@ declare class DiskLayer implements CacheLayer {
513
518
  private readonly serializer;
514
519
  private readonly maxFiles;
515
520
  private readonly maxEntryBytes;
521
+ private readonly maxWriteQueueDepth;
516
522
  private readonly protection;
517
523
  private writeQueue;
524
+ private writeQueueDepth;
518
525
  /**
519
526
  * Creates a disk-backed cache layer.
520
527
  */
@@ -584,6 +591,7 @@ declare class DiskLayer implements CacheLayer {
584
591
  private resolveDirectory;
585
592
  private normalizeMaxFiles;
586
593
  private normalizeMaxEntryBytes;
594
+ private normalizeMaxWriteQueueDepth;
587
595
  private readEntryFile;
588
596
  private readHandleWithLimit;
589
597
  private scanEntries;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-BDyuPmIq.js';
2
- export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheContextOptionsContext, n as CacheDegradationOptions, o as CacheEntryWriteKind, p as CacheEntryWriteOptions, q as CacheFetcher, r as CacheFetcherContext, s as CacheHealthCheckResult, t as CacheHitRateSnapshot, u as CacheInspectResult, v as CacheLayerLatency, w as CacheMGetEntry, x as CacheMSetEntry, y as CacheMetricsSnapshot, z as CacheMissError, A as CacheNamespace, B as CacheRateLimitOptions, D as CacheSnapshotEntry, E as CacheStackEvents, F as CacheStackOptions, G as CacheStatsSnapshot, H as CacheTtlPolicy, J as CacheTtlPolicyContext, K as CacheWarmEntry, L as CacheWarmOptions, M as CacheWarmProgress, N as CacheWriteBehindOptions, O as CacheWriteOptions, P as EvictionPolicy, Q as LayerTtlMap, R as MemoryLayer, S as MemoryLayerOptions, T as MemoryLayerSnapshotEntry, U as PatternMatcher, V as TagIndex, W as createHonoCacheMiddleware } from './edge-BDyuPmIq.js';
1
+ import { I as InvalidationBus, C as CacheLogger, a as InvalidationMessage, b as CacheTagIndex, c as CacheStack, d as CacheWrapOptions, e as CacheGetOptions, f as CacheLayer, g as CacheSerializer, h as CacheLayerSetManyEntry, i as CacheSingleFlightCoordinator, j as CacheSingleFlightExecutionOptions } from './edge-LBUuZAdr.js';
2
+ export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheContextOptionsContext, n as CacheDegradationOptions, o as CacheEntryResult, p as CacheEntryWriteKind, q as CacheEntryWriteOptions, r as CacheFetcher, s as CacheFetcherContext, t as CacheHealthCheckResult, u as CacheHitRateSnapshot, v as CacheInspectResult, w as CacheLayerLatency, x as CacheMGetEntry, y as CacheMSetEntry, z as CacheMetricsSnapshot, A as CacheMissError, B as CacheNamespace, D as CacheRateLimitOptions, E as CacheSnapshotEntry, F as CacheStackEvents, G as CacheStackOptions, H as CacheStatsSnapshot, J as CacheTtlPolicy, K as CacheTtlPolicyContext, L as CacheWarmEntry, M as CacheWarmOptions, N as CacheWarmProgress, O as CacheWriteBehindOptions, P as CacheWriteOptions, Q as EvictionPolicy, R as LayerTtlMap, S as MemoryLayer, T as MemoryLayerOptions, U as MemoryLayerSnapshotEntry, V as PatternMatcher, W as TagIndex, X as createHonoCacheMiddleware } from './edge-LBUuZAdr.js';
3
3
  import Redis from 'ioredis';
4
4
  import 'node:events';
5
5
 
@@ -480,6 +480,11 @@ interface DiskLayerOptions {
480
480
  * Set to `false` to disable the limit.
481
481
  */
482
482
  maxEntryBytes?: number | false;
483
+ /**
484
+ * Maximum pending write operations allowed in the serialized write queue.
485
+ * Defaults to 10,000. Set to `false` to disable the guard.
486
+ */
487
+ maxWriteQueueDepth?: number | false;
483
488
  /**
484
489
  * Encrypt cached data at rest using AES-256-GCM. Accepts a string or Buffer.
485
490
  * The key material is hashed with SHA-256 to derive the actual cipher key.
@@ -513,8 +518,10 @@ declare class DiskLayer implements CacheLayer {
513
518
  private readonly serializer;
514
519
  private readonly maxFiles;
515
520
  private readonly maxEntryBytes;
521
+ private readonly maxWriteQueueDepth;
516
522
  private readonly protection;
517
523
  private writeQueue;
524
+ private writeQueueDepth;
518
525
  /**
519
526
  * Creates a disk-backed cache layer.
520
527
  */
@@ -584,6 +591,7 @@ declare class DiskLayer implements CacheLayer {
584
591
  private resolveDirectory;
585
592
  private normalizeMaxFiles;
586
593
  private normalizeMaxEntryBytes;
594
+ private normalizeMaxWriteQueueDepth;
587
595
  private readEntryFile;
588
596
  private readHandleWithLimit;
589
597
  private scanEntries;
package/dist/index.js CHANGED
@@ -12,13 +12,13 @@ import {
12
12
  validateTag,
13
13
  validateTags,
14
14
  validateTtlPolicy
15
- } from "./chunk-NBMG7DHT.js";
15
+ } from "./chunk-L6L7QXYF.js";
16
16
  import {
17
17
  MemoryLayer,
18
18
  TagIndex,
19
19
  createHonoCacheMiddleware,
20
20
  normalizeHttpCacheUrl
21
- } from "./chunk-5CIBABDH.js";
21
+ } from "./chunk-XMUT66SH.js";
22
22
  import {
23
23
  PatternMatcher,
24
24
  createStoredValueEnvelope,
@@ -71,39 +71,6 @@ function cloneNamespaceMetrics(metrics) {
71
71
  )
72
72
  };
73
73
  }
74
- function diffNamespaceMetrics(before, after) {
75
- const latencyByLayer = Object.fromEntries(
76
- Object.entries(after.latencyByLayer).map(([layer, value]) => [
77
- layer,
78
- {
79
- avgMs: value.avgMs,
80
- maxMs: value.maxMs,
81
- count: Math.max(0, value.count - (before.latencyByLayer[layer]?.count ?? 0))
82
- }
83
- ])
84
- );
85
- return {
86
- hits: after.hits - before.hits,
87
- misses: after.misses - before.misses,
88
- fetches: after.fetches - before.fetches,
89
- sets: after.sets - before.sets,
90
- deletes: after.deletes - before.deletes,
91
- backfills: after.backfills - before.backfills,
92
- invalidations: after.invalidations - before.invalidations,
93
- staleHits: after.staleHits - before.staleHits,
94
- refreshes: after.refreshes - before.refreshes,
95
- refreshErrors: after.refreshErrors - before.refreshErrors,
96
- writeFailures: after.writeFailures - before.writeFailures,
97
- singleFlightWaits: after.singleFlightWaits - before.singleFlightWaits,
98
- negativeCacheHits: after.negativeCacheHits - before.negativeCacheHits,
99
- circuitBreakerTrips: after.circuitBreakerTrips - before.circuitBreakerTrips,
100
- degradedOperations: after.degradedOperations - before.degradedOperations,
101
- hitsByLayer: diffMetricMap(before.hitsByLayer, after.hitsByLayer),
102
- missesByLayer: diffMetricMap(before.missesByLayer, after.missesByLayer),
103
- latencyByLayer,
104
- resetAt: after.resetAt
105
- };
106
- }
107
74
  function addNamespaceMetrics(base, delta) {
108
75
  return {
109
76
  hits: base.hits + delta.hits,
@@ -139,14 +106,6 @@ function computeNamespaceHitRate(metrics) {
139
106
  }
140
107
  return { overall, byLayer };
141
108
  }
142
- function diffMetricMap(before, after) {
143
- const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
144
- const result = {};
145
- for (const key of keys) {
146
- result[key] = (after[key] ?? 0) - (before[key] ?? 0);
147
- }
148
- return result;
149
- }
150
109
  function addMetricMap(base, delta) {
151
110
  const keys = /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(delta)]);
152
111
  const result = {};
@@ -183,6 +142,20 @@ var CacheNamespace = class _CacheNamespace {
183
142
  async getOrSet(key, fetcher, options) {
184
143
  return this.trackMetrics(() => this.cache.getOrSet(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
185
144
  }
145
+ /**
146
+ * Returns a namespaced cache entry, or `null` on miss.
147
+ * Unlike `get()`, this distinguishes a stored `null` value from an absent key.
148
+ */
149
+ async getEntry(key) {
150
+ const entry = await this.trackMetrics(() => this.cache.getEntry(this.qualify(key)));
151
+ if (entry === null) {
152
+ return null;
153
+ }
154
+ return {
155
+ ...entry,
156
+ key
157
+ };
158
+ }
186
159
  /**
187
160
  * Like `get()`, but throws `CacheMissError` instead of returning `null`.
188
161
  */
@@ -417,13 +390,24 @@ var CacheNamespace = class _CacheNamespace {
417
390
  };
418
391
  }
419
392
  async trackMetrics(operation) {
420
- return this.getMetricsMutex().runExclusive(async () => {
421
- const before = this.cache.getMetrics();
422
- const result = await operation();
423
- const after = this.cache.getMetrics();
424
- this.metrics = addNamespaceMetrics(this.metrics, diffNamespaceMetrics(before, after));
425
- return result;
393
+ let result;
394
+ let metrics;
395
+ try {
396
+ ;
397
+ ({ result, metrics } = await this.cache.captureMetrics(operation));
398
+ } catch (error) {
399
+ const capturedMetrics = error.metrics;
400
+ if (capturedMetrics) {
401
+ await this.getMetricsMutex().runExclusive(() => {
402
+ this.metrics = addNamespaceMetrics(this.metrics, capturedMetrics);
403
+ });
404
+ }
405
+ throw error;
406
+ }
407
+ await this.getMetricsMutex().runExclusive(() => {
408
+ this.metrics = addNamespaceMetrics(this.metrics, metrics);
426
409
  });
410
+ return result;
427
411
  }
428
412
  getMetricsMutex() {
429
413
  const existing = _CacheNamespace.metricsMutexes.get(this.cache);
@@ -1027,6 +1011,9 @@ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
1027
1011
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
1028
1012
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1029
1013
  var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
1014
+ var SINGLE_FLIGHT_BACKOFF_FACTOR = 2;
1015
+ var SINGLE_FLIGHT_BACKOFF_JITTER = 0.2;
1016
+ var SINGLE_FLIGHT_MAX_POLL_MS = 1e3;
1030
1017
  var CacheStackReader = class {
1031
1018
  constructor(options) {
1032
1019
  this.options = options;
@@ -1250,6 +1237,7 @@ var CacheStackReader = class {
1250
1237
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
1251
1238
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
1252
1239
  const deadline = Date.now() + timeoutMs;
1240
+ let nextPollMs = pollIntervalMs;
1253
1241
  this.options.metricsCollector.increment("singleFlightWaits");
1254
1242
  this.options.emit("stampede-dedupe", { key });
1255
1243
  while (Date.now() < deadline) {
@@ -1258,7 +1246,13 @@ var CacheStackReader = class {
1258
1246
  this.options.metricsCollector.increment("hits");
1259
1247
  return hit.value;
1260
1248
  }
1261
- await this.options.sleep(pollIntervalMs);
1249
+ const remainingMs = deadline - Date.now();
1250
+ if (remainingMs <= 0) {
1251
+ break;
1252
+ }
1253
+ const delayMs = Math.min(this.jitterSingleFlightPoll(nextPollMs), remainingMs);
1254
+ await this.options.sleep(delayMs);
1255
+ nextPollMs = Math.min(nextPollMs * SINGLE_FLIGHT_BACKOFF_FACTOR, SINGLE_FLIGHT_MAX_POLL_MS, timeoutMs);
1262
1256
  }
1263
1257
  if (!this.options.singleFlightCoordinator) {
1264
1258
  return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext);
@@ -1270,12 +1264,18 @@ var CacheStackReader = class {
1270
1264
  () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext)
1271
1265
  );
1272
1266
  }
1267
+ jitterSingleFlightPoll(delayMs) {
1268
+ const jitterRange = delayMs * SINGLE_FLIGHT_BACKOFF_JITTER;
1269
+ return Math.max(1, Math.round(delayMs - jitterRange + Math.random() * jitterRange * 2));
1270
+ }
1273
1271
  async fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext = {
1274
1272
  key,
1275
1273
  currentValue: void 0,
1276
1274
  state: "miss"
1277
1275
  }) {
1278
- this.options.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1276
+ const circuitBreakerOptions = options?.circuitBreaker ?? this.options.circuitBreaker;
1277
+ const breakerKey = this.resolveCircuitBreakerKey(key, circuitBreakerOptions);
1278
+ this.options.circuitBreakerManager.assertClosed(breakerKey, circuitBreakerOptions);
1279
1279
  this.options.metricsCollector.increment("fetches");
1280
1280
  const fetchStart = Date.now();
1281
1281
  let fetched;
@@ -1285,13 +1285,13 @@ var CacheStackReader = class {
1285
1285
  { key, fetcher },
1286
1286
  () => fetcher(fetcherContext)
1287
1287
  );
1288
- this.options.circuitBreakerManager.recordSuccess(key);
1288
+ this.options.circuitBreakerManager.recordSuccess(breakerKey);
1289
1289
  this.options.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
1290
1290
  } catch (error) {
1291
- this.options.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
1291
+ this.options.recordCircuitFailure(key, breakerKey, circuitBreakerOptions, error);
1292
1292
  throw error;
1293
1293
  }
1294
- if (fetched === null || fetched === void 0) {
1294
+ if (fetched === void 0 || fetched === null && !this.shouldCacheNullValues(options)) {
1295
1295
  if (!this.shouldNegativeCache(options)) {
1296
1296
  return null;
1297
1297
  }
@@ -1333,6 +1333,18 @@ var CacheStackReader = class {
1333
1333
  await this.options.storeEntry(key, "value", fetched, options);
1334
1334
  return fetched;
1335
1335
  }
1336
+ resolveCircuitBreakerKey(key, options) {
1337
+ if (!options) {
1338
+ return `key:${key}`;
1339
+ }
1340
+ if (options.breakerKey) {
1341
+ return `custom:${options.breakerKey}`;
1342
+ }
1343
+ if (options.scope === "shared") {
1344
+ return "scope:shared";
1345
+ }
1346
+ return `key:${key}`;
1347
+ }
1336
1348
  runScheduleBackgroundRefresh(key, fetcher, options, fetcherContext) {
1337
1349
  this.scheduleBackgroundRefresh(key, fetcher, options, fetcherContext);
1338
1350
  }
@@ -1433,6 +1445,9 @@ var CacheStackReader = class {
1433
1445
  shouldNegativeCache(options) {
1434
1446
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
1435
1447
  }
1448
+ shouldCacheNullValues(options) {
1449
+ return options?.cacheNullValues ?? this.options.cacheNullValues ?? false;
1450
+ }
1436
1451
  isNegativeStoredValue(stored) {
1437
1452
  return isStoredValueEnvelope(stored) && stored.kind === "empty";
1438
1453
  }
@@ -1834,6 +1849,7 @@ var CircuitBreakerManager = class {
1834
1849
  // src/internal/FetchRateLimiter.ts
1835
1850
  var MAX_BUCKETS = 1e4;
1836
1851
  var MAX_QUEUE_PER_BUCKET = 1e4;
1852
+ var DEFAULT_QUEUE_OVERFLOW_POLICY = "reject";
1837
1853
  var FetchRateLimiter = class {
1838
1854
  buckets = /* @__PURE__ */ new Map();
1839
1855
  queuesByBucket = /* @__PURE__ */ new Map();
@@ -1858,15 +1874,24 @@ var FetchRateLimiter = class {
1858
1874
  const bucketKey = this.resolveBucketKey(normalized, context);
1859
1875
  const queue = this.queuesByBucket.get(bucketKey) ?? [];
1860
1876
  if (queue.length >= MAX_QUEUE_PER_BUCKET) {
1861
- this.rateLimitBypasses += 1;
1862
- task().then(resolve2, reject);
1877
+ if ((normalized.queueOverflow ?? DEFAULT_QUEUE_OVERFLOW_POLICY) === "bypass") {
1878
+ this.rateLimitBypasses += 1;
1879
+ task().then(resolve2, reject);
1880
+ return;
1881
+ }
1882
+ reject(new Error(`FetchRateLimiter queue overflow for bucket "${bucketKey}".`));
1863
1883
  return;
1864
1884
  }
1865
1885
  queue.push({
1866
1886
  bucketKey,
1867
1887
  options: normalized,
1868
- task,
1869
- resolve: resolve2,
1888
+ run: async () => {
1889
+ try {
1890
+ resolve2(await task());
1891
+ } catch (error) {
1892
+ reject(error);
1893
+ }
1894
+ },
1870
1895
  reject
1871
1896
  });
1872
1897
  this.queuesByBucket.set(bucketKey, queue);
@@ -1907,7 +1932,8 @@ var FetchRateLimiter = class {
1907
1932
  intervalMs,
1908
1933
  maxPerInterval,
1909
1934
  scope: options.scope ?? "global",
1910
- bucketKey: options.bucketKey
1935
+ bucketKey: options.bucketKey,
1936
+ queueOverflow: options.queueOverflow
1911
1937
  };
1912
1938
  }
1913
1939
  resolveBucketKey(options, context) {
@@ -1990,7 +2016,7 @@ var FetchRateLimiter = class {
1990
2016
  if (next.options.intervalMs && next.options.maxPerInterval) {
1991
2017
  bucket.startedAt.push(Date.now());
1992
2018
  }
1993
- void next.task().then(next.resolve, next.reject).finally(() => {
2019
+ void next.run().finally(() => {
1994
2020
  bucket.active -= 1;
1995
2021
  if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
1996
2022
  this.pendingBuckets.add(next.bucketKey);
@@ -2092,7 +2118,9 @@ var FetchRateLimiter = class {
2092
2118
  };
2093
2119
 
2094
2120
  // src/internal/MetricsCollector.ts
2121
+ import { AsyncLocalStorage } from "async_hooks";
2095
2122
  var MetricsCollector = class {
2123
+ captures = new AsyncLocalStorage();
2096
2124
  data = this.empty();
2097
2125
  get snapshot() {
2098
2126
  return {
@@ -2105,18 +2133,46 @@ var MetricsCollector = class {
2105
2133
  increment(field, amount = 1) {
2106
2134
  ;
2107
2135
  this.data[field] += amount;
2136
+ for (const capture of this.captures.getStore() ?? []) {
2137
+ ;
2138
+ capture[field] += amount;
2139
+ }
2108
2140
  }
2109
2141
  incrementLayer(map, layerName) {
2110
2142
  this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
2143
+ for (const capture of this.captures.getStore() ?? []) {
2144
+ capture[map][layerName] = (capture[map][layerName] ?? 0) + 1;
2145
+ }
2111
2146
  }
2112
2147
  /**
2113
2148
  * Records a read latency sample for the given layer.
2114
2149
  * Maintains a rolling average and max using Welford's online algorithm.
2115
2150
  */
2116
2151
  recordLatency(layerName, durationMs) {
2117
- const existing = this.data.latencyByLayer[layerName];
2152
+ this.recordLatencySample(this.data, layerName, durationMs);
2153
+ for (const capture of this.captures.getStore() ?? []) {
2154
+ this.recordLatencySample(capture, layerName, durationMs);
2155
+ }
2156
+ }
2157
+ async capture(operation) {
2158
+ const metrics = this.empty();
2159
+ const activeCaptures = this.captures.getStore();
2160
+ const captures = activeCaptures ? [...activeCaptures, metrics] : [metrics];
2161
+ try {
2162
+ const result = await this.captures.run(captures, operation);
2163
+ return { result, metrics };
2164
+ } catch (error) {
2165
+ if ((typeof error === "object" || typeof error === "function") && error !== null) {
2166
+ ;
2167
+ error.metrics = metrics;
2168
+ }
2169
+ throw error;
2170
+ }
2171
+ }
2172
+ recordLatencySample(metrics, layerName, durationMs) {
2173
+ const existing = metrics.latencyByLayer[layerName];
2118
2174
  if (!existing) {
2119
- this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
2175
+ metrics.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
2120
2176
  return;
2121
2177
  }
2122
2178
  existing.count += 1;
@@ -2529,7 +2585,7 @@ var CacheStack = class extends EventEmitter {
2529
2585
  emitError: (operation, context) => this.emitError(operation, context),
2530
2586
  formatError: (error) => this.formatError(error),
2531
2587
  storeEntry: (key, kind, value, options2) => this.storeEntry(key, kind, value, options2),
2532
- recordCircuitFailure: (key, options2, error) => this.recordCircuitFailure(key, options2, error),
2588
+ recordCircuitFailure: (key, breakerKey, options2, error) => this.recordCircuitFailure(key, breakerKey, options2, error),
2533
2589
  resolveLayerMs: (layerName, override, globalDefault, fallback) => this.resolveLayerMs(layerName, override, globalDefault, fallback),
2534
2590
  sleep: (ms) => this.sleep(ms),
2535
2591
  withTimeout: (promise, ms, createError) => this.withTimeout(promise, ms, createError),
@@ -2544,6 +2600,7 @@ var CacheStack = class extends EventEmitter {
2544
2600
  singleFlightRenewIntervalMs: options.singleFlightRenewIntervalMs,
2545
2601
  backgroundRefreshTimeoutMs: options.backgroundRefreshTimeoutMs,
2546
2602
  negativeCaching: options.negativeCaching,
2603
+ cacheNullValues: options.cacheNullValues,
2547
2604
  refreshAhead: options.refreshAhead,
2548
2605
  circuitBreaker: options.circuitBreaker,
2549
2606
  fetcherRateLimit: options.fetcherRateLimit
@@ -2596,6 +2653,64 @@ var CacheStack = class extends EventEmitter {
2596
2653
  async getOrSet(key, fetcher, options) {
2597
2654
  return this.get(key, fetcher, options);
2598
2655
  }
2656
+ /**
2657
+ * Returns a discriminated cache entry, or `null` on miss.
2658
+ * Unlike `get()`, this distinguishes a stored `null` value from an absent key.
2659
+ */
2660
+ async getEntry(key) {
2661
+ return this.observeOperation("layercache.get_entry", { "layercache.key": String(key ?? "") }, async () => {
2662
+ const userKey = validateCacheKey(key);
2663
+ const normalizedKey = this.qualifyKey(userKey);
2664
+ await this.awaitStartup("getEntry");
2665
+ let sawRetainableValue = false;
2666
+ for (let index = 0; index < this.layers.length; index += 1) {
2667
+ const layer = this.layers[index];
2668
+ if (!layer || this.shouldSkipLayer(layer)) {
2669
+ continue;
2670
+ }
2671
+ const readStart = performance.now();
2672
+ const stored = await this.readLayerEntry(layer, normalizedKey);
2673
+ this.metricsCollector.recordLatency(layer.name, performance.now() - readStart);
2674
+ if (stored === null) {
2675
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
2676
+ continue;
2677
+ }
2678
+ const resolved = resolveStoredValue(stored);
2679
+ if (resolved.state === "expired") {
2680
+ await layer.delete(normalizedKey);
2681
+ continue;
2682
+ }
2683
+ sawRetainableValue = true;
2684
+ await this.tagIndex.touch(normalizedKey);
2685
+ await this.reader.backfill(normalizedKey, stored, index - 1);
2686
+ this.metricsCollector.increment("hits");
2687
+ if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
2688
+ this.metricsCollector.increment("staleHits");
2689
+ }
2690
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
2691
+ this.logger.debug?.("hit", { key: normalizedKey, layer: layer.name, state: resolved.state });
2692
+ this.emit("hit", {
2693
+ key: normalizedKey,
2694
+ layer: layer.name,
2695
+ state: resolved.state
2696
+ });
2697
+ return {
2698
+ key: userKey,
2699
+ value: resolved.value,
2700
+ kind: resolved.envelope?.kind ?? "value",
2701
+ state: resolved.state,
2702
+ layer: layer.name
2703
+ };
2704
+ }
2705
+ if (!sawRetainableValue) {
2706
+ await this.tagIndex.remove(normalizedKey);
2707
+ }
2708
+ this.metricsCollector.increment("misses");
2709
+ this.logger.debug?.("miss", { key: normalizedKey, mode: "getEntry" });
2710
+ this.emit("miss", { key: normalizedKey, mode: "getEntry" });
2711
+ return null;
2712
+ });
2713
+ }
2599
2714
  /**
2600
2715
  * Like `get()`, but throws `CacheMissError` instead of returning `null`.
2601
2716
  * Useful when the value is expected to exist or the fetcher is expected to
@@ -3060,6 +3175,13 @@ var CacheStack = class extends EventEmitter {
3060
3175
  getMetrics() {
3061
3176
  return this.metricsCollector.snapshot;
3062
3177
  }
3178
+ /**
3179
+ * Runs an operation while collecting only the metrics emitted by its async context.
3180
+ * Used by namespaces so metrics tracking does not serialize the operation itself.
3181
+ */
3182
+ async captureMetrics(operation) {
3183
+ return this.metricsCollector.capture(operation);
3184
+ }
3063
3185
  /**
3064
3186
  * Returns metrics plus layer degradation state and active background refresh count.
3065
3187
  */
@@ -3363,7 +3485,7 @@ var CacheStack = class extends EventEmitter {
3363
3485
  for (const key of keys) {
3364
3486
  await this.tagIndex.remove(key);
3365
3487
  this.ttlResolver.deleteProfile(key);
3366
- this.circuitBreakerManager.delete(key);
3488
+ this.circuitBreakerManager.delete(`key:${key}`);
3367
3489
  }
3368
3490
  this.metricsCollector.increment("deletes", keys.length);
3369
3491
  this.metricsCollector.increment("invalidations");
@@ -3382,7 +3504,7 @@ var CacheStack = class extends EventEmitter {
3382
3504
  }
3383
3505
  await this.tagIndex.remove(key);
3384
3506
  this.ttlResolver.deleteProfile(key);
3385
- this.circuitBreakerManager.delete(key);
3507
+ this.circuitBreakerManager.delete(`key:${key}`);
3386
3508
  }
3387
3509
  this.metricsCollector.increment("invalidations");
3388
3510
  this.logger.debug?.("expire", { keys });
@@ -3424,7 +3546,7 @@ var CacheStack = class extends EventEmitter {
3424
3546
  for (const key of keys) {
3425
3547
  await this.tagIndex.remove(key);
3426
3548
  this.ttlResolver.deleteProfile(key);
3427
- this.circuitBreakerManager.delete(key);
3549
+ this.circuitBreakerManager.delete(`key:${key}`);
3428
3550
  }
3429
3551
  }
3430
3552
  }
@@ -3669,15 +3791,15 @@ var CacheStack = class extends EventEmitter {
3669
3791
  isGracefulDegradationEnabled() {
3670
3792
  return Boolean(this.options.gracefulDegradation);
3671
3793
  }
3672
- recordCircuitFailure(key, options, error) {
3794
+ recordCircuitFailure(key, breakerKey, options, error) {
3673
3795
  if (!options) {
3674
3796
  return;
3675
3797
  }
3676
- this.circuitBreakerManager.recordFailure(key, options);
3677
- if (this.circuitBreakerManager.isOpen(key)) {
3798
+ this.circuitBreakerManager.recordFailure(breakerKey, options);
3799
+ if (this.circuitBreakerManager.isOpen(breakerKey)) {
3678
3800
  this.metricsCollector.increment("circuitBreakerTrips");
3679
3801
  }
3680
- this.emitError("fetch", { key, error: this.formatError(error) });
3802
+ this.emitError("fetch", { key, breakerKey, error: this.formatError(error) });
3681
3803
  }
3682
3804
  emitError(operation, context) {
3683
3805
  this.logger.error?.(operation, context);
@@ -4712,6 +4834,7 @@ var PayloadProtectionError = class extends Error {
4712
4834
 
4713
4835
  // src/layers/DiskLayer.ts
4714
4836
  var FILE_SCAN_CONCURRENCY = 32;
4837
+ var DEFAULT_MAX_WRITE_QUEUE_DEPTH = 1e4;
4715
4838
  var DiskLayer = class {
4716
4839
  name;
4717
4840
  defaultTtl;
@@ -4720,8 +4843,10 @@ var DiskLayer = class {
4720
4843
  serializer;
4721
4844
  maxFiles;
4722
4845
  maxEntryBytes;
4846
+ maxWriteQueueDepth;
4723
4847
  protection;
4724
4848
  writeQueue = Promise.resolve();
4849
+ writeQueueDepth = 0;
4725
4850
  /**
4726
4851
  * Creates a disk-backed cache layer.
4727
4852
  */
@@ -4732,6 +4857,7 @@ var DiskLayer = class {
4732
4857
  this.serializer = options.serializer ?? new JsonSerializer();
4733
4858
  this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
4734
4859
  this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
4860
+ this.maxWriteQueueDepth = this.normalizeMaxWriteQueueDepth(options.maxWriteQueueDepth);
4735
4861
  this.protection = new PayloadProtection({
4736
4862
  encryptionKey: options.encryptionKey,
4737
4863
  signingKey: options.signingKey
@@ -4947,6 +5073,16 @@ var DiskLayer = class {
4947
5073
  }
4948
5074
  return normalized;
4949
5075
  }
5076
+ normalizeMaxWriteQueueDepth(maxWriteQueueDepth) {
5077
+ if (maxWriteQueueDepth === false) {
5078
+ return false;
5079
+ }
5080
+ const normalized = maxWriteQueueDepth ?? DEFAULT_MAX_WRITE_QUEUE_DEPTH;
5081
+ if (!Number.isInteger(normalized) || normalized <= 0) {
5082
+ throw new Error("DiskLayer.maxWriteQueueDepth must be a positive integer or false.");
5083
+ }
5084
+ return normalized;
5085
+ }
4950
5086
  async readEntryFile(filePath) {
4951
5087
  let handle;
4952
5088
  try {
@@ -5059,7 +5195,13 @@ var DiskLayer = class {
5059
5195
  }
5060
5196
  }
5061
5197
  enqueueWrite(operation) {
5062
- const next = this.writeQueue.then(operation, operation);
5198
+ if (this.maxWriteQueueDepth !== false && this.writeQueueDepth >= this.maxWriteQueueDepth) {
5199
+ return Promise.reject(new Error(`DiskLayer write queue limit (${this.maxWriteQueueDepth}) exceeded.`));
5200
+ }
5201
+ this.writeQueueDepth += 1;
5202
+ const next = this.writeQueue.then(operation, operation).finally(() => {
5203
+ this.writeQueueDepth -= 1;
5204
+ });
5063
5205
  this.writeQueue = next.catch(() => void 0);
5064
5206
  return next;
5065
5207
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "3.0.0",
3
+ "version": "3.1.1",
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",
@@ -97,8 +97,11 @@
97
97
  "ioredis": "^5.6.1",
98
98
  "ioredis-mock": "^8.13.0",
99
99
  "tsup": "^8.5.0",
100
- "tsx": "^4.19.3",
100
+ "tsx": "^4.22.4",
101
101
  "typescript": "^5.8.3",
102
102
  "vitest": "^4.1.2"
103
+ },
104
+ "overrides": {
105
+ "esbuild": "0.28.1"
103
106
  }
104
107
  }