layercache 1.2.9 → 1.3.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 +3 -7
- package/benchmarks/direct.ts +221 -0
- package/benchmarks/edge-utils.ts +28 -0
- package/benchmarks/edge.ts +491 -0
- package/benchmarks/http.ts +99 -0
- package/benchmarks/memory-pressure.ts +144 -0
- package/benchmarks/multi-process-fanout.ts +231 -0
- package/benchmarks/multi-process-worker.ts +151 -0
- package/benchmarks/paths.ts +25 -0
- package/benchmarks/queue-amplification-utils.ts +48 -0
- package/benchmarks/queue-amplification.ts +230 -0
- package/benchmarks/redis-latency-proxy.ts +100 -0
- package/benchmarks/redis.ts +107 -0
- package/benchmarks/scenario-utils.ts +38 -0
- package/benchmarks/server.ts +157 -0
- package/benchmarks/slow-redis-latency.ts +309 -0
- package/benchmarks/slow-redis-utils.ts +29 -0
- package/benchmarks/slow-redis.ts +47 -0
- package/benchmarks/stats.ts +46 -0
- package/benchmarks/workload.ts +77 -0
- package/dist/cli.cjs +23 -1
- package/dist/cli.js +23 -1
- package/dist/{edge-BXWTKlI1.d.cts → edge-CUHTP9Bc.d.cts} +2 -0
- package/dist/{edge-BXWTKlI1.d.ts → edge-CUHTP9Bc.d.ts} +2 -0
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/index.cjs +414 -71
- package/dist/index.d.cts +62 -5
- package/dist/index.d.ts +62 -5
- package/dist/index.js +412 -69
- package/package.json +12 -2
- package/packages/nestjs/dist/index.cjs +103 -37
- package/packages/nestjs/dist/index.d.cts +2 -0
- package/packages/nestjs/dist/index.d.ts +2 -0
- package/packages/nestjs/dist/index.js +103 -37
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "layercache",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -74,7 +74,15 @@
|
|
|
74
74
|
"lint": "biome check .",
|
|
75
75
|
"lint:fix": "biome check --write .",
|
|
76
76
|
"bench:latency": "tsx benchmarks/latency.ts",
|
|
77
|
-
"bench:stampede": "tsx benchmarks/stampede.ts"
|
|
77
|
+
"bench:stampede": "tsx benchmarks/stampede.ts",
|
|
78
|
+
"bench:direct": "tsx benchmarks/direct.ts",
|
|
79
|
+
"bench:http": "tsx benchmarks/http.ts",
|
|
80
|
+
"bench:edge": "tsx benchmarks/edge.ts",
|
|
81
|
+
"bench:slow-redis": "tsx benchmarks/slow-redis.ts",
|
|
82
|
+
"bench:memory-pressure": "tsx benchmarks/memory-pressure.ts",
|
|
83
|
+
"bench:queue-amplification": "tsx benchmarks/queue-amplification.ts",
|
|
84
|
+
"bench:multi-process-fanout": "tsx benchmarks/multi-process-fanout.ts",
|
|
85
|
+
"bench:all": "npm run bench:direct && npm run bench:edge && npm run bench:slow-redis && npm run bench:queue-amplification && npm run bench:http && npm run bench:multi-process-fanout"
|
|
78
86
|
},
|
|
79
87
|
"dependencies": {
|
|
80
88
|
"@msgpack/msgpack": "^3.0.0",
|
|
@@ -90,8 +98,10 @@
|
|
|
90
98
|
"@biomejs/biome": "^1.9.4",
|
|
91
99
|
"@nestjs/common": "^11.1.0",
|
|
92
100
|
"@nestjs/core": "^11.1.0",
|
|
101
|
+
"@types/autocannon": "^7.12.7",
|
|
93
102
|
"@types/node": "^22.15.2",
|
|
94
103
|
"@vitest/coverage-v8": "^4.1.2",
|
|
104
|
+
"autocannon": "^8.0.0",
|
|
95
105
|
"ioredis": "^5.6.1",
|
|
96
106
|
"ioredis-mock": "^8.13.0",
|
|
97
107
|
"reflect-metadata": "^0.2.2",
|
|
@@ -741,15 +741,14 @@ function createInstanceId() {
|
|
|
741
741
|
if (globalThis.crypto?.randomUUID) {
|
|
742
742
|
return globalThis.crypto.randomUUID();
|
|
743
743
|
}
|
|
744
|
-
const bytes = new Uint8Array(16);
|
|
745
744
|
if (globalThis.crypto?.getRandomValues) {
|
|
745
|
+
const bytes = new Uint8Array(16);
|
|
746
746
|
globalThis.crypto.getRandomValues(bytes);
|
|
747
|
-
|
|
748
|
-
for (let i = 0; i < bytes.length; i += 1) {
|
|
749
|
-
bytes[i] = Math.floor(Math.random() * 256);
|
|
750
|
-
}
|
|
747
|
+
return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
751
748
|
}
|
|
752
|
-
|
|
749
|
+
throw new Error(
|
|
750
|
+
"layercache requires a cryptographic random source. Neither crypto.randomUUID nor crypto.getRandomValues is available in this runtime."
|
|
751
|
+
);
|
|
753
752
|
}
|
|
754
753
|
|
|
755
754
|
// ../../src/internal/CacheStackGeneration.ts
|
|
@@ -1728,7 +1727,8 @@ var CircuitBreakerManager = class {
|
|
|
1728
1727
|
}
|
|
1729
1728
|
const remainingMs = state.openUntil - now;
|
|
1730
1729
|
const remainingSecs = Math.ceil(remainingMs / 1e3);
|
|
1731
|
-
|
|
1730
|
+
const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
1731
|
+
throw new Error(`Circuit breaker is open for key "${displayKey}" (resets in ${remainingSecs}s).`);
|
|
1732
1732
|
}
|
|
1733
1733
|
recordFailure(key, options) {
|
|
1734
1734
|
if (!options) {
|
|
@@ -2493,7 +2493,14 @@ var JsonSerializer = class {
|
|
|
2493
2493
|
}
|
|
2494
2494
|
deserialize(payload) {
|
|
2495
2495
|
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
2496
|
-
|
|
2496
|
+
let parsed;
|
|
2497
|
+
try {
|
|
2498
|
+
parsed = JSON.parse(normalized);
|
|
2499
|
+
} catch (error) {
|
|
2500
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2501
|
+
throw new Error(`JsonSerializer: failed to parse JSON payload: ${message}`);
|
|
2502
|
+
}
|
|
2503
|
+
return sanitizeStructuredData(parsed, {
|
|
2497
2504
|
label: "JSON payload",
|
|
2498
2505
|
maxDepth: 200,
|
|
2499
2506
|
maxNodes: 1e4
|
|
@@ -2503,27 +2510,68 @@ var JsonSerializer = class {
|
|
|
2503
2510
|
|
|
2504
2511
|
// ../../src/stampede/StampedeGuard.ts
|
|
2505
2512
|
var StampedeGuard = class {
|
|
2506
|
-
|
|
2513
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
2514
|
+
maxInFlight;
|
|
2515
|
+
entryTimeoutMs;
|
|
2516
|
+
constructor(options = {}) {
|
|
2517
|
+
this.maxInFlight = options.maxInFlight ?? 1e4;
|
|
2518
|
+
this.entryTimeoutMs = options.entryTimeoutMs;
|
|
2519
|
+
}
|
|
2507
2520
|
async execute(key, task) {
|
|
2508
|
-
const
|
|
2521
|
+
const existing = this.inFlight.get(key);
|
|
2522
|
+
if (existing) {
|
|
2523
|
+
existing.references += 1;
|
|
2524
|
+
try {
|
|
2525
|
+
return await existing.promise;
|
|
2526
|
+
} finally {
|
|
2527
|
+
this.releaseEntry(key, existing);
|
|
2528
|
+
}
|
|
2529
|
+
}
|
|
2530
|
+
if (this.inFlight.size >= this.maxInFlight) {
|
|
2531
|
+
throw new Error(
|
|
2532
|
+
`StampedeGuard: in-flight limit of ${this.maxInFlight} exceeded. Rejecting new key to prevent memory exhaustion.`
|
|
2533
|
+
);
|
|
2534
|
+
}
|
|
2535
|
+
const taskPromise = Promise.resolve().then(task);
|
|
2536
|
+
const guardedPromise = this.entryTimeoutMs ? this.withTimeout(key, taskPromise, this.entryTimeoutMs) : taskPromise;
|
|
2537
|
+
const entry = {
|
|
2538
|
+
promise: guardedPromise,
|
|
2539
|
+
references: 1
|
|
2540
|
+
};
|
|
2541
|
+
this.inFlight.set(key, entry);
|
|
2509
2542
|
try {
|
|
2510
|
-
return await entry.
|
|
2543
|
+
return await entry.promise;
|
|
2511
2544
|
} finally {
|
|
2512
|
-
|
|
2513
|
-
const current = this.mutexes.get(key);
|
|
2514
|
-
if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
|
|
2515
|
-
this.mutexes.delete(key);
|
|
2516
|
-
}
|
|
2545
|
+
this.releaseEntry(key, entry);
|
|
2517
2546
|
}
|
|
2518
2547
|
}
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2548
|
+
withTimeout(key, promise, timeoutMs) {
|
|
2549
|
+
return new Promise((resolve, reject) => {
|
|
2550
|
+
const timer = setTimeout(() => {
|
|
2551
|
+
reject(
|
|
2552
|
+
new Error(
|
|
2553
|
+
`StampedeGuard: task for key "${key.slice(0, 64)}${key.length > 64 ? "..." : ""}" timed out after ${timeoutMs}ms.`
|
|
2554
|
+
)
|
|
2555
|
+
);
|
|
2556
|
+
}, timeoutMs);
|
|
2557
|
+
promise.then(
|
|
2558
|
+
(value) => {
|
|
2559
|
+
clearTimeout(timer);
|
|
2560
|
+
resolve(value);
|
|
2561
|
+
},
|
|
2562
|
+
(error) => {
|
|
2563
|
+
clearTimeout(timer);
|
|
2564
|
+
reject(error);
|
|
2565
|
+
}
|
|
2566
|
+
);
|
|
2567
|
+
});
|
|
2568
|
+
}
|
|
2569
|
+
releaseEntry(key, entry) {
|
|
2570
|
+
entry.references -= 1;
|
|
2571
|
+
const current = this.inFlight.get(key);
|
|
2572
|
+
if (current === entry && entry.references === 0) {
|
|
2573
|
+
this.inFlight.delete(key);
|
|
2524
2574
|
}
|
|
2525
|
-
entry.references += 1;
|
|
2526
|
-
return entry;
|
|
2527
2575
|
}
|
|
2528
2576
|
};
|
|
2529
2577
|
|
|
@@ -2583,6 +2631,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2583
2631
|
const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
|
|
2584
2632
|
this.ttlResolver = new TtlResolver({ maxProfileEntries });
|
|
2585
2633
|
this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
|
|
2634
|
+
this.stampedeGuard = new StampedeGuard({
|
|
2635
|
+
maxInFlight: options.stampedeMaxInFlight,
|
|
2636
|
+
entryTimeoutMs: options.stampedeEntryTimeoutMs
|
|
2637
|
+
});
|
|
2586
2638
|
this.currentGeneration = options.generation;
|
|
2587
2639
|
if (options.publishSetInvalidation !== void 0) {
|
|
2588
2640
|
console.warn(
|
|
@@ -2661,7 +2713,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2661
2713
|
}
|
|
2662
2714
|
layers;
|
|
2663
2715
|
options;
|
|
2664
|
-
stampedeGuard
|
|
2716
|
+
stampedeGuard;
|
|
2665
2717
|
metricsCollector = new MetricsCollector();
|
|
2666
2718
|
instanceId = createInstanceId();
|
|
2667
2719
|
startup;
|
|
@@ -2738,7 +2790,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2738
2790
|
if (!fetcher) {
|
|
2739
2791
|
return null;
|
|
2740
2792
|
}
|
|
2741
|
-
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
2793
|
+
return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
|
|
2742
2794
|
}
|
|
2743
2795
|
/**
|
|
2744
2796
|
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
@@ -2899,7 +2951,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2899
2951
|
return promise;
|
|
2900
2952
|
}
|
|
2901
2953
|
if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
|
|
2902
|
-
|
|
2954
|
+
const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key;
|
|
2955
|
+
throw new Error(`mget received conflicting entries for key "${displayKey}".`);
|
|
2903
2956
|
}
|
|
2904
2957
|
return existing.promise;
|
|
2905
2958
|
})
|
|
@@ -3229,12 +3282,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
3229
3282
|
await this.handleInvalidationMessage(message);
|
|
3230
3283
|
});
|
|
3231
3284
|
}
|
|
3232
|
-
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
3285
|
+
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
|
|
3233
3286
|
const fetchTask = async () => {
|
|
3234
|
-
const
|
|
3235
|
-
if (
|
|
3236
|
-
this.
|
|
3237
|
-
|
|
3287
|
+
const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
|
|
3288
|
+
if (shouldRecheckFreshLayers) {
|
|
3289
|
+
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
3290
|
+
if (secondHit.found) {
|
|
3291
|
+
this.metricsCollector.increment("hits");
|
|
3292
|
+
return secondHit.value;
|
|
3293
|
+
}
|
|
3238
3294
|
}
|
|
3239
3295
|
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
|
|
3240
3296
|
};
|
|
@@ -3242,12 +3298,22 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
3242
3298
|
if (!this.options.singleFlightCoordinator) {
|
|
3243
3299
|
return fetchTask();
|
|
3244
3300
|
}
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3301
|
+
try {
|
|
3302
|
+
return await this.options.singleFlightCoordinator.execute(
|
|
3303
|
+
key,
|
|
3304
|
+
this.resolveSingleFlightOptions(),
|
|
3305
|
+
fetchTask,
|
|
3306
|
+
() => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
|
|
3307
|
+
);
|
|
3308
|
+
} catch (error) {
|
|
3309
|
+
if (!this.isGracefulDegradationEnabled()) {
|
|
3310
|
+
throw error;
|
|
3311
|
+
}
|
|
3312
|
+
this.metricsCollector.increment("degradedOperations");
|
|
3313
|
+
this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
|
|
3314
|
+
this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
|
|
3315
|
+
return fetchTask();
|
|
3316
|
+
}
|
|
3251
3317
|
};
|
|
3252
3318
|
if (this.options.stampedePrevention === false) {
|
|
3253
3319
|
return singleFlightTask();
|
|
@@ -169,6 +169,8 @@ interface CacheStackOptions {
|
|
|
169
169
|
logger?: CacheLogger | boolean;
|
|
170
170
|
metrics?: boolean;
|
|
171
171
|
stampedePrevention?: boolean;
|
|
172
|
+
stampedeMaxInFlight?: number;
|
|
173
|
+
stampedeEntryTimeoutMs?: number;
|
|
172
174
|
invalidationBus?: InvalidationBus;
|
|
173
175
|
tagIndex?: CacheTagIndex;
|
|
174
176
|
generation?: number;
|
|
@@ -169,6 +169,8 @@ interface CacheStackOptions {
|
|
|
169
169
|
logger?: CacheLogger | boolean;
|
|
170
170
|
metrics?: boolean;
|
|
171
171
|
stampedePrevention?: boolean;
|
|
172
|
+
stampedeMaxInFlight?: number;
|
|
173
|
+
stampedeEntryTimeoutMs?: number;
|
|
172
174
|
invalidationBus?: InvalidationBus;
|
|
173
175
|
tagIndex?: CacheTagIndex;
|
|
174
176
|
generation?: number;
|
|
@@ -705,15 +705,14 @@ function createInstanceId() {
|
|
|
705
705
|
if (globalThis.crypto?.randomUUID) {
|
|
706
706
|
return globalThis.crypto.randomUUID();
|
|
707
707
|
}
|
|
708
|
-
const bytes = new Uint8Array(16);
|
|
709
708
|
if (globalThis.crypto?.getRandomValues) {
|
|
709
|
+
const bytes = new Uint8Array(16);
|
|
710
710
|
globalThis.crypto.getRandomValues(bytes);
|
|
711
|
-
|
|
712
|
-
for (let i = 0; i < bytes.length; i += 1) {
|
|
713
|
-
bytes[i] = Math.floor(Math.random() * 256);
|
|
714
|
-
}
|
|
711
|
+
return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
715
712
|
}
|
|
716
|
-
|
|
713
|
+
throw new Error(
|
|
714
|
+
"layercache requires a cryptographic random source. Neither crypto.randomUUID nor crypto.getRandomValues is available in this runtime."
|
|
715
|
+
);
|
|
717
716
|
}
|
|
718
717
|
|
|
719
718
|
// ../../src/internal/CacheStackGeneration.ts
|
|
@@ -1692,7 +1691,8 @@ var CircuitBreakerManager = class {
|
|
|
1692
1691
|
}
|
|
1693
1692
|
const remainingMs = state.openUntil - now;
|
|
1694
1693
|
const remainingSecs = Math.ceil(remainingMs / 1e3);
|
|
1695
|
-
|
|
1694
|
+
const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
1695
|
+
throw new Error(`Circuit breaker is open for key "${displayKey}" (resets in ${remainingSecs}s).`);
|
|
1696
1696
|
}
|
|
1697
1697
|
recordFailure(key, options) {
|
|
1698
1698
|
if (!options) {
|
|
@@ -2457,7 +2457,14 @@ var JsonSerializer = class {
|
|
|
2457
2457
|
}
|
|
2458
2458
|
deserialize(payload) {
|
|
2459
2459
|
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
2460
|
-
|
|
2460
|
+
let parsed;
|
|
2461
|
+
try {
|
|
2462
|
+
parsed = JSON.parse(normalized);
|
|
2463
|
+
} catch (error) {
|
|
2464
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2465
|
+
throw new Error(`JsonSerializer: failed to parse JSON payload: ${message}`);
|
|
2466
|
+
}
|
|
2467
|
+
return sanitizeStructuredData(parsed, {
|
|
2461
2468
|
label: "JSON payload",
|
|
2462
2469
|
maxDepth: 200,
|
|
2463
2470
|
maxNodes: 1e4
|
|
@@ -2467,27 +2474,68 @@ var JsonSerializer = class {
|
|
|
2467
2474
|
|
|
2468
2475
|
// ../../src/stampede/StampedeGuard.ts
|
|
2469
2476
|
var StampedeGuard = class {
|
|
2470
|
-
|
|
2477
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
2478
|
+
maxInFlight;
|
|
2479
|
+
entryTimeoutMs;
|
|
2480
|
+
constructor(options = {}) {
|
|
2481
|
+
this.maxInFlight = options.maxInFlight ?? 1e4;
|
|
2482
|
+
this.entryTimeoutMs = options.entryTimeoutMs;
|
|
2483
|
+
}
|
|
2471
2484
|
async execute(key, task) {
|
|
2472
|
-
const
|
|
2485
|
+
const existing = this.inFlight.get(key);
|
|
2486
|
+
if (existing) {
|
|
2487
|
+
existing.references += 1;
|
|
2488
|
+
try {
|
|
2489
|
+
return await existing.promise;
|
|
2490
|
+
} finally {
|
|
2491
|
+
this.releaseEntry(key, existing);
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
if (this.inFlight.size >= this.maxInFlight) {
|
|
2495
|
+
throw new Error(
|
|
2496
|
+
`StampedeGuard: in-flight limit of ${this.maxInFlight} exceeded. Rejecting new key to prevent memory exhaustion.`
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
const taskPromise = Promise.resolve().then(task);
|
|
2500
|
+
const guardedPromise = this.entryTimeoutMs ? this.withTimeout(key, taskPromise, this.entryTimeoutMs) : taskPromise;
|
|
2501
|
+
const entry = {
|
|
2502
|
+
promise: guardedPromise,
|
|
2503
|
+
references: 1
|
|
2504
|
+
};
|
|
2505
|
+
this.inFlight.set(key, entry);
|
|
2473
2506
|
try {
|
|
2474
|
-
return await entry.
|
|
2507
|
+
return await entry.promise;
|
|
2475
2508
|
} finally {
|
|
2476
|
-
|
|
2477
|
-
const current = this.mutexes.get(key);
|
|
2478
|
-
if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
|
|
2479
|
-
this.mutexes.delete(key);
|
|
2480
|
-
}
|
|
2509
|
+
this.releaseEntry(key, entry);
|
|
2481
2510
|
}
|
|
2482
2511
|
}
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2512
|
+
withTimeout(key, promise, timeoutMs) {
|
|
2513
|
+
return new Promise((resolve, reject) => {
|
|
2514
|
+
const timer = setTimeout(() => {
|
|
2515
|
+
reject(
|
|
2516
|
+
new Error(
|
|
2517
|
+
`StampedeGuard: task for key "${key.slice(0, 64)}${key.length > 64 ? "..." : ""}" timed out after ${timeoutMs}ms.`
|
|
2518
|
+
)
|
|
2519
|
+
);
|
|
2520
|
+
}, timeoutMs);
|
|
2521
|
+
promise.then(
|
|
2522
|
+
(value) => {
|
|
2523
|
+
clearTimeout(timer);
|
|
2524
|
+
resolve(value);
|
|
2525
|
+
},
|
|
2526
|
+
(error) => {
|
|
2527
|
+
clearTimeout(timer);
|
|
2528
|
+
reject(error);
|
|
2529
|
+
}
|
|
2530
|
+
);
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
releaseEntry(key, entry) {
|
|
2534
|
+
entry.references -= 1;
|
|
2535
|
+
const current = this.inFlight.get(key);
|
|
2536
|
+
if (current === entry && entry.references === 0) {
|
|
2537
|
+
this.inFlight.delete(key);
|
|
2488
2538
|
}
|
|
2489
|
-
entry.references += 1;
|
|
2490
|
-
return entry;
|
|
2491
2539
|
}
|
|
2492
2540
|
};
|
|
2493
2541
|
|
|
@@ -2547,6 +2595,10 @@ var CacheStack = class extends EventEmitter {
|
|
|
2547
2595
|
const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
|
|
2548
2596
|
this.ttlResolver = new TtlResolver({ maxProfileEntries });
|
|
2549
2597
|
this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
|
|
2598
|
+
this.stampedeGuard = new StampedeGuard({
|
|
2599
|
+
maxInFlight: options.stampedeMaxInFlight,
|
|
2600
|
+
entryTimeoutMs: options.stampedeEntryTimeoutMs
|
|
2601
|
+
});
|
|
2550
2602
|
this.currentGeneration = options.generation;
|
|
2551
2603
|
if (options.publishSetInvalidation !== void 0) {
|
|
2552
2604
|
console.warn(
|
|
@@ -2625,7 +2677,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
2625
2677
|
}
|
|
2626
2678
|
layers;
|
|
2627
2679
|
options;
|
|
2628
|
-
stampedeGuard
|
|
2680
|
+
stampedeGuard;
|
|
2629
2681
|
metricsCollector = new MetricsCollector();
|
|
2630
2682
|
instanceId = createInstanceId();
|
|
2631
2683
|
startup;
|
|
@@ -2702,7 +2754,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
2702
2754
|
if (!fetcher) {
|
|
2703
2755
|
return null;
|
|
2704
2756
|
}
|
|
2705
|
-
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
2757
|
+
return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
|
|
2706
2758
|
}
|
|
2707
2759
|
/**
|
|
2708
2760
|
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
@@ -2863,7 +2915,8 @@ var CacheStack = class extends EventEmitter {
|
|
|
2863
2915
|
return promise;
|
|
2864
2916
|
}
|
|
2865
2917
|
if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
|
|
2866
|
-
|
|
2918
|
+
const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key;
|
|
2919
|
+
throw new Error(`mget received conflicting entries for key "${displayKey}".`);
|
|
2867
2920
|
}
|
|
2868
2921
|
return existing.promise;
|
|
2869
2922
|
})
|
|
@@ -3193,12 +3246,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
3193
3246
|
await this.handleInvalidationMessage(message);
|
|
3194
3247
|
});
|
|
3195
3248
|
}
|
|
3196
|
-
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
3249
|
+
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
|
|
3197
3250
|
const fetchTask = async () => {
|
|
3198
|
-
const
|
|
3199
|
-
if (
|
|
3200
|
-
this.
|
|
3201
|
-
|
|
3251
|
+
const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
|
|
3252
|
+
if (shouldRecheckFreshLayers) {
|
|
3253
|
+
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
3254
|
+
if (secondHit.found) {
|
|
3255
|
+
this.metricsCollector.increment("hits");
|
|
3256
|
+
return secondHit.value;
|
|
3257
|
+
}
|
|
3202
3258
|
}
|
|
3203
3259
|
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
|
|
3204
3260
|
};
|
|
@@ -3206,12 +3262,22 @@ var CacheStack = class extends EventEmitter {
|
|
|
3206
3262
|
if (!this.options.singleFlightCoordinator) {
|
|
3207
3263
|
return fetchTask();
|
|
3208
3264
|
}
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3265
|
+
try {
|
|
3266
|
+
return await this.options.singleFlightCoordinator.execute(
|
|
3267
|
+
key,
|
|
3268
|
+
this.resolveSingleFlightOptions(),
|
|
3269
|
+
fetchTask,
|
|
3270
|
+
() => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
|
|
3271
|
+
);
|
|
3272
|
+
} catch (error) {
|
|
3273
|
+
if (!this.isGracefulDegradationEnabled()) {
|
|
3274
|
+
throw error;
|
|
3275
|
+
}
|
|
3276
|
+
this.metricsCollector.increment("degradedOperations");
|
|
3277
|
+
this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
|
|
3278
|
+
this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
|
|
3279
|
+
return fetchTask();
|
|
3280
|
+
}
|
|
3215
3281
|
};
|
|
3216
3282
|
if (this.options.stampedePrevention === false) {
|
|
3217
3283
|
return singleFlightTask();
|