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/README.md +7 -3
- package/dist/{chunk-NBMG7DHT.js → chunk-L6L7QXYF.js} +10 -1
- package/dist/{chunk-5CIBABDH.js → chunk-XMUT66SH.js} +54 -71
- package/dist/cli.js +1 -1
- package/dist/{edge-BDyuPmIq.d.cts → edge-LBUuZAdr.d.cts} +56 -2
- package/dist/{edge-BDyuPmIq.d.ts → edge-LBUuZAdr.d.ts} +56 -2
- package/dist/edge.cjs +53 -71
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/edge.js +1 -1
- package/dist/index.cjs +274 -141
- package/dist/index.d.cts +10 -2
- package/dist/index.d.ts +10 -2
- package/dist/index.js +213 -71
- package/package.json +5 -2
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-
|
|
2
|
-
export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheContextOptionsContext, n as CacheDegradationOptions, o as
|
|
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-
|
|
2
|
-
export { k as CacheAdaptiveTtlOptions, l as CacheCircuitBreakerOptions, m as CacheContextOptionsContext, n as CacheDegradationOptions, o as
|
|
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-
|
|
15
|
+
} from "./chunk-L6L7QXYF.js";
|
|
16
16
|
import {
|
|
17
17
|
MemoryLayer,
|
|
18
18
|
TagIndex,
|
|
19
19
|
createHonoCacheMiddleware,
|
|
20
20
|
normalizeHttpCacheUrl
|
|
21
|
-
} from "./chunk-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
1291
|
+
this.options.recordCircuitFailure(key, breakerKey, circuitBreakerOptions, error);
|
|
1292
1292
|
throw error;
|
|
1293
1293
|
}
|
|
1294
|
-
if (fetched ===
|
|
1294
|
+
if (fetched === void 0 || fetched === null && !this.shouldCacheNullValues(options)) {
|
|
1295
1295
|
if (!this.shouldNegativeCache(options)) {
|
|
1296
1296
|
return null;
|
|
1297
1297
|
}
|
|
@@ -1333,6 +1333,18 @@ var CacheStackReader = class {
|
|
|
1333
1333
|
await this.options.storeEntry(key, "value", fetched, options);
|
|
1334
1334
|
return fetched;
|
|
1335
1335
|
}
|
|
1336
|
+
resolveCircuitBreakerKey(key, options) {
|
|
1337
|
+
if (!options) {
|
|
1338
|
+
return `key:${key}`;
|
|
1339
|
+
}
|
|
1340
|
+
if (options.breakerKey) {
|
|
1341
|
+
return `custom:${options.breakerKey}`;
|
|
1342
|
+
}
|
|
1343
|
+
if (options.scope === "shared") {
|
|
1344
|
+
return "scope:shared";
|
|
1345
|
+
}
|
|
1346
|
+
return `key:${key}`;
|
|
1347
|
+
}
|
|
1336
1348
|
runScheduleBackgroundRefresh(key, fetcher, options, fetcherContext) {
|
|
1337
1349
|
this.scheduleBackgroundRefresh(key, fetcher, options, fetcherContext);
|
|
1338
1350
|
}
|
|
@@ -1433,6 +1445,9 @@ var CacheStackReader = class {
|
|
|
1433
1445
|
shouldNegativeCache(options) {
|
|
1434
1446
|
return options?.negativeCache ?? this.options.negativeCaching ?? false;
|
|
1435
1447
|
}
|
|
1448
|
+
shouldCacheNullValues(options) {
|
|
1449
|
+
return options?.cacheNullValues ?? this.options.cacheNullValues ?? false;
|
|
1450
|
+
}
|
|
1436
1451
|
isNegativeStoredValue(stored) {
|
|
1437
1452
|
return isStoredValueEnvelope(stored) && stored.kind === "empty";
|
|
1438
1453
|
}
|
|
@@ -1834,6 +1849,7 @@ var CircuitBreakerManager = class {
|
|
|
1834
1849
|
// src/internal/FetchRateLimiter.ts
|
|
1835
1850
|
var MAX_BUCKETS = 1e4;
|
|
1836
1851
|
var MAX_QUEUE_PER_BUCKET = 1e4;
|
|
1852
|
+
var DEFAULT_QUEUE_OVERFLOW_POLICY = "reject";
|
|
1837
1853
|
var FetchRateLimiter = class {
|
|
1838
1854
|
buckets = /* @__PURE__ */ new Map();
|
|
1839
1855
|
queuesByBucket = /* @__PURE__ */ new Map();
|
|
@@ -1858,15 +1874,24 @@ var FetchRateLimiter = class {
|
|
|
1858
1874
|
const bucketKey = this.resolveBucketKey(normalized, context);
|
|
1859
1875
|
const queue = this.queuesByBucket.get(bucketKey) ?? [];
|
|
1860
1876
|
if (queue.length >= MAX_QUEUE_PER_BUCKET) {
|
|
1861
|
-
|
|
1862
|
-
|
|
1877
|
+
if ((normalized.queueOverflow ?? DEFAULT_QUEUE_OVERFLOW_POLICY) === "bypass") {
|
|
1878
|
+
this.rateLimitBypasses += 1;
|
|
1879
|
+
task().then(resolve2, reject);
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
reject(new Error(`FetchRateLimiter queue overflow for bucket "${bucketKey}".`));
|
|
1863
1883
|
return;
|
|
1864
1884
|
}
|
|
1865
1885
|
queue.push({
|
|
1866
1886
|
bucketKey,
|
|
1867
1887
|
options: normalized,
|
|
1868
|
-
|
|
1869
|
-
|
|
1888
|
+
run: async () => {
|
|
1889
|
+
try {
|
|
1890
|
+
resolve2(await task());
|
|
1891
|
+
} catch (error) {
|
|
1892
|
+
reject(error);
|
|
1893
|
+
}
|
|
1894
|
+
},
|
|
1870
1895
|
reject
|
|
1871
1896
|
});
|
|
1872
1897
|
this.queuesByBucket.set(bucketKey, queue);
|
|
@@ -1907,7 +1932,8 @@ var FetchRateLimiter = class {
|
|
|
1907
1932
|
intervalMs,
|
|
1908
1933
|
maxPerInterval,
|
|
1909
1934
|
scope: options.scope ?? "global",
|
|
1910
|
-
bucketKey: options.bucketKey
|
|
1935
|
+
bucketKey: options.bucketKey,
|
|
1936
|
+
queueOverflow: options.queueOverflow
|
|
1911
1937
|
};
|
|
1912
1938
|
}
|
|
1913
1939
|
resolveBucketKey(options, context) {
|
|
@@ -1990,7 +2016,7 @@ var FetchRateLimiter = class {
|
|
|
1990
2016
|
if (next.options.intervalMs && next.options.maxPerInterval) {
|
|
1991
2017
|
bucket.startedAt.push(Date.now());
|
|
1992
2018
|
}
|
|
1993
|
-
void next.
|
|
2019
|
+
void next.run().finally(() => {
|
|
1994
2020
|
bucket.active -= 1;
|
|
1995
2021
|
if ((this.queuesByBucket.get(next.bucketKey)?.length ?? 0) > 0) {
|
|
1996
2022
|
this.pendingBuckets.add(next.bucketKey);
|
|
@@ -2092,7 +2118,9 @@ var FetchRateLimiter = class {
|
|
|
2092
2118
|
};
|
|
2093
2119
|
|
|
2094
2120
|
// src/internal/MetricsCollector.ts
|
|
2121
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2095
2122
|
var MetricsCollector = class {
|
|
2123
|
+
captures = new AsyncLocalStorage();
|
|
2096
2124
|
data = this.empty();
|
|
2097
2125
|
get snapshot() {
|
|
2098
2126
|
return {
|
|
@@ -2105,18 +2133,46 @@ var MetricsCollector = class {
|
|
|
2105
2133
|
increment(field, amount = 1) {
|
|
2106
2134
|
;
|
|
2107
2135
|
this.data[field] += amount;
|
|
2136
|
+
for (const capture of this.captures.getStore() ?? []) {
|
|
2137
|
+
;
|
|
2138
|
+
capture[field] += amount;
|
|
2139
|
+
}
|
|
2108
2140
|
}
|
|
2109
2141
|
incrementLayer(map, layerName) {
|
|
2110
2142
|
this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
|
|
2143
|
+
for (const capture of this.captures.getStore() ?? []) {
|
|
2144
|
+
capture[map][layerName] = (capture[map][layerName] ?? 0) + 1;
|
|
2145
|
+
}
|
|
2111
2146
|
}
|
|
2112
2147
|
/**
|
|
2113
2148
|
* Records a read latency sample for the given layer.
|
|
2114
2149
|
* Maintains a rolling average and max using Welford's online algorithm.
|
|
2115
2150
|
*/
|
|
2116
2151
|
recordLatency(layerName, durationMs) {
|
|
2117
|
-
|
|
2152
|
+
this.recordLatencySample(this.data, layerName, durationMs);
|
|
2153
|
+
for (const capture of this.captures.getStore() ?? []) {
|
|
2154
|
+
this.recordLatencySample(capture, layerName, durationMs);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
async capture(operation) {
|
|
2158
|
+
const metrics = this.empty();
|
|
2159
|
+
const activeCaptures = this.captures.getStore();
|
|
2160
|
+
const captures = activeCaptures ? [...activeCaptures, metrics] : [metrics];
|
|
2161
|
+
try {
|
|
2162
|
+
const result = await this.captures.run(captures, operation);
|
|
2163
|
+
return { result, metrics };
|
|
2164
|
+
} catch (error) {
|
|
2165
|
+
if ((typeof error === "object" || typeof error === "function") && error !== null) {
|
|
2166
|
+
;
|
|
2167
|
+
error.metrics = metrics;
|
|
2168
|
+
}
|
|
2169
|
+
throw error;
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
recordLatencySample(metrics, layerName, durationMs) {
|
|
2173
|
+
const existing = metrics.latencyByLayer[layerName];
|
|
2118
2174
|
if (!existing) {
|
|
2119
|
-
|
|
2175
|
+
metrics.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
|
|
2120
2176
|
return;
|
|
2121
2177
|
}
|
|
2122
2178
|
existing.count += 1;
|
|
@@ -2529,7 +2585,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
2529
2585
|
emitError: (operation, context) => this.emitError(operation, context),
|
|
2530
2586
|
formatError: (error) => this.formatError(error),
|
|
2531
2587
|
storeEntry: (key, kind, value, options2) => this.storeEntry(key, kind, value, options2),
|
|
2532
|
-
recordCircuitFailure: (key, options2, error) => this.recordCircuitFailure(key, options2, error),
|
|
2588
|
+
recordCircuitFailure: (key, breakerKey, options2, error) => this.recordCircuitFailure(key, breakerKey, options2, error),
|
|
2533
2589
|
resolveLayerMs: (layerName, override, globalDefault, fallback) => this.resolveLayerMs(layerName, override, globalDefault, fallback),
|
|
2534
2590
|
sleep: (ms) => this.sleep(ms),
|
|
2535
2591
|
withTimeout: (promise, ms, createError) => this.withTimeout(promise, ms, createError),
|
|
@@ -2544,6 +2600,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
2544
2600
|
singleFlightRenewIntervalMs: options.singleFlightRenewIntervalMs,
|
|
2545
2601
|
backgroundRefreshTimeoutMs: options.backgroundRefreshTimeoutMs,
|
|
2546
2602
|
negativeCaching: options.negativeCaching,
|
|
2603
|
+
cacheNullValues: options.cacheNullValues,
|
|
2547
2604
|
refreshAhead: options.refreshAhead,
|
|
2548
2605
|
circuitBreaker: options.circuitBreaker,
|
|
2549
2606
|
fetcherRateLimit: options.fetcherRateLimit
|
|
@@ -2596,6 +2653,64 @@ var CacheStack = class extends EventEmitter {
|
|
|
2596
2653
|
async getOrSet(key, fetcher, options) {
|
|
2597
2654
|
return this.get(key, fetcher, options);
|
|
2598
2655
|
}
|
|
2656
|
+
/**
|
|
2657
|
+
* Returns a discriminated cache entry, or `null` on miss.
|
|
2658
|
+
* Unlike `get()`, this distinguishes a stored `null` value from an absent key.
|
|
2659
|
+
*/
|
|
2660
|
+
async getEntry(key) {
|
|
2661
|
+
return this.observeOperation("layercache.get_entry", { "layercache.key": String(key ?? "") }, async () => {
|
|
2662
|
+
const userKey = validateCacheKey(key);
|
|
2663
|
+
const normalizedKey = this.qualifyKey(userKey);
|
|
2664
|
+
await this.awaitStartup("getEntry");
|
|
2665
|
+
let sawRetainableValue = false;
|
|
2666
|
+
for (let index = 0; index < this.layers.length; index += 1) {
|
|
2667
|
+
const layer = this.layers[index];
|
|
2668
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
2669
|
+
continue;
|
|
2670
|
+
}
|
|
2671
|
+
const readStart = performance.now();
|
|
2672
|
+
const stored = await this.readLayerEntry(layer, normalizedKey);
|
|
2673
|
+
this.metricsCollector.recordLatency(layer.name, performance.now() - readStart);
|
|
2674
|
+
if (stored === null) {
|
|
2675
|
+
this.metricsCollector.incrementLayer("missesByLayer", layer.name);
|
|
2676
|
+
continue;
|
|
2677
|
+
}
|
|
2678
|
+
const resolved = resolveStoredValue(stored);
|
|
2679
|
+
if (resolved.state === "expired") {
|
|
2680
|
+
await layer.delete(normalizedKey);
|
|
2681
|
+
continue;
|
|
2682
|
+
}
|
|
2683
|
+
sawRetainableValue = true;
|
|
2684
|
+
await this.tagIndex.touch(normalizedKey);
|
|
2685
|
+
await this.reader.backfill(normalizedKey, stored, index - 1);
|
|
2686
|
+
this.metricsCollector.increment("hits");
|
|
2687
|
+
if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
|
|
2688
|
+
this.metricsCollector.increment("staleHits");
|
|
2689
|
+
}
|
|
2690
|
+
this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
|
|
2691
|
+
this.logger.debug?.("hit", { key: normalizedKey, layer: layer.name, state: resolved.state });
|
|
2692
|
+
this.emit("hit", {
|
|
2693
|
+
key: normalizedKey,
|
|
2694
|
+
layer: layer.name,
|
|
2695
|
+
state: resolved.state
|
|
2696
|
+
});
|
|
2697
|
+
return {
|
|
2698
|
+
key: userKey,
|
|
2699
|
+
value: resolved.value,
|
|
2700
|
+
kind: resolved.envelope?.kind ?? "value",
|
|
2701
|
+
state: resolved.state,
|
|
2702
|
+
layer: layer.name
|
|
2703
|
+
};
|
|
2704
|
+
}
|
|
2705
|
+
if (!sawRetainableValue) {
|
|
2706
|
+
await this.tagIndex.remove(normalizedKey);
|
|
2707
|
+
}
|
|
2708
|
+
this.metricsCollector.increment("misses");
|
|
2709
|
+
this.logger.debug?.("miss", { key: normalizedKey, mode: "getEntry" });
|
|
2710
|
+
this.emit("miss", { key: normalizedKey, mode: "getEntry" });
|
|
2711
|
+
return null;
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2599
2714
|
/**
|
|
2600
2715
|
* Like `get()`, but throws `CacheMissError` instead of returning `null`.
|
|
2601
2716
|
* Useful when the value is expected to exist or the fetcher is expected to
|
|
@@ -3060,6 +3175,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
3060
3175
|
getMetrics() {
|
|
3061
3176
|
return this.metricsCollector.snapshot;
|
|
3062
3177
|
}
|
|
3178
|
+
/**
|
|
3179
|
+
* Runs an operation while collecting only the metrics emitted by its async context.
|
|
3180
|
+
* Used by namespaces so metrics tracking does not serialize the operation itself.
|
|
3181
|
+
*/
|
|
3182
|
+
async captureMetrics(operation) {
|
|
3183
|
+
return this.metricsCollector.capture(operation);
|
|
3184
|
+
}
|
|
3063
3185
|
/**
|
|
3064
3186
|
* Returns metrics plus layer degradation state and active background refresh count.
|
|
3065
3187
|
*/
|
|
@@ -3363,7 +3485,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
3363
3485
|
for (const key of keys) {
|
|
3364
3486
|
await this.tagIndex.remove(key);
|
|
3365
3487
|
this.ttlResolver.deleteProfile(key);
|
|
3366
|
-
this.circuitBreakerManager.delete(key);
|
|
3488
|
+
this.circuitBreakerManager.delete(`key:${key}`);
|
|
3367
3489
|
}
|
|
3368
3490
|
this.metricsCollector.increment("deletes", keys.length);
|
|
3369
3491
|
this.metricsCollector.increment("invalidations");
|
|
@@ -3382,7 +3504,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
3382
3504
|
}
|
|
3383
3505
|
await this.tagIndex.remove(key);
|
|
3384
3506
|
this.ttlResolver.deleteProfile(key);
|
|
3385
|
-
this.circuitBreakerManager.delete(key);
|
|
3507
|
+
this.circuitBreakerManager.delete(`key:${key}`);
|
|
3386
3508
|
}
|
|
3387
3509
|
this.metricsCollector.increment("invalidations");
|
|
3388
3510
|
this.logger.debug?.("expire", { keys });
|
|
@@ -3424,7 +3546,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
3424
3546
|
for (const key of keys) {
|
|
3425
3547
|
await this.tagIndex.remove(key);
|
|
3426
3548
|
this.ttlResolver.deleteProfile(key);
|
|
3427
|
-
this.circuitBreakerManager.delete(key);
|
|
3549
|
+
this.circuitBreakerManager.delete(`key:${key}`);
|
|
3428
3550
|
}
|
|
3429
3551
|
}
|
|
3430
3552
|
}
|
|
@@ -3669,15 +3791,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
3669
3791
|
isGracefulDegradationEnabled() {
|
|
3670
3792
|
return Boolean(this.options.gracefulDegradation);
|
|
3671
3793
|
}
|
|
3672
|
-
recordCircuitFailure(key, options, error) {
|
|
3794
|
+
recordCircuitFailure(key, breakerKey, options, error) {
|
|
3673
3795
|
if (!options) {
|
|
3674
3796
|
return;
|
|
3675
3797
|
}
|
|
3676
|
-
this.circuitBreakerManager.recordFailure(
|
|
3677
|
-
if (this.circuitBreakerManager.isOpen(
|
|
3798
|
+
this.circuitBreakerManager.recordFailure(breakerKey, options);
|
|
3799
|
+
if (this.circuitBreakerManager.isOpen(breakerKey)) {
|
|
3678
3800
|
this.metricsCollector.increment("circuitBreakerTrips");
|
|
3679
3801
|
}
|
|
3680
|
-
this.emitError("fetch", { key, error: this.formatError(error) });
|
|
3802
|
+
this.emitError("fetch", { key, breakerKey, error: this.formatError(error) });
|
|
3681
3803
|
}
|
|
3682
3804
|
emitError(operation, context) {
|
|
3683
3805
|
this.logger.error?.(operation, context);
|
|
@@ -4712,6 +4834,7 @@ var PayloadProtectionError = class extends Error {
|
|
|
4712
4834
|
|
|
4713
4835
|
// src/layers/DiskLayer.ts
|
|
4714
4836
|
var FILE_SCAN_CONCURRENCY = 32;
|
|
4837
|
+
var DEFAULT_MAX_WRITE_QUEUE_DEPTH = 1e4;
|
|
4715
4838
|
var DiskLayer = class {
|
|
4716
4839
|
name;
|
|
4717
4840
|
defaultTtl;
|
|
@@ -4720,8 +4843,10 @@ var DiskLayer = class {
|
|
|
4720
4843
|
serializer;
|
|
4721
4844
|
maxFiles;
|
|
4722
4845
|
maxEntryBytes;
|
|
4846
|
+
maxWriteQueueDepth;
|
|
4723
4847
|
protection;
|
|
4724
4848
|
writeQueue = Promise.resolve();
|
|
4849
|
+
writeQueueDepth = 0;
|
|
4725
4850
|
/**
|
|
4726
4851
|
* Creates a disk-backed cache layer.
|
|
4727
4852
|
*/
|
|
@@ -4732,6 +4857,7 @@ var DiskLayer = class {
|
|
|
4732
4857
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
4733
4858
|
this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
|
|
4734
4859
|
this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
|
|
4860
|
+
this.maxWriteQueueDepth = this.normalizeMaxWriteQueueDepth(options.maxWriteQueueDepth);
|
|
4735
4861
|
this.protection = new PayloadProtection({
|
|
4736
4862
|
encryptionKey: options.encryptionKey,
|
|
4737
4863
|
signingKey: options.signingKey
|
|
@@ -4947,6 +5073,16 @@ var DiskLayer = class {
|
|
|
4947
5073
|
}
|
|
4948
5074
|
return normalized;
|
|
4949
5075
|
}
|
|
5076
|
+
normalizeMaxWriteQueueDepth(maxWriteQueueDepth) {
|
|
5077
|
+
if (maxWriteQueueDepth === false) {
|
|
5078
|
+
return false;
|
|
5079
|
+
}
|
|
5080
|
+
const normalized = maxWriteQueueDepth ?? DEFAULT_MAX_WRITE_QUEUE_DEPTH;
|
|
5081
|
+
if (!Number.isInteger(normalized) || normalized <= 0) {
|
|
5082
|
+
throw new Error("DiskLayer.maxWriteQueueDepth must be a positive integer or false.");
|
|
5083
|
+
}
|
|
5084
|
+
return normalized;
|
|
5085
|
+
}
|
|
4950
5086
|
async readEntryFile(filePath) {
|
|
4951
5087
|
let handle;
|
|
4952
5088
|
try {
|
|
@@ -5059,7 +5195,13 @@ var DiskLayer = class {
|
|
|
5059
5195
|
}
|
|
5060
5196
|
}
|
|
5061
5197
|
enqueueWrite(operation) {
|
|
5062
|
-
|
|
5198
|
+
if (this.maxWriteQueueDepth !== false && this.writeQueueDepth >= this.maxWriteQueueDepth) {
|
|
5199
|
+
return Promise.reject(new Error(`DiskLayer write queue limit (${this.maxWriteQueueDepth}) exceeded.`));
|
|
5200
|
+
}
|
|
5201
|
+
this.writeQueueDepth += 1;
|
|
5202
|
+
const next = this.writeQueue.then(operation, operation).finally(() => {
|
|
5203
|
+
this.writeQueueDepth -= 1;
|
|
5204
|
+
});
|
|
5063
5205
|
this.writeQueue = next.catch(() => void 0);
|
|
5064
5206
|
return next;
|
|
5065
5207
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "layercache",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"description": "Production-ready multi-layer caching for Node.js. Stack memory + Redis + disk behind one API with stampede prevention, tag invalidation, stale serving, and full observability.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cache",
|
|
@@ -97,8 +97,11 @@
|
|
|
97
97
|
"ioredis": "^5.6.1",
|
|
98
98
|
"ioredis-mock": "^8.13.0",
|
|
99
99
|
"tsup": "^8.5.0",
|
|
100
|
-
"tsx": "^4.
|
|
100
|
+
"tsx": "^4.22.4",
|
|
101
101
|
"typescript": "^5.8.3",
|
|
102
102
|
"vitest": "^4.1.2"
|
|
103
|
+
},
|
|
104
|
+
"overrides": {
|
|
105
|
+
"esbuild": "0.28.1"
|
|
103
106
|
}
|
|
104
107
|
}
|