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/dist/index.js
CHANGED
|
@@ -457,15 +457,14 @@ function createInstanceId() {
|
|
|
457
457
|
if (globalThis.crypto?.randomUUID) {
|
|
458
458
|
return globalThis.crypto.randomUUID();
|
|
459
459
|
}
|
|
460
|
-
const bytes = new Uint8Array(16);
|
|
461
460
|
if (globalThis.crypto?.getRandomValues) {
|
|
461
|
+
const bytes = new Uint8Array(16);
|
|
462
462
|
globalThis.crypto.getRandomValues(bytes);
|
|
463
|
-
|
|
464
|
-
for (let i = 0; i < bytes.length; i += 1) {
|
|
465
|
-
bytes[i] = Math.floor(Math.random() * 256);
|
|
466
|
-
}
|
|
463
|
+
return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
467
464
|
}
|
|
468
|
-
|
|
465
|
+
throw new Error(
|
|
466
|
+
"layercache requires a cryptographic random source. Neither crypto.randomUUID nor crypto.getRandomValues is available in this runtime."
|
|
467
|
+
);
|
|
469
468
|
}
|
|
470
469
|
|
|
471
470
|
// src/internal/CacheStackGeneration.ts
|
|
@@ -1286,7 +1285,8 @@ var CircuitBreakerManager = class {
|
|
|
1286
1285
|
}
|
|
1287
1286
|
const remainingMs = state.openUntil - now;
|
|
1288
1287
|
const remainingSecs = Math.ceil(remainingMs / 1e3);
|
|
1289
|
-
|
|
1288
|
+
const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
1289
|
+
throw new Error(`Circuit breaker is open for key "${displayKey}" (resets in ${remainingSecs}s).`);
|
|
1290
1290
|
}
|
|
1291
1291
|
recordFailure(key, options) {
|
|
1292
1292
|
if (!options) {
|
|
@@ -1802,7 +1802,14 @@ var JsonSerializer = class {
|
|
|
1802
1802
|
}
|
|
1803
1803
|
deserialize(payload) {
|
|
1804
1804
|
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
1805
|
-
|
|
1805
|
+
let parsed;
|
|
1806
|
+
try {
|
|
1807
|
+
parsed = JSON.parse(normalized);
|
|
1808
|
+
} catch (error) {
|
|
1809
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1810
|
+
throw new Error(`JsonSerializer: failed to parse JSON payload: ${message}`);
|
|
1811
|
+
}
|
|
1812
|
+
return sanitizeStructuredData(parsed, {
|
|
1806
1813
|
label: "JSON payload",
|
|
1807
1814
|
maxDepth: 200,
|
|
1808
1815
|
maxNodes: 1e4
|
|
@@ -1811,29 +1818,69 @@ var JsonSerializer = class {
|
|
|
1811
1818
|
};
|
|
1812
1819
|
|
|
1813
1820
|
// src/stampede/StampedeGuard.ts
|
|
1814
|
-
import { Mutex as Mutex2 } from "async-mutex";
|
|
1815
1821
|
var StampedeGuard = class {
|
|
1816
|
-
|
|
1822
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
1823
|
+
maxInFlight;
|
|
1824
|
+
entryTimeoutMs;
|
|
1825
|
+
constructor(options = {}) {
|
|
1826
|
+
this.maxInFlight = options.maxInFlight ?? 1e4;
|
|
1827
|
+
this.entryTimeoutMs = options.entryTimeoutMs;
|
|
1828
|
+
}
|
|
1817
1829
|
async execute(key, task) {
|
|
1818
|
-
const
|
|
1830
|
+
const existing = this.inFlight.get(key);
|
|
1831
|
+
if (existing) {
|
|
1832
|
+
existing.references += 1;
|
|
1833
|
+
try {
|
|
1834
|
+
return await existing.promise;
|
|
1835
|
+
} finally {
|
|
1836
|
+
this.releaseEntry(key, existing);
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
if (this.inFlight.size >= this.maxInFlight) {
|
|
1840
|
+
throw new Error(
|
|
1841
|
+
`StampedeGuard: in-flight limit of ${this.maxInFlight} exceeded. Rejecting new key to prevent memory exhaustion.`
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
const taskPromise = Promise.resolve().then(task);
|
|
1845
|
+
const guardedPromise = this.entryTimeoutMs ? this.withTimeout(key, taskPromise, this.entryTimeoutMs) : taskPromise;
|
|
1846
|
+
const entry = {
|
|
1847
|
+
promise: guardedPromise,
|
|
1848
|
+
references: 1
|
|
1849
|
+
};
|
|
1850
|
+
this.inFlight.set(key, entry);
|
|
1819
1851
|
try {
|
|
1820
|
-
return await entry.
|
|
1852
|
+
return await entry.promise;
|
|
1821
1853
|
} 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
|
-
}
|
|
1854
|
+
this.releaseEntry(key, entry);
|
|
1827
1855
|
}
|
|
1828
1856
|
}
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1857
|
+
withTimeout(key, promise, timeoutMs) {
|
|
1858
|
+
return new Promise((resolve2, reject) => {
|
|
1859
|
+
const timer = setTimeout(() => {
|
|
1860
|
+
reject(
|
|
1861
|
+
new Error(
|
|
1862
|
+
`StampedeGuard: task for key "${key.slice(0, 64)}${key.length > 64 ? "..." : ""}" timed out after ${timeoutMs}ms.`
|
|
1863
|
+
)
|
|
1864
|
+
);
|
|
1865
|
+
}, timeoutMs);
|
|
1866
|
+
promise.then(
|
|
1867
|
+
(value) => {
|
|
1868
|
+
clearTimeout(timer);
|
|
1869
|
+
resolve2(value);
|
|
1870
|
+
},
|
|
1871
|
+
(error) => {
|
|
1872
|
+
clearTimeout(timer);
|
|
1873
|
+
reject(error);
|
|
1874
|
+
}
|
|
1875
|
+
);
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
releaseEntry(key, entry) {
|
|
1879
|
+
entry.references -= 1;
|
|
1880
|
+
const current = this.inFlight.get(key);
|
|
1881
|
+
if (current === entry && entry.references === 0) {
|
|
1882
|
+
this.inFlight.delete(key);
|
|
1834
1883
|
}
|
|
1835
|
-
entry.references += 1;
|
|
1836
|
-
return entry;
|
|
1837
1884
|
}
|
|
1838
1885
|
};
|
|
1839
1886
|
|
|
@@ -1893,6 +1940,10 @@ var CacheStack = class extends EventEmitter {
|
|
|
1893
1940
|
const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
|
|
1894
1941
|
this.ttlResolver = new TtlResolver({ maxProfileEntries });
|
|
1895
1942
|
this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
|
|
1943
|
+
this.stampedeGuard = new StampedeGuard({
|
|
1944
|
+
maxInFlight: options.stampedeMaxInFlight,
|
|
1945
|
+
entryTimeoutMs: options.stampedeEntryTimeoutMs
|
|
1946
|
+
});
|
|
1896
1947
|
this.currentGeneration = options.generation;
|
|
1897
1948
|
if (options.publishSetInvalidation !== void 0) {
|
|
1898
1949
|
console.warn(
|
|
@@ -1971,7 +2022,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1971
2022
|
}
|
|
1972
2023
|
layers;
|
|
1973
2024
|
options;
|
|
1974
|
-
stampedeGuard
|
|
2025
|
+
stampedeGuard;
|
|
1975
2026
|
metricsCollector = new MetricsCollector();
|
|
1976
2027
|
instanceId = createInstanceId();
|
|
1977
2028
|
startup;
|
|
@@ -2048,7 +2099,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
2048
2099
|
if (!fetcher) {
|
|
2049
2100
|
return null;
|
|
2050
2101
|
}
|
|
2051
|
-
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
2102
|
+
return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
|
|
2052
2103
|
}
|
|
2053
2104
|
/**
|
|
2054
2105
|
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
@@ -2209,7 +2260,8 @@ var CacheStack = class extends EventEmitter {
|
|
|
2209
2260
|
return promise;
|
|
2210
2261
|
}
|
|
2211
2262
|
if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
|
|
2212
|
-
|
|
2263
|
+
const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key;
|
|
2264
|
+
throw new Error(`mget received conflicting entries for key "${displayKey}".`);
|
|
2213
2265
|
}
|
|
2214
2266
|
return existing.promise;
|
|
2215
2267
|
})
|
|
@@ -2539,12 +2591,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
2539
2591
|
await this.handleInvalidationMessage(message);
|
|
2540
2592
|
});
|
|
2541
2593
|
}
|
|
2542
|
-
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
2594
|
+
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
|
|
2543
2595
|
const fetchTask = async () => {
|
|
2544
|
-
const
|
|
2545
|
-
if (
|
|
2546
|
-
this.
|
|
2547
|
-
|
|
2596
|
+
const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
|
|
2597
|
+
if (shouldRecheckFreshLayers) {
|
|
2598
|
+
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
2599
|
+
if (secondHit.found) {
|
|
2600
|
+
this.metricsCollector.increment("hits");
|
|
2601
|
+
return secondHit.value;
|
|
2602
|
+
}
|
|
2548
2603
|
}
|
|
2549
2604
|
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
|
|
2550
2605
|
};
|
|
@@ -2552,12 +2607,22 @@ var CacheStack = class extends EventEmitter {
|
|
|
2552
2607
|
if (!this.options.singleFlightCoordinator) {
|
|
2553
2608
|
return fetchTask();
|
|
2554
2609
|
}
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2610
|
+
try {
|
|
2611
|
+
return await this.options.singleFlightCoordinator.execute(
|
|
2612
|
+
key,
|
|
2613
|
+
this.resolveSingleFlightOptions(),
|
|
2614
|
+
fetchTask,
|
|
2615
|
+
() => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
|
|
2616
|
+
);
|
|
2617
|
+
} catch (error) {
|
|
2618
|
+
if (!this.isGracefulDegradationEnabled()) {
|
|
2619
|
+
throw error;
|
|
2620
|
+
}
|
|
2621
|
+
this.metricsCollector.increment("degradedOperations");
|
|
2622
|
+
this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
|
|
2623
|
+
this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
|
|
2624
|
+
return fetchTask();
|
|
2625
|
+
}
|
|
2561
2626
|
};
|
|
2562
2627
|
if (this.options.stampedePrevention === false) {
|
|
2563
2628
|
return singleFlightTask();
|
|
@@ -3248,6 +3313,11 @@ var RedisInvalidationBus = class {
|
|
|
3248
3313
|
|
|
3249
3314
|
// src/http/createCacheStatsHandler.ts
|
|
3250
3315
|
function createCacheStatsHandler(cache, options = {}) {
|
|
3316
|
+
if (options.allowPublicAccess === true) {
|
|
3317
|
+
console.warn(
|
|
3318
|
+
"[layercache] WARNING: Stats endpoint is publicly accessible without authentication. Set allowPublicAccess: false (or provide an authorize callback) before deploying to production."
|
|
3319
|
+
);
|
|
3320
|
+
}
|
|
3251
3321
|
return async (request, response) => {
|
|
3252
3322
|
response.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
3253
3323
|
response.setHeader?.("cache-control", "no-store");
|
|
@@ -3290,6 +3360,11 @@ function createCachedMethodDecorator(options) {
|
|
|
3290
3360
|
|
|
3291
3361
|
// src/integrations/fastify.ts
|
|
3292
3362
|
function createFastifyLayercachePlugin(cache, options = {}) {
|
|
3363
|
+
if (options.exposeStatsRoute === true && options.allowPublicStatsRoute === true) {
|
|
3364
|
+
console.warn(
|
|
3365
|
+
"[layercache] WARNING: Cache stats route is publicly accessible without authentication. Set allowPublicStatsRoute: false (or provide an authorizeStatsRoute callback) before deploying to production."
|
|
3366
|
+
);
|
|
3367
|
+
}
|
|
3293
3368
|
return async (fastify) => {
|
|
3294
3369
|
fastify.decorate("cache", cache);
|
|
3295
3370
|
if (options.exposeStatsRoute === true && fastify.get) {
|
|
@@ -3485,6 +3560,7 @@ var RedisLayer = class {
|
|
|
3485
3560
|
compression;
|
|
3486
3561
|
compressionThreshold;
|
|
3487
3562
|
decompressionMaxBytes;
|
|
3563
|
+
commandTimeoutMs;
|
|
3488
3564
|
disconnectOnDispose;
|
|
3489
3565
|
constructor(options) {
|
|
3490
3566
|
this.client = options.client;
|
|
@@ -3497,6 +3573,7 @@ var RedisLayer = class {
|
|
|
3497
3573
|
this.compression = options.compression;
|
|
3498
3574
|
this.compressionThreshold = options.compressionThreshold ?? 1024;
|
|
3499
3575
|
this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
|
|
3576
|
+
this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
|
|
3500
3577
|
this.disconnectOnDispose = options.disconnectOnDispose ?? false;
|
|
3501
3578
|
}
|
|
3502
3579
|
async get(key) {
|
|
@@ -3504,7 +3581,11 @@ var RedisLayer = class {
|
|
|
3504
3581
|
return unwrapStoredValue(payload);
|
|
3505
3582
|
}
|
|
3506
3583
|
async getEntry(key) {
|
|
3507
|
-
|
|
3584
|
+
this.validateKey(key);
|
|
3585
|
+
const payload = await this.runCommand(
|
|
3586
|
+
`get(${this.displayKey(key)})`,
|
|
3587
|
+
() => this.client.getBuffer(this.withPrefix(key))
|
|
3588
|
+
);
|
|
3508
3589
|
if (payload === null) {
|
|
3509
3590
|
return null;
|
|
3510
3591
|
}
|
|
@@ -3514,11 +3595,14 @@ var RedisLayer = class {
|
|
|
3514
3595
|
if (keys.length === 0) {
|
|
3515
3596
|
return [];
|
|
3516
3597
|
}
|
|
3598
|
+
for (const key of keys) {
|
|
3599
|
+
this.validateKey(key);
|
|
3600
|
+
}
|
|
3517
3601
|
const pipeline = this.client.pipeline();
|
|
3518
3602
|
for (const key of keys) {
|
|
3519
3603
|
pipeline.getBuffer(this.withPrefix(key));
|
|
3520
3604
|
}
|
|
3521
|
-
const results = await pipeline.exec();
|
|
3605
|
+
const results = await this.runCommand(`mget(${keys.length})`, () => pipeline.exec());
|
|
3522
3606
|
if (results === null) {
|
|
3523
3607
|
return keys.map(() => null);
|
|
3524
3608
|
}
|
|
@@ -3536,6 +3620,9 @@ var RedisLayer = class {
|
|
|
3536
3620
|
if (entries.length === 0) {
|
|
3537
3621
|
return;
|
|
3538
3622
|
}
|
|
3623
|
+
for (const entry of entries) {
|
|
3624
|
+
this.validateKey(entry.key);
|
|
3625
|
+
}
|
|
3539
3626
|
const pipeline = this.client.pipeline();
|
|
3540
3627
|
for (const entry of entries) {
|
|
3541
3628
|
const serialized = this.primarySerializer().serialize(entry.value);
|
|
@@ -3547,33 +3634,46 @@ var RedisLayer = class {
|
|
|
3547
3634
|
pipeline.set(normalizedKey, payload);
|
|
3548
3635
|
}
|
|
3549
3636
|
}
|
|
3550
|
-
await pipeline.exec();
|
|
3637
|
+
await this.runCommand(`mset(${entries.length})`, () => pipeline.exec());
|
|
3551
3638
|
}
|
|
3552
3639
|
async set(key, value, ttl = this.defaultTtl) {
|
|
3640
|
+
this.validateKey(key);
|
|
3553
3641
|
const serialized = this.primarySerializer().serialize(value);
|
|
3554
3642
|
const payload = await this.encodePayload(serialized);
|
|
3555
3643
|
const normalizedKey = this.withPrefix(key);
|
|
3556
3644
|
if (ttl && ttl > 0) {
|
|
3557
|
-
await this.
|
|
3645
|
+
await this.runCommand(
|
|
3646
|
+
`set(${this.displayKey(key)})`,
|
|
3647
|
+
() => this.client.set(normalizedKey, payload, "EX", ttl)
|
|
3648
|
+
);
|
|
3558
3649
|
return;
|
|
3559
3650
|
}
|
|
3560
|
-
await this.client.set(normalizedKey, payload);
|
|
3651
|
+
await this.runCommand(`set(${this.displayKey(key)})`, () => this.client.set(normalizedKey, payload));
|
|
3561
3652
|
}
|
|
3562
3653
|
async delete(key) {
|
|
3563
|
-
|
|
3654
|
+
this.validateKey(key);
|
|
3655
|
+
await this.runCommand(`delete(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
|
|
3564
3656
|
}
|
|
3565
3657
|
async deleteMany(keys) {
|
|
3566
3658
|
if (keys.length === 0) {
|
|
3567
3659
|
return;
|
|
3568
3660
|
}
|
|
3569
|
-
|
|
3661
|
+
for (const key of keys) {
|
|
3662
|
+
this.validateKey(key);
|
|
3663
|
+
}
|
|
3664
|
+
await this.runCommand(
|
|
3665
|
+
`deleteMany(${keys.length})`,
|
|
3666
|
+
() => this.client.del(...keys.map((key) => this.withPrefix(key)))
|
|
3667
|
+
);
|
|
3570
3668
|
}
|
|
3571
3669
|
async has(key) {
|
|
3572
|
-
|
|
3670
|
+
this.validateKey(key);
|
|
3671
|
+
const exists = await this.runCommand(`has(${this.displayKey(key)})`, () => this.client.exists(this.withPrefix(key)));
|
|
3573
3672
|
return exists > 0;
|
|
3574
3673
|
}
|
|
3575
3674
|
async ttl(key) {
|
|
3576
|
-
|
|
3675
|
+
this.validateKey(key);
|
|
3676
|
+
const remaining = await this.runCommand(`ttl(${this.displayKey(key)})`, () => this.client.ttl(this.withPrefix(key)));
|
|
3577
3677
|
if (remaining < 0) {
|
|
3578
3678
|
return null;
|
|
3579
3679
|
}
|
|
@@ -3581,13 +3681,16 @@ var RedisLayer = class {
|
|
|
3581
3681
|
}
|
|
3582
3682
|
async size() {
|
|
3583
3683
|
if (!this.prefix) {
|
|
3584
|
-
return this.client.dbsize();
|
|
3684
|
+
return this.runCommand("dbsize()", () => this.client.dbsize());
|
|
3585
3685
|
}
|
|
3586
3686
|
const pattern = `${this.prefix}*`;
|
|
3587
3687
|
let cursor = "0";
|
|
3588
3688
|
let count = 0;
|
|
3589
3689
|
do {
|
|
3590
|
-
const [nextCursor, keys] = await this.
|
|
3690
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
3691
|
+
`scan("${pattern}")`,
|
|
3692
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
3693
|
+
);
|
|
3591
3694
|
cursor = nextCursor;
|
|
3592
3695
|
count += keys.length;
|
|
3593
3696
|
} while (cursor !== "0");
|
|
@@ -3595,7 +3698,7 @@ var RedisLayer = class {
|
|
|
3595
3698
|
}
|
|
3596
3699
|
async ping() {
|
|
3597
3700
|
try {
|
|
3598
|
-
return await this.client.ping() === "PONG";
|
|
3701
|
+
return await this.runCommand("ping()", () => this.client.ping()) === "PONG";
|
|
3599
3702
|
} catch {
|
|
3600
3703
|
return false;
|
|
3601
3704
|
}
|
|
@@ -3618,14 +3721,17 @@ var RedisLayer = class {
|
|
|
3618
3721
|
const pattern = `${this.prefix}*`;
|
|
3619
3722
|
let cursor = "0";
|
|
3620
3723
|
do {
|
|
3621
|
-
const [nextCursor, keys] = await this.
|
|
3724
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
3725
|
+
`scan("${pattern}")`,
|
|
3726
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
3727
|
+
);
|
|
3622
3728
|
cursor = nextCursor;
|
|
3623
3729
|
if (keys.length === 0) {
|
|
3624
3730
|
continue;
|
|
3625
3731
|
}
|
|
3626
3732
|
for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
|
|
3627
3733
|
const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
|
|
3628
|
-
await this.client.del(...batch);
|
|
3734
|
+
await this.runCommand(`clear-del(${batch.length})`, () => this.client.del(...batch));
|
|
3629
3735
|
}
|
|
3630
3736
|
} while (cursor !== "0");
|
|
3631
3737
|
}
|
|
@@ -3641,7 +3747,10 @@ var RedisLayer = class {
|
|
|
3641
3747
|
const pattern = `${this.prefix}*`;
|
|
3642
3748
|
let cursor = "0";
|
|
3643
3749
|
do {
|
|
3644
|
-
const [nextCursor, keys] = await this.
|
|
3750
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
3751
|
+
`scan("${pattern}")`,
|
|
3752
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
3753
|
+
);
|
|
3645
3754
|
cursor = nextCursor;
|
|
3646
3755
|
for (const key of keys) {
|
|
3647
3756
|
await visitor(this.prefix ? key.slice(this.prefix.length) : key);
|
|
@@ -3652,7 +3761,10 @@ var RedisLayer = class {
|
|
|
3652
3761
|
const matches = [];
|
|
3653
3762
|
let cursor = "0";
|
|
3654
3763
|
do {
|
|
3655
|
-
const [nextCursor, keys] = await this.
|
|
3764
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
3765
|
+
`scan("${pattern}")`,
|
|
3766
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
3767
|
+
);
|
|
3656
3768
|
cursor = nextCursor;
|
|
3657
3769
|
matches.push(...keys);
|
|
3658
3770
|
} while (cursor !== "0");
|
|
@@ -3661,6 +3773,23 @@ var RedisLayer = class {
|
|
|
3661
3773
|
withPrefix(key) {
|
|
3662
3774
|
return `${this.prefix}${key}`;
|
|
3663
3775
|
}
|
|
3776
|
+
validateKey(key) {
|
|
3777
|
+
if (key.length === 0) {
|
|
3778
|
+
throw new Error("RedisLayer: key must not be empty.");
|
|
3779
|
+
}
|
|
3780
|
+
if (key.length > 1024) {
|
|
3781
|
+
throw new Error(`RedisLayer: key length must be at most 1 024 characters (got ${key.length}).`);
|
|
3782
|
+
}
|
|
3783
|
+
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
3784
|
+
throw new Error("RedisLayer: key contains unsupported control characters.");
|
|
3785
|
+
}
|
|
3786
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
3787
|
+
throw new Error("RedisLayer: key contains unsupported surrogate code points.");
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
displayKey(key) {
|
|
3791
|
+
return key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
3792
|
+
}
|
|
3664
3793
|
async deserializeOrDelete(key, payload) {
|
|
3665
3794
|
let decodedPayload;
|
|
3666
3795
|
try {
|
|
@@ -3684,20 +3813,30 @@ var RedisLayer = class {
|
|
|
3684
3813
|
}
|
|
3685
3814
|
async deleteCorruptedKey(key) {
|
|
3686
3815
|
try {
|
|
3687
|
-
await this.client.del(this.withPrefix(key));
|
|
3816
|
+
await this.runCommand(`deleteCorrupted(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
|
|
3688
3817
|
} catch (deleteError) {
|
|
3689
|
-
|
|
3818
|
+
const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
3819
|
+
console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${displayKey}"`, deleteError);
|
|
3690
3820
|
}
|
|
3691
3821
|
}
|
|
3692
3822
|
async rewriteWithPrimarySerializer(key, value) {
|
|
3693
3823
|
const serialized = this.primarySerializer().serialize(value);
|
|
3694
3824
|
const payload = await this.encodePayload(serialized);
|
|
3695
|
-
const ttl = await this.
|
|
3825
|
+
const ttl = await this.runCommand(
|
|
3826
|
+
`rewrite-ttl(${this.displayKey(key)})`,
|
|
3827
|
+
() => this.client.ttl(this.withPrefix(key))
|
|
3828
|
+
);
|
|
3696
3829
|
if (ttl > 0) {
|
|
3697
|
-
await this.
|
|
3830
|
+
await this.runCommand(
|
|
3831
|
+
`rewrite-set(${this.displayKey(key)})`,
|
|
3832
|
+
() => this.client.set(this.withPrefix(key), payload, "EX", ttl)
|
|
3833
|
+
);
|
|
3698
3834
|
return;
|
|
3699
3835
|
}
|
|
3700
|
-
await this.
|
|
3836
|
+
await this.runCommand(
|
|
3837
|
+
`rewrite-set(${this.displayKey(key)})`,
|
|
3838
|
+
() => this.client.set(this.withPrefix(key), payload)
|
|
3839
|
+
);
|
|
3701
3840
|
}
|
|
3702
3841
|
primarySerializer() {
|
|
3703
3842
|
const serializer = this.serializers[0];
|
|
@@ -3793,12 +3932,163 @@ var RedisLayer = class {
|
|
|
3793
3932
|
source.pipe(decompressor);
|
|
3794
3933
|
});
|
|
3795
3934
|
}
|
|
3935
|
+
normalizeCommandTimeoutMs(value) {
|
|
3936
|
+
if (value === void 0) {
|
|
3937
|
+
return void 0;
|
|
3938
|
+
}
|
|
3939
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
3940
|
+
throw new Error("RedisLayer.commandTimeoutMs must be a positive number.");
|
|
3941
|
+
}
|
|
3942
|
+
return value;
|
|
3943
|
+
}
|
|
3944
|
+
async runCommand(operation, command) {
|
|
3945
|
+
const promise = command();
|
|
3946
|
+
if (!this.commandTimeoutMs) {
|
|
3947
|
+
return promise;
|
|
3948
|
+
}
|
|
3949
|
+
let timer;
|
|
3950
|
+
return Promise.race([
|
|
3951
|
+
promise,
|
|
3952
|
+
new Promise((_, reject) => {
|
|
3953
|
+
timer = setTimeout(() => {
|
|
3954
|
+
reject(new Error(`RedisLayer command ${operation} timed out after ${this.commandTimeoutMs}ms.`));
|
|
3955
|
+
}, this.commandTimeoutMs);
|
|
3956
|
+
timer.unref?.();
|
|
3957
|
+
})
|
|
3958
|
+
]).finally(() => {
|
|
3959
|
+
if (timer) {
|
|
3960
|
+
clearTimeout(timer);
|
|
3961
|
+
}
|
|
3962
|
+
});
|
|
3963
|
+
}
|
|
3796
3964
|
};
|
|
3797
3965
|
|
|
3798
3966
|
// src/layers/DiskLayer.ts
|
|
3799
|
-
import { createHash, randomBytes as
|
|
3967
|
+
import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
|
|
3800
3968
|
import { promises as fs2 } from "fs";
|
|
3801
3969
|
import { join, resolve } from "path";
|
|
3970
|
+
|
|
3971
|
+
// src/internal/PayloadProtection.ts
|
|
3972
|
+
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes as randomBytes2, timingSafeEqual } from "crypto";
|
|
3973
|
+
var MAGIC_ENCRYPTED = Buffer.from("LCP1:");
|
|
3974
|
+
var MAGIC_SIGNED = Buffer.from("LCS1:");
|
|
3975
|
+
var ALGORITHM = "aes-256-gcm";
|
|
3976
|
+
var IV_LENGTH = 12;
|
|
3977
|
+
var AUTH_TAG_LENGTH = 16;
|
|
3978
|
+
var HMAC_LENGTH = 32;
|
|
3979
|
+
var PayloadProtection = class {
|
|
3980
|
+
encryptionKey;
|
|
3981
|
+
signingKey;
|
|
3982
|
+
constructor(options) {
|
|
3983
|
+
if (options.encryptionKey) {
|
|
3984
|
+
const raw = Buffer.isBuffer(options.encryptionKey) ? options.encryptionKey : Buffer.from(options.encryptionKey, "utf8");
|
|
3985
|
+
this.encryptionKey = createHash("sha256").update(raw).digest();
|
|
3986
|
+
}
|
|
3987
|
+
if (options.signingKey && !options.encryptionKey) {
|
|
3988
|
+
const raw = Buffer.isBuffer(options.signingKey) ? options.signingKey : Buffer.from(options.signingKey, "utf8");
|
|
3989
|
+
this.signingKey = createHash("sha256").update(raw).digest();
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
/** Returns `true` when any protection (encryption or signing) is configured. */
|
|
3993
|
+
get isEnabled() {
|
|
3994
|
+
return this.encryptionKey !== void 0 || this.signingKey !== void 0;
|
|
3995
|
+
}
|
|
3996
|
+
/**
|
|
3997
|
+
* Applies the configured protection (encryption or signing) to a payload.
|
|
3998
|
+
* Returns the input unchanged when no protection is configured.
|
|
3999
|
+
*/
|
|
4000
|
+
protect(payload) {
|
|
4001
|
+
if (this.encryptionKey) {
|
|
4002
|
+
return this.encrypt(payload, this.encryptionKey);
|
|
4003
|
+
}
|
|
4004
|
+
if (this.signingKey) {
|
|
4005
|
+
return this.sign(payload, this.signingKey);
|
|
4006
|
+
}
|
|
4007
|
+
return payload;
|
|
4008
|
+
}
|
|
4009
|
+
/**
|
|
4010
|
+
* Removes the protection layer from a payload.
|
|
4011
|
+
*
|
|
4012
|
+
* - Protected payloads are decrypted/verified using the configured keys.
|
|
4013
|
+
* - Legacy unprotected payloads pass through unchanged when **no** protection
|
|
4014
|
+
* is configured.
|
|
4015
|
+
* - If protection **is** configured but the payload is not protected, the
|
|
4016
|
+
* payload is treated as a legacy entry. Callers can handle this case by
|
|
4017
|
+
* checking `isEnabled` separately.
|
|
4018
|
+
*/
|
|
4019
|
+
unprotect(payload) {
|
|
4020
|
+
if (this.startsWith(payload, MAGIC_ENCRYPTED)) {
|
|
4021
|
+
if (!this.encryptionKey) {
|
|
4022
|
+
throw new PayloadProtectionError("Encrypted payload but no encryptionKey configured.");
|
|
4023
|
+
}
|
|
4024
|
+
return this.decrypt(payload, this.encryptionKey);
|
|
4025
|
+
}
|
|
4026
|
+
if (this.startsWith(payload, MAGIC_SIGNED)) {
|
|
4027
|
+
if (!this.signingKey) {
|
|
4028
|
+
throw new PayloadProtectionError("Signed payload but no signingKey configured.");
|
|
4029
|
+
}
|
|
4030
|
+
return this.verify(payload, this.signingKey);
|
|
4031
|
+
}
|
|
4032
|
+
return payload;
|
|
4033
|
+
}
|
|
4034
|
+
// ── Encryption (AES-256-GCM) ──────────────────────────────────────────
|
|
4035
|
+
encrypt(plaintext, key) {
|
|
4036
|
+
const iv = randomBytes2(IV_LENGTH);
|
|
4037
|
+
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
4038
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
4039
|
+
const authTag = cipher.getAuthTag();
|
|
4040
|
+
return Buffer.concat([MAGIC_ENCRYPTED, iv, authTag, encrypted]);
|
|
4041
|
+
}
|
|
4042
|
+
decrypt(payload, key) {
|
|
4043
|
+
const headerEnd = MAGIC_ENCRYPTED.length;
|
|
4044
|
+
const iv = payload.subarray(headerEnd, headerEnd + IV_LENGTH);
|
|
4045
|
+
const authTag = payload.subarray(headerEnd + IV_LENGTH, headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
|
|
4046
|
+
const ciphertext = payload.subarray(headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
|
|
4047
|
+
try {
|
|
4048
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv, {
|
|
4049
|
+
authTagLength: AUTH_TAG_LENGTH
|
|
4050
|
+
});
|
|
4051
|
+
decipher.setAuthTag(authTag);
|
|
4052
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
4053
|
+
} catch {
|
|
4054
|
+
throw new PayloadProtectionError(
|
|
4055
|
+
"Decryption failed. The data may have been tampered with or the encryptionKey is incorrect."
|
|
4056
|
+
);
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
// ── Signing (HMAC-SHA256) ─────────────────────────────────────────────
|
|
4060
|
+
sign(payload, key) {
|
|
4061
|
+
const hmac = createHmac("sha256", key).update(payload).digest();
|
|
4062
|
+
return Buffer.concat([MAGIC_SIGNED, hmac, payload]);
|
|
4063
|
+
}
|
|
4064
|
+
verify(payload, key) {
|
|
4065
|
+
const headerEnd = MAGIC_SIGNED.length;
|
|
4066
|
+
const receivedHmac = payload.subarray(headerEnd, headerEnd + HMAC_LENGTH);
|
|
4067
|
+
const data = payload.subarray(headerEnd + HMAC_LENGTH);
|
|
4068
|
+
const expectedHmac = createHmac("sha256", key).update(data).digest();
|
|
4069
|
+
if (receivedHmac.length !== HMAC_LENGTH || !timingSafeEqual(receivedHmac, expectedHmac)) {
|
|
4070
|
+
throw new PayloadProtectionError(
|
|
4071
|
+
"HMAC verification failed. The data may have been tampered with or the signingKey is incorrect."
|
|
4072
|
+
);
|
|
4073
|
+
}
|
|
4074
|
+
return data;
|
|
4075
|
+
}
|
|
4076
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
4077
|
+
startsWith(buffer, prefix) {
|
|
4078
|
+
if (buffer.length < prefix.length) {
|
|
4079
|
+
return false;
|
|
4080
|
+
}
|
|
4081
|
+
return buffer.subarray(0, prefix.length).equals(prefix);
|
|
4082
|
+
}
|
|
4083
|
+
};
|
|
4084
|
+
var PayloadProtectionError = class extends Error {
|
|
4085
|
+
constructor(message) {
|
|
4086
|
+
super(message);
|
|
4087
|
+
this.name = "PayloadProtectionError";
|
|
4088
|
+
}
|
|
4089
|
+
};
|
|
4090
|
+
|
|
4091
|
+
// src/layers/DiskLayer.ts
|
|
3802
4092
|
var FILE_SCAN_CONCURRENCY = 32;
|
|
3803
4093
|
var DiskLayer = class {
|
|
3804
4094
|
name;
|
|
@@ -3808,6 +4098,7 @@ var DiskLayer = class {
|
|
|
3808
4098
|
serializer;
|
|
3809
4099
|
maxFiles;
|
|
3810
4100
|
maxEntryBytes;
|
|
4101
|
+
protection;
|
|
3811
4102
|
writeQueue = Promise.resolve();
|
|
3812
4103
|
constructor(options) {
|
|
3813
4104
|
this.directory = this.resolveDirectory(options.directory);
|
|
@@ -3816,6 +4107,10 @@ var DiskLayer = class {
|
|
|
3816
4107
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
3817
4108
|
this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
|
|
3818
4109
|
this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
|
|
4110
|
+
this.protection = new PayloadProtection({
|
|
4111
|
+
encryptionKey: options.encryptionKey,
|
|
4112
|
+
signingKey: options.signingKey
|
|
4113
|
+
});
|
|
3819
4114
|
}
|
|
3820
4115
|
async get(key) {
|
|
3821
4116
|
return unwrapStoredValue(await this.getEntry(key));
|
|
@@ -3848,10 +4143,12 @@ var DiskLayer = class {
|
|
|
3848
4143
|
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
3849
4144
|
};
|
|
3850
4145
|
const payload = this.serializer.serialize(entry);
|
|
4146
|
+
const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf8");
|
|
4147
|
+
const protectedPayload = this.protection.protect(raw);
|
|
3851
4148
|
const targetPath = this.keyToPath(key);
|
|
3852
|
-
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${
|
|
4149
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes3(8).toString("hex")}.tmp`;
|
|
3853
4150
|
try {
|
|
3854
|
-
await fs2.writeFile(tempPath,
|
|
4151
|
+
await fs2.writeFile(tempPath, protectedPayload);
|
|
3855
4152
|
await fs2.rename(tempPath, targetPath);
|
|
3856
4153
|
} catch (error) {
|
|
3857
4154
|
await this.safeDelete(tempPath);
|
|
@@ -3949,7 +4246,7 @@ var DiskLayer = class {
|
|
|
3949
4246
|
async dispose() {
|
|
3950
4247
|
}
|
|
3951
4248
|
keyToPath(key) {
|
|
3952
|
-
const hash =
|
|
4249
|
+
const hash = createHash2("sha256").update(key).digest("hex");
|
|
3953
4250
|
return join(this.directory, `${hash}.lc`);
|
|
3954
4251
|
}
|
|
3955
4252
|
resolveDirectory(directory) {
|
|
@@ -3963,10 +4260,13 @@ var DiskLayer = class {
|
|
|
3963
4260
|
}
|
|
3964
4261
|
normalizeMaxFiles(maxFiles) {
|
|
3965
4262
|
if (maxFiles === void 0) {
|
|
4263
|
+
return 5e4;
|
|
4264
|
+
}
|
|
4265
|
+
if (maxFiles === Number.POSITIVE_INFINITY) {
|
|
3966
4266
|
return void 0;
|
|
3967
4267
|
}
|
|
3968
4268
|
if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
|
|
3969
|
-
throw new Error("DiskLayer.maxFiles must be a positive integer.");
|
|
4269
|
+
throw new Error("DiskLayer.maxFiles must be a positive integer or Infinity.");
|
|
3970
4270
|
}
|
|
3971
4271
|
return maxFiles;
|
|
3972
4272
|
}
|
|
@@ -4078,7 +4378,8 @@ var DiskLayer = class {
|
|
|
4078
4378
|
);
|
|
4079
4379
|
}
|
|
4080
4380
|
deserializeEntry(raw) {
|
|
4081
|
-
const
|
|
4381
|
+
const unprotected = this.protection.unprotect(raw);
|
|
4382
|
+
const entry = this.serializer.deserialize(unprotected);
|
|
4082
4383
|
if (!isDiskEntry(entry)) {
|
|
4083
4384
|
throw new Error("Invalid disk cache entry.");
|
|
4084
4385
|
}
|
|
@@ -4200,7 +4501,10 @@ var MemcachedLayer = class {
|
|
|
4200
4501
|
validateKey(key) {
|
|
4201
4502
|
const fullKey = this.withPrefix(key);
|
|
4202
4503
|
if (Buffer.byteLength(fullKey, "utf8") > 250) {
|
|
4203
|
-
|
|
4504
|
+
const displayKey = fullKey.slice(0, 64);
|
|
4505
|
+
throw new Error(
|
|
4506
|
+
`MemcachedLayer: key exceeds 250-byte Memcached limit: "${displayKey}${fullKey.length > 64 ? "..." : ""}"`
|
|
4507
|
+
);
|
|
4204
4508
|
}
|
|
4205
4509
|
if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
|
|
4206
4510
|
throw new Error(
|
|
@@ -4243,14 +4547,19 @@ return 0
|
|
|
4243
4547
|
var RedisSingleFlightCoordinator = class {
|
|
4244
4548
|
client;
|
|
4245
4549
|
prefix;
|
|
4550
|
+
commandTimeoutMs;
|
|
4246
4551
|
constructor(options) {
|
|
4247
4552
|
this.client = options.client;
|
|
4248
4553
|
this.prefix = options.prefix ?? "layercache:singleflight";
|
|
4554
|
+
this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
|
|
4249
4555
|
}
|
|
4250
4556
|
async execute(key, options, worker, waiter) {
|
|
4251
4557
|
const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
|
|
4252
4558
|
const token = randomUUID();
|
|
4253
|
-
const acquired = await this.
|
|
4559
|
+
const acquired = await this.runCommand(
|
|
4560
|
+
`acquire("${key}")`,
|
|
4561
|
+
() => this.client.set(lockKey, token, "PX", options.leaseMs, "NX")
|
|
4562
|
+
);
|
|
4254
4563
|
if (acquired === "OK") {
|
|
4255
4564
|
const renewTimer = this.startLeaseRenewal(lockKey, token, options);
|
|
4256
4565
|
try {
|
|
@@ -4259,7 +4568,7 @@ var RedisSingleFlightCoordinator = class {
|
|
|
4259
4568
|
if (renewTimer) {
|
|
4260
4569
|
clearInterval(renewTimer);
|
|
4261
4570
|
}
|
|
4262
|
-
await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
|
|
4571
|
+
await this.runCommand(`release("${key}")`, () => this.client.eval(RELEASE_SCRIPT, 1, lockKey, token));
|
|
4263
4572
|
}
|
|
4264
4573
|
}
|
|
4265
4574
|
return waiter();
|
|
@@ -4270,11 +4579,45 @@ var RedisSingleFlightCoordinator = class {
|
|
|
4270
4579
|
return void 0;
|
|
4271
4580
|
}
|
|
4272
4581
|
const timer = setInterval(() => {
|
|
4273
|
-
void this.
|
|
4582
|
+
void this.runCommand(
|
|
4583
|
+
`renew("${lockKey}")`,
|
|
4584
|
+
() => this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs))
|
|
4585
|
+
).catch(() => void 0);
|
|
4274
4586
|
}, renewIntervalMs);
|
|
4275
4587
|
timer.unref?.();
|
|
4276
4588
|
return timer;
|
|
4277
4589
|
}
|
|
4590
|
+
normalizeCommandTimeoutMs(value) {
|
|
4591
|
+
if (value === void 0) {
|
|
4592
|
+
return void 0;
|
|
4593
|
+
}
|
|
4594
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
4595
|
+
throw new Error("RedisSingleFlightCoordinator.commandTimeoutMs must be a positive number.");
|
|
4596
|
+
}
|
|
4597
|
+
return value;
|
|
4598
|
+
}
|
|
4599
|
+
async runCommand(operation, command) {
|
|
4600
|
+
const promise = command();
|
|
4601
|
+
if (!this.commandTimeoutMs) {
|
|
4602
|
+
return promise;
|
|
4603
|
+
}
|
|
4604
|
+
let timer;
|
|
4605
|
+
return Promise.race([
|
|
4606
|
+
promise,
|
|
4607
|
+
new Promise((_, reject) => {
|
|
4608
|
+
timer = setTimeout(() => {
|
|
4609
|
+
reject(
|
|
4610
|
+
new Error(`RedisSingleFlightCoordinator command ${operation} timed out after ${this.commandTimeoutMs}ms.`)
|
|
4611
|
+
);
|
|
4612
|
+
}, this.commandTimeoutMs);
|
|
4613
|
+
timer.unref?.();
|
|
4614
|
+
})
|
|
4615
|
+
]).finally(() => {
|
|
4616
|
+
if (timer) {
|
|
4617
|
+
clearTimeout(timer);
|
|
4618
|
+
}
|
|
4619
|
+
});
|
|
4620
|
+
}
|
|
4278
4621
|
};
|
|
4279
4622
|
|
|
4280
4623
|
// src/metrics/PrometheusExporter.ts
|