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.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,8 +2264,12 @@ 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({
@@ -2288,7 +2317,8 @@ var FetchRateLimiter = class {
2288
2317
  intervalMs,
2289
2318
  maxPerInterval,
2290
2319
  scope: options.scope ?? "global",
2291
- bucketKey: options.bucketKey
2320
+ bucketKey: options.bucketKey,
2321
+ queueOverflow: options.queueOverflow
2292
2322
  };
2293
2323
  }
2294
2324
  resolveBucketKey(options, context) {
@@ -2473,7 +2503,9 @@ var FetchRateLimiter = class {
2473
2503
  };
2474
2504
 
2475
2505
  // src/internal/MetricsCollector.ts
2506
+ var import_node_async_hooks = require("async_hooks");
2476
2507
  var MetricsCollector = class {
2508
+ captures = new import_node_async_hooks.AsyncLocalStorage();
2477
2509
  data = this.empty();
2478
2510
  get snapshot() {
2479
2511
  return {
@@ -2486,18 +2518,46 @@ var MetricsCollector = class {
2486
2518
  increment(field, amount = 1) {
2487
2519
  ;
2488
2520
  this.data[field] += amount;
2521
+ for (const capture of this.captures.getStore() ?? []) {
2522
+ ;
2523
+ capture[field] += amount;
2524
+ }
2489
2525
  }
2490
2526
  incrementLayer(map, layerName) {
2491
2527
  this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
2528
+ for (const capture of this.captures.getStore() ?? []) {
2529
+ capture[map][layerName] = (capture[map][layerName] ?? 0) + 1;
2530
+ }
2492
2531
  }
2493
2532
  /**
2494
2533
  * Records a read latency sample for the given layer.
2495
2534
  * Maintains a rolling average and max using Welford's online algorithm.
2496
2535
  */
2497
2536
  recordLatency(layerName, durationMs) {
2498
- const existing = this.data.latencyByLayer[layerName];
2537
+ this.recordLatencySample(this.data, layerName, durationMs);
2538
+ for (const capture of this.captures.getStore() ?? []) {
2539
+ this.recordLatencySample(capture, layerName, durationMs);
2540
+ }
2541
+ }
2542
+ async capture(operation) {
2543
+ const metrics = this.empty();
2544
+ const activeCaptures = this.captures.getStore();
2545
+ const captures = activeCaptures ? [...activeCaptures, metrics] : [metrics];
2546
+ try {
2547
+ const result = await this.captures.run(captures, operation);
2548
+ return { result, metrics };
2549
+ } catch (error) {
2550
+ if ((typeof error === "object" || typeof error === "function") && error !== null) {
2551
+ ;
2552
+ error.metrics = metrics;
2553
+ }
2554
+ throw error;
2555
+ }
2556
+ }
2557
+ recordLatencySample(metrics, layerName, durationMs) {
2558
+ const existing = metrics.latencyByLayer[layerName];
2499
2559
  if (!existing) {
2500
- this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
2560
+ metrics.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
2501
2561
  return;
2502
2562
  }
2503
2563
  existing.count += 1;
@@ -2665,30 +2725,34 @@ var TtlResolver = class {
2665
2725
  };
2666
2726
 
2667
2727
  // src/invalidation/TagIndex.ts
2668
- var MAX_PATTERN_RECURSION_DEPTH = 500;
2728
+ var DEFAULT_TOUCH_REFRESH_INTERVAL_MS = 1e3;
2669
2729
  var TagIndex = class {
2670
2730
  tagToKeys = /* @__PURE__ */ new Map();
2671
2731
  keyToTags = /* @__PURE__ */ new Map();
2672
2732
  knownKeys = /* @__PURE__ */ new Map();
2673
2733
  maxKnownKeys;
2734
+ touchRefreshIntervalMs;
2674
2735
  nextNodeId = 1;
2675
2736
  root = this.createTrieNode();
2676
2737
  constructor(options = {}) {
2677
2738
  this.maxKnownKeys = options.maxKnownKeys ?? 1e5;
2739
+ this.touchRefreshIntervalMs = options.touchRefreshIntervalMs ?? DEFAULT_TOUCH_REFRESH_INTERVAL_MS;
2678
2740
  }
2679
2741
  /**
2680
2742
  * Records a key as known without changing tag assignments.
2681
2743
  */
2682
2744
  async touch(key) {
2683
- this.insertKnownKey(key);
2684
- this.pruneKnownKeysIfNeeded();
2745
+ if (this.insertKnownKey(key)) {
2746
+ this.pruneKnownKeysIfNeeded();
2747
+ }
2685
2748
  }
2686
2749
  /**
2687
2750
  * Replaces the tags associated with a key and records the key as known.
2688
2751
  */
2689
2752
  async track(key, tags) {
2690
- this.insertKnownKey(key);
2691
- this.pruneKnownKeysIfNeeded();
2753
+ if (this.insertKnownKey(key)) {
2754
+ this.pruneKnownKeysIfNeeded();
2755
+ }
2692
2756
  if (tags.length === 0) {
2693
2757
  return;
2694
2758
  }
@@ -2758,9 +2822,14 @@ var TagIndex = class {
2758
2822
  * Returns known keys matching a wildcard pattern.
2759
2823
  */
2760
2824
  async matchPattern(pattern) {
2761
- const matches = /* @__PURE__ */ new Set();
2762
- this.collectPatternMatches(this.root, "", pattern, 0, matches, /* @__PURE__ */ new Set(), 0);
2763
- return [...matches];
2825
+ const literalPrefix = this.literalPrefix(pattern);
2826
+ const node = this.findNode(literalPrefix);
2827
+ if (!node) {
2828
+ return [];
2829
+ }
2830
+ const candidates = [];
2831
+ this.collectFromNode(node, literalPrefix, candidates);
2832
+ return candidates.filter((key) => PatternMatcher.matches(pattern, key));
2764
2833
  }
2765
2834
  /**
2766
2835
  * Visits known keys matching a wildcard pattern.
@@ -2790,13 +2859,18 @@ var TagIndex = class {
2790
2859
  };
2791
2860
  }
2792
2861
  insertKnownKey(key) {
2793
- const isNew = !this.knownKeys.has(key);
2862
+ const previousTouch = this.knownKeys.get(key);
2863
+ const isNew = previousTouch === void 0;
2864
+ const now = Date.now();
2865
+ if (!isNew && now - previousTouch < this.touchRefreshIntervalMs) {
2866
+ return false;
2867
+ }
2794
2868
  if (!isNew) {
2795
2869
  this.knownKeys.delete(key);
2796
2870
  }
2797
- this.knownKeys.set(key, Date.now());
2871
+ this.knownKeys.set(key, now);
2798
2872
  if (!isNew) {
2799
- return;
2873
+ return true;
2800
2874
  }
2801
2875
  let node = this.root;
2802
2876
  for (const character of key) {
@@ -2808,6 +2882,7 @@ var TagIndex = class {
2808
2882
  node = child;
2809
2883
  }
2810
2884
  node.terminal = true;
2885
+ return true;
2811
2886
  }
2812
2887
  findNode(prefix) {
2813
2888
  let node = this.root;
@@ -2820,74 +2895,41 @@ var TagIndex = class {
2820
2895
  return node;
2821
2896
  }
2822
2897
  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);
2898
+ const stack = [{ node, prefix }];
2899
+ while (stack.length > 0) {
2900
+ const current = stack.pop();
2901
+ if (!current) {
2902
+ continue;
2903
+ }
2904
+ if (current.node.terminal) {
2905
+ matches.push(current.prefix);
2906
+ }
2907
+ const children = [...current.node.children].reverse();
2908
+ for (const [character, child] of children) {
2909
+ stack.push({ node: child, prefix: `${current.prefix}${character}` });
2910
+ }
2828
2911
  }
2829
2912
  }
2830
2913
  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);
2914
+ const stack = [{ node, prefix }];
2915
+ while (stack.length > 0) {
2916
+ const current = stack.pop();
2917
+ if (!current) {
2918
+ continue;
2850
2919
  }
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);
2920
+ if (current.node.terminal) {
2921
+ await visitor(current.prefix);
2861
2922
  }
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
- );
2923
+ const children = [...current.node.children].reverse();
2924
+ for (const [character, child] of children) {
2925
+ stack.push({ node: child, prefix: `${current.prefix}${character}` });
2875
2926
  }
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
2927
  }
2890
2928
  }
2929
+ literalPrefix(pattern) {
2930
+ const wildcardIndex = pattern.search(/[*?]/);
2931
+ return wildcardIndex === -1 ? pattern : pattern.slice(0, wildcardIndex);
2932
+ }
2891
2933
  pruneKnownKeysIfNeeded() {
2892
2934
  if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
2893
2935
  return;
@@ -3195,7 +3237,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
3195
3237
  emitError: (operation, context) => this.emitError(operation, context),
3196
3238
  formatError: (error) => this.formatError(error),
3197
3239
  storeEntry: (key, kind, value, options2) => this.storeEntry(key, kind, value, options2),
3198
- recordCircuitFailure: (key, options2, error) => this.recordCircuitFailure(key, options2, error),
3240
+ recordCircuitFailure: (key, breakerKey, options2, error) => this.recordCircuitFailure(key, breakerKey, options2, error),
3199
3241
  resolveLayerMs: (layerName, override, globalDefault, fallback) => this.resolveLayerMs(layerName, override, globalDefault, fallback),
3200
3242
  sleep: (ms) => this.sleep(ms),
3201
3243
  withTimeout: (promise, ms, createError) => this.withTimeout(promise, ms, createError),
@@ -3210,6 +3252,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
3210
3252
  singleFlightRenewIntervalMs: options.singleFlightRenewIntervalMs,
3211
3253
  backgroundRefreshTimeoutMs: options.backgroundRefreshTimeoutMs,
3212
3254
  negativeCaching: options.negativeCaching,
3255
+ cacheNullValues: options.cacheNullValues,
3213
3256
  refreshAhead: options.refreshAhead,
3214
3257
  circuitBreaker: options.circuitBreaker,
3215
3258
  fetcherRateLimit: options.fetcherRateLimit
@@ -3262,6 +3305,64 @@ var CacheStack = class extends import_node_events.EventEmitter {
3262
3305
  async getOrSet(key, fetcher, options) {
3263
3306
  return this.get(key, fetcher, options);
3264
3307
  }
3308
+ /**
3309
+ * Returns a discriminated cache entry, or `null` on miss.
3310
+ * Unlike `get()`, this distinguishes a stored `null` value from an absent key.
3311
+ */
3312
+ async getEntry(key) {
3313
+ return this.observeOperation("layercache.get_entry", { "layercache.key": String(key ?? "") }, async () => {
3314
+ const userKey = validateCacheKey(key);
3315
+ const normalizedKey = this.qualifyKey(userKey);
3316
+ await this.awaitStartup("getEntry");
3317
+ let sawRetainableValue = false;
3318
+ for (let index = 0; index < this.layers.length; index += 1) {
3319
+ const layer = this.layers[index];
3320
+ if (!layer || this.shouldSkipLayer(layer)) {
3321
+ continue;
3322
+ }
3323
+ const readStart = performance.now();
3324
+ const stored = await this.readLayerEntry(layer, normalizedKey);
3325
+ this.metricsCollector.recordLatency(layer.name, performance.now() - readStart);
3326
+ if (stored === null) {
3327
+ this.metricsCollector.incrementLayer("missesByLayer", layer.name);
3328
+ continue;
3329
+ }
3330
+ const resolved = resolveStoredValue(stored);
3331
+ if (resolved.state === "expired") {
3332
+ await layer.delete(normalizedKey);
3333
+ continue;
3334
+ }
3335
+ sawRetainableValue = true;
3336
+ await this.tagIndex.touch(normalizedKey);
3337
+ await this.reader.backfill(normalizedKey, stored, index - 1);
3338
+ this.metricsCollector.increment("hits");
3339
+ if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
3340
+ this.metricsCollector.increment("staleHits");
3341
+ }
3342
+ this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
3343
+ this.logger.debug?.("hit", { key: normalizedKey, layer: layer.name, state: resolved.state });
3344
+ this.emit("hit", {
3345
+ key: normalizedKey,
3346
+ layer: layer.name,
3347
+ state: resolved.state
3348
+ });
3349
+ return {
3350
+ key: userKey,
3351
+ value: resolved.value,
3352
+ kind: resolved.envelope?.kind ?? "value",
3353
+ state: resolved.state,
3354
+ layer: layer.name
3355
+ };
3356
+ }
3357
+ if (!sawRetainableValue) {
3358
+ await this.tagIndex.remove(normalizedKey);
3359
+ }
3360
+ this.metricsCollector.increment("misses");
3361
+ this.logger.debug?.("miss", { key: normalizedKey, mode: "getEntry" });
3362
+ this.emit("miss", { key: normalizedKey, mode: "getEntry" });
3363
+ return null;
3364
+ });
3365
+ }
3265
3366
  /**
3266
3367
  * Like `get()`, but throws `CacheMissError` instead of returning `null`.
3267
3368
  * Useful when the value is expected to exist or the fetcher is expected to
@@ -3726,6 +3827,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
3726
3827
  getMetrics() {
3727
3828
  return this.metricsCollector.snapshot;
3728
3829
  }
3830
+ /**
3831
+ * Runs an operation while collecting only the metrics emitted by its async context.
3832
+ * Used by namespaces so metrics tracking does not serialize the operation itself.
3833
+ */
3834
+ async captureMetrics(operation) {
3835
+ return this.metricsCollector.capture(operation);
3836
+ }
3729
3837
  /**
3730
3838
  * Returns metrics plus layer degradation state and active background refresh count.
3731
3839
  */
@@ -4029,7 +4137,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
4029
4137
  for (const key of keys) {
4030
4138
  await this.tagIndex.remove(key);
4031
4139
  this.ttlResolver.deleteProfile(key);
4032
- this.circuitBreakerManager.delete(key);
4140
+ this.circuitBreakerManager.delete(`key:${key}`);
4033
4141
  }
4034
4142
  this.metricsCollector.increment("deletes", keys.length);
4035
4143
  this.metricsCollector.increment("invalidations");
@@ -4048,7 +4156,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
4048
4156
  }
4049
4157
  await this.tagIndex.remove(key);
4050
4158
  this.ttlResolver.deleteProfile(key);
4051
- this.circuitBreakerManager.delete(key);
4159
+ this.circuitBreakerManager.delete(`key:${key}`);
4052
4160
  }
4053
4161
  this.metricsCollector.increment("invalidations");
4054
4162
  this.logger.debug?.("expire", { keys });
@@ -4090,7 +4198,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
4090
4198
  for (const key of keys) {
4091
4199
  await this.tagIndex.remove(key);
4092
4200
  this.ttlResolver.deleteProfile(key);
4093
- this.circuitBreakerManager.delete(key);
4201
+ this.circuitBreakerManager.delete(`key:${key}`);
4094
4202
  }
4095
4203
  }
4096
4204
  }
@@ -4335,15 +4443,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
4335
4443
  isGracefulDegradationEnabled() {
4336
4444
  return Boolean(this.options.gracefulDegradation);
4337
4445
  }
4338
- recordCircuitFailure(key, options, error) {
4446
+ recordCircuitFailure(key, breakerKey, options, error) {
4339
4447
  if (!options) {
4340
4448
  return;
4341
4449
  }
4342
- this.circuitBreakerManager.recordFailure(key, options);
4343
- if (this.circuitBreakerManager.isOpen(key)) {
4450
+ this.circuitBreakerManager.recordFailure(breakerKey, options);
4451
+ if (this.circuitBreakerManager.isOpen(breakerKey)) {
4344
4452
  this.metricsCollector.increment("circuitBreakerTrips");
4345
4453
  }
4346
- this.emitError("fetch", { key, error: this.formatError(error) });
4454
+ this.emitError("fetch", { key, breakerKey, error: this.formatError(error) });
4347
4455
  }
4348
4456
  emitError(operation, context) {
4349
4457
  this.logger.error?.(operation, context);
@@ -5989,6 +6097,7 @@ var PayloadProtectionError = class extends Error {
5989
6097
 
5990
6098
  // src/layers/DiskLayer.ts
5991
6099
  var FILE_SCAN_CONCURRENCY = 32;
6100
+ var DEFAULT_MAX_WRITE_QUEUE_DEPTH = 1e4;
5992
6101
  var DiskLayer = class {
5993
6102
  name;
5994
6103
  defaultTtl;
@@ -5997,8 +6106,10 @@ var DiskLayer = class {
5997
6106
  serializer;
5998
6107
  maxFiles;
5999
6108
  maxEntryBytes;
6109
+ maxWriteQueueDepth;
6000
6110
  protection;
6001
6111
  writeQueue = Promise.resolve();
6112
+ writeQueueDepth = 0;
6002
6113
  /**
6003
6114
  * Creates a disk-backed cache layer.
6004
6115
  */
@@ -6009,6 +6120,7 @@ var DiskLayer = class {
6009
6120
  this.serializer = options.serializer ?? new JsonSerializer();
6010
6121
  this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
6011
6122
  this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
6123
+ this.maxWriteQueueDepth = this.normalizeMaxWriteQueueDepth(options.maxWriteQueueDepth);
6012
6124
  this.protection = new PayloadProtection({
6013
6125
  encryptionKey: options.encryptionKey,
6014
6126
  signingKey: options.signingKey
@@ -6224,6 +6336,16 @@ var DiskLayer = class {
6224
6336
  }
6225
6337
  return normalized;
6226
6338
  }
6339
+ normalizeMaxWriteQueueDepth(maxWriteQueueDepth) {
6340
+ if (maxWriteQueueDepth === false) {
6341
+ return false;
6342
+ }
6343
+ const normalized = maxWriteQueueDepth ?? DEFAULT_MAX_WRITE_QUEUE_DEPTH;
6344
+ if (!Number.isInteger(normalized) || normalized <= 0) {
6345
+ throw new Error("DiskLayer.maxWriteQueueDepth must be a positive integer or false.");
6346
+ }
6347
+ return normalized;
6348
+ }
6227
6349
  async readEntryFile(filePath) {
6228
6350
  let handle;
6229
6351
  try {
@@ -6336,7 +6458,13 @@ var DiskLayer = class {
6336
6458
  }
6337
6459
  }
6338
6460
  enqueueWrite(operation) {
6339
- const next = this.writeQueue.then(operation, operation);
6461
+ if (this.maxWriteQueueDepth !== false && this.writeQueueDepth >= this.maxWriteQueueDepth) {
6462
+ return Promise.reject(new Error(`DiskLayer write queue limit (${this.maxWriteQueueDepth}) exceeded.`));
6463
+ }
6464
+ this.writeQueueDepth += 1;
6465
+ const next = this.writeQueue.then(operation, operation).finally(() => {
6466
+ this.writeQueueDepth -= 1;
6467
+ });
6340
6468
  this.writeQueue = next.catch(() => void 0);
6341
6469
  return next;
6342
6470
  }