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.cjs
CHANGED
|
@@ -545,15 +545,14 @@ function createInstanceId() {
|
|
|
545
545
|
if (globalThis.crypto?.randomUUID) {
|
|
546
546
|
return globalThis.crypto.randomUUID();
|
|
547
547
|
}
|
|
548
|
-
const bytes = new Uint8Array(16);
|
|
549
548
|
if (globalThis.crypto?.getRandomValues) {
|
|
549
|
+
const bytes = new Uint8Array(16);
|
|
550
550
|
globalThis.crypto.getRandomValues(bytes);
|
|
551
|
-
|
|
552
|
-
for (let i = 0; i < bytes.length; i += 1) {
|
|
553
|
-
bytes[i] = Math.floor(Math.random() * 256);
|
|
554
|
-
}
|
|
551
|
+
return `layercache-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
555
552
|
}
|
|
556
|
-
|
|
553
|
+
throw new Error(
|
|
554
|
+
"layercache requires a cryptographic random source. Neither crypto.randomUUID nor crypto.getRandomValues is available in this runtime."
|
|
555
|
+
);
|
|
557
556
|
}
|
|
558
557
|
|
|
559
558
|
// src/internal/CacheStackGeneration.ts
|
|
@@ -1532,7 +1531,8 @@ var CircuitBreakerManager = class {
|
|
|
1532
1531
|
}
|
|
1533
1532
|
const remainingMs = state.openUntil - now;
|
|
1534
1533
|
const remainingSecs = Math.ceil(remainingMs / 1e3);
|
|
1535
|
-
|
|
1534
|
+
const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
1535
|
+
throw new Error(`Circuit breaker is open for key "${displayKey}" (resets in ${remainingSecs}s).`);
|
|
1536
1536
|
}
|
|
1537
1537
|
recordFailure(key, options) {
|
|
1538
1538
|
if (!options) {
|
|
@@ -2297,7 +2297,14 @@ var JsonSerializer = class {
|
|
|
2297
2297
|
}
|
|
2298
2298
|
deserialize(payload) {
|
|
2299
2299
|
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
2300
|
-
|
|
2300
|
+
let parsed;
|
|
2301
|
+
try {
|
|
2302
|
+
parsed = JSON.parse(normalized);
|
|
2303
|
+
} catch (error) {
|
|
2304
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2305
|
+
throw new Error(`JsonSerializer: failed to parse JSON payload: ${message}`);
|
|
2306
|
+
}
|
|
2307
|
+
return sanitizeStructuredData(parsed, {
|
|
2301
2308
|
label: "JSON payload",
|
|
2302
2309
|
maxDepth: 200,
|
|
2303
2310
|
maxNodes: 1e4
|
|
@@ -2306,29 +2313,69 @@ var JsonSerializer = class {
|
|
|
2306
2313
|
};
|
|
2307
2314
|
|
|
2308
2315
|
// src/stampede/StampedeGuard.ts
|
|
2309
|
-
var import_async_mutex2 = require("async-mutex");
|
|
2310
2316
|
var StampedeGuard = class {
|
|
2311
|
-
|
|
2317
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
2318
|
+
maxInFlight;
|
|
2319
|
+
entryTimeoutMs;
|
|
2320
|
+
constructor(options = {}) {
|
|
2321
|
+
this.maxInFlight = options.maxInFlight ?? 1e4;
|
|
2322
|
+
this.entryTimeoutMs = options.entryTimeoutMs;
|
|
2323
|
+
}
|
|
2312
2324
|
async execute(key, task) {
|
|
2313
|
-
const
|
|
2325
|
+
const existing = this.inFlight.get(key);
|
|
2326
|
+
if (existing) {
|
|
2327
|
+
existing.references += 1;
|
|
2328
|
+
try {
|
|
2329
|
+
return await existing.promise;
|
|
2330
|
+
} finally {
|
|
2331
|
+
this.releaseEntry(key, existing);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
if (this.inFlight.size >= this.maxInFlight) {
|
|
2335
|
+
throw new Error(
|
|
2336
|
+
`StampedeGuard: in-flight limit of ${this.maxInFlight} exceeded. Rejecting new key to prevent memory exhaustion.`
|
|
2337
|
+
);
|
|
2338
|
+
}
|
|
2339
|
+
const taskPromise = Promise.resolve().then(task);
|
|
2340
|
+
const guardedPromise = this.entryTimeoutMs ? this.withTimeout(key, taskPromise, this.entryTimeoutMs) : taskPromise;
|
|
2341
|
+
const entry = {
|
|
2342
|
+
promise: guardedPromise,
|
|
2343
|
+
references: 1
|
|
2344
|
+
};
|
|
2345
|
+
this.inFlight.set(key, entry);
|
|
2314
2346
|
try {
|
|
2315
|
-
return await entry.
|
|
2347
|
+
return await entry.promise;
|
|
2316
2348
|
} finally {
|
|
2317
|
-
|
|
2318
|
-
const current = this.mutexes.get(key);
|
|
2319
|
-
if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
|
|
2320
|
-
this.mutexes.delete(key);
|
|
2321
|
-
}
|
|
2349
|
+
this.releaseEntry(key, entry);
|
|
2322
2350
|
}
|
|
2323
2351
|
}
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2352
|
+
withTimeout(key, promise, timeoutMs) {
|
|
2353
|
+
return new Promise((resolve2, reject) => {
|
|
2354
|
+
const timer = setTimeout(() => {
|
|
2355
|
+
reject(
|
|
2356
|
+
new Error(
|
|
2357
|
+
`StampedeGuard: task for key "${key.slice(0, 64)}${key.length > 64 ? "..." : ""}" timed out after ${timeoutMs}ms.`
|
|
2358
|
+
)
|
|
2359
|
+
);
|
|
2360
|
+
}, timeoutMs);
|
|
2361
|
+
promise.then(
|
|
2362
|
+
(value) => {
|
|
2363
|
+
clearTimeout(timer);
|
|
2364
|
+
resolve2(value);
|
|
2365
|
+
},
|
|
2366
|
+
(error) => {
|
|
2367
|
+
clearTimeout(timer);
|
|
2368
|
+
reject(error);
|
|
2369
|
+
}
|
|
2370
|
+
);
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
releaseEntry(key, entry) {
|
|
2374
|
+
entry.references -= 1;
|
|
2375
|
+
const current = this.inFlight.get(key);
|
|
2376
|
+
if (current === entry && entry.references === 0) {
|
|
2377
|
+
this.inFlight.delete(key);
|
|
2329
2378
|
}
|
|
2330
|
-
entry.references += 1;
|
|
2331
|
-
return entry;
|
|
2332
2379
|
}
|
|
2333
2380
|
};
|
|
2334
2381
|
|
|
@@ -2388,6 +2435,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2388
2435
|
const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
|
|
2389
2436
|
this.ttlResolver = new TtlResolver({ maxProfileEntries });
|
|
2390
2437
|
this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
|
|
2438
|
+
this.stampedeGuard = new StampedeGuard({
|
|
2439
|
+
maxInFlight: options.stampedeMaxInFlight,
|
|
2440
|
+
entryTimeoutMs: options.stampedeEntryTimeoutMs
|
|
2441
|
+
});
|
|
2391
2442
|
this.currentGeneration = options.generation;
|
|
2392
2443
|
if (options.publishSetInvalidation !== void 0) {
|
|
2393
2444
|
console.warn(
|
|
@@ -2466,7 +2517,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2466
2517
|
}
|
|
2467
2518
|
layers;
|
|
2468
2519
|
options;
|
|
2469
|
-
stampedeGuard
|
|
2520
|
+
stampedeGuard;
|
|
2470
2521
|
metricsCollector = new MetricsCollector();
|
|
2471
2522
|
instanceId = createInstanceId();
|
|
2472
2523
|
startup;
|
|
@@ -2543,7 +2594,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2543
2594
|
if (!fetcher) {
|
|
2544
2595
|
return null;
|
|
2545
2596
|
}
|
|
2546
|
-
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
2597
|
+
return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
|
|
2547
2598
|
}
|
|
2548
2599
|
/**
|
|
2549
2600
|
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
@@ -2704,7 +2755,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2704
2755
|
return promise;
|
|
2705
2756
|
}
|
|
2706
2757
|
if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
|
|
2707
|
-
|
|
2758
|
+
const displayKey = entry.key.length > 64 ? `${entry.key.slice(0, 64)}...` : entry.key;
|
|
2759
|
+
throw new Error(`mget received conflicting entries for key "${displayKey}".`);
|
|
2708
2760
|
}
|
|
2709
2761
|
return existing.promise;
|
|
2710
2762
|
})
|
|
@@ -3034,12 +3086,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
3034
3086
|
await this.handleInvalidationMessage(message);
|
|
3035
3087
|
});
|
|
3036
3088
|
}
|
|
3037
|
-
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
3089
|
+
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
|
|
3038
3090
|
const fetchTask = async () => {
|
|
3039
|
-
const
|
|
3040
|
-
if (
|
|
3041
|
-
this.
|
|
3042
|
-
|
|
3091
|
+
const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
|
|
3092
|
+
if (shouldRecheckFreshLayers) {
|
|
3093
|
+
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
3094
|
+
if (secondHit.found) {
|
|
3095
|
+
this.metricsCollector.increment("hits");
|
|
3096
|
+
return secondHit.value;
|
|
3097
|
+
}
|
|
3043
3098
|
}
|
|
3044
3099
|
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
|
|
3045
3100
|
};
|
|
@@ -3047,12 +3102,22 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
3047
3102
|
if (!this.options.singleFlightCoordinator) {
|
|
3048
3103
|
return fetchTask();
|
|
3049
3104
|
}
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3105
|
+
try {
|
|
3106
|
+
return await this.options.singleFlightCoordinator.execute(
|
|
3107
|
+
key,
|
|
3108
|
+
this.resolveSingleFlightOptions(),
|
|
3109
|
+
fetchTask,
|
|
3110
|
+
() => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
|
|
3111
|
+
);
|
|
3112
|
+
} catch (error) {
|
|
3113
|
+
if (!this.isGracefulDegradationEnabled()) {
|
|
3114
|
+
throw error;
|
|
3115
|
+
}
|
|
3116
|
+
this.metricsCollector.increment("degradedOperations");
|
|
3117
|
+
this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
|
|
3118
|
+
this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
|
|
3119
|
+
return fetchTask();
|
|
3120
|
+
}
|
|
3056
3121
|
};
|
|
3057
3122
|
if (this.options.stampedePrevention === false) {
|
|
3058
3123
|
return singleFlightTask();
|
|
@@ -3923,6 +3988,11 @@ function simpleHash(value) {
|
|
|
3923
3988
|
|
|
3924
3989
|
// src/http/createCacheStatsHandler.ts
|
|
3925
3990
|
function createCacheStatsHandler(cache, options = {}) {
|
|
3991
|
+
if (options.allowPublicAccess === true) {
|
|
3992
|
+
console.warn(
|
|
3993
|
+
"[layercache] WARNING: Stats endpoint is publicly accessible without authentication. Set allowPublicAccess: false (or provide an authorize callback) before deploying to production."
|
|
3994
|
+
);
|
|
3995
|
+
}
|
|
3926
3996
|
return async (request, response) => {
|
|
3927
3997
|
response.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
3928
3998
|
response.setHeader?.("cache-control", "no-store");
|
|
@@ -3965,6 +4035,11 @@ function createCachedMethodDecorator(options) {
|
|
|
3965
4035
|
|
|
3966
4036
|
// src/integrations/fastify.ts
|
|
3967
4037
|
function createFastifyLayercachePlugin(cache, options = {}) {
|
|
4038
|
+
if (options.exposeStatsRoute === true && options.allowPublicStatsRoute === true) {
|
|
4039
|
+
console.warn(
|
|
4040
|
+
"[layercache] WARNING: Cache stats route is publicly accessible without authentication. Set allowPublicStatsRoute: false (or provide an authorizeStatsRoute callback) before deploying to production."
|
|
4041
|
+
);
|
|
4042
|
+
}
|
|
3968
4043
|
return async (fastify) => {
|
|
3969
4044
|
fastify.decorate("cache", cache);
|
|
3970
4045
|
if (options.exposeStatsRoute === true && fastify.get) {
|
|
@@ -4393,6 +4468,7 @@ var RedisLayer = class {
|
|
|
4393
4468
|
compression;
|
|
4394
4469
|
compressionThreshold;
|
|
4395
4470
|
decompressionMaxBytes;
|
|
4471
|
+
commandTimeoutMs;
|
|
4396
4472
|
disconnectOnDispose;
|
|
4397
4473
|
constructor(options) {
|
|
4398
4474
|
this.client = options.client;
|
|
@@ -4405,6 +4481,7 @@ var RedisLayer = class {
|
|
|
4405
4481
|
this.compression = options.compression;
|
|
4406
4482
|
this.compressionThreshold = options.compressionThreshold ?? 1024;
|
|
4407
4483
|
this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
|
|
4484
|
+
this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
|
|
4408
4485
|
this.disconnectOnDispose = options.disconnectOnDispose ?? false;
|
|
4409
4486
|
}
|
|
4410
4487
|
async get(key) {
|
|
@@ -4412,7 +4489,11 @@ var RedisLayer = class {
|
|
|
4412
4489
|
return unwrapStoredValue(payload);
|
|
4413
4490
|
}
|
|
4414
4491
|
async getEntry(key) {
|
|
4415
|
-
|
|
4492
|
+
this.validateKey(key);
|
|
4493
|
+
const payload = await this.runCommand(
|
|
4494
|
+
`get(${this.displayKey(key)})`,
|
|
4495
|
+
() => this.client.getBuffer(this.withPrefix(key))
|
|
4496
|
+
);
|
|
4416
4497
|
if (payload === null) {
|
|
4417
4498
|
return null;
|
|
4418
4499
|
}
|
|
@@ -4422,11 +4503,14 @@ var RedisLayer = class {
|
|
|
4422
4503
|
if (keys.length === 0) {
|
|
4423
4504
|
return [];
|
|
4424
4505
|
}
|
|
4506
|
+
for (const key of keys) {
|
|
4507
|
+
this.validateKey(key);
|
|
4508
|
+
}
|
|
4425
4509
|
const pipeline = this.client.pipeline();
|
|
4426
4510
|
for (const key of keys) {
|
|
4427
4511
|
pipeline.getBuffer(this.withPrefix(key));
|
|
4428
4512
|
}
|
|
4429
|
-
const results = await pipeline.exec();
|
|
4513
|
+
const results = await this.runCommand(`mget(${keys.length})`, () => pipeline.exec());
|
|
4430
4514
|
if (results === null) {
|
|
4431
4515
|
return keys.map(() => null);
|
|
4432
4516
|
}
|
|
@@ -4444,6 +4528,9 @@ var RedisLayer = class {
|
|
|
4444
4528
|
if (entries.length === 0) {
|
|
4445
4529
|
return;
|
|
4446
4530
|
}
|
|
4531
|
+
for (const entry of entries) {
|
|
4532
|
+
this.validateKey(entry.key);
|
|
4533
|
+
}
|
|
4447
4534
|
const pipeline = this.client.pipeline();
|
|
4448
4535
|
for (const entry of entries) {
|
|
4449
4536
|
const serialized = this.primarySerializer().serialize(entry.value);
|
|
@@ -4455,33 +4542,46 @@ var RedisLayer = class {
|
|
|
4455
4542
|
pipeline.set(normalizedKey, payload);
|
|
4456
4543
|
}
|
|
4457
4544
|
}
|
|
4458
|
-
await pipeline.exec();
|
|
4545
|
+
await this.runCommand(`mset(${entries.length})`, () => pipeline.exec());
|
|
4459
4546
|
}
|
|
4460
4547
|
async set(key, value, ttl = this.defaultTtl) {
|
|
4548
|
+
this.validateKey(key);
|
|
4461
4549
|
const serialized = this.primarySerializer().serialize(value);
|
|
4462
4550
|
const payload = await this.encodePayload(serialized);
|
|
4463
4551
|
const normalizedKey = this.withPrefix(key);
|
|
4464
4552
|
if (ttl && ttl > 0) {
|
|
4465
|
-
await this.
|
|
4553
|
+
await this.runCommand(
|
|
4554
|
+
`set(${this.displayKey(key)})`,
|
|
4555
|
+
() => this.client.set(normalizedKey, payload, "EX", ttl)
|
|
4556
|
+
);
|
|
4466
4557
|
return;
|
|
4467
4558
|
}
|
|
4468
|
-
await this.client.set(normalizedKey, payload);
|
|
4559
|
+
await this.runCommand(`set(${this.displayKey(key)})`, () => this.client.set(normalizedKey, payload));
|
|
4469
4560
|
}
|
|
4470
4561
|
async delete(key) {
|
|
4471
|
-
|
|
4562
|
+
this.validateKey(key);
|
|
4563
|
+
await this.runCommand(`delete(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
|
|
4472
4564
|
}
|
|
4473
4565
|
async deleteMany(keys) {
|
|
4474
4566
|
if (keys.length === 0) {
|
|
4475
4567
|
return;
|
|
4476
4568
|
}
|
|
4477
|
-
|
|
4569
|
+
for (const key of keys) {
|
|
4570
|
+
this.validateKey(key);
|
|
4571
|
+
}
|
|
4572
|
+
await this.runCommand(
|
|
4573
|
+
`deleteMany(${keys.length})`,
|
|
4574
|
+
() => this.client.del(...keys.map((key) => this.withPrefix(key)))
|
|
4575
|
+
);
|
|
4478
4576
|
}
|
|
4479
4577
|
async has(key) {
|
|
4480
|
-
|
|
4578
|
+
this.validateKey(key);
|
|
4579
|
+
const exists = await this.runCommand(`has(${this.displayKey(key)})`, () => this.client.exists(this.withPrefix(key)));
|
|
4481
4580
|
return exists > 0;
|
|
4482
4581
|
}
|
|
4483
4582
|
async ttl(key) {
|
|
4484
|
-
|
|
4583
|
+
this.validateKey(key);
|
|
4584
|
+
const remaining = await this.runCommand(`ttl(${this.displayKey(key)})`, () => this.client.ttl(this.withPrefix(key)));
|
|
4485
4585
|
if (remaining < 0) {
|
|
4486
4586
|
return null;
|
|
4487
4587
|
}
|
|
@@ -4489,13 +4589,16 @@ var RedisLayer = class {
|
|
|
4489
4589
|
}
|
|
4490
4590
|
async size() {
|
|
4491
4591
|
if (!this.prefix) {
|
|
4492
|
-
return this.client.dbsize();
|
|
4592
|
+
return this.runCommand("dbsize()", () => this.client.dbsize());
|
|
4493
4593
|
}
|
|
4494
4594
|
const pattern = `${this.prefix}*`;
|
|
4495
4595
|
let cursor = "0";
|
|
4496
4596
|
let count = 0;
|
|
4497
4597
|
do {
|
|
4498
|
-
const [nextCursor, keys] = await this.
|
|
4598
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
4599
|
+
`scan("${pattern}")`,
|
|
4600
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
4601
|
+
);
|
|
4499
4602
|
cursor = nextCursor;
|
|
4500
4603
|
count += keys.length;
|
|
4501
4604
|
} while (cursor !== "0");
|
|
@@ -4503,7 +4606,7 @@ var RedisLayer = class {
|
|
|
4503
4606
|
}
|
|
4504
4607
|
async ping() {
|
|
4505
4608
|
try {
|
|
4506
|
-
return await this.client.ping() === "PONG";
|
|
4609
|
+
return await this.runCommand("ping()", () => this.client.ping()) === "PONG";
|
|
4507
4610
|
} catch {
|
|
4508
4611
|
return false;
|
|
4509
4612
|
}
|
|
@@ -4526,14 +4629,17 @@ var RedisLayer = class {
|
|
|
4526
4629
|
const pattern = `${this.prefix}*`;
|
|
4527
4630
|
let cursor = "0";
|
|
4528
4631
|
do {
|
|
4529
|
-
const [nextCursor, keys] = await this.
|
|
4632
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
4633
|
+
`scan("${pattern}")`,
|
|
4634
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
4635
|
+
);
|
|
4530
4636
|
cursor = nextCursor;
|
|
4531
4637
|
if (keys.length === 0) {
|
|
4532
4638
|
continue;
|
|
4533
4639
|
}
|
|
4534
4640
|
for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
|
|
4535
4641
|
const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
|
|
4536
|
-
await this.client.del(...batch);
|
|
4642
|
+
await this.runCommand(`clear-del(${batch.length})`, () => this.client.del(...batch));
|
|
4537
4643
|
}
|
|
4538
4644
|
} while (cursor !== "0");
|
|
4539
4645
|
}
|
|
@@ -4549,7 +4655,10 @@ var RedisLayer = class {
|
|
|
4549
4655
|
const pattern = `${this.prefix}*`;
|
|
4550
4656
|
let cursor = "0";
|
|
4551
4657
|
do {
|
|
4552
|
-
const [nextCursor, keys] = await this.
|
|
4658
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
4659
|
+
`scan("${pattern}")`,
|
|
4660
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
4661
|
+
);
|
|
4553
4662
|
cursor = nextCursor;
|
|
4554
4663
|
for (const key of keys) {
|
|
4555
4664
|
await visitor(this.prefix ? key.slice(this.prefix.length) : key);
|
|
@@ -4560,7 +4669,10 @@ var RedisLayer = class {
|
|
|
4560
4669
|
const matches = [];
|
|
4561
4670
|
let cursor = "0";
|
|
4562
4671
|
do {
|
|
4563
|
-
const [nextCursor, keys] = await this.
|
|
4672
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
4673
|
+
`scan("${pattern}")`,
|
|
4674
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
4675
|
+
);
|
|
4564
4676
|
cursor = nextCursor;
|
|
4565
4677
|
matches.push(...keys);
|
|
4566
4678
|
} while (cursor !== "0");
|
|
@@ -4569,6 +4681,23 @@ var RedisLayer = class {
|
|
|
4569
4681
|
withPrefix(key) {
|
|
4570
4682
|
return `${this.prefix}${key}`;
|
|
4571
4683
|
}
|
|
4684
|
+
validateKey(key) {
|
|
4685
|
+
if (key.length === 0) {
|
|
4686
|
+
throw new Error("RedisLayer: key must not be empty.");
|
|
4687
|
+
}
|
|
4688
|
+
if (key.length > 1024) {
|
|
4689
|
+
throw new Error(`RedisLayer: key length must be at most 1 024 characters (got ${key.length}).`);
|
|
4690
|
+
}
|
|
4691
|
+
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
4692
|
+
throw new Error("RedisLayer: key contains unsupported control characters.");
|
|
4693
|
+
}
|
|
4694
|
+
if (/[\uD800-\uDFFF]/.test(key)) {
|
|
4695
|
+
throw new Error("RedisLayer: key contains unsupported surrogate code points.");
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
4698
|
+
displayKey(key) {
|
|
4699
|
+
return key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
4700
|
+
}
|
|
4572
4701
|
async deserializeOrDelete(key, payload) {
|
|
4573
4702
|
let decodedPayload;
|
|
4574
4703
|
try {
|
|
@@ -4592,20 +4721,30 @@ var RedisLayer = class {
|
|
|
4592
4721
|
}
|
|
4593
4722
|
async deleteCorruptedKey(key) {
|
|
4594
4723
|
try {
|
|
4595
|
-
await this.client.del(this.withPrefix(key));
|
|
4724
|
+
await this.runCommand(`deleteCorrupted(${this.displayKey(key)})`, () => this.client.del(this.withPrefix(key)));
|
|
4596
4725
|
} catch (deleteError) {
|
|
4597
|
-
|
|
4726
|
+
const displayKey = key.length > 64 ? `${key.slice(0, 64)}...` : key;
|
|
4727
|
+
console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${displayKey}"`, deleteError);
|
|
4598
4728
|
}
|
|
4599
4729
|
}
|
|
4600
4730
|
async rewriteWithPrimarySerializer(key, value) {
|
|
4601
4731
|
const serialized = this.primarySerializer().serialize(value);
|
|
4602
4732
|
const payload = await this.encodePayload(serialized);
|
|
4603
|
-
const ttl = await this.
|
|
4733
|
+
const ttl = await this.runCommand(
|
|
4734
|
+
`rewrite-ttl(${this.displayKey(key)})`,
|
|
4735
|
+
() => this.client.ttl(this.withPrefix(key))
|
|
4736
|
+
);
|
|
4604
4737
|
if (ttl > 0) {
|
|
4605
|
-
await this.
|
|
4738
|
+
await this.runCommand(
|
|
4739
|
+
`rewrite-set(${this.displayKey(key)})`,
|
|
4740
|
+
() => this.client.set(this.withPrefix(key), payload, "EX", ttl)
|
|
4741
|
+
);
|
|
4606
4742
|
return;
|
|
4607
4743
|
}
|
|
4608
|
-
await this.
|
|
4744
|
+
await this.runCommand(
|
|
4745
|
+
`rewrite-set(${this.displayKey(key)})`,
|
|
4746
|
+
() => this.client.set(this.withPrefix(key), payload)
|
|
4747
|
+
);
|
|
4609
4748
|
}
|
|
4610
4749
|
primarySerializer() {
|
|
4611
4750
|
const serializer = this.serializers[0];
|
|
@@ -4701,12 +4840,163 @@ var RedisLayer = class {
|
|
|
4701
4840
|
source.pipe(decompressor);
|
|
4702
4841
|
});
|
|
4703
4842
|
}
|
|
4843
|
+
normalizeCommandTimeoutMs(value) {
|
|
4844
|
+
if (value === void 0) {
|
|
4845
|
+
return void 0;
|
|
4846
|
+
}
|
|
4847
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
4848
|
+
throw new Error("RedisLayer.commandTimeoutMs must be a positive number.");
|
|
4849
|
+
}
|
|
4850
|
+
return value;
|
|
4851
|
+
}
|
|
4852
|
+
async runCommand(operation, command) {
|
|
4853
|
+
const promise = command();
|
|
4854
|
+
if (!this.commandTimeoutMs) {
|
|
4855
|
+
return promise;
|
|
4856
|
+
}
|
|
4857
|
+
let timer;
|
|
4858
|
+
return Promise.race([
|
|
4859
|
+
promise,
|
|
4860
|
+
new Promise((_, reject) => {
|
|
4861
|
+
timer = setTimeout(() => {
|
|
4862
|
+
reject(new Error(`RedisLayer command ${operation} timed out after ${this.commandTimeoutMs}ms.`));
|
|
4863
|
+
}, this.commandTimeoutMs);
|
|
4864
|
+
timer.unref?.();
|
|
4865
|
+
})
|
|
4866
|
+
]).finally(() => {
|
|
4867
|
+
if (timer) {
|
|
4868
|
+
clearTimeout(timer);
|
|
4869
|
+
}
|
|
4870
|
+
});
|
|
4871
|
+
}
|
|
4704
4872
|
};
|
|
4705
4873
|
|
|
4706
4874
|
// src/layers/DiskLayer.ts
|
|
4707
|
-
var
|
|
4875
|
+
var import_node_crypto3 = require("crypto");
|
|
4708
4876
|
var import_node_fs2 = require("fs");
|
|
4709
4877
|
var import_node_path2 = require("path");
|
|
4878
|
+
|
|
4879
|
+
// src/internal/PayloadProtection.ts
|
|
4880
|
+
var import_node_crypto2 = require("crypto");
|
|
4881
|
+
var MAGIC_ENCRYPTED = Buffer.from("LCP1:");
|
|
4882
|
+
var MAGIC_SIGNED = Buffer.from("LCS1:");
|
|
4883
|
+
var ALGORITHM = "aes-256-gcm";
|
|
4884
|
+
var IV_LENGTH = 12;
|
|
4885
|
+
var AUTH_TAG_LENGTH = 16;
|
|
4886
|
+
var HMAC_LENGTH = 32;
|
|
4887
|
+
var PayloadProtection = class {
|
|
4888
|
+
encryptionKey;
|
|
4889
|
+
signingKey;
|
|
4890
|
+
constructor(options) {
|
|
4891
|
+
if (options.encryptionKey) {
|
|
4892
|
+
const raw = Buffer.isBuffer(options.encryptionKey) ? options.encryptionKey : Buffer.from(options.encryptionKey, "utf8");
|
|
4893
|
+
this.encryptionKey = (0, import_node_crypto2.createHash)("sha256").update(raw).digest();
|
|
4894
|
+
}
|
|
4895
|
+
if (options.signingKey && !options.encryptionKey) {
|
|
4896
|
+
const raw = Buffer.isBuffer(options.signingKey) ? options.signingKey : Buffer.from(options.signingKey, "utf8");
|
|
4897
|
+
this.signingKey = (0, import_node_crypto2.createHash)("sha256").update(raw).digest();
|
|
4898
|
+
}
|
|
4899
|
+
}
|
|
4900
|
+
/** Returns `true` when any protection (encryption or signing) is configured. */
|
|
4901
|
+
get isEnabled() {
|
|
4902
|
+
return this.encryptionKey !== void 0 || this.signingKey !== void 0;
|
|
4903
|
+
}
|
|
4904
|
+
/**
|
|
4905
|
+
* Applies the configured protection (encryption or signing) to a payload.
|
|
4906
|
+
* Returns the input unchanged when no protection is configured.
|
|
4907
|
+
*/
|
|
4908
|
+
protect(payload) {
|
|
4909
|
+
if (this.encryptionKey) {
|
|
4910
|
+
return this.encrypt(payload, this.encryptionKey);
|
|
4911
|
+
}
|
|
4912
|
+
if (this.signingKey) {
|
|
4913
|
+
return this.sign(payload, this.signingKey);
|
|
4914
|
+
}
|
|
4915
|
+
return payload;
|
|
4916
|
+
}
|
|
4917
|
+
/**
|
|
4918
|
+
* Removes the protection layer from a payload.
|
|
4919
|
+
*
|
|
4920
|
+
* - Protected payloads are decrypted/verified using the configured keys.
|
|
4921
|
+
* - Legacy unprotected payloads pass through unchanged when **no** protection
|
|
4922
|
+
* is configured.
|
|
4923
|
+
* - If protection **is** configured but the payload is not protected, the
|
|
4924
|
+
* payload is treated as a legacy entry. Callers can handle this case by
|
|
4925
|
+
* checking `isEnabled` separately.
|
|
4926
|
+
*/
|
|
4927
|
+
unprotect(payload) {
|
|
4928
|
+
if (this.startsWith(payload, MAGIC_ENCRYPTED)) {
|
|
4929
|
+
if (!this.encryptionKey) {
|
|
4930
|
+
throw new PayloadProtectionError("Encrypted payload but no encryptionKey configured.");
|
|
4931
|
+
}
|
|
4932
|
+
return this.decrypt(payload, this.encryptionKey);
|
|
4933
|
+
}
|
|
4934
|
+
if (this.startsWith(payload, MAGIC_SIGNED)) {
|
|
4935
|
+
if (!this.signingKey) {
|
|
4936
|
+
throw new PayloadProtectionError("Signed payload but no signingKey configured.");
|
|
4937
|
+
}
|
|
4938
|
+
return this.verify(payload, this.signingKey);
|
|
4939
|
+
}
|
|
4940
|
+
return payload;
|
|
4941
|
+
}
|
|
4942
|
+
// ── Encryption (AES-256-GCM) ──────────────────────────────────────────
|
|
4943
|
+
encrypt(plaintext, key) {
|
|
4944
|
+
const iv = (0, import_node_crypto2.randomBytes)(IV_LENGTH);
|
|
4945
|
+
const cipher = (0, import_node_crypto2.createCipheriv)(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
4946
|
+
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
4947
|
+
const authTag = cipher.getAuthTag();
|
|
4948
|
+
return Buffer.concat([MAGIC_ENCRYPTED, iv, authTag, encrypted]);
|
|
4949
|
+
}
|
|
4950
|
+
decrypt(payload, key) {
|
|
4951
|
+
const headerEnd = MAGIC_ENCRYPTED.length;
|
|
4952
|
+
const iv = payload.subarray(headerEnd, headerEnd + IV_LENGTH);
|
|
4953
|
+
const authTag = payload.subarray(headerEnd + IV_LENGTH, headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
|
|
4954
|
+
const ciphertext = payload.subarray(headerEnd + IV_LENGTH + AUTH_TAG_LENGTH);
|
|
4955
|
+
try {
|
|
4956
|
+
const decipher = (0, import_node_crypto2.createDecipheriv)(ALGORITHM, key, iv, {
|
|
4957
|
+
authTagLength: AUTH_TAG_LENGTH
|
|
4958
|
+
});
|
|
4959
|
+
decipher.setAuthTag(authTag);
|
|
4960
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
4961
|
+
} catch {
|
|
4962
|
+
throw new PayloadProtectionError(
|
|
4963
|
+
"Decryption failed. The data may have been tampered with or the encryptionKey is incorrect."
|
|
4964
|
+
);
|
|
4965
|
+
}
|
|
4966
|
+
}
|
|
4967
|
+
// ── Signing (HMAC-SHA256) ─────────────────────────────────────────────
|
|
4968
|
+
sign(payload, key) {
|
|
4969
|
+
const hmac = (0, import_node_crypto2.createHmac)("sha256", key).update(payload).digest();
|
|
4970
|
+
return Buffer.concat([MAGIC_SIGNED, hmac, payload]);
|
|
4971
|
+
}
|
|
4972
|
+
verify(payload, key) {
|
|
4973
|
+
const headerEnd = MAGIC_SIGNED.length;
|
|
4974
|
+
const receivedHmac = payload.subarray(headerEnd, headerEnd + HMAC_LENGTH);
|
|
4975
|
+
const data = payload.subarray(headerEnd + HMAC_LENGTH);
|
|
4976
|
+
const expectedHmac = (0, import_node_crypto2.createHmac)("sha256", key).update(data).digest();
|
|
4977
|
+
if (receivedHmac.length !== HMAC_LENGTH || !(0, import_node_crypto2.timingSafeEqual)(receivedHmac, expectedHmac)) {
|
|
4978
|
+
throw new PayloadProtectionError(
|
|
4979
|
+
"HMAC verification failed. The data may have been tampered with or the signingKey is incorrect."
|
|
4980
|
+
);
|
|
4981
|
+
}
|
|
4982
|
+
return data;
|
|
4983
|
+
}
|
|
4984
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
4985
|
+
startsWith(buffer, prefix) {
|
|
4986
|
+
if (buffer.length < prefix.length) {
|
|
4987
|
+
return false;
|
|
4988
|
+
}
|
|
4989
|
+
return buffer.subarray(0, prefix.length).equals(prefix);
|
|
4990
|
+
}
|
|
4991
|
+
};
|
|
4992
|
+
var PayloadProtectionError = class extends Error {
|
|
4993
|
+
constructor(message) {
|
|
4994
|
+
super(message);
|
|
4995
|
+
this.name = "PayloadProtectionError";
|
|
4996
|
+
}
|
|
4997
|
+
};
|
|
4998
|
+
|
|
4999
|
+
// src/layers/DiskLayer.ts
|
|
4710
5000
|
var FILE_SCAN_CONCURRENCY = 32;
|
|
4711
5001
|
var DiskLayer = class {
|
|
4712
5002
|
name;
|
|
@@ -4716,6 +5006,7 @@ var DiskLayer = class {
|
|
|
4716
5006
|
serializer;
|
|
4717
5007
|
maxFiles;
|
|
4718
5008
|
maxEntryBytes;
|
|
5009
|
+
protection;
|
|
4719
5010
|
writeQueue = Promise.resolve();
|
|
4720
5011
|
constructor(options) {
|
|
4721
5012
|
this.directory = this.resolveDirectory(options.directory);
|
|
@@ -4724,6 +5015,10 @@ var DiskLayer = class {
|
|
|
4724
5015
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
4725
5016
|
this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
|
|
4726
5017
|
this.maxEntryBytes = this.normalizeMaxEntryBytes(options.maxEntryBytes);
|
|
5018
|
+
this.protection = new PayloadProtection({
|
|
5019
|
+
encryptionKey: options.encryptionKey,
|
|
5020
|
+
signingKey: options.signingKey
|
|
5021
|
+
});
|
|
4727
5022
|
}
|
|
4728
5023
|
async get(key) {
|
|
4729
5024
|
return unwrapStoredValue(await this.getEntry(key));
|
|
@@ -4756,10 +5051,12 @@ var DiskLayer = class {
|
|
|
4756
5051
|
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
4757
5052
|
};
|
|
4758
5053
|
const payload = this.serializer.serialize(entry);
|
|
5054
|
+
const raw = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, "utf8");
|
|
5055
|
+
const protectedPayload = this.protection.protect(raw);
|
|
4759
5056
|
const targetPath = this.keyToPath(key);
|
|
4760
|
-
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${(0,
|
|
5057
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${(0, import_node_crypto3.randomBytes)(8).toString("hex")}.tmp`;
|
|
4761
5058
|
try {
|
|
4762
|
-
await import_node_fs2.promises.writeFile(tempPath,
|
|
5059
|
+
await import_node_fs2.promises.writeFile(tempPath, protectedPayload);
|
|
4763
5060
|
await import_node_fs2.promises.rename(tempPath, targetPath);
|
|
4764
5061
|
} catch (error) {
|
|
4765
5062
|
await this.safeDelete(tempPath);
|
|
@@ -4857,7 +5154,7 @@ var DiskLayer = class {
|
|
|
4857
5154
|
async dispose() {
|
|
4858
5155
|
}
|
|
4859
5156
|
keyToPath(key) {
|
|
4860
|
-
const hash = (0,
|
|
5157
|
+
const hash = (0, import_node_crypto3.createHash)("sha256").update(key).digest("hex");
|
|
4861
5158
|
return (0, import_node_path2.join)(this.directory, `${hash}.lc`);
|
|
4862
5159
|
}
|
|
4863
5160
|
resolveDirectory(directory) {
|
|
@@ -4871,10 +5168,13 @@ var DiskLayer = class {
|
|
|
4871
5168
|
}
|
|
4872
5169
|
normalizeMaxFiles(maxFiles) {
|
|
4873
5170
|
if (maxFiles === void 0) {
|
|
5171
|
+
return 5e4;
|
|
5172
|
+
}
|
|
5173
|
+
if (maxFiles === Number.POSITIVE_INFINITY) {
|
|
4874
5174
|
return void 0;
|
|
4875
5175
|
}
|
|
4876
5176
|
if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
|
|
4877
|
-
throw new Error("DiskLayer.maxFiles must be a positive integer.");
|
|
5177
|
+
throw new Error("DiskLayer.maxFiles must be a positive integer or Infinity.");
|
|
4878
5178
|
}
|
|
4879
5179
|
return maxFiles;
|
|
4880
5180
|
}
|
|
@@ -4986,7 +5286,8 @@ var DiskLayer = class {
|
|
|
4986
5286
|
);
|
|
4987
5287
|
}
|
|
4988
5288
|
deserializeEntry(raw) {
|
|
4989
|
-
const
|
|
5289
|
+
const unprotected = this.protection.unprotect(raw);
|
|
5290
|
+
const entry = this.serializer.deserialize(unprotected);
|
|
4990
5291
|
if (!isDiskEntry(entry)) {
|
|
4991
5292
|
throw new Error("Invalid disk cache entry.");
|
|
4992
5293
|
}
|
|
@@ -5108,7 +5409,10 @@ var MemcachedLayer = class {
|
|
|
5108
5409
|
validateKey(key) {
|
|
5109
5410
|
const fullKey = this.withPrefix(key);
|
|
5110
5411
|
if (Buffer.byteLength(fullKey, "utf8") > 250) {
|
|
5111
|
-
|
|
5412
|
+
const displayKey = fullKey.slice(0, 64);
|
|
5413
|
+
throw new Error(
|
|
5414
|
+
`MemcachedLayer: key exceeds 250-byte Memcached limit: "${displayKey}${fullKey.length > 64 ? "..." : ""}"`
|
|
5415
|
+
);
|
|
5112
5416
|
}
|
|
5113
5417
|
if (/[\s\x00-\x1f\x7f]/.test(fullKey)) {
|
|
5114
5418
|
throw new Error(
|
|
@@ -5135,7 +5439,7 @@ var MsgpackSerializer = class {
|
|
|
5135
5439
|
};
|
|
5136
5440
|
|
|
5137
5441
|
// src/singleflight/RedisSingleFlightCoordinator.ts
|
|
5138
|
-
var
|
|
5442
|
+
var import_node_crypto4 = require("crypto");
|
|
5139
5443
|
var RELEASE_SCRIPT = `
|
|
5140
5444
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
5141
5445
|
return redis.call("del", KEYS[1])
|
|
@@ -5151,14 +5455,19 @@ return 0
|
|
|
5151
5455
|
var RedisSingleFlightCoordinator = class {
|
|
5152
5456
|
client;
|
|
5153
5457
|
prefix;
|
|
5458
|
+
commandTimeoutMs;
|
|
5154
5459
|
constructor(options) {
|
|
5155
5460
|
this.client = options.client;
|
|
5156
5461
|
this.prefix = options.prefix ?? "layercache:singleflight";
|
|
5462
|
+
this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
|
|
5157
5463
|
}
|
|
5158
5464
|
async execute(key, options, worker, waiter) {
|
|
5159
5465
|
const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
|
|
5160
|
-
const token = (0,
|
|
5161
|
-
const acquired = await this.
|
|
5466
|
+
const token = (0, import_node_crypto4.randomUUID)();
|
|
5467
|
+
const acquired = await this.runCommand(
|
|
5468
|
+
`acquire("${key}")`,
|
|
5469
|
+
() => this.client.set(lockKey, token, "PX", options.leaseMs, "NX")
|
|
5470
|
+
);
|
|
5162
5471
|
if (acquired === "OK") {
|
|
5163
5472
|
const renewTimer = this.startLeaseRenewal(lockKey, token, options);
|
|
5164
5473
|
try {
|
|
@@ -5167,7 +5476,7 @@ var RedisSingleFlightCoordinator = class {
|
|
|
5167
5476
|
if (renewTimer) {
|
|
5168
5477
|
clearInterval(renewTimer);
|
|
5169
5478
|
}
|
|
5170
|
-
await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
|
|
5479
|
+
await this.runCommand(`release("${key}")`, () => this.client.eval(RELEASE_SCRIPT, 1, lockKey, token));
|
|
5171
5480
|
}
|
|
5172
5481
|
}
|
|
5173
5482
|
return waiter();
|
|
@@ -5178,11 +5487,45 @@ var RedisSingleFlightCoordinator = class {
|
|
|
5178
5487
|
return void 0;
|
|
5179
5488
|
}
|
|
5180
5489
|
const timer = setInterval(() => {
|
|
5181
|
-
void this.
|
|
5490
|
+
void this.runCommand(
|
|
5491
|
+
`renew("${lockKey}")`,
|
|
5492
|
+
() => this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs))
|
|
5493
|
+
).catch(() => void 0);
|
|
5182
5494
|
}, renewIntervalMs);
|
|
5183
5495
|
timer.unref?.();
|
|
5184
5496
|
return timer;
|
|
5185
5497
|
}
|
|
5498
|
+
normalizeCommandTimeoutMs(value) {
|
|
5499
|
+
if (value === void 0) {
|
|
5500
|
+
return void 0;
|
|
5501
|
+
}
|
|
5502
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
5503
|
+
throw new Error("RedisSingleFlightCoordinator.commandTimeoutMs must be a positive number.");
|
|
5504
|
+
}
|
|
5505
|
+
return value;
|
|
5506
|
+
}
|
|
5507
|
+
async runCommand(operation, command) {
|
|
5508
|
+
const promise = command();
|
|
5509
|
+
if (!this.commandTimeoutMs) {
|
|
5510
|
+
return promise;
|
|
5511
|
+
}
|
|
5512
|
+
let timer;
|
|
5513
|
+
return Promise.race([
|
|
5514
|
+
promise,
|
|
5515
|
+
new Promise((_, reject) => {
|
|
5516
|
+
timer = setTimeout(() => {
|
|
5517
|
+
reject(
|
|
5518
|
+
new Error(`RedisSingleFlightCoordinator command ${operation} timed out after ${this.commandTimeoutMs}ms.`)
|
|
5519
|
+
);
|
|
5520
|
+
}, this.commandTimeoutMs);
|
|
5521
|
+
timer.unref?.();
|
|
5522
|
+
})
|
|
5523
|
+
]).finally(() => {
|
|
5524
|
+
if (timer) {
|
|
5525
|
+
clearTimeout(timer);
|
|
5526
|
+
}
|
|
5527
|
+
});
|
|
5528
|
+
}
|
|
5186
5529
|
};
|
|
5187
5530
|
|
|
5188
5531
|
// src/metrics/PrometheusExporter.ts
|