layercache 1.2.9 → 1.3.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 +10 -3
- 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/index.cjs +158 -51
- package/dist/index.d.cts +14 -2
- package/dist/index.d.ts +14 -2
- package/dist/index.js +158 -51
- package/package.json +12 -2
- package/packages/nestjs/dist/index.cjs +47 -27
- package/packages/nestjs/dist/index.js +47 -27
package/dist/index.js
CHANGED
|
@@ -1811,29 +1811,35 @@ var JsonSerializer = class {
|
|
|
1811
1811
|
};
|
|
1812
1812
|
|
|
1813
1813
|
// src/stampede/StampedeGuard.ts
|
|
1814
|
-
import { Mutex as Mutex2 } from "async-mutex";
|
|
1815
1814
|
var StampedeGuard = class {
|
|
1816
|
-
|
|
1815
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
1817
1816
|
async execute(key, task) {
|
|
1818
|
-
const
|
|
1817
|
+
const existing = this.inFlight.get(key);
|
|
1818
|
+
if (existing) {
|
|
1819
|
+
existing.references += 1;
|
|
1820
|
+
try {
|
|
1821
|
+
return await existing.promise;
|
|
1822
|
+
} finally {
|
|
1823
|
+
this.releaseEntry(key, existing);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
const entry = {
|
|
1827
|
+
promise: Promise.resolve().then(task),
|
|
1828
|
+
references: 1
|
|
1829
|
+
};
|
|
1830
|
+
this.inFlight.set(key, entry);
|
|
1819
1831
|
try {
|
|
1820
|
-
return await entry.
|
|
1832
|
+
return await entry.promise;
|
|
1821
1833
|
} finally {
|
|
1822
|
-
|
|
1823
|
-
const current = this.mutexes.get(key);
|
|
1824
|
-
if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
|
|
1825
|
-
this.mutexes.delete(key);
|
|
1826
|
-
}
|
|
1834
|
+
this.releaseEntry(key, entry);
|
|
1827
1835
|
}
|
|
1828
1836
|
}
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
this.
|
|
1837
|
+
releaseEntry(key, entry) {
|
|
1838
|
+
entry.references -= 1;
|
|
1839
|
+
const current = this.inFlight.get(key);
|
|
1840
|
+
if (current === entry && entry.references === 0) {
|
|
1841
|
+
this.inFlight.delete(key);
|
|
1834
1842
|
}
|
|
1835
|
-
entry.references += 1;
|
|
1836
|
-
return entry;
|
|
1837
1843
|
}
|
|
1838
1844
|
};
|
|
1839
1845
|
|
|
@@ -2048,7 +2054,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
2048
2054
|
if (!fetcher) {
|
|
2049
2055
|
return null;
|
|
2050
2056
|
}
|
|
2051
|
-
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
2057
|
+
return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
|
|
2052
2058
|
}
|
|
2053
2059
|
/**
|
|
2054
2060
|
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
@@ -2539,12 +2545,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
2539
2545
|
await this.handleInvalidationMessage(message);
|
|
2540
2546
|
});
|
|
2541
2547
|
}
|
|
2542
|
-
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
2548
|
+
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
|
|
2543
2549
|
const fetchTask = async () => {
|
|
2544
|
-
const
|
|
2545
|
-
if (
|
|
2546
|
-
this.
|
|
2547
|
-
|
|
2550
|
+
const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
|
|
2551
|
+
if (shouldRecheckFreshLayers) {
|
|
2552
|
+
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
2553
|
+
if (secondHit.found) {
|
|
2554
|
+
this.metricsCollector.increment("hits");
|
|
2555
|
+
return secondHit.value;
|
|
2556
|
+
}
|
|
2548
2557
|
}
|
|
2549
2558
|
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
|
|
2550
2559
|
};
|
|
@@ -2552,12 +2561,22 @@ var CacheStack = class extends EventEmitter {
|
|
|
2552
2561
|
if (!this.options.singleFlightCoordinator) {
|
|
2553
2562
|
return fetchTask();
|
|
2554
2563
|
}
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2564
|
+
try {
|
|
2565
|
+
return await this.options.singleFlightCoordinator.execute(
|
|
2566
|
+
key,
|
|
2567
|
+
this.resolveSingleFlightOptions(),
|
|
2568
|
+
fetchTask,
|
|
2569
|
+
() => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
|
|
2570
|
+
);
|
|
2571
|
+
} catch (error) {
|
|
2572
|
+
if (!this.isGracefulDegradationEnabled()) {
|
|
2573
|
+
throw error;
|
|
2574
|
+
}
|
|
2575
|
+
this.metricsCollector.increment("degradedOperations");
|
|
2576
|
+
this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
|
|
2577
|
+
this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
|
|
2578
|
+
return fetchTask();
|
|
2579
|
+
}
|
|
2561
2580
|
};
|
|
2562
2581
|
if (this.options.stampedePrevention === false) {
|
|
2563
2582
|
return singleFlightTask();
|
|
@@ -3485,6 +3504,7 @@ var RedisLayer = class {
|
|
|
3485
3504
|
compression;
|
|
3486
3505
|
compressionThreshold;
|
|
3487
3506
|
decompressionMaxBytes;
|
|
3507
|
+
commandTimeoutMs;
|
|
3488
3508
|
disconnectOnDispose;
|
|
3489
3509
|
constructor(options) {
|
|
3490
3510
|
this.client = options.client;
|
|
@@ -3497,6 +3517,7 @@ var RedisLayer = class {
|
|
|
3497
3517
|
this.compression = options.compression;
|
|
3498
3518
|
this.compressionThreshold = options.compressionThreshold ?? 1024;
|
|
3499
3519
|
this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
|
|
3520
|
+
this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
|
|
3500
3521
|
this.disconnectOnDispose = options.disconnectOnDispose ?? false;
|
|
3501
3522
|
}
|
|
3502
3523
|
async get(key) {
|
|
@@ -3504,7 +3525,7 @@ var RedisLayer = class {
|
|
|
3504
3525
|
return unwrapStoredValue(payload);
|
|
3505
3526
|
}
|
|
3506
3527
|
async getEntry(key) {
|
|
3507
|
-
const payload = await this.client.getBuffer(this.withPrefix(key));
|
|
3528
|
+
const payload = await this.runCommand(`get("${key}")`, () => this.client.getBuffer(this.withPrefix(key)));
|
|
3508
3529
|
if (payload === null) {
|
|
3509
3530
|
return null;
|
|
3510
3531
|
}
|
|
@@ -3518,7 +3539,7 @@ var RedisLayer = class {
|
|
|
3518
3539
|
for (const key of keys) {
|
|
3519
3540
|
pipeline.getBuffer(this.withPrefix(key));
|
|
3520
3541
|
}
|
|
3521
|
-
const results = await pipeline.exec();
|
|
3542
|
+
const results = await this.runCommand(`mget(${keys.length})`, () => pipeline.exec());
|
|
3522
3543
|
if (results === null) {
|
|
3523
3544
|
return keys.map(() => null);
|
|
3524
3545
|
}
|
|
@@ -3547,33 +3568,36 @@ var RedisLayer = class {
|
|
|
3547
3568
|
pipeline.set(normalizedKey, payload);
|
|
3548
3569
|
}
|
|
3549
3570
|
}
|
|
3550
|
-
await pipeline.exec();
|
|
3571
|
+
await this.runCommand(`mset(${entries.length})`, () => pipeline.exec());
|
|
3551
3572
|
}
|
|
3552
3573
|
async set(key, value, ttl = this.defaultTtl) {
|
|
3553
3574
|
const serialized = this.primarySerializer().serialize(value);
|
|
3554
3575
|
const payload = await this.encodePayload(serialized);
|
|
3555
3576
|
const normalizedKey = this.withPrefix(key);
|
|
3556
3577
|
if (ttl && ttl > 0) {
|
|
3557
|
-
await this.client.set(normalizedKey, payload, "EX", ttl);
|
|
3578
|
+
await this.runCommand(`set("${key}")`, () => this.client.set(normalizedKey, payload, "EX", ttl));
|
|
3558
3579
|
return;
|
|
3559
3580
|
}
|
|
3560
|
-
await this.client.set(normalizedKey, payload);
|
|
3581
|
+
await this.runCommand(`set("${key}")`, () => this.client.set(normalizedKey, payload));
|
|
3561
3582
|
}
|
|
3562
3583
|
async delete(key) {
|
|
3563
|
-
await this.client.del(this.withPrefix(key));
|
|
3584
|
+
await this.runCommand(`delete("${key}")`, () => this.client.del(this.withPrefix(key)));
|
|
3564
3585
|
}
|
|
3565
3586
|
async deleteMany(keys) {
|
|
3566
3587
|
if (keys.length === 0) {
|
|
3567
3588
|
return;
|
|
3568
3589
|
}
|
|
3569
|
-
await this.
|
|
3590
|
+
await this.runCommand(
|
|
3591
|
+
`deleteMany(${keys.length})`,
|
|
3592
|
+
() => this.client.del(...keys.map((key) => this.withPrefix(key)))
|
|
3593
|
+
);
|
|
3570
3594
|
}
|
|
3571
3595
|
async has(key) {
|
|
3572
|
-
const exists = await this.client.exists(this.withPrefix(key));
|
|
3596
|
+
const exists = await this.runCommand(`has("${key}")`, () => this.client.exists(this.withPrefix(key)));
|
|
3573
3597
|
return exists > 0;
|
|
3574
3598
|
}
|
|
3575
3599
|
async ttl(key) {
|
|
3576
|
-
const remaining = await this.client.ttl(this.withPrefix(key));
|
|
3600
|
+
const remaining = await this.runCommand(`ttl("${key}")`, () => this.client.ttl(this.withPrefix(key)));
|
|
3577
3601
|
if (remaining < 0) {
|
|
3578
3602
|
return null;
|
|
3579
3603
|
}
|
|
@@ -3581,13 +3605,16 @@ var RedisLayer = class {
|
|
|
3581
3605
|
}
|
|
3582
3606
|
async size() {
|
|
3583
3607
|
if (!this.prefix) {
|
|
3584
|
-
return this.client.dbsize();
|
|
3608
|
+
return this.runCommand("dbsize()", () => this.client.dbsize());
|
|
3585
3609
|
}
|
|
3586
3610
|
const pattern = `${this.prefix}*`;
|
|
3587
3611
|
let cursor = "0";
|
|
3588
3612
|
let count = 0;
|
|
3589
3613
|
do {
|
|
3590
|
-
const [nextCursor, keys] = await this.
|
|
3614
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
3615
|
+
`scan("${pattern}")`,
|
|
3616
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
3617
|
+
);
|
|
3591
3618
|
cursor = nextCursor;
|
|
3592
3619
|
count += keys.length;
|
|
3593
3620
|
} while (cursor !== "0");
|
|
@@ -3595,7 +3622,7 @@ var RedisLayer = class {
|
|
|
3595
3622
|
}
|
|
3596
3623
|
async ping() {
|
|
3597
3624
|
try {
|
|
3598
|
-
return await this.client.ping() === "PONG";
|
|
3625
|
+
return await this.runCommand("ping()", () => this.client.ping()) === "PONG";
|
|
3599
3626
|
} catch {
|
|
3600
3627
|
return false;
|
|
3601
3628
|
}
|
|
@@ -3618,14 +3645,17 @@ var RedisLayer = class {
|
|
|
3618
3645
|
const pattern = `${this.prefix}*`;
|
|
3619
3646
|
let cursor = "0";
|
|
3620
3647
|
do {
|
|
3621
|
-
const [nextCursor, keys] = await this.
|
|
3648
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
3649
|
+
`scan("${pattern}")`,
|
|
3650
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
3651
|
+
);
|
|
3622
3652
|
cursor = nextCursor;
|
|
3623
3653
|
if (keys.length === 0) {
|
|
3624
3654
|
continue;
|
|
3625
3655
|
}
|
|
3626
3656
|
for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
|
|
3627
3657
|
const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
|
|
3628
|
-
await this.client.del(...batch);
|
|
3658
|
+
await this.runCommand(`clear-del(${batch.length})`, () => this.client.del(...batch));
|
|
3629
3659
|
}
|
|
3630
3660
|
} while (cursor !== "0");
|
|
3631
3661
|
}
|
|
@@ -3641,7 +3671,10 @@ var RedisLayer = class {
|
|
|
3641
3671
|
const pattern = `${this.prefix}*`;
|
|
3642
3672
|
let cursor = "0";
|
|
3643
3673
|
do {
|
|
3644
|
-
const [nextCursor, keys] = await this.
|
|
3674
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
3675
|
+
`scan("${pattern}")`,
|
|
3676
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
3677
|
+
);
|
|
3645
3678
|
cursor = nextCursor;
|
|
3646
3679
|
for (const key of keys) {
|
|
3647
3680
|
await visitor(this.prefix ? key.slice(this.prefix.length) : key);
|
|
@@ -3652,7 +3685,10 @@ var RedisLayer = class {
|
|
|
3652
3685
|
const matches = [];
|
|
3653
3686
|
let cursor = "0";
|
|
3654
3687
|
do {
|
|
3655
|
-
const [nextCursor, keys] = await this.
|
|
3688
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
3689
|
+
`scan("${pattern}")`,
|
|
3690
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
3691
|
+
);
|
|
3656
3692
|
cursor = nextCursor;
|
|
3657
3693
|
matches.push(...keys);
|
|
3658
3694
|
} while (cursor !== "0");
|
|
@@ -3684,7 +3720,7 @@ var RedisLayer = class {
|
|
|
3684
3720
|
}
|
|
3685
3721
|
async deleteCorruptedKey(key) {
|
|
3686
3722
|
try {
|
|
3687
|
-
await this.client.del(this.withPrefix(key));
|
|
3723
|
+
await this.runCommand(`deleteCorrupted("${key}")`, () => this.client.del(this.withPrefix(key)));
|
|
3688
3724
|
} catch (deleteError) {
|
|
3689
3725
|
console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${key}"`, deleteError);
|
|
3690
3726
|
}
|
|
@@ -3692,12 +3728,15 @@ var RedisLayer = class {
|
|
|
3692
3728
|
async rewriteWithPrimarySerializer(key, value) {
|
|
3693
3729
|
const serialized = this.primarySerializer().serialize(value);
|
|
3694
3730
|
const payload = await this.encodePayload(serialized);
|
|
3695
|
-
const ttl = await this.client.ttl(this.withPrefix(key));
|
|
3731
|
+
const ttl = await this.runCommand(`rewrite-ttl("${key}")`, () => this.client.ttl(this.withPrefix(key)));
|
|
3696
3732
|
if (ttl > 0) {
|
|
3697
|
-
await this.
|
|
3733
|
+
await this.runCommand(
|
|
3734
|
+
`rewrite-set("${key}")`,
|
|
3735
|
+
() => this.client.set(this.withPrefix(key), payload, "EX", ttl)
|
|
3736
|
+
);
|
|
3698
3737
|
return;
|
|
3699
3738
|
}
|
|
3700
|
-
await this.client.set(this.withPrefix(key), payload);
|
|
3739
|
+
await this.runCommand(`rewrite-set("${key}")`, () => this.client.set(this.withPrefix(key), payload));
|
|
3701
3740
|
}
|
|
3702
3741
|
primarySerializer() {
|
|
3703
3742
|
const serializer = this.serializers[0];
|
|
@@ -3793,6 +3832,35 @@ var RedisLayer = class {
|
|
|
3793
3832
|
source.pipe(decompressor);
|
|
3794
3833
|
});
|
|
3795
3834
|
}
|
|
3835
|
+
normalizeCommandTimeoutMs(value) {
|
|
3836
|
+
if (value === void 0) {
|
|
3837
|
+
return void 0;
|
|
3838
|
+
}
|
|
3839
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
3840
|
+
throw new Error("RedisLayer.commandTimeoutMs must be a positive number.");
|
|
3841
|
+
}
|
|
3842
|
+
return value;
|
|
3843
|
+
}
|
|
3844
|
+
async runCommand(operation, command) {
|
|
3845
|
+
const promise = command();
|
|
3846
|
+
if (!this.commandTimeoutMs) {
|
|
3847
|
+
return promise;
|
|
3848
|
+
}
|
|
3849
|
+
let timer;
|
|
3850
|
+
return Promise.race([
|
|
3851
|
+
promise,
|
|
3852
|
+
new Promise((_, reject) => {
|
|
3853
|
+
timer = setTimeout(() => {
|
|
3854
|
+
reject(new Error(`RedisLayer command ${operation} timed out after ${this.commandTimeoutMs}ms.`));
|
|
3855
|
+
}, this.commandTimeoutMs);
|
|
3856
|
+
timer.unref?.();
|
|
3857
|
+
})
|
|
3858
|
+
]).finally(() => {
|
|
3859
|
+
if (timer) {
|
|
3860
|
+
clearTimeout(timer);
|
|
3861
|
+
}
|
|
3862
|
+
});
|
|
3863
|
+
}
|
|
3796
3864
|
};
|
|
3797
3865
|
|
|
3798
3866
|
// src/layers/DiskLayer.ts
|
|
@@ -4243,14 +4311,19 @@ return 0
|
|
|
4243
4311
|
var RedisSingleFlightCoordinator = class {
|
|
4244
4312
|
client;
|
|
4245
4313
|
prefix;
|
|
4314
|
+
commandTimeoutMs;
|
|
4246
4315
|
constructor(options) {
|
|
4247
4316
|
this.client = options.client;
|
|
4248
4317
|
this.prefix = options.prefix ?? "layercache:singleflight";
|
|
4318
|
+
this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
|
|
4249
4319
|
}
|
|
4250
4320
|
async execute(key, options, worker, waiter) {
|
|
4251
4321
|
const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
|
|
4252
4322
|
const token = randomUUID();
|
|
4253
|
-
const acquired = await this.
|
|
4323
|
+
const acquired = await this.runCommand(
|
|
4324
|
+
`acquire("${key}")`,
|
|
4325
|
+
() => this.client.set(lockKey, token, "PX", options.leaseMs, "NX")
|
|
4326
|
+
);
|
|
4254
4327
|
if (acquired === "OK") {
|
|
4255
4328
|
const renewTimer = this.startLeaseRenewal(lockKey, token, options);
|
|
4256
4329
|
try {
|
|
@@ -4259,7 +4332,7 @@ var RedisSingleFlightCoordinator = class {
|
|
|
4259
4332
|
if (renewTimer) {
|
|
4260
4333
|
clearInterval(renewTimer);
|
|
4261
4334
|
}
|
|
4262
|
-
await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
|
|
4335
|
+
await this.runCommand(`release("${key}")`, () => this.client.eval(RELEASE_SCRIPT, 1, lockKey, token));
|
|
4263
4336
|
}
|
|
4264
4337
|
}
|
|
4265
4338
|
return waiter();
|
|
@@ -4270,11 +4343,45 @@ var RedisSingleFlightCoordinator = class {
|
|
|
4270
4343
|
return void 0;
|
|
4271
4344
|
}
|
|
4272
4345
|
const timer = setInterval(() => {
|
|
4273
|
-
void this.
|
|
4346
|
+
void this.runCommand(
|
|
4347
|
+
`renew("${lockKey}")`,
|
|
4348
|
+
() => this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs))
|
|
4349
|
+
).catch(() => void 0);
|
|
4274
4350
|
}, renewIntervalMs);
|
|
4275
4351
|
timer.unref?.();
|
|
4276
4352
|
return timer;
|
|
4277
4353
|
}
|
|
4354
|
+
normalizeCommandTimeoutMs(value) {
|
|
4355
|
+
if (value === void 0) {
|
|
4356
|
+
return void 0;
|
|
4357
|
+
}
|
|
4358
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
4359
|
+
throw new Error("RedisSingleFlightCoordinator.commandTimeoutMs must be a positive number.");
|
|
4360
|
+
}
|
|
4361
|
+
return value;
|
|
4362
|
+
}
|
|
4363
|
+
async runCommand(operation, command) {
|
|
4364
|
+
const promise = command();
|
|
4365
|
+
if (!this.commandTimeoutMs) {
|
|
4366
|
+
return promise;
|
|
4367
|
+
}
|
|
4368
|
+
let timer;
|
|
4369
|
+
return Promise.race([
|
|
4370
|
+
promise,
|
|
4371
|
+
new Promise((_, reject) => {
|
|
4372
|
+
timer = setTimeout(() => {
|
|
4373
|
+
reject(
|
|
4374
|
+
new Error(`RedisSingleFlightCoordinator command ${operation} timed out after ${this.commandTimeoutMs}ms.`)
|
|
4375
|
+
);
|
|
4376
|
+
}, this.commandTimeoutMs);
|
|
4377
|
+
timer.unref?.();
|
|
4378
|
+
})
|
|
4379
|
+
]).finally(() => {
|
|
4380
|
+
if (timer) {
|
|
4381
|
+
clearTimeout(timer);
|
|
4382
|
+
}
|
|
4383
|
+
});
|
|
4384
|
+
}
|
|
4278
4385
|
};
|
|
4279
4386
|
|
|
4280
4387
|
// src/metrics/PrometheusExporter.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "layercache",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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",
|
|
@@ -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",
|
|
@@ -2503,27 +2503,34 @@ var JsonSerializer = class {
|
|
|
2503
2503
|
|
|
2504
2504
|
// ../../src/stampede/StampedeGuard.ts
|
|
2505
2505
|
var StampedeGuard = class {
|
|
2506
|
-
|
|
2506
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
2507
2507
|
async execute(key, task) {
|
|
2508
|
-
const
|
|
2508
|
+
const existing = this.inFlight.get(key);
|
|
2509
|
+
if (existing) {
|
|
2510
|
+
existing.references += 1;
|
|
2511
|
+
try {
|
|
2512
|
+
return await existing.promise;
|
|
2513
|
+
} finally {
|
|
2514
|
+
this.releaseEntry(key, existing);
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
const entry = {
|
|
2518
|
+
promise: Promise.resolve().then(task),
|
|
2519
|
+
references: 1
|
|
2520
|
+
};
|
|
2521
|
+
this.inFlight.set(key, entry);
|
|
2509
2522
|
try {
|
|
2510
|
-
return await entry.
|
|
2523
|
+
return await entry.promise;
|
|
2511
2524
|
} 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
|
-
}
|
|
2525
|
+
this.releaseEntry(key, entry);
|
|
2517
2526
|
}
|
|
2518
2527
|
}
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
this.
|
|
2528
|
+
releaseEntry(key, entry) {
|
|
2529
|
+
entry.references -= 1;
|
|
2530
|
+
const current = this.inFlight.get(key);
|
|
2531
|
+
if (current === entry && entry.references === 0) {
|
|
2532
|
+
this.inFlight.delete(key);
|
|
2524
2533
|
}
|
|
2525
|
-
entry.references += 1;
|
|
2526
|
-
return entry;
|
|
2527
2534
|
}
|
|
2528
2535
|
};
|
|
2529
2536
|
|
|
@@ -2738,7 +2745,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2738
2745
|
if (!fetcher) {
|
|
2739
2746
|
return null;
|
|
2740
2747
|
}
|
|
2741
|
-
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
2748
|
+
return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
|
|
2742
2749
|
}
|
|
2743
2750
|
/**
|
|
2744
2751
|
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
@@ -3229,12 +3236,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
3229
3236
|
await this.handleInvalidationMessage(message);
|
|
3230
3237
|
});
|
|
3231
3238
|
}
|
|
3232
|
-
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
3239
|
+
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
|
|
3233
3240
|
const fetchTask = async () => {
|
|
3234
|
-
const
|
|
3235
|
-
if (
|
|
3236
|
-
this.
|
|
3237
|
-
|
|
3241
|
+
const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
|
|
3242
|
+
if (shouldRecheckFreshLayers) {
|
|
3243
|
+
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
3244
|
+
if (secondHit.found) {
|
|
3245
|
+
this.metricsCollector.increment("hits");
|
|
3246
|
+
return secondHit.value;
|
|
3247
|
+
}
|
|
3238
3248
|
}
|
|
3239
3249
|
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
|
|
3240
3250
|
};
|
|
@@ -3242,12 +3252,22 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
3242
3252
|
if (!this.options.singleFlightCoordinator) {
|
|
3243
3253
|
return fetchTask();
|
|
3244
3254
|
}
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3255
|
+
try {
|
|
3256
|
+
return await this.options.singleFlightCoordinator.execute(
|
|
3257
|
+
key,
|
|
3258
|
+
this.resolveSingleFlightOptions(),
|
|
3259
|
+
fetchTask,
|
|
3260
|
+
() => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
|
|
3261
|
+
);
|
|
3262
|
+
} catch (error) {
|
|
3263
|
+
if (!this.isGracefulDegradationEnabled()) {
|
|
3264
|
+
throw error;
|
|
3265
|
+
}
|
|
3266
|
+
this.metricsCollector.increment("degradedOperations");
|
|
3267
|
+
this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
|
|
3268
|
+
this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
|
|
3269
|
+
return fetchTask();
|
|
3270
|
+
}
|
|
3251
3271
|
};
|
|
3252
3272
|
if (this.options.stampedePrevention === false) {
|
|
3253
3273
|
return singleFlightTask();
|
|
@@ -2467,27 +2467,34 @@ var JsonSerializer = class {
|
|
|
2467
2467
|
|
|
2468
2468
|
// ../../src/stampede/StampedeGuard.ts
|
|
2469
2469
|
var StampedeGuard = class {
|
|
2470
|
-
|
|
2470
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
2471
2471
|
async execute(key, task) {
|
|
2472
|
-
const
|
|
2472
|
+
const existing = this.inFlight.get(key);
|
|
2473
|
+
if (existing) {
|
|
2474
|
+
existing.references += 1;
|
|
2475
|
+
try {
|
|
2476
|
+
return await existing.promise;
|
|
2477
|
+
} finally {
|
|
2478
|
+
this.releaseEntry(key, existing);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
const entry = {
|
|
2482
|
+
promise: Promise.resolve().then(task),
|
|
2483
|
+
references: 1
|
|
2484
|
+
};
|
|
2485
|
+
this.inFlight.set(key, entry);
|
|
2473
2486
|
try {
|
|
2474
|
-
return await entry.
|
|
2487
|
+
return await entry.promise;
|
|
2475
2488
|
} 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
|
-
}
|
|
2489
|
+
this.releaseEntry(key, entry);
|
|
2481
2490
|
}
|
|
2482
2491
|
}
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
this.
|
|
2492
|
+
releaseEntry(key, entry) {
|
|
2493
|
+
entry.references -= 1;
|
|
2494
|
+
const current = this.inFlight.get(key);
|
|
2495
|
+
if (current === entry && entry.references === 0) {
|
|
2496
|
+
this.inFlight.delete(key);
|
|
2488
2497
|
}
|
|
2489
|
-
entry.references += 1;
|
|
2490
|
-
return entry;
|
|
2491
2498
|
}
|
|
2492
2499
|
};
|
|
2493
2500
|
|
|
@@ -2702,7 +2709,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
2702
2709
|
if (!fetcher) {
|
|
2703
2710
|
return null;
|
|
2704
2711
|
}
|
|
2705
|
-
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
2712
|
+
return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
|
|
2706
2713
|
}
|
|
2707
2714
|
/**
|
|
2708
2715
|
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
@@ -3193,12 +3200,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
3193
3200
|
await this.handleInvalidationMessage(message);
|
|
3194
3201
|
});
|
|
3195
3202
|
}
|
|
3196
|
-
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
3203
|
+
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
|
|
3197
3204
|
const fetchTask = async () => {
|
|
3198
|
-
const
|
|
3199
|
-
if (
|
|
3200
|
-
this.
|
|
3201
|
-
|
|
3205
|
+
const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
|
|
3206
|
+
if (shouldRecheckFreshLayers) {
|
|
3207
|
+
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
3208
|
+
if (secondHit.found) {
|
|
3209
|
+
this.metricsCollector.increment("hits");
|
|
3210
|
+
return secondHit.value;
|
|
3211
|
+
}
|
|
3202
3212
|
}
|
|
3203
3213
|
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
|
|
3204
3214
|
};
|
|
@@ -3206,12 +3216,22 @@ var CacheStack = class extends EventEmitter {
|
|
|
3206
3216
|
if (!this.options.singleFlightCoordinator) {
|
|
3207
3217
|
return fetchTask();
|
|
3208
3218
|
}
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3219
|
+
try {
|
|
3220
|
+
return await this.options.singleFlightCoordinator.execute(
|
|
3221
|
+
key,
|
|
3222
|
+
this.resolveSingleFlightOptions(),
|
|
3223
|
+
fetchTask,
|
|
3224
|
+
() => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
|
|
3225
|
+
);
|
|
3226
|
+
} catch (error) {
|
|
3227
|
+
if (!this.isGracefulDegradationEnabled()) {
|
|
3228
|
+
throw error;
|
|
3229
|
+
}
|
|
3230
|
+
this.metricsCollector.increment("degradedOperations");
|
|
3231
|
+
this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
|
|
3232
|
+
this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
|
|
3233
|
+
return fetchTask();
|
|
3234
|
+
}
|
|
3215
3235
|
};
|
|
3216
3236
|
if (this.options.stampedePrevention === false) {
|
|
3217
3237
|
return singleFlightTask();
|