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.cjs CHANGED
@@ -98,39 +98,6 @@ function cloneNamespaceMetrics(metrics) {
98
98
  )
99
99
  };
100
100
  }
101
- function diffNamespaceMetrics(before, after) {
102
- const latencyByLayer = Object.fromEntries(
103
- Object.entries(after.latencyByLayer).map(([layer, value]) => [
104
- layer,
105
- {
106
- avgMs: value.avgMs,
107
- maxMs: value.maxMs,
108
- count: Math.max(0, value.count - (before.latencyByLayer[layer]?.count ?? 0))
109
- }
110
- ])
111
- );
112
- return {
113
- hits: after.hits - before.hits,
114
- misses: after.misses - before.misses,
115
- fetches: after.fetches - before.fetches,
116
- sets: after.sets - before.sets,
117
- deletes: after.deletes - before.deletes,
118
- backfills: after.backfills - before.backfills,
119
- invalidations: after.invalidations - before.invalidations,
120
- staleHits: after.staleHits - before.staleHits,
121
- refreshes: after.refreshes - before.refreshes,
122
- refreshErrors: after.refreshErrors - before.refreshErrors,
123
- writeFailures: after.writeFailures - before.writeFailures,
124
- singleFlightWaits: after.singleFlightWaits - before.singleFlightWaits,
125
- negativeCacheHits: after.negativeCacheHits - before.negativeCacheHits,
126
- circuitBreakerTrips: after.circuitBreakerTrips - before.circuitBreakerTrips,
127
- degradedOperations: after.degradedOperations - before.degradedOperations,
128
- hitsByLayer: diffMetricMap(before.hitsByLayer, after.hitsByLayer),
129
- missesByLayer: diffMetricMap(before.missesByLayer, after.missesByLayer),
130
- latencyByLayer,
131
- resetAt: after.resetAt
132
- };
133
- }
134
101
  function addNamespaceMetrics(base, delta) {
135
102
  return {
136
103
  hits: base.hits + delta.hits,
@@ -166,14 +133,6 @@ function computeNamespaceHitRate(metrics) {
166
133
  }
167
134
  return { overall, byLayer };
168
135
  }
169
- function diffMetricMap(before, after) {
170
- const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
171
- const result = {};
172
- for (const key of keys) {
173
- result[key] = (after[key] ?? 0) - (before[key] ?? 0);
174
- }
175
- return result;
176
- }
177
136
  function addMetricMap(base, delta) {
178
137
  const keys = /* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(delta)]);
179
138
  const result = {};
@@ -210,6 +169,20 @@ var CacheNamespace = class _CacheNamespace {
210
169
  async getOrSet(key, fetcher, options) {
211
170
  return this.trackMetrics(() => this.cache.getOrSet(this.qualify(key), fetcher, this.qualifyGetOptions(options)));
212
171
  }
172
+ /**
173
+ * Returns a namespaced cache entry, or `null` on miss.
174
+ * Unlike `get()`, this distinguishes a stored `null` value from an absent key.
175
+ */
176
+ async getEntry(key) {
177
+ const entry = await this.trackMetrics(() => this.cache.getEntry(this.qualify(key)));
178
+ if (entry === null) {
179
+ return null;
180
+ }
181
+ return {
182
+ ...entry,
183
+ key
184
+ };
185
+ }
213
186
  /**
214
187
  * Like `get()`, but throws `CacheMissError` instead of returning `null`.
215
188
  */
@@ -444,13 +417,24 @@ var CacheNamespace = class _CacheNamespace {
444
417
  };
445
418
  }
446
419
  async trackMetrics(operation) {
447
- return this.getMetricsMutex().runExclusive(async () => {
448
- const before = this.cache.getMetrics();
449
- const result = await operation();
450
- const after = this.cache.getMetrics();
451
- this.metrics = addNamespaceMetrics(this.metrics, diffNamespaceMetrics(before, after));
452
- return result;
420
+ let result;
421
+ let metrics;
422
+ try {
423
+ ;
424
+ ({ result, metrics } = await this.cache.captureMetrics(operation));
425
+ } catch (error) {
426
+ const capturedMetrics = error.metrics;
427
+ if (capturedMetrics) {
428
+ await this.getMetricsMutex().runExclusive(() => {
429
+ this.metrics = addNamespaceMetrics(this.metrics, capturedMetrics);
430
+ });
431
+ }
432
+ throw error;
433
+ }
434
+ await this.getMetricsMutex().runExclusive(() => {
435
+ this.metrics = addNamespaceMetrics(this.metrics, metrics);
453
436
  });
437
+ return result;
454
438
  }
455
439
  getMetricsMutex() {
456
440
  const existing = _CacheNamespace.metricsMutexes.get(this.cache);
@@ -1271,6 +1255,9 @@ var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
1271
1255
  var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
1272
1256
  var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
1273
1257
  var DEFAULT_BACKGROUND_REFRESH_TIMEOUT_MS = 3e4;
1258
+ var SINGLE_FLIGHT_BACKOFF_FACTOR = 2;
1259
+ var SINGLE_FLIGHT_BACKOFF_JITTER = 0.2;
1260
+ var SINGLE_FLIGHT_MAX_POLL_MS = 1e3;
1274
1261
  var CacheStackReader = class {
1275
1262
  constructor(options) {
1276
1263
  this.options = options;
@@ -1494,6 +1481,7 @@ var CacheStackReader = class {
1494
1481
  const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
1495
1482
  const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
1496
1483
  const deadline = Date.now() + timeoutMs;
1484
+ let nextPollMs = pollIntervalMs;
1497
1485
  this.options.metricsCollector.increment("singleFlightWaits");
1498
1486
  this.options.emit("stampede-dedupe", { key });
1499
1487
  while (Date.now() < deadline) {
@@ -1502,7 +1490,13 @@ var CacheStackReader = class {
1502
1490
  this.options.metricsCollector.increment("hits");
1503
1491
  return hit.value;
1504
1492
  }
1505
- await this.options.sleep(pollIntervalMs);
1493
+ const remainingMs = deadline - Date.now();
1494
+ if (remainingMs <= 0) {
1495
+ break;
1496
+ }
1497
+ const delayMs = Math.min(this.jitterSingleFlightPoll(nextPollMs), remainingMs);
1498
+ await this.options.sleep(delayMs);
1499
+ nextPollMs = Math.min(nextPollMs * SINGLE_FLIGHT_BACKOFF_FACTOR, SINGLE_FLIGHT_MAX_POLL_MS, timeoutMs);
1506
1500
  }
1507
1501
  if (!this.options.singleFlightCoordinator) {
1508
1502
  return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext);
@@ -1514,12 +1508,18 @@ var CacheStackReader = class {
1514
1508
  () => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext)
1515
1509
  );
1516
1510
  }
1511
+ jitterSingleFlightPoll(delayMs) {
1512
+ const jitterRange = delayMs * SINGLE_FLIGHT_BACKOFF_JITTER;
1513
+ return Math.max(1, Math.round(delayMs - jitterRange + Math.random() * jitterRange * 2));
1514
+ }
1517
1515
  async fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, fetcherContext = {
1518
1516
  key,
1519
1517
  currentValue: void 0,
1520
1518
  state: "miss"
1521
1519
  }) {
1522
- this.options.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
1520
+ const circuitBreakerOptions = options?.circuitBreaker ?? this.options.circuitBreaker;
1521
+ const breakerKey = this.resolveCircuitBreakerKey(key, circuitBreakerOptions);
1522
+ this.options.circuitBreakerManager.assertClosed(breakerKey, circuitBreakerOptions);
1523
1523
  this.options.metricsCollector.increment("fetches");
1524
1524
  const fetchStart = Date.now();
1525
1525
  let fetched;
@@ -1529,13 +1529,13 @@ var CacheStackReader = class {
1529
1529
  { key, fetcher },
1530
1530
  () => fetcher(fetcherContext)
1531
1531
  );
1532
- this.options.circuitBreakerManager.recordSuccess(key);
1532
+ this.options.circuitBreakerManager.recordSuccess(breakerKey);
1533
1533
  this.options.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
1534
1534
  } catch (error) {
1535
- this.options.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
1535
+ this.options.recordCircuitFailure(key, breakerKey, circuitBreakerOptions, error);
1536
1536
  throw error;
1537
1537
  }
1538
- if (fetched === null || fetched === void 0) {
1538
+ if (fetched === void 0 || fetched === null && !this.shouldCacheNullValues(options)) {
1539
1539
  if (!this.shouldNegativeCache(options)) {
1540
1540
  return null;
1541
1541
  }
@@ -1577,6 +1577,18 @@ var CacheStackReader = class {
1577
1577
  await this.options.storeEntry(key, "value", fetched, options);
1578
1578
  return fetched;
1579
1579
  }
1580
+ resolveCircuitBreakerKey(key, options) {
1581
+ if (!options) {
1582
+ return `key:${key}`;
1583
+ }
1584
+ if (options.breakerKey) {
1585
+ return `custom:${options.breakerKey}`;
1586
+ }
1587
+ if (options.scope === "shared") {
1588
+ return "scope:shared";
1589
+ }
1590
+ return `key:${key}`;
1591
+ }
1580
1592
  runScheduleBackgroundRefresh(key, fetcher, options, fetcherContext) {
1581
1593
  this.scheduleBackgroundRefresh(key, fetcher, options, fetcherContext);
1582
1594
  }
@@ -1677,6 +1689,9 @@ var CacheStackReader = class {
1677
1689
  shouldNegativeCache(options) {
1678
1690
  return options?.negativeCache ?? this.options.negativeCaching ?? false;
1679
1691
  }
1692
+ shouldCacheNullValues(options) {
1693
+ return options?.cacheNullValues ?? this.options.cacheNullValues ?? false;
1694
+ }
1680
1695
  isNegativeStoredValue(stored) {
1681
1696
  return isStoredValueEnvelope(stored) && stored.kind === "empty";
1682
1697
  }
@@ -2019,9 +2034,12 @@ function validateRateLimitOptions(name, options) {
2019
2034
  validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
2020
2035
  validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
2021
2036
  validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
2022
- if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
2037
+ if (options.scope !== void 0 && !["global", "key", "fetcher"].includes(options.scope)) {
2023
2038
  throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
2024
2039
  }
2040
+ if (options.queueOverflow !== void 0 && !["reject", "bypass"].includes(options.queueOverflow)) {
2041
+ throw new Error(`${name}.queueOverflow must be one of "reject" or "bypass".`);
2042
+ }
2025
2043
  if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
2026
2044
  throw new Error(`${name}.bucketKey must not be empty.`);
2027
2045
  }
@@ -2102,6 +2120,12 @@ function validateCircuitBreakerOptions(options) {
2102
2120
  }
2103
2121
  validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
2104
2122
  validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
2123
+ if (options.scope !== void 0 && !["key", "shared"].includes(options.scope)) {
2124
+ throw new Error('circuitBreaker.scope must be one of "key" or "shared".');
2125
+ }
2126
+ if (options.breakerKey !== void 0 && options.breakerKey.length === 0) {
2127
+ throw new Error("circuitBreaker.breakerKey must not be empty.");
2128
+ }
2105
2129
  }
2106
2130
  function validateContextEntryOptions(name, options) {
2107
2131
  if (!options) {
@@ -2215,6 +2239,7 @@ var CircuitBreakerManager = class {
2215
2239
  // src/internal/FetchRateLimiter.ts
2216
2240
  var MAX_BUCKETS = 1e4;
2217
2241
  var MAX_QUEUE_PER_BUCKET = 1e4;
2242
+ var DEFAULT_QUEUE_OVERFLOW_POLICY = "reject";
2218
2243
  var FetchRateLimiter = class {
2219
2244
  buckets = /* @__PURE__ */ new Map();
2220
2245
  queuesByBucket = /* @__PURE__ */ new Map();
@@ -2239,15 +2264,24 @@ var FetchRateLimiter = class {
2239
2264
  const bucketKey = this.resolveBucketKey(normalized, context);
2240
2265
  const queue = this.queuesByBucket.get(bucketKey) ?? [];
2241
2266
  if (queue.length >= MAX_QUEUE_PER_BUCKET) {
2242
- this.rateLimitBypasses += 1;
2243
- task().then(resolve2, reject);
2267
+ if ((normalized.queueOverflow ?? DEFAULT_QUEUE_OVERFLOW_POLICY) === "bypass") {
2268
+ this.rateLimitBypasses += 1;
2269
+ task().then(resolve2, reject);
2270
+ return;
2271
+ }
2272
+ reject(new Error(`FetchRateLimiter queue overflow for bucket "${bucketKey}".`));
2244
2273
  return;
2245
2274
  }
2246
2275
  queue.push({
2247
2276
  bucketKey,
2248
2277
  options: normalized,
2249
- task,
2250
- resolve: resolve2,
2278
+ run: async () => {
2279
+ try {
2280
+ resolve2(await task());
2281
+ } catch (error) {
2282
+ reject(error);
2283
+ }
2284
+ },
2251
2285
  reject
2252
2286
  });
2253
2287
  this.queuesByBucket.set(bucketKey, queue);
@@ -2288,7 +2322,8 @@ var FetchRateLimiter = class {
2288
2322
  intervalMs,
2289
2323
  maxPerInterval,
2290
2324
  scope: options.scope ?? "global",
2291
- bucketKey: options.bucketKey
2325
+ bucketKey: options.bucketKey,
2326
+ queueOverflow: options.queueOverflow
2292
2327
  };
2293
2328
  }
2294
2329
  resolveBucketKey(options, context) {
@@ -2371,7 +2406,7 @@ var FetchRateLimiter = class {
2371
2406
  if (next.options.intervalMs && next.options.maxPerInterval) {
2372
2407
  bucket.startedAt.push(Date.now());
2373
2408
  }
2374
- void next.task().then(next.resolve, next.reject).finally(() => {
2409
+ void next.run().finally(() => {
2375
2410
  bucket.active -= 1;
2376
2411
  if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
2377
2412
  this.pendingBuckets.add(next.bucketKey);
@@ -2473,7 +2508,9 @@ var FetchRateLimiter = class {
2473
2508
  };
2474
2509
 
2475
2510
  // src/internal/MetricsCollector.ts
2511
+ var import_node_async_hooks = require("async_hooks");
2476
2512
  var MetricsCollector = class {
2513
+ captures = new import_node_async_hooks.AsyncLocalStorage();
2477
2514
  data = this.empty();
2478
2515
  get snapshot() {
2479
2516
  return {
@@ -2486,18 +2523,46 @@ var MetricsCollector = class {
2486
2523
  increment(field, amount = 1) {
2487
2524
  ;
2488
2525
  this.data[field] += amount;
2526
+ for (const capture of this.captures.getStore() ?? []) {
2527
+ ;
2528
+ capture[field] += amount;
2529
+ }
2489
2530
  }
2490
2531
  incrementLayer(map, layerName) {
2491
2532
  this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
2533
+ for (const capture of this.captures.getStore() ?? []) {
2534
+ capture[map][layerName] = (capture[map][layerName] ?? 0) + 1;
2535
+ }
2492
2536
  }
2493
2537
  /**
2494
2538
  * Records a read latency sample for the given layer.
2495
2539
  * Maintains a rolling average and max using Welford's online algorithm.
2496
2540
  */
2497
2541
  recordLatency(layerName, durationMs) {
2498
- const existing = this.data.latencyByLayer[layerName];
2542
+ this.recordLatencySample(this.data, layerName, durationMs);
2543
+ for (const capture of this.captures.getStore() ?? []) {
2544
+ this.recordLatencySample(capture, layerName, durationMs);
2545
+ }
2546
+ }
2547
+ async capture(operation) {
2548
+ const metrics = this.empty();
2549
+ const activeCaptures = this.captures.getStore();
2550
+ const captures = activeCaptures ? [...activeCaptures, metrics] : [metrics];
2551
+ try {
2552
+ const result = await this.captures.run(captures, operation);
2553
+ return { result, metrics };
2554
+ } catch (error) {
2555
+ if ((typeof error === "object" || typeof error === "function") && error !== null) {
2556
+ ;
2557
+ error.metrics = metrics;
2558
+ }
2559
+ throw error;
2560
+ }
2561
+ }
2562
+ recordLatencySample(metrics, layerName, durationMs) {
2563
+ const existing = metrics.latencyByLayer[layerName];
2499
2564
  if (!existing) {
2500
- this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
2565
+ metrics.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
2501
2566
  return;
2502
2567
  }
2503
2568
  existing.count += 1;
@@ -2665,30 +2730,34 @@ var TtlResolver = class {
2665
2730
  };
2666
2731
 
2667
2732
  // src/invalidation/TagIndex.ts
2668
- var MAX_PATTERN_RECURSION_DEPTH = 500;
2733
+ var DEFAULT_TOUCH_REFRESH_INTERVAL_MS = 1e3;
2669
2734
  var TagIndex = class {
2670
2735
  tagToKeys = /* @__PURE__ */ new Map();
2671
2736
  keyToTags = /* @__PURE__ */ new Map();
2672
2737
  knownKeys = /* @__PURE__ */ new Map();
2673
2738
  maxKnownKeys;
2739
+ touchRefreshIntervalMs;
2674
2740
  nextNodeId = 1;
2675
2741
  root = this.createTrieNode();
2676
2742
  constructor(options = {}) {
2677
2743
  this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
2744
+ this.touchRefreshIntervalMs = options.touchRefreshIntervalMs ?? DEFAULT_TOUCH_REFRESH_INTERVAL_MS;
2678
2745
  }
2679
2746
  /**
2680
2747
  * Records a key as known without changing tag assignments.
2681
2748
  */
2682
2749
  async touch(key) {
2683
- this.insertKnownKey(key);
2684
- this.pruneKnownKeysIfNeeded();
2750
+ if (this.insertKnownKey(key)) {
2751
+ this.pruneKnownKeysIfNeeded();
2752
+ }
2685
2753
  }
2686
2754
  /**
2687
2755
  * Replaces the tags associated with a key and records the key as known.
2688
2756
  */
2689
2757
  async track(key, tags) {
2690
- this.insertKnownKey(key);
2691
- this.pruneKnownKeysIfNeeded();
2758
+ if (this.insertKnownKey(key)) {
2759
+ this.pruneKnownKeysIfNeeded();
2760
+ }
2692
2761
  if (tags.length === 0) {
2693
2762
  return;
2694
2763
  }
@@ -2758,9 +2827,14 @@ var TagIndex = class {
2758
2827
  * Returns known keys matching a wildcard pattern.
2759
2828
  */
2760
2829
  async matchPattern(pattern) {
2761
- const matches = /* @__PURE__ */ new Set();
2762
- this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set(), 0);
2763
- return [...matches];
2830
+ const literalPrefix = this.literalPrefix(pattern);
2831
+ const node = this.findNode(literalPrefix);
2832
+ if (!node) {
2833
+ return [];
2834
+ }
2835
+ const candidates = [];
2836
+ this.collectFromNode(node, literalPrefix, candidates);
2837
+ return candidates.filter((key) => PatternMatcher.matches(pattern, key));
2764
2838
  }
2765
2839
  /**
2766
2840
  * Visits known keys matching a wildcard pattern.
@@ -2790,13 +2864,18 @@ var TagIndex = class {
2790
2864
  };
2791
2865
  }
2792
2866
  insertKnownKey(key) {
2793
- const isNew = !this.knownKeys.has(key);
2867
+ const previousTouch = this.knownKeys.get(key);
2868
+ const isNew = previousTouch === void 0;
2869
+ const now = Date.now();
2870
+ if (!isNew && now - previousTouch < this.touchRefreshIntervalMs) {
2871
+ return false;
2872
+ }
2794
2873
  if (!isNew) {
2795
2874
  this.knownKeys.delete(key);
2796
2875
  }
2797
- this.knownKeys.set(key, Date.now());
2876
+ this.knownKeys.set(key, now);
2798
2877
  if (!isNew) {
2799
- return;
2878
+ return true;
2800
2879
  }
2801
2880
  let node = this.root;
2802
2881
  for (const character of key) {
@@ -2808,6 +2887,7 @@ var TagIndex = class {
2808
2887
  node = child;
2809
2888
  }
2810
2889
  node.terminal = true;
2890
+ return true;
2811
2891
  }
2812
2892
  findNode(prefix) {
2813
2893
  let node = this.root;
@@ -2820,74 +2900,41 @@ var TagIndex = class {
2820
2900
  return node;
2821
2901
  }
2822
2902
  collectFromNode(node, prefix, matches) {
2823
- if (node.terminal) {
2824
- matches.push(prefix);
2825
- }
2826
- for (const [character, child] of node.children) {
2827
- this.collectFromNode(child, `${prefix}${character}`, matches);
2903
+ const stack = [{ node, prefix }];
2904
+ while (stack.length > 0) {
2905
+ const current = stack.pop();
2906
+ if (!current) {
2907
+ continue;
2908
+ }
2909
+ if (current.node.terminal) {
2910
+ matches.push(current.prefix);
2911
+ }
2912
+ const children = [...current.node.children].reverse();
2913
+ for (const [character, child] of children) {
2914
+ stack.push({ node: child, prefix: `${current.prefix}${character}` });
2915
+ }
2828
2916
  }
2829
2917
  }
2830
2918
  async visitFromNode(node, prefix, visitor) {
2831
- if (node.terminal) {
2832
- await visitor(prefix);
2833
- }
2834
- for (const [character, child] of node.children) {
2835
- await this.visitFromNode(child, `${prefix}${character}`, visitor);
2836
- }
2837
- }
2838
- collectPatternMatches(node, prefix, pattern, patternIndex, matches, visited, depth) {
2839
- if (depth > MAX_PATTERN_RECURSION_DEPTH) {
2840
- return;
2841
- }
2842
- const stateKey = `${node.id}:${patternIndex}`;
2843
- if (visited.has(stateKey)) {
2844
- return;
2845
- }
2846
- visited.add(stateKey);
2847
- if (patternIndex === pattern.length) {
2848
- if (node.terminal) {
2849
- matches.add(prefix);
2919
+ const stack = [{ node, prefix }];
2920
+ while (stack.length > 0) {
2921
+ const current = stack.pop();
2922
+ if (!current) {
2923
+ continue;
2850
2924
  }
2851
- return;
2852
- }
2853
- const patternChar = pattern[patternIndex];
2854
- if (patternChar === void 0) {
2855
- return;
2856
- }
2857
- if (patternChar === "*") {
2858
- this.collectPatternMatches(node, prefix, pattern, patternIndex + 1, matches, visited, depth + 1);
2859
- for (const [character, child2] of node.children) {
2860
- this.collectPatternMatches(child2, `${prefix}${character}`, pattern, patternIndex, matches, visited, depth + 1);
2925
+ if (current.node.terminal) {
2926
+ await visitor(current.prefix);
2861
2927
  }
2862
- return;
2863
- }
2864
- if (patternChar === "?") {
2865
- for (const [character, child2] of node.children) {
2866
- this.collectPatternMatches(
2867
- child2,
2868
- `${prefix}${character}`,
2869
- pattern,
2870
- patternIndex + 1,
2871
- matches,
2872
- visited,
2873
- depth + 1
2874
- );
2928
+ const children = [...current.node.children].reverse();
2929
+ for (const [character, child] of children) {
2930
+ stack.push({ node: child, prefix: `${current.prefix}${character}` });
2875
2931
  }
2876
- return;
2877
- }
2878
- const child = node.children.get(patternChar);
2879
- if (child) {
2880
- this.collectPatternMatches(
2881
- child,
2882
- `${prefix}${patternChar}`,
2883
- pattern,
2884
- patternIndex + 1,
2885
- matches,
2886
- visited,
2887
- depth + 1
2888
- );
2889
2932
  }
2890
2933
  }
2934
+ literalPrefix(pattern) {
2935
+ const wildcardIndex = pattern.search(/[*?]/);
2936
+ return wildcardIndex === -1 ? pattern : pattern.slice(0, wildcardIndex);
2937
+ }
2891
2938
  pruneKnownKeysIfNeeded() {
2892
2939
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
2893
2940
  return;
@@ -3195,7 +3242,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
3195
3242
  emitError: (operation, context) => this.emitError(operation, context),
3196
3243
  formatError: (error) => this.formatError(error),
3197
3244
  storeEntry: (key, kind, value, options2) => this.storeEntry(key, kind, value, options2),
3198
- recordCircuitFailure: (key, options2, error) => this.recordCircuitFailure(key, options2, error),
3245
+ recordCircuitFailure: (key, breakerKey, options2, error) => this.recordCircuitFailure(key, breakerKey, options2, error),
3199
3246
  resolveLayerMs: (layerName, override, globalDefault, fallback) => this.resolveLayerMs(layerName, override, globalDefault, fallback),
3200
3247
  sleep: (ms) => this.sleep(ms),
3201
3248
  withTimeout: (promise, ms, createError) => this.withTimeout(promise, ms, createError),
@@ -3210,6 +3257,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
3210
3257
  singleFlightRenewIntervalMs: options.singleFlightRenewIntervalMs,
3211
3258
  backgroundRefreshTimeoutMs: options.backgroundRefreshTimeoutMs,
3212
3259
  negativeCaching: options.negativeCaching,
3260
+ cacheNullValues: options.cacheNullValues,
3213
3261
  refreshAhead: options.refreshAhead,
3214
3262
  circuitBreaker: options.circuitBreaker,
3215
3263
  fetcherRateLimit: options.fetcherRateLimit
@@ -3262,6 +3310,64 @@ var CacheStack = class extends import_node_events.EventEmitter {
3262
3310
  async getOrSet(key, fetcher, options) {
3263
3311
  return this.get(key, fetcher, options);
3264
3312
  }
3313
+ /**
3314
+ * Returns a discriminated cache entry, or `null` on miss.
3315
+ * Unlike `get()`, this distinguishes a stored `null` value from an absent key.
3316
+ */
3317
+ async getEntry(key) {
3318
+ return this.observeOperation("layercache.get_entry", { "layercache.key": String(key ?? "") }, async () => {
3319
+ const userKey = validateCacheKey(key);
3320
+ const normalizedKey = this.qualifyKey(userKey);
3321
+ await this.awaitStartup("getEntry");
3322
+ let sawRetainableValue = false;
3323
+ for (let index = 0; index < this.layers.length; index += 1) {
3324
+ const layer = this.layers[index];
3325
+ if (!layer || this.shouldSkipLayer(layer)) {
3326
+ continue;
3327
+ }
3328
+ const readStart = performance.now();
3329
+ const stored = await this.readLayerEntry(layer, normalizedKey);
3330
+ this.metricsCollector.recordLatency(layer.name, performance.now() - readStart);
3331
+ if (stored === null) {
3332
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
3333
+ continue;
3334
+ }
3335
+ const resolved = resolveStoredValue(stored);
3336
+ if (resolved.state === "expired") {
3337
+ await layer.delete(normalizedKey);
3338
+ continue;
3339
+ }
3340
+ sawRetainableValue = true;
3341
+ await this.tagIndex.touch(normalizedKey);
3342
+ await this.reader.backfill(normalizedKey, stored, index - 1);
3343
+ this.metricsCollector.increment("hits");
3344
+ if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
3345
+ this.metricsCollector.increment("staleHits");
3346
+ }
3347
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
3348
+ this.logger.debug?.("hit", { key: normalizedKey, layer: layer.name, state: resolved.state });
3349
+ this.emit("hit", {
3350
+ key: normalizedKey,
3351
+ layer: layer.name,
3352
+ state: resolved.state
3353
+ });
3354
+ return {
3355
+ key: userKey,
3356
+ value: resolved.value,
3357
+ kind: resolved.envelope?.kind ?? "value",
3358
+ state: resolved.state,
3359
+ layer: layer.name
3360
+ };
3361
+ }
3362
+ if (!sawRetainableValue) {
3363
+ await this.tagIndex.remove(normalizedKey);
3364
+ }
3365
+ this.metricsCollector.increment("misses");
3366
+ this.logger.debug?.("miss", { key: normalizedKey, mode: "getEntry" });
3367
+ this.emit("miss", { key: normalizedKey, mode: "getEntry" });
3368
+ return null;
3369
+ });
3370
+ }
3265
3371
  /**
3266
3372
  * Like `get()`, but throws `CacheMissError` instead of returning `null`.
3267
3373
  * Useful when the value is expected to exist or the fetcher is expected to
@@ -3726,6 +3832,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
3726
3832
  getMetrics() {
3727
3833
  return this.metricsCollector.snapshot;
3728
3834
  }
3835
+ /**
3836
+ * Runs an operation while collecting only the metrics emitted by its async context.
3837
+ * Used by namespaces so metrics tracking does not serialize the operation itself.
3838
+ */
3839
+ async captureMetrics(operation) {
3840
+ return this.metricsCollector.capture(operation);
3841
+ }
3729
3842
  /**
3730
3843
  * Returns metrics plus layer degradation state and active background refresh count.
3731
3844
  */
@@ -4029,7 +4142,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
4029
4142
  for (const key of keys) {
4030
4143
  await this.tagIndex.remove(key);
4031
4144
  this.ttlResolver.deleteProfile(key);
4032
- this.circuitBreakerManager.delete(key);
4145
+ this.circuitBreakerManager.delete(`key:${key}`);
4033
4146
  }
4034
4147
  this.metricsCollector.increment("deletes", keys.length);
4035
4148
  this.metricsCollector.increment("invalidations");
@@ -4048,7 +4161,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
4048
4161
  }
4049
4162
  await this.tagIndex.remove(key);
4050
4163
  this.ttlResolver.deleteProfile(key);
4051
- this.circuitBreakerManager.delete(key);
4164
+ this.circuitBreakerManager.delete(`key:${key}`);
4052
4165
  }
4053
4166
  this.metricsCollector.increment("invalidations");
4054
4167
  this.logger.debug?.("expire", { keys });
@@ -4090,7 +4203,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
4090
4203
  for (const key of keys) {
4091
4204
  await this.tagIndex.remove(key);
4092
4205
  this.ttlResolver.deleteProfile(key);
4093
- this.circuitBreakerManager.delete(key);
4206
+ this.circuitBreakerManager.delete(`key:${key}`);
4094
4207
  }
4095
4208
  }
4096
4209
  }
@@ -4335,15 +4448,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
4335
4448
  isGracefulDegradationEnabled() {
4336
4449
  return Boolean(this.options.gracefulDegradation);
4337
4450
  }
4338
- recordCircuitFailure(key, options, error) {
4451
+ recordCircuitFailure(key, breakerKey, options, error) {
4339
4452
  if (!options) {
4340
4453
  return;
4341
4454
  }
4342
- this.circuitBreakerManager.recordFailure(key, options);
4343
- if (this.circuitBreakerManager.isOpen(key)) {
4455
+ this.circuitBreakerManager.recordFailure(breakerKey, options);
4456
+ if (this.circuitBreakerManager.isOpen(breakerKey)) {
4344
4457
  this.metricsCollector.increment("circuitBreakerTrips");
4345
4458
  }
4346
- this.emitError("fetch", { key, error: this.formatError(error) });
4459
+ this.emitError("fetch", { key, breakerKey, error: this.formatError(error) });
4347
4460
  }
4348
4461
  emitError(operation, context) {
4349
4462
  this.logger.error?.(operation, context);
@@ -5989,6 +6102,7 @@ var PayloadProtectionError = class extends Error {
5989
6102
 
5990
6103
  // src/layers/DiskLayer.ts
5991
6104
  var FILE_SCAN_CONCURRENCY = 32;
6105
+ var DEFAULT_MAX_WRITE_QUEUE_DEPTH = 1e4;
5992
6106
  var DiskLayer = class {
5993
6107
  name;
5994
6108
  defaultTtl;
@@ -5997,8 +6111,10 @@ var DiskLayer = class {
5997
6111
  serializer;
5998
6112
  maxFiles;
5999
6113
  maxEntryBytes;
6114
+ maxWriteQueueDepth;
6000
6115
  protection;
6001
6116
  writeQueue = Promise.resolve();
6117
+ writeQueueDepth = 0;
6002
6118
  /**
6003
6119
  * Creates a disk-backed cache layer.
6004
6120
  */
@@ -6009,6 +6125,7 @@ var DiskLayer = class {
6009
6125
  this.serializer = options.serializer ?? new JsonSerializer();
6010
6126
  this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
6011
6127
  this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
6128
+ this.maxWriteQueueDepth = this.normalizeMaxWriteQueueDepth(options.maxWriteQueueDepth);
6012
6129
  this.protection = new PayloadProtection({
6013
6130
  encryptionKey: options.encryptionKey,
6014
6131
  signingKey: options.signingKey
@@ -6224,6 +6341,16 @@ var DiskLayer = class {
6224
6341
  }
6225
6342
  return normalized;
6226
6343
  }
6344
+ normalizeMaxWriteQueueDepth(maxWriteQueueDepth) {
6345
+ if (maxWriteQueueDepth === false) {
6346
+ return false;
6347
+ }
6348
+ const normalized = maxWriteQueueDepth ?? DEFAULT_MAX_WRITE_QUEUE_DEPTH;
6349
+ if (!Number.isInteger(normalized) || normalized <= 0) {
6350
+ throw new Error("DiskLayer.maxWriteQueueDepth must be a positive integer or false.");
6351
+ }
6352
+ return normalized;
6353
+ }
6227
6354
  async readEntryFile(filePath) {
6228
6355
  let handle;
6229
6356
  try {
@@ -6336,7 +6463,13 @@ var DiskLayer = class {
6336
6463
  }
6337
6464
  }
6338
6465
  enqueueWrite(operation) {
6339
- const next = this.writeQueue.then(operation, operation);
6466
+ if (this.maxWriteQueueDepth !== false && this.writeQueueDepth >= this.maxWriteQueueDepth) {
6467
+ return Promise.reject(new Error(`DiskLayer write queue limit (${this.maxWriteQueueDepth}) exceeded.`));
6468
+ }
6469
+ this.writeQueueDepth += 1;
6470
+ const next = this.writeQueue.then(operation, operation).finally(() => {
6471
+ this.writeQueueDepth -= 1;
6472
+ });
6340
6473
  this.writeQueue = next.catch(() => void 0);
6341
6474
  return next;
6342
6475
  }