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