layercache 1.2.1 → 1.2.2
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 +40 -4
- package/dist/{chunk-GF47Y3XR.js → chunk-IXCMHVHP.js} +55 -24
- package/dist/cli.cjs +55 -24
- package/dist/cli.js +1 -1
- package/dist/{edge-C1sBhTfv.d.cts → edge-DLpdQN0W.d.cts} +5 -0
- package/dist/{edge-C1sBhTfv.d.ts → edge-DLpdQN0W.d.ts} +5 -0
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/index.cjs +238 -60
- package/dist/index.d.cts +10 -3
- package/dist/index.d.ts +10 -3
- package/dist/index.js +185 -38
- package/package.json +2 -2
- package/packages/nestjs/dist/index.cjs +100 -28
- package/packages/nestjs/dist/index.d.cts +5 -0
- package/packages/nestjs/dist/index.d.ts +5 -0
- package/packages/nestjs/dist/index.js +100 -28
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
RedisTagIndex
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-IXCMHVHP.js";
|
|
4
4
|
import {
|
|
5
5
|
MemoryLayer,
|
|
6
6
|
TagIndex,
|
|
@@ -360,11 +360,12 @@ var CircuitBreakerManager = class {
|
|
|
360
360
|
|
|
361
361
|
// src/internal/FetchRateLimiter.ts
|
|
362
362
|
var FetchRateLimiter = class {
|
|
363
|
-
active = 0;
|
|
364
363
|
queue = [];
|
|
365
|
-
|
|
364
|
+
buckets = /* @__PURE__ */ new Map();
|
|
365
|
+
fetcherBuckets = /* @__PURE__ */ new WeakMap();
|
|
366
|
+
nextFetcherBucketId = 0;
|
|
366
367
|
drainTimer;
|
|
367
|
-
async schedule(options, task) {
|
|
368
|
+
async schedule(options, context, task) {
|
|
368
369
|
if (!options) {
|
|
369
370
|
return task();
|
|
370
371
|
}
|
|
@@ -372,8 +373,14 @@ var FetchRateLimiter = class {
|
|
|
372
373
|
if (!normalized) {
|
|
373
374
|
return task();
|
|
374
375
|
}
|
|
375
|
-
return new Promise((
|
|
376
|
-
this.queue.push({
|
|
376
|
+
return new Promise((resolve2, reject) => {
|
|
377
|
+
this.queue.push({
|
|
378
|
+
bucketKey: this.resolveBucketKey(normalized, context),
|
|
379
|
+
options: normalized,
|
|
380
|
+
task,
|
|
381
|
+
resolve: resolve2,
|
|
382
|
+
reject
|
|
383
|
+
});
|
|
377
384
|
this.drain();
|
|
378
385
|
});
|
|
379
386
|
}
|
|
@@ -387,63 +394,109 @@ var FetchRateLimiter = class {
|
|
|
387
394
|
return {
|
|
388
395
|
maxConcurrent,
|
|
389
396
|
intervalMs,
|
|
390
|
-
maxPerInterval
|
|
397
|
+
maxPerInterval,
|
|
398
|
+
scope: options.scope ?? "global",
|
|
399
|
+
bucketKey: options.bucketKey
|
|
391
400
|
};
|
|
392
401
|
}
|
|
402
|
+
resolveBucketKey(options, context) {
|
|
403
|
+
if (options.bucketKey) {
|
|
404
|
+
return `custom:${options.bucketKey}`;
|
|
405
|
+
}
|
|
406
|
+
if (options.scope === "key") {
|
|
407
|
+
return `key:${context.key}`;
|
|
408
|
+
}
|
|
409
|
+
if (options.scope === "fetcher") {
|
|
410
|
+
const existing = this.fetcherBuckets.get(context.fetcher);
|
|
411
|
+
if (existing) {
|
|
412
|
+
return existing;
|
|
413
|
+
}
|
|
414
|
+
const bucket = `fetcher:${this.nextFetcherBucketId}`;
|
|
415
|
+
this.nextFetcherBucketId += 1;
|
|
416
|
+
this.fetcherBuckets.set(context.fetcher, bucket);
|
|
417
|
+
return bucket;
|
|
418
|
+
}
|
|
419
|
+
return "global";
|
|
420
|
+
}
|
|
393
421
|
drain() {
|
|
394
422
|
if (this.drainTimer) {
|
|
395
423
|
clearTimeout(this.drainTimer);
|
|
396
424
|
this.drainTimer = void 0;
|
|
397
425
|
}
|
|
398
426
|
while (this.queue.length > 0) {
|
|
399
|
-
|
|
400
|
-
|
|
427
|
+
let nextIndex = -1;
|
|
428
|
+
let nextWaitMs = Number.POSITIVE_INFINITY;
|
|
429
|
+
for (let index = 0; index < this.queue.length; index += 1) {
|
|
430
|
+
const next2 = this.queue[index];
|
|
431
|
+
if (!next2) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
const waitMs = this.waitTime(next2.bucketKey, next2.options);
|
|
435
|
+
if (waitMs <= 0) {
|
|
436
|
+
nextIndex = index;
|
|
437
|
+
break;
|
|
438
|
+
}
|
|
439
|
+
nextWaitMs = Math.min(nextWaitMs, waitMs);
|
|
440
|
+
}
|
|
441
|
+
if (nextIndex < 0) {
|
|
442
|
+
if (Number.isFinite(nextWaitMs)) {
|
|
443
|
+
this.drainTimer = setTimeout(() => {
|
|
444
|
+
this.drainTimer = void 0;
|
|
445
|
+
this.drain();
|
|
446
|
+
}, nextWaitMs);
|
|
447
|
+
this.drainTimer.unref?.();
|
|
448
|
+
}
|
|
401
449
|
return;
|
|
402
450
|
}
|
|
403
|
-
const
|
|
404
|
-
if (
|
|
405
|
-
this.drainTimer = setTimeout(() => {
|
|
406
|
-
this.drainTimer = void 0;
|
|
407
|
-
this.drain();
|
|
408
|
-
}, waitMs);
|
|
409
|
-
this.drainTimer.unref?.();
|
|
451
|
+
const next = this.queue.splice(nextIndex, 1)[0];
|
|
452
|
+
if (!next) {
|
|
410
453
|
return;
|
|
411
454
|
}
|
|
412
|
-
this.
|
|
413
|
-
|
|
414
|
-
|
|
455
|
+
const bucket = this.bucketState(next.bucketKey);
|
|
456
|
+
bucket.active += 1;
|
|
457
|
+
bucket.startedAt.push(Date.now());
|
|
415
458
|
void next.task().then(next.resolve, next.reject).finally(() => {
|
|
416
|
-
|
|
459
|
+
bucket.active -= 1;
|
|
417
460
|
this.drain();
|
|
418
461
|
});
|
|
419
462
|
}
|
|
420
463
|
}
|
|
421
|
-
waitTime(options) {
|
|
464
|
+
waitTime(bucketKey, options) {
|
|
465
|
+
const bucket = this.bucketState(bucketKey);
|
|
422
466
|
const now = Date.now();
|
|
423
|
-
if (options.maxConcurrent &&
|
|
467
|
+
if (options.maxConcurrent && bucket.active >= options.maxConcurrent) {
|
|
424
468
|
return 1;
|
|
425
469
|
}
|
|
426
470
|
if (!options.intervalMs || !options.maxPerInterval) {
|
|
427
471
|
return 0;
|
|
428
472
|
}
|
|
429
|
-
this.prune(now, options.intervalMs);
|
|
430
|
-
if (
|
|
473
|
+
this.prune(bucket, now, options.intervalMs);
|
|
474
|
+
if (bucket.startedAt.length < options.maxPerInterval) {
|
|
431
475
|
return 0;
|
|
432
476
|
}
|
|
433
|
-
const oldest =
|
|
477
|
+
const oldest = bucket.startedAt[0];
|
|
434
478
|
if (!oldest) {
|
|
435
479
|
return 0;
|
|
436
480
|
}
|
|
437
481
|
return Math.max(1, options.intervalMs - (now - oldest));
|
|
438
482
|
}
|
|
439
|
-
prune(now, intervalMs) {
|
|
440
|
-
while (
|
|
441
|
-
const startedAt =
|
|
483
|
+
prune(bucket, now, intervalMs) {
|
|
484
|
+
while (bucket.startedAt.length > 0) {
|
|
485
|
+
const startedAt = bucket.startedAt[0];
|
|
442
486
|
if (startedAt === void 0 || now - startedAt < intervalMs) {
|
|
443
487
|
break;
|
|
444
488
|
}
|
|
445
|
-
|
|
489
|
+
bucket.startedAt.shift();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
bucketState(bucketKey) {
|
|
493
|
+
const existing = this.buckets.get(bucketKey);
|
|
494
|
+
if (existing) {
|
|
495
|
+
return existing;
|
|
446
496
|
}
|
|
497
|
+
const bucket = { active: 0, startedAt: [] };
|
|
498
|
+
this.buckets.set(bucketKey, bucket);
|
|
499
|
+
return bucket;
|
|
447
500
|
}
|
|
448
501
|
};
|
|
449
502
|
|
|
@@ -1314,6 +1367,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1314
1367
|
try {
|
|
1315
1368
|
fetched = await this.fetchRateLimiter.schedule(
|
|
1316
1369
|
options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
|
|
1370
|
+
{ key, fetcher },
|
|
1317
1371
|
fetcher
|
|
1318
1372
|
);
|
|
1319
1373
|
this.circuitBreakerManager.recordSuccess(key);
|
|
@@ -1571,7 +1625,8 @@ var CacheStack = class extends EventEmitter {
|
|
|
1571
1625
|
return {
|
|
1572
1626
|
leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
|
|
1573
1627
|
waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
|
|
1574
|
-
pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
|
|
1628
|
+
pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
|
|
1629
|
+
renewIntervalMs: this.options.singleFlightRenewIntervalMs
|
|
1575
1630
|
};
|
|
1576
1631
|
}
|
|
1577
1632
|
async deleteKeys(keys) {
|
|
@@ -1631,7 +1686,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1631
1686
|
return String(error);
|
|
1632
1687
|
}
|
|
1633
1688
|
sleep(ms) {
|
|
1634
|
-
return new Promise((
|
|
1689
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1635
1690
|
}
|
|
1636
1691
|
shouldBroadcastL1Invalidation() {
|
|
1637
1692
|
return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
|
|
@@ -1776,6 +1831,8 @@ var CacheStack = class extends EventEmitter {
|
|
|
1776
1831
|
this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
|
|
1777
1832
|
this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
|
|
1778
1833
|
this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
|
|
1834
|
+
this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
|
|
1835
|
+
this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
|
|
1779
1836
|
this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
|
|
1780
1837
|
this.validateCircuitBreakerOptions(this.options.circuitBreaker);
|
|
1781
1838
|
if (this.options.generation !== void 0) {
|
|
@@ -1795,6 +1852,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1795
1852
|
this.validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
|
|
1796
1853
|
this.validateAdaptiveTtlOptions(options.adaptiveTtl);
|
|
1797
1854
|
this.validateCircuitBreakerOptions(options.circuitBreaker);
|
|
1855
|
+
this.validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
|
|
1798
1856
|
}
|
|
1799
1857
|
validateLayerNumberOption(name, value) {
|
|
1800
1858
|
if (value === void 0) {
|
|
@@ -1819,6 +1877,20 @@ var CacheStack = class extends EventEmitter {
|
|
|
1819
1877
|
throw new Error(`${name} must be a positive finite number.`);
|
|
1820
1878
|
}
|
|
1821
1879
|
}
|
|
1880
|
+
validateRateLimitOptions(name, options) {
|
|
1881
|
+
if (!options) {
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
|
|
1885
|
+
this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
|
|
1886
|
+
this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
|
|
1887
|
+
if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
|
|
1888
|
+
throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
|
|
1889
|
+
}
|
|
1890
|
+
if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
|
|
1891
|
+
throw new Error(`${name}.bucketKey must not be empty.`);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1822
1894
|
validateNonNegativeNumber(name, value) {
|
|
1823
1895
|
if (!Number.isFinite(value) || value < 0) {
|
|
1824
1896
|
throw new Error(`${name} must be a non-negative finite number.`);
|
|
@@ -2224,15 +2296,35 @@ import { promisify } from "util";
|
|
|
2224
2296
|
import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
|
|
2225
2297
|
|
|
2226
2298
|
// src/serialization/JsonSerializer.ts
|
|
2299
|
+
var DANGEROUS_JSON_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
2227
2300
|
var JsonSerializer = class {
|
|
2228
2301
|
serialize(value) {
|
|
2229
2302
|
return JSON.stringify(value);
|
|
2230
2303
|
}
|
|
2231
2304
|
deserialize(payload) {
|
|
2232
2305
|
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
2233
|
-
return JSON.parse(normalized);
|
|
2306
|
+
return sanitizeJsonValue(JSON.parse(normalized));
|
|
2234
2307
|
}
|
|
2235
2308
|
};
|
|
2309
|
+
function sanitizeJsonValue(value) {
|
|
2310
|
+
if (Array.isArray(value)) {
|
|
2311
|
+
return value.map((entry) => sanitizeJsonValue(entry));
|
|
2312
|
+
}
|
|
2313
|
+
if (!isPlainObject(value)) {
|
|
2314
|
+
return value;
|
|
2315
|
+
}
|
|
2316
|
+
const sanitized = {};
|
|
2317
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
2318
|
+
if (DANGEROUS_JSON_KEYS.has(key)) {
|
|
2319
|
+
continue;
|
|
2320
|
+
}
|
|
2321
|
+
sanitized[key] = sanitizeJsonValue(entry);
|
|
2322
|
+
}
|
|
2323
|
+
return sanitized;
|
|
2324
|
+
}
|
|
2325
|
+
function isPlainObject(value) {
|
|
2326
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
2327
|
+
}
|
|
2236
2328
|
|
|
2237
2329
|
// src/layers/RedisLayer.ts
|
|
2238
2330
|
var BATCH_DELETE_SIZE = 500;
|
|
@@ -2479,7 +2571,7 @@ var RedisLayer = class {
|
|
|
2479
2571
|
// src/layers/DiskLayer.ts
|
|
2480
2572
|
import { createHash } from "crypto";
|
|
2481
2573
|
import { promises as fs } from "fs";
|
|
2482
|
-
import { join } from "path";
|
|
2574
|
+
import { join, resolve } from "path";
|
|
2483
2575
|
var DiskLayer = class {
|
|
2484
2576
|
name;
|
|
2485
2577
|
defaultTtl;
|
|
@@ -2489,11 +2581,11 @@ var DiskLayer = class {
|
|
|
2489
2581
|
maxFiles;
|
|
2490
2582
|
writeQueue = Promise.resolve();
|
|
2491
2583
|
constructor(options) {
|
|
2492
|
-
this.directory = options.directory;
|
|
2584
|
+
this.directory = this.resolveDirectory(options.directory);
|
|
2493
2585
|
this.defaultTtl = options.ttl;
|
|
2494
2586
|
this.name = options.name ?? "disk";
|
|
2495
2587
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
2496
|
-
this.maxFiles = options.maxFiles;
|
|
2588
|
+
this.maxFiles = this.normalizeMaxFiles(options.maxFiles);
|
|
2497
2589
|
}
|
|
2498
2590
|
async get(key) {
|
|
2499
2591
|
return unwrapStoredValue(await this.getEntry(key));
|
|
@@ -2508,7 +2600,7 @@ var DiskLayer = class {
|
|
|
2508
2600
|
}
|
|
2509
2601
|
let entry;
|
|
2510
2602
|
try {
|
|
2511
|
-
entry = this.
|
|
2603
|
+
entry = this.deserializeEntry(raw);
|
|
2512
2604
|
} catch {
|
|
2513
2605
|
await this.safeDelete(filePath);
|
|
2514
2606
|
return null;
|
|
@@ -2559,8 +2651,9 @@ var DiskLayer = class {
|
|
|
2559
2651
|
}
|
|
2560
2652
|
let entry;
|
|
2561
2653
|
try {
|
|
2562
|
-
entry = this.
|
|
2654
|
+
entry = this.deserializeEntry(raw);
|
|
2563
2655
|
} catch {
|
|
2656
|
+
await this.safeDelete(filePath);
|
|
2564
2657
|
return null;
|
|
2565
2658
|
}
|
|
2566
2659
|
if (entry.expiresAt === null) {
|
|
@@ -2617,7 +2710,7 @@ var DiskLayer = class {
|
|
|
2617
2710
|
}
|
|
2618
2711
|
let entry;
|
|
2619
2712
|
try {
|
|
2620
|
-
entry = this.
|
|
2713
|
+
entry = this.deserializeEntry(raw);
|
|
2621
2714
|
} catch {
|
|
2622
2715
|
await this.safeDelete(filePath);
|
|
2623
2716
|
return;
|
|
@@ -2649,6 +2742,31 @@ var DiskLayer = class {
|
|
|
2649
2742
|
const hash = createHash("sha256").update(key).digest("hex");
|
|
2650
2743
|
return join(this.directory, `${hash}.lc`);
|
|
2651
2744
|
}
|
|
2745
|
+
resolveDirectory(directory) {
|
|
2746
|
+
if (typeof directory !== "string" || directory.trim().length === 0) {
|
|
2747
|
+
throw new Error("DiskLayer.directory must be a non-empty path.");
|
|
2748
|
+
}
|
|
2749
|
+
if (directory.includes("\0")) {
|
|
2750
|
+
throw new Error("DiskLayer.directory must not contain null bytes.");
|
|
2751
|
+
}
|
|
2752
|
+
return resolve(directory);
|
|
2753
|
+
}
|
|
2754
|
+
normalizeMaxFiles(maxFiles) {
|
|
2755
|
+
if (maxFiles === void 0) {
|
|
2756
|
+
return void 0;
|
|
2757
|
+
}
|
|
2758
|
+
if (!Number.isInteger(maxFiles) || maxFiles <= 0) {
|
|
2759
|
+
throw new Error("DiskLayer.maxFiles must be a positive integer.");
|
|
2760
|
+
}
|
|
2761
|
+
return maxFiles;
|
|
2762
|
+
}
|
|
2763
|
+
deserializeEntry(raw) {
|
|
2764
|
+
const entry = this.serializer.deserialize(raw);
|
|
2765
|
+
if (!isDiskEntry(entry)) {
|
|
2766
|
+
throw new Error("Invalid disk cache entry.");
|
|
2767
|
+
}
|
|
2768
|
+
return entry;
|
|
2769
|
+
}
|
|
2652
2770
|
async safeDelete(filePath) {
|
|
2653
2771
|
try {
|
|
2654
2772
|
await fs.unlink(filePath);
|
|
@@ -2693,6 +2811,14 @@ var DiskLayer = class {
|
|
|
2693
2811
|
await Promise.all(toEvict.map(({ filePath }) => this.safeDelete(filePath)));
|
|
2694
2812
|
}
|
|
2695
2813
|
};
|
|
2814
|
+
function isDiskEntry(value) {
|
|
2815
|
+
if (!value || typeof value !== "object") {
|
|
2816
|
+
return false;
|
|
2817
|
+
}
|
|
2818
|
+
const candidate = value;
|
|
2819
|
+
const validExpiry = candidate.expiresAt === null || typeof candidate.expiresAt === "number";
|
|
2820
|
+
return typeof candidate.key === "string" && validExpiry && "value" in candidate;
|
|
2821
|
+
}
|
|
2696
2822
|
|
|
2697
2823
|
// src/layers/MemcachedLayer.ts
|
|
2698
2824
|
var MemcachedLayer = class {
|
|
@@ -2772,6 +2898,12 @@ if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
|
2772
2898
|
end
|
|
2773
2899
|
return 0
|
|
2774
2900
|
`;
|
|
2901
|
+
var RENEW_SCRIPT = `
|
|
2902
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
2903
|
+
return redis.call("pexpire", KEYS[1], ARGV[2])
|
|
2904
|
+
end
|
|
2905
|
+
return 0
|
|
2906
|
+
`;
|
|
2775
2907
|
var RedisSingleFlightCoordinator = class {
|
|
2776
2908
|
client;
|
|
2777
2909
|
prefix;
|
|
@@ -2784,14 +2916,29 @@ var RedisSingleFlightCoordinator = class {
|
|
|
2784
2916
|
const token = randomUUID();
|
|
2785
2917
|
const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
|
|
2786
2918
|
if (acquired === "OK") {
|
|
2919
|
+
const renewTimer = this.startLeaseRenewal(lockKey, token, options);
|
|
2787
2920
|
try {
|
|
2788
2921
|
return await worker();
|
|
2789
2922
|
} finally {
|
|
2923
|
+
if (renewTimer) {
|
|
2924
|
+
clearInterval(renewTimer);
|
|
2925
|
+
}
|
|
2790
2926
|
await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
|
|
2791
2927
|
}
|
|
2792
2928
|
}
|
|
2793
2929
|
return waiter();
|
|
2794
2930
|
}
|
|
2931
|
+
startLeaseRenewal(lockKey, token, options) {
|
|
2932
|
+
const renewIntervalMs = options.renewIntervalMs ?? Math.max(100, Math.floor(options.leaseMs / 2));
|
|
2933
|
+
if (renewIntervalMs <= 0 || renewIntervalMs >= options.leaseMs) {
|
|
2934
|
+
return void 0;
|
|
2935
|
+
}
|
|
2936
|
+
const timer = setInterval(() => {
|
|
2937
|
+
void this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs)).catch(() => void 0);
|
|
2938
|
+
}, renewIntervalMs);
|
|
2939
|
+
timer.unref?.();
|
|
2940
|
+
return timer;
|
|
2941
|
+
}
|
|
2795
2942
|
};
|
|
2796
2943
|
|
|
2797
2944
|
// src/metrics/PrometheusExporter.ts
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "layercache",
|
|
3
|
-
"version": "1.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.2",
|
|
4
|
+
"description": "Hardened multi-layer caching for Node.js with memory, Redis, stampede prevention, and operational invalidation helpers.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -598,11 +598,12 @@ var CircuitBreakerManager = class {
|
|
|
598
598
|
|
|
599
599
|
// ../../src/internal/FetchRateLimiter.ts
|
|
600
600
|
var FetchRateLimiter = class {
|
|
601
|
-
active = 0;
|
|
602
601
|
queue = [];
|
|
603
|
-
|
|
602
|
+
buckets = /* @__PURE__ */ new Map();
|
|
603
|
+
fetcherBuckets = /* @__PURE__ */ new WeakMap();
|
|
604
|
+
nextFetcherBucketId = 0;
|
|
604
605
|
drainTimer;
|
|
605
|
-
async schedule(options, task) {
|
|
606
|
+
async schedule(options, context, task) {
|
|
606
607
|
if (!options) {
|
|
607
608
|
return task();
|
|
608
609
|
}
|
|
@@ -611,7 +612,13 @@ var FetchRateLimiter = class {
|
|
|
611
612
|
return task();
|
|
612
613
|
}
|
|
613
614
|
return new Promise((resolve, reject) => {
|
|
614
|
-
this.queue.push({
|
|
615
|
+
this.queue.push({
|
|
616
|
+
bucketKey: this.resolveBucketKey(normalized, context),
|
|
617
|
+
options: normalized,
|
|
618
|
+
task,
|
|
619
|
+
resolve,
|
|
620
|
+
reject
|
|
621
|
+
});
|
|
615
622
|
this.drain();
|
|
616
623
|
});
|
|
617
624
|
}
|
|
@@ -625,63 +632,109 @@ var FetchRateLimiter = class {
|
|
|
625
632
|
return {
|
|
626
633
|
maxConcurrent,
|
|
627
634
|
intervalMs,
|
|
628
|
-
maxPerInterval
|
|
635
|
+
maxPerInterval,
|
|
636
|
+
scope: options.scope ?? "global",
|
|
637
|
+
bucketKey: options.bucketKey
|
|
629
638
|
};
|
|
630
639
|
}
|
|
640
|
+
resolveBucketKey(options, context) {
|
|
641
|
+
if (options.bucketKey) {
|
|
642
|
+
return `custom:${options.bucketKey}`;
|
|
643
|
+
}
|
|
644
|
+
if (options.scope === "key") {
|
|
645
|
+
return `key:${context.key}`;
|
|
646
|
+
}
|
|
647
|
+
if (options.scope === "fetcher") {
|
|
648
|
+
const existing = this.fetcherBuckets.get(context.fetcher);
|
|
649
|
+
if (existing) {
|
|
650
|
+
return existing;
|
|
651
|
+
}
|
|
652
|
+
const bucket = `fetcher:${this.nextFetcherBucketId}`;
|
|
653
|
+
this.nextFetcherBucketId += 1;
|
|
654
|
+
this.fetcherBuckets.set(context.fetcher, bucket);
|
|
655
|
+
return bucket;
|
|
656
|
+
}
|
|
657
|
+
return "global";
|
|
658
|
+
}
|
|
631
659
|
drain() {
|
|
632
660
|
if (this.drainTimer) {
|
|
633
661
|
clearTimeout(this.drainTimer);
|
|
634
662
|
this.drainTimer = void 0;
|
|
635
663
|
}
|
|
636
664
|
while (this.queue.length > 0) {
|
|
637
|
-
|
|
638
|
-
|
|
665
|
+
let nextIndex = -1;
|
|
666
|
+
let nextWaitMs = Number.POSITIVE_INFINITY;
|
|
667
|
+
for (let index = 0; index < this.queue.length; index += 1) {
|
|
668
|
+
const next2 = this.queue[index];
|
|
669
|
+
if (!next2) {
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const waitMs = this.waitTime(next2.bucketKey, next2.options);
|
|
673
|
+
if (waitMs <= 0) {
|
|
674
|
+
nextIndex = index;
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
nextWaitMs = Math.min(nextWaitMs, waitMs);
|
|
678
|
+
}
|
|
679
|
+
if (nextIndex < 0) {
|
|
680
|
+
if (Number.isFinite(nextWaitMs)) {
|
|
681
|
+
this.drainTimer = setTimeout(() => {
|
|
682
|
+
this.drainTimer = void 0;
|
|
683
|
+
this.drain();
|
|
684
|
+
}, nextWaitMs);
|
|
685
|
+
this.drainTimer.unref?.();
|
|
686
|
+
}
|
|
639
687
|
return;
|
|
640
688
|
}
|
|
641
|
-
const
|
|
642
|
-
if (
|
|
643
|
-
this.drainTimer = setTimeout(() => {
|
|
644
|
-
this.drainTimer = void 0;
|
|
645
|
-
this.drain();
|
|
646
|
-
}, waitMs);
|
|
647
|
-
this.drainTimer.unref?.();
|
|
689
|
+
const next = this.queue.splice(nextIndex, 1)[0];
|
|
690
|
+
if (!next) {
|
|
648
691
|
return;
|
|
649
692
|
}
|
|
650
|
-
this.
|
|
651
|
-
|
|
652
|
-
|
|
693
|
+
const bucket = this.bucketState(next.bucketKey);
|
|
694
|
+
bucket.active += 1;
|
|
695
|
+
bucket.startedAt.push(Date.now());
|
|
653
696
|
void next.task().then(next.resolve, next.reject).finally(() => {
|
|
654
|
-
|
|
697
|
+
bucket.active -= 1;
|
|
655
698
|
this.drain();
|
|
656
699
|
});
|
|
657
700
|
}
|
|
658
701
|
}
|
|
659
|
-
waitTime(options) {
|
|
702
|
+
waitTime(bucketKey, options) {
|
|
703
|
+
const bucket = this.bucketState(bucketKey);
|
|
660
704
|
const now = Date.now();
|
|
661
|
-
if (options.maxConcurrent &&
|
|
705
|
+
if (options.maxConcurrent && bucket.active >= options.maxConcurrent) {
|
|
662
706
|
return 1;
|
|
663
707
|
}
|
|
664
708
|
if (!options.intervalMs || !options.maxPerInterval) {
|
|
665
709
|
return 0;
|
|
666
710
|
}
|
|
667
|
-
this.prune(now, options.intervalMs);
|
|
668
|
-
if (
|
|
711
|
+
this.prune(bucket, now, options.intervalMs);
|
|
712
|
+
if (bucket.startedAt.length < options.maxPerInterval) {
|
|
669
713
|
return 0;
|
|
670
714
|
}
|
|
671
|
-
const oldest =
|
|
715
|
+
const oldest = bucket.startedAt[0];
|
|
672
716
|
if (!oldest) {
|
|
673
717
|
return 0;
|
|
674
718
|
}
|
|
675
719
|
return Math.max(1, options.intervalMs - (now - oldest));
|
|
676
720
|
}
|
|
677
|
-
prune(now, intervalMs) {
|
|
678
|
-
while (
|
|
679
|
-
const startedAt =
|
|
721
|
+
prune(bucket, now, intervalMs) {
|
|
722
|
+
while (bucket.startedAt.length > 0) {
|
|
723
|
+
const startedAt = bucket.startedAt[0];
|
|
680
724
|
if (startedAt === void 0 || now - startedAt < intervalMs) {
|
|
681
725
|
break;
|
|
682
726
|
}
|
|
683
|
-
|
|
727
|
+
bucket.startedAt.shift();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
bucketState(bucketKey) {
|
|
731
|
+
const existing = this.buckets.get(bucketKey);
|
|
732
|
+
if (existing) {
|
|
733
|
+
return existing;
|
|
684
734
|
}
|
|
735
|
+
const bucket = { active: 0, startedAt: [] };
|
|
736
|
+
this.buckets.set(bucketKey, bucket);
|
|
737
|
+
return bucket;
|
|
685
738
|
}
|
|
686
739
|
};
|
|
687
740
|
|
|
@@ -1787,6 +1840,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1787
1840
|
try {
|
|
1788
1841
|
fetched = await this.fetchRateLimiter.schedule(
|
|
1789
1842
|
options?.fetcherRateLimit ?? this.options.fetcherRateLimit,
|
|
1843
|
+
{ key, fetcher },
|
|
1790
1844
|
fetcher
|
|
1791
1845
|
);
|
|
1792
1846
|
this.circuitBreakerManager.recordSuccess(key);
|
|
@@ -2044,7 +2098,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2044
2098
|
return {
|
|
2045
2099
|
leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
|
|
2046
2100
|
waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
|
|
2047
|
-
pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
|
|
2101
|
+
pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS,
|
|
2102
|
+
renewIntervalMs: this.options.singleFlightRenewIntervalMs
|
|
2048
2103
|
};
|
|
2049
2104
|
}
|
|
2050
2105
|
async deleteKeys(keys) {
|
|
@@ -2249,6 +2304,8 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2249
2304
|
this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
|
|
2250
2305
|
this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
|
|
2251
2306
|
this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
|
|
2307
|
+
this.validatePositiveNumber("singleFlightRenewIntervalMs", this.options.singleFlightRenewIntervalMs);
|
|
2308
|
+
this.validateRateLimitOptions("fetcherRateLimit", this.options.fetcherRateLimit);
|
|
2252
2309
|
this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
|
|
2253
2310
|
this.validateCircuitBreakerOptions(this.options.circuitBreaker);
|
|
2254
2311
|
if (this.options.generation !== void 0) {
|
|
@@ -2268,6 +2325,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2268
2325
|
this.validateTtlPolicy("options.ttlPolicy", options.ttlPolicy);
|
|
2269
2326
|
this.validateAdaptiveTtlOptions(options.adaptiveTtl);
|
|
2270
2327
|
this.validateCircuitBreakerOptions(options.circuitBreaker);
|
|
2328
|
+
this.validateRateLimitOptions("options.fetcherRateLimit", options.fetcherRateLimit);
|
|
2271
2329
|
}
|
|
2272
2330
|
validateLayerNumberOption(name, value) {
|
|
2273
2331
|
if (value === void 0) {
|
|
@@ -2292,6 +2350,20 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
2292
2350
|
throw new Error(`${name} must be a positive finite number.`);
|
|
2293
2351
|
}
|
|
2294
2352
|
}
|
|
2353
|
+
validateRateLimitOptions(name, options) {
|
|
2354
|
+
if (!options) {
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
this.validatePositiveNumber(`${name}.maxConcurrent`, options.maxConcurrent);
|
|
2358
|
+
this.validatePositiveNumber(`${name}.intervalMs`, options.intervalMs);
|
|
2359
|
+
this.validatePositiveNumber(`${name}.maxPerInterval`, options.maxPerInterval);
|
|
2360
|
+
if (options.scope && !["global", "key", "fetcher"].includes(options.scope)) {
|
|
2361
|
+
throw new Error(`${name}.scope must be one of "global", "key", or "fetcher".`);
|
|
2362
|
+
}
|
|
2363
|
+
if (options.bucketKey !== void 0 && options.bucketKey.length === 0) {
|
|
2364
|
+
throw new Error(`${name}.bucketKey must not be empty.`);
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2295
2367
|
validateNonNegativeNumber(name, value) {
|
|
2296
2368
|
if (!Number.isFinite(value) || value < 0) {
|
|
2297
2369
|
throw new Error(`${name} must be a non-negative finite number.`);
|
|
@@ -156,6 +156,7 @@ interface CacheSingleFlightExecutionOptions {
|
|
|
156
156
|
leaseMs: number;
|
|
157
157
|
waitTimeoutMs: number;
|
|
158
158
|
pollIntervalMs: number;
|
|
159
|
+
renewIntervalMs?: number;
|
|
159
160
|
}
|
|
160
161
|
interface CacheSingleFlightCoordinator {
|
|
161
162
|
execute<T>(key: string, options: CacheSingleFlightExecutionOptions, worker: () => Promise<T>, waiter: () => Promise<T>): Promise<T>;
|
|
@@ -189,6 +190,7 @@ interface CacheStackOptions {
|
|
|
189
190
|
singleFlightLeaseMs?: number;
|
|
190
191
|
singleFlightTimeoutMs?: number;
|
|
191
192
|
singleFlightPollMs?: number;
|
|
193
|
+
singleFlightRenewIntervalMs?: number;
|
|
192
194
|
/**
|
|
193
195
|
* Maximum number of entries in `accessProfiles` and `circuitBreakers` maps
|
|
194
196
|
* before the oldest entries are pruned. Prevents unbounded memory growth.
|
|
@@ -219,6 +221,8 @@ interface CacheRateLimitOptions {
|
|
|
219
221
|
maxConcurrent?: number;
|
|
220
222
|
intervalMs?: number;
|
|
221
223
|
maxPerInterval?: number;
|
|
224
|
+
scope?: 'global' | 'key' | 'fetcher';
|
|
225
|
+
bucketKey?: string;
|
|
222
226
|
}
|
|
223
227
|
interface CacheWriteBehindOptions {
|
|
224
228
|
flushIntervalMs?: number;
|
|
@@ -527,6 +531,7 @@ declare class CacheStack extends EventEmitter {
|
|
|
527
531
|
private validateWriteOptions;
|
|
528
532
|
private validateLayerNumberOption;
|
|
529
533
|
private validatePositiveNumber;
|
|
534
|
+
private validateRateLimitOptions;
|
|
530
535
|
private validateNonNegativeNumber;
|
|
531
536
|
private validateCacheKey;
|
|
532
537
|
private validateTtlPolicy;
|