layercache 3.0.0 → 3.1.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.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,8 +1874,12 @@ 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({
@@ -1907,7 +1927,8 @@ var FetchRateLimiter = class {
1907
1927
  intervalMs,
1908
1928
  maxPerInterval,
1909
1929
  scope: options.scope ?? "global",
1910
- bucketKey: options.bucketKey
1930
+ bucketKey: options.bucketKey,
1931
+ queueOverflow: options.queueOverflow
1911
1932
  };
1912
1933
  }
1913
1934
  resolveBucketKey(options, context) {
@@ -2092,7 +2113,9 @@ var FetchRateLimiter = class {
2092
2113
  };
2093
2114
 
2094
2115
  // src/internal/MetricsCollector.ts
2116
+ import { AsyncLocalStorage } from "async_hooks";
2095
2117
  var MetricsCollector = class {
2118
+ captures = new AsyncLocalStorage();
2096
2119
  data = this.empty();
2097
2120
  get snapshot() {
2098
2121
  return {
@@ -2105,18 +2128,46 @@ var MetricsCollector = class {
2105
2128
  increment(field, amount = 1) {
2106
2129
  ;
2107
2130
  this.data[field] += amount;
2131
+ for (const capture of this.captures.getStore() ?? []) {
2132
+ ;
2133
+ capture[field] += amount;
2134
+ }
2108
2135
  }
2109
2136
  incrementLayer(map, layerName) {
2110
2137
  this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
2138
+ for (const capture of this.captures.getStore() ?? []) {
2139
+ capture[map][layerName] = (capture[map][layerName] ?? 0) + 1;
2140
+ }
2111
2141
  }
2112
2142
  /**
2113
2143
  * Records a read latency sample for the given layer.
2114
2144
  * Maintains a rolling average and max using Welford's online algorithm.
2115
2145
  */
2116
2146
  recordLatency(layerName, durationMs) {
2117
- const existing = this.data.latencyByLayer[layerName];
2147
+ this.recordLatencySample(this.data, layerName, durationMs);
2148
+ for (const capture of this.captures.getStore() ?? []) {
2149
+ this.recordLatencySample(capture, layerName, durationMs);
2150
+ }
2151
+ }
2152
+ async capture(operation) {
2153
+ const metrics = this.empty();
2154
+ const activeCaptures = this.captures.getStore();
2155
+ const captures = activeCaptures ? [...activeCaptures, metrics] : [metrics];
2156
+ try {
2157
+ const result = await this.captures.run(captures, operation);
2158
+ return { result, metrics };
2159
+ } catch (error) {
2160
+ if ((typeof error === "object" || typeof error === "function") && error !== null) {
2161
+ ;
2162
+ error.metrics = metrics;
2163
+ }
2164
+ throw error;
2165
+ }
2166
+ }
2167
+ recordLatencySample(metrics, layerName, durationMs) {
2168
+ const existing = metrics.latencyByLayer[layerName];
2118
2169
  if (!existing) {
2119
- this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
2170
+ metrics.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
2120
2171
  return;
2121
2172
  }
2122
2173
  existing.count += 1;
@@ -2529,7 +2580,7 @@ var CacheStack = class extends EventEmitter {
2529
2580
  emitError: (operation, context) => this.emitError(operation, context),
2530
2581
  formatError: (error) => this.formatError(error),
2531
2582
  storeEntry: (key, kind, value, options2) => this.storeEntry(key, kind, value, options2),
2532
- recordCircuitFailure: (key, options2, error) => this.recordCircuitFailure(key, options2, error),
2583
+ recordCircuitFailure: (key, breakerKey, options2, error) => this.recordCircuitFailure(key, breakerKey, options2, error),
2533
2584
  resolveLayerMs: (layerName, override, globalDefault, fallback) => this.resolveLayerMs(layerName, override, globalDefault, fallback),
2534
2585
  sleep: (ms) => this.sleep(ms),
2535
2586
  withTimeout: (promise, ms, createError) => this.withTimeout(promise, ms, createError),
@@ -2544,6 +2595,7 @@ var CacheStack = class extends EventEmitter {
2544
2595
  singleFlightRenewIntervalMs: options.singleFlightRenewIntervalMs,
2545
2596
  backgroundRefreshTimeoutMs: options.backgroundRefreshTimeoutMs,
2546
2597
  negativeCaching: options.negativeCaching,
2598
+ cacheNullValues: options.cacheNullValues,
2547
2599
  refreshAhead: options.refreshAhead,
2548
2600
  circuitBreaker: options.circuitBreaker,
2549
2601
  fetcherRateLimit: options.fetcherRateLimit
@@ -2596,6 +2648,64 @@ var CacheStack = class extends EventEmitter {
2596
2648
  async getOrSet(key, fetcher, options) {
2597
2649
  return this.get(key, fetcher, options);
2598
2650
  }
2651
+ /**
2652
+ * Returns a discriminated cache entry, or `null` on miss.
2653
+ * Unlike `get()`, this distinguishes a stored `null` value from an absent key.
2654
+ */
2655
+ async getEntry(key) {
2656
+ return this.observeOperation("layercache.get_entry", { "layercache.key": String(key ?? "") }, async () => {
2657
+ const userKey = validateCacheKey(key);
2658
+ const normalizedKey = this.qualifyKey(userKey);
2659
+ await this.awaitStartup("getEntry");
2660
+ let sawRetainableValue = false;
2661
+ for (let index = 0; index < this.layers.length; index += 1) {
2662
+ const layer = this.layers[index];
2663
+ if (!layer || this.shouldSkipLayer(layer)) {
2664
+ continue;
2665
+ }
2666
+ const readStart = performance.now();
2667
+ const stored = await this.readLayerEntry(layer, normalizedKey);
2668
+ this.metricsCollector.recordLatency(layer.name, performance.now() - readStart);
2669
+ if (stored === null) {
2670
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
2671
+ continue;
2672
+ }
2673
+ const resolved = resolveStoredValue(stored);
2674
+ if (resolved.state === "expired") {
2675
+ await layer.delete(normalizedKey);
2676
+ continue;
2677
+ }
2678
+ sawRetainableValue = true;
2679
+ await this.tagIndex.touch(normalizedKey);
2680
+ await this.reader.backfill(normalizedKey, stored, index - 1);
2681
+ this.metricsCollector.increment("hits");
2682
+ if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
2683
+ this.metricsCollector.increment("staleHits");
2684
+ }
2685
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
2686
+ this.logger.debug?.("hit", { key: normalizedKey, layer: layer.name, state: resolved.state });
2687
+ this.emit("hit", {
2688
+ key: normalizedKey,
2689
+ layer: layer.name,
2690
+ state: resolved.state
2691
+ });
2692
+ return {
2693
+ key: userKey,
2694
+ value: resolved.value,
2695
+ kind: resolved.envelope?.kind ?? "value",
2696
+ state: resolved.state,
2697
+ layer: layer.name
2698
+ };
2699
+ }
2700
+ if (!sawRetainableValue) {
2701
+ await this.tagIndex.remove(normalizedKey);
2702
+ }
2703
+ this.metricsCollector.increment("misses");
2704
+ this.logger.debug?.("miss", { key: normalizedKey, mode: "getEntry" });
2705
+ this.emit("miss", { key: normalizedKey, mode: "getEntry" });
2706
+ return null;
2707
+ });
2708
+ }
2599
2709
  /**
2600
2710
  * Like `get()`, but throws `CacheMissError` instead of returning `null`.
2601
2711
  * Useful when the value is expected to exist or the fetcher is expected to
@@ -3060,6 +3170,13 @@ var CacheStack = class extends EventEmitter {
3060
3170
  getMetrics() {
3061
3171
  return this.metricsCollector.snapshot;
3062
3172
  }
3173
+ /**
3174
+ * Runs an operation while collecting only the metrics emitted by its async context.
3175
+ * Used by namespaces so metrics tracking does not serialize the operation itself.
3176
+ */
3177
+ async captureMetrics(operation) {
3178
+ return this.metricsCollector.capture(operation);
3179
+ }
3063
3180
  /**
3064
3181
  * Returns metrics plus layer degradation state and active background refresh count.
3065
3182
  */
@@ -3363,7 +3480,7 @@ var CacheStack = class extends EventEmitter {
3363
3480
  for (const key of keys) {
3364
3481
  await this.tagIndex.remove(key);
3365
3482
  this.ttlResolver.deleteProfile(key);
3366
- this.circuitBreakerManager.delete(key);
3483
+ this.circuitBreakerManager.delete(`key:${key}`);
3367
3484
  }
3368
3485
  this.metricsCollector.increment("deletes", keys.length);
3369
3486
  this.metricsCollector.increment("invalidations");
@@ -3382,7 +3499,7 @@ var CacheStack = class extends EventEmitter {
3382
3499
  }
3383
3500
  await this.tagIndex.remove(key);
3384
3501
  this.ttlResolver.deleteProfile(key);
3385
- this.circuitBreakerManager.delete(key);
3502
+ this.circuitBreakerManager.delete(`key:${key}`);
3386
3503
  }
3387
3504
  this.metricsCollector.increment("invalidations");
3388
3505
  this.logger.debug?.("expire", { keys });
@@ -3424,7 +3541,7 @@ var CacheStack = class extends EventEmitter {
3424
3541
  for (const key of keys) {
3425
3542
  await this.tagIndex.remove(key);
3426
3543
  this.ttlResolver.deleteProfile(key);
3427
- this.circuitBreakerManager.delete(key);
3544
+ this.circuitBreakerManager.delete(`key:${key}`);
3428
3545
  }
3429
3546
  }
3430
3547
  }
@@ -3669,15 +3786,15 @@ var CacheStack = class extends EventEmitter {
3669
3786
  isGracefulDegradationEnabled() {
3670
3787
  return Boolean(this.options.gracefulDegradation);
3671
3788
  }
3672
- recordCircuitFailure(key, options, error) {
3789
+ recordCircuitFailure(key, breakerKey, options, error) {
3673
3790
  if (!options) {
3674
3791
  return;
3675
3792
  }
3676
- this.circuitBreakerManager.recordFailure(key, options);
3677
- if (this.circuitBreakerManager.isOpen(key)) {
3793
+ this.circuitBreakerManager.recordFailure(breakerKey, options);
3794
+ if (this.circuitBreakerManager.isOpen(breakerKey)) {
3678
3795
  this.metricsCollector.increment("circuitBreakerTrips");
3679
3796
  }
3680
- this.emitError("fetch", { key, error: this.formatError(error) });
3797
+ this.emitError("fetch", { key, breakerKey, error: this.formatError(error) });
3681
3798
  }
3682
3799
  emitError(operation, context) {
3683
3800
  this.logger.error?.(operation, context);
@@ -4712,6 +4829,7 @@ var PayloadProtectionError = class extends Error {
4712
4829
 
4713
4830
  // src/layers/DiskLayer.ts
4714
4831
  var FILE_SCAN_CONCURRENCY = 32;
4832
+ var DEFAULT_MAX_WRITE_QUEUE_DEPTH = 1e4;
4715
4833
  var DiskLayer = class {
4716
4834
  name;
4717
4835
  defaultTtl;
@@ -4720,8 +4838,10 @@ var DiskLayer = class {
4720
4838
  serializer;
4721
4839
  maxFiles;
4722
4840
  maxEntryBytes;
4841
+ maxWriteQueueDepth;
4723
4842
  protection;
4724
4843
  writeQueue = Promise.resolve();
4844
+ writeQueueDepth = 0;
4725
4845
  /**
4726
4846
  * Creates a disk-backed cache layer.
4727
4847
  */
@@ -4732,6 +4852,7 @@ var DiskLayer = class {
4732
4852
  this.serializer = options.serializer ?? new JsonSerializer();
4733
4853
  this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
4734
4854
  this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
4855
+ this.maxWriteQueueDepth = this.normalizeMaxWriteQueueDepth(options.maxWriteQueueDepth);
4735
4856
  this.protection = new PayloadProtection({
4736
4857
  encryptionKey: options.encryptionKey,
4737
4858
  signingKey: options.signingKey
@@ -4947,6 +5068,16 @@ var DiskLayer = class {
4947
5068
  }
4948
5069
  return normalized;
4949
5070
  }
5071
+ normalizeMaxWriteQueueDepth(maxWriteQueueDepth) {
5072
+ if (maxWriteQueueDepth === false) {
5073
+ return false;
5074
+ }
5075
+ const normalized = maxWriteQueueDepth ?? DEFAULT_MAX_WRITE_QUEUE_DEPTH;
5076
+ if (!Number.isInteger(normalized) || normalized <= 0) {
5077
+ throw new Error("DiskLayer.maxWriteQueueDepth must be a positive integer or false.");
5078
+ }
5079
+ return normalized;
5080
+ }
4950
5081
  async readEntryFile(filePath) {
4951
5082
  let handle;
4952
5083
  try {
@@ -5059,7 +5190,13 @@ var DiskLayer = class {
5059
5190
  }
5060
5191
  }
5061
5192
  enqueueWrite(operation) {
5062
- const next = this.writeQueue.then(operation, operation);
5193
+ if (this.maxWriteQueueDepth !== false && this.writeQueueDepth >= this.maxWriteQueueDepth) {
5194
+ return Promise.reject(new Error(`DiskLayer write queue limit (${this.maxWriteQueueDepth}) exceeded.`));
5195
+ }
5196
+ this.writeQueueDepth += 1;
5197
+ const next = this.writeQueue.then(operation, operation).finally(() => {
5198
+ this.writeQueueDepth -= 1;
5199
+ });
5063
5200
  this.writeQueue = next.catch(() => void 0);
5064
5201
  return next;
5065
5202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "layercache",
3
- "version": "3.0.0",
3
+ "version": "3.1.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",