layercache 1.2.8 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- 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 +14 -1
- package/dist/cli.js +14 -1
- package/dist/{edge-DBs8Ko5W.d.cts → edge-BXWTKlI1.d.cts} +1 -0
- package/dist/{edge-DBs8Ko5W.d.ts → edge-BXWTKlI1.d.ts} +1 -0
- package/dist/edge.d.cts +1 -1
- package/dist/edge.d.ts +1 -1
- package/dist/index.cjs +304 -112
- package/dist/index.d.cts +17 -4
- package/dist/index.d.ts +17 -4
- package/dist/index.js +301 -109
- package/package.json +12 -2
- package/packages/nestjs/dist/index.cjs +146 -68
- package/packages/nestjs/dist/index.d.cts +1 -0
- package/packages/nestjs/dist/index.d.ts +1 -0
- package/packages/nestjs/dist/index.js +146 -68
package/dist/index.js
CHANGED
|
@@ -440,7 +440,7 @@ function normalizeForSerialization(value) {
|
|
|
440
440
|
}
|
|
441
441
|
function serializeKeyPart(value) {
|
|
442
442
|
if (typeof value === "string") {
|
|
443
|
-
return `s:${value}`;
|
|
443
|
+
return `s:${value.replace(/%/g, "%25").replace(/:/g, "%3A")}`;
|
|
444
444
|
}
|
|
445
445
|
if (typeof value === "number") {
|
|
446
446
|
return `n:${value}`;
|
|
@@ -671,6 +671,7 @@ var CacheStackLayerWriter = class {
|
|
|
671
671
|
}
|
|
672
672
|
const results = await Promise.allSettled(operations.map((operation) => operation()));
|
|
673
673
|
const failures = results.filter((result) => result.status === "rejected");
|
|
674
|
+
const degraded = results.filter((result) => result.status === "fulfilled");
|
|
674
675
|
if (failures.length === 0) {
|
|
675
676
|
return;
|
|
676
677
|
}
|
|
@@ -849,6 +850,7 @@ function planFreshReadPolicies({
|
|
|
849
850
|
}
|
|
850
851
|
|
|
851
852
|
// src/internal/CacheStackSnapshotManager.ts
|
|
853
|
+
import { randomBytes } from "crypto";
|
|
852
854
|
import { constants, promises as fs } from "fs";
|
|
853
855
|
import path from "path";
|
|
854
856
|
|
|
@@ -948,6 +950,42 @@ async function readUtf8HandleWithLimit(handle, byteLimit) {
|
|
|
948
950
|
return Buffer.concat(chunks).toString("utf8");
|
|
949
951
|
}
|
|
950
952
|
|
|
953
|
+
// src/internal/StructuredDataSanitizer.ts
|
|
954
|
+
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
955
|
+
function sanitizeStructuredData(value, options) {
|
|
956
|
+
return sanitizeValue(value, 0, { count: 0 }, options);
|
|
957
|
+
}
|
|
958
|
+
function sanitizeValue(value, depth, state, options) {
|
|
959
|
+
state.count += 1;
|
|
960
|
+
if (state.count > options.maxNodes) {
|
|
961
|
+
throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
|
|
962
|
+
}
|
|
963
|
+
if (depth > options.maxDepth) {
|
|
964
|
+
throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
|
|
965
|
+
}
|
|
966
|
+
if (Array.isArray(value)) {
|
|
967
|
+
const sanitized2 = [];
|
|
968
|
+
for (const entry of value) {
|
|
969
|
+
sanitized2.push(sanitizeValue(entry, depth + 1, state, options));
|
|
970
|
+
}
|
|
971
|
+
return sanitized2;
|
|
972
|
+
}
|
|
973
|
+
if (!isPlainObject(value)) {
|
|
974
|
+
return value;
|
|
975
|
+
}
|
|
976
|
+
const sanitized = options.createObject?.() ?? {};
|
|
977
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
978
|
+
if (DANGEROUS_KEYS.has(key)) {
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
|
|
982
|
+
}
|
|
983
|
+
return sanitized;
|
|
984
|
+
}
|
|
985
|
+
function isPlainObject(value) {
|
|
986
|
+
return Object.prototype.toString.call(value) === "[object Object]";
|
|
987
|
+
}
|
|
988
|
+
|
|
951
989
|
// src/internal/CacheStackSnapshotManager.ts
|
|
952
990
|
var DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE = 50;
|
|
953
991
|
var CacheStackSnapshotManager = class {
|
|
@@ -972,7 +1010,16 @@ var CacheStackSnapshotManager = class {
|
|
|
972
1010
|
const batch = normalizedEntries.slice(index, index + DEFAULT_SNAPSHOT_IMPORT_BATCH_SIZE);
|
|
973
1011
|
await Promise.all(
|
|
974
1012
|
batch.map(async (entry) => {
|
|
975
|
-
await Promise.all(
|
|
1013
|
+
await Promise.all(
|
|
1014
|
+
this.options.layers.map(async (layer) => {
|
|
1015
|
+
if (this.options.shouldSkipLayer(layer)) return;
|
|
1016
|
+
try {
|
|
1017
|
+
await layer.set(entry.key, entry.value, entry.ttl);
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
await this.options.handleLayerFailure(layer, "write", error);
|
|
1020
|
+
}
|
|
1021
|
+
})
|
|
1022
|
+
);
|
|
976
1023
|
await this.options.tagIndex.touch(entry.key);
|
|
977
1024
|
})
|
|
978
1025
|
);
|
|
@@ -982,7 +1029,7 @@ var CacheStackSnapshotManager = class {
|
|
|
982
1029
|
const targetPath = await validateSnapshotFilePath(filePath, "write", snapshotBaseDir);
|
|
983
1030
|
const tempPath = path.join(
|
|
984
1031
|
path.dirname(targetPath),
|
|
985
|
-
`.layercache-${process.pid}-${Date.now()}-${
|
|
1032
|
+
`.layercache-${process.pid}-${Date.now()}-${randomBytes(8).toString("hex")}.tmp`
|
|
986
1033
|
);
|
|
987
1034
|
let handle;
|
|
988
1035
|
try {
|
|
@@ -1082,7 +1129,13 @@ var CacheStackSnapshotManager = class {
|
|
|
1082
1129
|
});
|
|
1083
1130
|
}
|
|
1084
1131
|
sanitizeSnapshotValue(value) {
|
|
1085
|
-
|
|
1132
|
+
const roundTripped = this.options.snapshotSerializer.deserialize(this.options.snapshotSerializer.serialize(value));
|
|
1133
|
+
return sanitizeStructuredData(roundTripped, {
|
|
1134
|
+
label: "Snapshot value",
|
|
1135
|
+
maxDepth: 64,
|
|
1136
|
+
maxNodes: 1e4,
|
|
1137
|
+
createObject: () => /* @__PURE__ */ Object.create(null)
|
|
1138
|
+
});
|
|
1086
1139
|
}
|
|
1087
1140
|
};
|
|
1088
1141
|
|
|
@@ -1462,7 +1515,13 @@ var FetchRateLimiter = class {
|
|
|
1462
1515
|
this.pendingBuckets.add(next.bucketKey);
|
|
1463
1516
|
}
|
|
1464
1517
|
this.cleanupBucket(next.bucketKey, bucket, next.options.intervalMs);
|
|
1465
|
-
this.
|
|
1518
|
+
if (!this.drainTimer) {
|
|
1519
|
+
this.drainTimer = setTimeout(() => {
|
|
1520
|
+
this.drainTimer = void 0;
|
|
1521
|
+
this.drain();
|
|
1522
|
+
}, 0);
|
|
1523
|
+
this.drainTimer.unref?.();
|
|
1524
|
+
}
|
|
1466
1525
|
});
|
|
1467
1526
|
}
|
|
1468
1527
|
}
|
|
@@ -1504,6 +1563,9 @@ var FetchRateLimiter = class {
|
|
|
1504
1563
|
}
|
|
1505
1564
|
if (this.buckets.size >= MAX_BUCKETS) {
|
|
1506
1565
|
this.evictIdleBuckets();
|
|
1566
|
+
if (this.buckets.size >= MAX_BUCKETS) {
|
|
1567
|
+
throw new Error(`FetchRateLimiter bucket limit (${MAX_BUCKETS}) exceeded.`);
|
|
1568
|
+
}
|
|
1507
1569
|
}
|
|
1508
1570
|
const bucket = { active: 0, startedAt: [] };
|
|
1509
1571
|
this.buckets.set(bucketKey, bucket);
|
|
@@ -1733,38 +1795,6 @@ var TtlResolver = class {
|
|
|
1733
1795
|
}
|
|
1734
1796
|
};
|
|
1735
1797
|
|
|
1736
|
-
// src/internal/StructuredDataSanitizer.ts
|
|
1737
|
-
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
|
|
1738
|
-
function sanitizeStructuredData(value, options) {
|
|
1739
|
-
return sanitizeValue(value, 0, { count: 0 }, options);
|
|
1740
|
-
}
|
|
1741
|
-
function sanitizeValue(value, depth, state, options) {
|
|
1742
|
-
state.count += 1;
|
|
1743
|
-
if (state.count > options.maxNodes) {
|
|
1744
|
-
throw new Error(`${options.label} exceeds max node count of ${options.maxNodes}.`);
|
|
1745
|
-
}
|
|
1746
|
-
if (depth > options.maxDepth) {
|
|
1747
|
-
throw new Error(`${options.label} exceeds max depth of ${options.maxDepth}.`);
|
|
1748
|
-
}
|
|
1749
|
-
if (Array.isArray(value)) {
|
|
1750
|
-
return value.map((entry) => sanitizeValue(entry, depth + 1, state, options));
|
|
1751
|
-
}
|
|
1752
|
-
if (!isPlainObject(value)) {
|
|
1753
|
-
return value;
|
|
1754
|
-
}
|
|
1755
|
-
const sanitized = options.createObject?.() ?? {};
|
|
1756
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
1757
|
-
if (DANGEROUS_KEYS.has(key)) {
|
|
1758
|
-
continue;
|
|
1759
|
-
}
|
|
1760
|
-
sanitized[key] = sanitizeValue(entry, depth + 1, state, options);
|
|
1761
|
-
}
|
|
1762
|
-
return sanitized;
|
|
1763
|
-
}
|
|
1764
|
-
function isPlainObject(value) {
|
|
1765
|
-
return Object.prototype.toString.call(value) === "[object Object]";
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
1798
|
// src/serialization/JsonSerializer.ts
|
|
1769
1799
|
var JsonSerializer = class {
|
|
1770
1800
|
serialize(value) {
|
|
@@ -1781,29 +1811,35 @@ var JsonSerializer = class {
|
|
|
1781
1811
|
};
|
|
1782
1812
|
|
|
1783
1813
|
// src/stampede/StampedeGuard.ts
|
|
1784
|
-
import { Mutex as Mutex2 } from "async-mutex";
|
|
1785
1814
|
var StampedeGuard = class {
|
|
1786
|
-
|
|
1815
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
1787
1816
|
async execute(key, task) {
|
|
1788
|
-
const
|
|
1817
|
+
const existing = this.inFlight.get(key);
|
|
1818
|
+
if (existing) {
|
|
1819
|
+
existing.references += 1;
|
|
1820
|
+
try {
|
|
1821
|
+
return await existing.promise;
|
|
1822
|
+
} finally {
|
|
1823
|
+
this.releaseEntry(key, existing);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
const entry = {
|
|
1827
|
+
promise: Promise.resolve().then(task),
|
|
1828
|
+
references: 1
|
|
1829
|
+
};
|
|
1830
|
+
this.inFlight.set(key, entry);
|
|
1789
1831
|
try {
|
|
1790
|
-
return await entry.
|
|
1832
|
+
return await entry.promise;
|
|
1791
1833
|
} finally {
|
|
1792
|
-
|
|
1793
|
-
const current = this.mutexes.get(key);
|
|
1794
|
-
if (current === entry && entry.references === 0 && !entry.mutex.isLocked()) {
|
|
1795
|
-
this.mutexes.delete(key);
|
|
1796
|
-
}
|
|
1834
|
+
this.releaseEntry(key, entry);
|
|
1797
1835
|
}
|
|
1798
1836
|
}
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
this.
|
|
1837
|
+
releaseEntry(key, entry) {
|
|
1838
|
+
entry.references -= 1;
|
|
1839
|
+
const current = this.inFlight.get(key);
|
|
1840
|
+
if (current === entry && entry.references === 0) {
|
|
1841
|
+
this.inFlight.delete(key);
|
|
1804
1842
|
}
|
|
1805
|
-
entry.references += 1;
|
|
1806
|
-
return entry;
|
|
1807
1843
|
}
|
|
1808
1844
|
};
|
|
1809
1845
|
|
|
@@ -1929,6 +1965,8 @@ var CacheStack = class extends EventEmitter {
|
|
|
1929
1965
|
tagIndex: this.tagIndex,
|
|
1930
1966
|
snapshotSerializer: this.snapshotSerializer,
|
|
1931
1967
|
readLayerEntry: this.readLayerEntry.bind(this),
|
|
1968
|
+
shouldSkipLayer: (layer) => this.shouldSkipLayer(layer),
|
|
1969
|
+
handleLayerFailure: async (layer, operation, error) => this.handleLayerFailure(layer, operation, error),
|
|
1932
1970
|
qualifyKey: this.qualifyKey.bind(this),
|
|
1933
1971
|
stripQualifiedKey: this.stripQualifiedKey.bind(this),
|
|
1934
1972
|
validateCacheKey,
|
|
@@ -1953,6 +1991,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1953
1991
|
layerWriter;
|
|
1954
1992
|
snapshots;
|
|
1955
1993
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
1994
|
+
backgroundRefreshAbort = /* @__PURE__ */ new Map();
|
|
1956
1995
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
1957
1996
|
maintenance = new CacheStackMaintenance();
|
|
1958
1997
|
ttlResolver;
|
|
@@ -2015,7 +2054,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
2015
2054
|
if (!fetcher) {
|
|
2016
2055
|
return null;
|
|
2017
2056
|
}
|
|
2018
|
-
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
2057
|
+
return this.fetchWithGuards(normalizedKey, fetcher, options, void 0, void 0, true);
|
|
2019
2058
|
}
|
|
2020
2059
|
/**
|
|
2021
2060
|
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
@@ -2197,7 +2236,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
2197
2236
|
}
|
|
2198
2237
|
for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
|
|
2199
2238
|
const layer = this.layers[layerIndex];
|
|
2200
|
-
if (!layer) continue;
|
|
2239
|
+
if (!layer || this.shouldSkipLayer(layer)) continue;
|
|
2201
2240
|
const keys = [...pending];
|
|
2202
2241
|
if (keys.length === 0) {
|
|
2203
2242
|
break;
|
|
@@ -2214,6 +2253,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
2214
2253
|
await layer.delete(key);
|
|
2215
2254
|
continue;
|
|
2216
2255
|
}
|
|
2256
|
+
if (resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error") {
|
|
2257
|
+
this.metricsCollector.increment("staleHits", indexesByKey.get(key)?.length ?? 1);
|
|
2258
|
+
}
|
|
2217
2259
|
await this.tagIndex.touch(key);
|
|
2218
2260
|
await this.backfill(key, stored, layerIndex - 1);
|
|
2219
2261
|
resultsByKey.set(key, resolved.value);
|
|
@@ -2469,7 +2511,25 @@ var CacheStack = class extends EventEmitter {
|
|
|
2469
2511
|
await this.unsubscribeInvalidation?.();
|
|
2470
2512
|
await this.flushWriteBehindQueue();
|
|
2471
2513
|
await this.maintenance.waitForGenerationCleanup();
|
|
2472
|
-
|
|
2514
|
+
for (const key of this.backgroundRefreshAbort.keys()) {
|
|
2515
|
+
this.backgroundRefreshAbort.set(key, true);
|
|
2516
|
+
}
|
|
2517
|
+
await Promise.allSettled(
|
|
2518
|
+
[...this.backgroundRefreshes.values()].map((promise) => {
|
|
2519
|
+
let timer;
|
|
2520
|
+
return Promise.race([
|
|
2521
|
+
promise,
|
|
2522
|
+
new Promise((resolve2) => {
|
|
2523
|
+
timer = setTimeout(resolve2, 5e3);
|
|
2524
|
+
timer.unref?.();
|
|
2525
|
+
})
|
|
2526
|
+
]).finally(() => {
|
|
2527
|
+
if (timer) clearTimeout(timer);
|
|
2528
|
+
});
|
|
2529
|
+
})
|
|
2530
|
+
);
|
|
2531
|
+
this.backgroundRefreshes.clear();
|
|
2532
|
+
this.backgroundRefreshAbort.clear();
|
|
2473
2533
|
this.maintenance.disposeWriteBehindTimer();
|
|
2474
2534
|
this.fetchRateLimiter.dispose();
|
|
2475
2535
|
await Promise.allSettled(this.layers.map((layer) => layer.dispose?.() ?? Promise.resolve()));
|
|
@@ -2485,12 +2545,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
2485
2545
|
await this.handleInvalidationMessage(message);
|
|
2486
2546
|
});
|
|
2487
2547
|
}
|
|
2488
|
-
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch) {
|
|
2548
|
+
async fetchWithGuards(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch, initialMissConfirmed = false) {
|
|
2489
2549
|
const fetchTask = async () => {
|
|
2490
|
-
const
|
|
2491
|
-
if (
|
|
2492
|
-
this.
|
|
2493
|
-
|
|
2550
|
+
const shouldRecheckFreshLayers = !(initialMissConfirmed && this.options.singleFlightCoordinator);
|
|
2551
|
+
if (shouldRecheckFreshLayers) {
|
|
2552
|
+
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
2553
|
+
if (secondHit.found) {
|
|
2554
|
+
this.metricsCollector.increment("hits");
|
|
2555
|
+
return secondHit.value;
|
|
2556
|
+
}
|
|
2494
2557
|
}
|
|
2495
2558
|
return this.fetchAndPopulate(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch);
|
|
2496
2559
|
};
|
|
@@ -2498,12 +2561,22 @@ var CacheStack = class extends EventEmitter {
|
|
|
2498
2561
|
if (!this.options.singleFlightCoordinator) {
|
|
2499
2562
|
return fetchTask();
|
|
2500
2563
|
}
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2564
|
+
try {
|
|
2565
|
+
return await this.options.singleFlightCoordinator.execute(
|
|
2566
|
+
key,
|
|
2567
|
+
this.resolveSingleFlightOptions(),
|
|
2568
|
+
fetchTask,
|
|
2569
|
+
() => this.waitForFreshValue(key, fetcher, options, expectedClearEpoch, expectedKeyEpoch)
|
|
2570
|
+
);
|
|
2571
|
+
} catch (error) {
|
|
2572
|
+
if (!this.isGracefulDegradationEnabled()) {
|
|
2573
|
+
throw error;
|
|
2574
|
+
}
|
|
2575
|
+
this.metricsCollector.increment("degradedOperations");
|
|
2576
|
+
this.logger.warn?.("single-flight-coordinator-degraded", { key, error: this.formatError(error) });
|
|
2577
|
+
this.emitError("single-flight", { key, degraded: true, error: this.formatError(error) });
|
|
2578
|
+
return fetchTask();
|
|
2579
|
+
}
|
|
2507
2580
|
};
|
|
2508
2581
|
if (this.options.stampedePrevention === false) {
|
|
2509
2582
|
return singleFlightTask();
|
|
@@ -2736,15 +2809,19 @@ var CacheStack = class extends EventEmitter {
|
|
|
2736
2809
|
}
|
|
2737
2810
|
const clearEpoch = this.maintenance.currentClearEpoch();
|
|
2738
2811
|
const keyEpoch = this.maintenance.currentKeyEpoch(key);
|
|
2812
|
+
this.backgroundRefreshAbort.set(key, false);
|
|
2739
2813
|
const refresh = (async () => {
|
|
2740
2814
|
this.metricsCollector.increment("refreshes");
|
|
2741
2815
|
try {
|
|
2816
|
+
if (this.backgroundRefreshAbort.get(key)) return;
|
|
2742
2817
|
await this.runBackgroundRefresh(key, fetcher, options, clearEpoch, keyEpoch);
|
|
2743
2818
|
} catch (error) {
|
|
2819
|
+
if (this.backgroundRefreshAbort.get(key)) return;
|
|
2744
2820
|
this.metricsCollector.increment("refreshErrors");
|
|
2745
2821
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
2746
2822
|
} finally {
|
|
2747
2823
|
this.backgroundRefreshes.delete(key);
|
|
2824
|
+
this.backgroundRefreshAbort.delete(key);
|
|
2748
2825
|
}
|
|
2749
2826
|
})();
|
|
2750
2827
|
this.backgroundRefreshes.set(key, refresh);
|
|
@@ -2847,7 +2924,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
2847
2924
|
timer.unref?.();
|
|
2848
2925
|
})
|
|
2849
2926
|
]);
|
|
2850
|
-
if (result && typeof result === "object" && "kind" in result) {
|
|
2927
|
+
if (result !== null && result !== void 0 && typeof result === "object" && "kind" in result) {
|
|
2851
2928
|
if (result.kind === "error") {
|
|
2852
2929
|
throw result.error;
|
|
2853
2930
|
}
|
|
@@ -2865,7 +2942,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
2865
2942
|
}
|
|
2866
2943
|
async observeOperation(name, attributes, execute) {
|
|
2867
2944
|
const id = this.nextOperationId;
|
|
2868
|
-
this.nextOperationId
|
|
2945
|
+
this.nextOperationId = (this.nextOperationId + 1) % Number.MAX_SAFE_INTEGER;
|
|
2869
2946
|
this.emit("operation-start", { id, name, attributes });
|
|
2870
2947
|
try {
|
|
2871
2948
|
const result = await execute();
|
|
@@ -3101,6 +3178,7 @@ var RedisInvalidationBus = class {
|
|
|
3101
3178
|
logger;
|
|
3102
3179
|
handlers = /* @__PURE__ */ new Set();
|
|
3103
3180
|
sharedListener;
|
|
3181
|
+
subscribePromise;
|
|
3104
3182
|
constructor(options) {
|
|
3105
3183
|
this.publisher = options.publisher;
|
|
3106
3184
|
this.subscriber = options.subscriber ?? options.publisher.duplicate();
|
|
@@ -3108,15 +3186,27 @@ var RedisInvalidationBus = class {
|
|
|
3108
3186
|
this.logger = options.logger;
|
|
3109
3187
|
}
|
|
3110
3188
|
async subscribe(handler) {
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
await
|
|
3189
|
+
const previousPromise = this.subscribePromise;
|
|
3190
|
+
let resolveThis;
|
|
3191
|
+
this.subscribePromise = new Promise((resolve2) => {
|
|
3192
|
+
resolveThis = resolve2;
|
|
3193
|
+
});
|
|
3194
|
+
if (previousPromise) {
|
|
3195
|
+
await previousPromise;
|
|
3196
|
+
}
|
|
3197
|
+
try {
|
|
3198
|
+
if (this.handlers.size === 0) {
|
|
3199
|
+
const listener = (_channel, payload) => {
|
|
3200
|
+
void this.dispatchToHandlers(payload);
|
|
3201
|
+
};
|
|
3202
|
+
this.sharedListener = listener;
|
|
3203
|
+
this.subscriber.on("message", listener);
|
|
3204
|
+
await this.subscriber.subscribe(this.channel);
|
|
3205
|
+
}
|
|
3206
|
+
this.handlers.add(handler);
|
|
3207
|
+
} finally {
|
|
3208
|
+
resolveThis();
|
|
3118
3209
|
}
|
|
3119
|
-
this.handlers.add(handler);
|
|
3120
3210
|
return async () => {
|
|
3121
3211
|
this.handlers.delete(handler);
|
|
3122
3212
|
if (this.handlers.size === 0 && this.sharedListener) {
|
|
@@ -3317,10 +3407,21 @@ function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
|
|
|
3317
3407
|
}
|
|
3318
3408
|
|
|
3319
3409
|
// src/integrations/opentelemetry.ts
|
|
3410
|
+
var MAX_SPANS = 1e4;
|
|
3320
3411
|
function createOpenTelemetryPlugin(cache, tracer) {
|
|
3321
3412
|
const spans = /* @__PURE__ */ new Map();
|
|
3322
3413
|
const onStart = (event) => {
|
|
3323
|
-
|
|
3414
|
+
try {
|
|
3415
|
+
if (spans.size >= MAX_SPANS) {
|
|
3416
|
+
const oldest = spans.keys().next().value;
|
|
3417
|
+
if (oldest !== void 0) {
|
|
3418
|
+
spans.get(oldest)?.end();
|
|
3419
|
+
spans.delete(oldest);
|
|
3420
|
+
}
|
|
3421
|
+
}
|
|
3422
|
+
spans.set(event.id, tracer.startSpan(event.name, { attributes: event.attributes }));
|
|
3423
|
+
} catch {
|
|
3424
|
+
}
|
|
3324
3425
|
};
|
|
3325
3426
|
const onEnd = (event) => {
|
|
3326
3427
|
const span = spans.get(event.id);
|
|
@@ -3328,12 +3429,15 @@ function createOpenTelemetryPlugin(cache, tracer) {
|
|
|
3328
3429
|
return;
|
|
3329
3430
|
}
|
|
3330
3431
|
spans.delete(event.id);
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3432
|
+
try {
|
|
3433
|
+
span.setAttribute?.("layercache.success", event.success);
|
|
3434
|
+
if (event.result) {
|
|
3435
|
+
span.setAttribute?.("layercache.result", event.result);
|
|
3436
|
+
}
|
|
3437
|
+
if (event.error !== void 0) {
|
|
3438
|
+
span.recordException?.(event.error);
|
|
3439
|
+
}
|
|
3440
|
+
} catch {
|
|
3337
3441
|
}
|
|
3338
3442
|
span.end();
|
|
3339
3443
|
};
|
|
@@ -3400,6 +3504,7 @@ var RedisLayer = class {
|
|
|
3400
3504
|
compression;
|
|
3401
3505
|
compressionThreshold;
|
|
3402
3506
|
decompressionMaxBytes;
|
|
3507
|
+
commandTimeoutMs;
|
|
3403
3508
|
disconnectOnDispose;
|
|
3404
3509
|
constructor(options) {
|
|
3405
3510
|
this.client = options.client;
|
|
@@ -3412,6 +3517,7 @@ var RedisLayer = class {
|
|
|
3412
3517
|
this.compression = options.compression;
|
|
3413
3518
|
this.compressionThreshold = options.compressionThreshold ?? 1024;
|
|
3414
3519
|
this.decompressionMaxBytes = options.decompressionMaxBytes ?? 64 * 1024 * 1024;
|
|
3520
|
+
this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
|
|
3415
3521
|
this.disconnectOnDispose = options.disconnectOnDispose ?? false;
|
|
3416
3522
|
}
|
|
3417
3523
|
async get(key) {
|
|
@@ -3419,7 +3525,7 @@ var RedisLayer = class {
|
|
|
3419
3525
|
return unwrapStoredValue(payload);
|
|
3420
3526
|
}
|
|
3421
3527
|
async getEntry(key) {
|
|
3422
|
-
const payload = await this.client.getBuffer(this.withPrefix(key));
|
|
3528
|
+
const payload = await this.runCommand(`get("${key}")`, () => this.client.getBuffer(this.withPrefix(key)));
|
|
3423
3529
|
if (payload === null) {
|
|
3424
3530
|
return null;
|
|
3425
3531
|
}
|
|
@@ -3433,7 +3539,7 @@ var RedisLayer = class {
|
|
|
3433
3539
|
for (const key of keys) {
|
|
3434
3540
|
pipeline.getBuffer(this.withPrefix(key));
|
|
3435
3541
|
}
|
|
3436
|
-
const results = await pipeline.exec();
|
|
3542
|
+
const results = await this.runCommand(`mget(${keys.length})`, () => pipeline.exec());
|
|
3437
3543
|
if (results === null) {
|
|
3438
3544
|
return keys.map(() => null);
|
|
3439
3545
|
}
|
|
@@ -3462,33 +3568,36 @@ var RedisLayer = class {
|
|
|
3462
3568
|
pipeline.set(normalizedKey, payload);
|
|
3463
3569
|
}
|
|
3464
3570
|
}
|
|
3465
|
-
await pipeline.exec();
|
|
3571
|
+
await this.runCommand(`mset(${entries.length})`, () => pipeline.exec());
|
|
3466
3572
|
}
|
|
3467
3573
|
async set(key, value, ttl = this.defaultTtl) {
|
|
3468
3574
|
const serialized = this.primarySerializer().serialize(value);
|
|
3469
3575
|
const payload = await this.encodePayload(serialized);
|
|
3470
3576
|
const normalizedKey = this.withPrefix(key);
|
|
3471
3577
|
if (ttl && ttl > 0) {
|
|
3472
|
-
await this.client.set(normalizedKey, payload, "EX", ttl);
|
|
3578
|
+
await this.runCommand(`set("${key}")`, () => this.client.set(normalizedKey, payload, "EX", ttl));
|
|
3473
3579
|
return;
|
|
3474
3580
|
}
|
|
3475
|
-
await this.client.set(normalizedKey, payload);
|
|
3581
|
+
await this.runCommand(`set("${key}")`, () => this.client.set(normalizedKey, payload));
|
|
3476
3582
|
}
|
|
3477
3583
|
async delete(key) {
|
|
3478
|
-
await this.client.del(this.withPrefix(key));
|
|
3584
|
+
await this.runCommand(`delete("${key}")`, () => this.client.del(this.withPrefix(key)));
|
|
3479
3585
|
}
|
|
3480
3586
|
async deleteMany(keys) {
|
|
3481
3587
|
if (keys.length === 0) {
|
|
3482
3588
|
return;
|
|
3483
3589
|
}
|
|
3484
|
-
await this.
|
|
3590
|
+
await this.runCommand(
|
|
3591
|
+
`deleteMany(${keys.length})`,
|
|
3592
|
+
() => this.client.del(...keys.map((key) => this.withPrefix(key)))
|
|
3593
|
+
);
|
|
3485
3594
|
}
|
|
3486
3595
|
async has(key) {
|
|
3487
|
-
const exists = await this.client.exists(this.withPrefix(key));
|
|
3596
|
+
const exists = await this.runCommand(`has("${key}")`, () => this.client.exists(this.withPrefix(key)));
|
|
3488
3597
|
return exists > 0;
|
|
3489
3598
|
}
|
|
3490
3599
|
async ttl(key) {
|
|
3491
|
-
const remaining = await this.client.ttl(this.withPrefix(key));
|
|
3600
|
+
const remaining = await this.runCommand(`ttl("${key}")`, () => this.client.ttl(this.withPrefix(key)));
|
|
3492
3601
|
if (remaining < 0) {
|
|
3493
3602
|
return null;
|
|
3494
3603
|
}
|
|
@@ -3496,13 +3605,16 @@ var RedisLayer = class {
|
|
|
3496
3605
|
}
|
|
3497
3606
|
async size() {
|
|
3498
3607
|
if (!this.prefix) {
|
|
3499
|
-
return this.client.dbsize();
|
|
3608
|
+
return this.runCommand("dbsize()", () => this.client.dbsize());
|
|
3500
3609
|
}
|
|
3501
3610
|
const pattern = `${this.prefix}*`;
|
|
3502
3611
|
let cursor = "0";
|
|
3503
3612
|
let count = 0;
|
|
3504
3613
|
do {
|
|
3505
|
-
const [nextCursor, keys] = await this.
|
|
3614
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
3615
|
+
`scan("${pattern}")`,
|
|
3616
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
3617
|
+
);
|
|
3506
3618
|
cursor = nextCursor;
|
|
3507
3619
|
count += keys.length;
|
|
3508
3620
|
} while (cursor !== "0");
|
|
@@ -3510,7 +3622,7 @@ var RedisLayer = class {
|
|
|
3510
3622
|
}
|
|
3511
3623
|
async ping() {
|
|
3512
3624
|
try {
|
|
3513
|
-
return await this.client.ping() === "PONG";
|
|
3625
|
+
return await this.runCommand("ping()", () => this.client.ping()) === "PONG";
|
|
3514
3626
|
} catch {
|
|
3515
3627
|
return false;
|
|
3516
3628
|
}
|
|
@@ -3533,14 +3645,17 @@ var RedisLayer = class {
|
|
|
3533
3645
|
const pattern = `${this.prefix}*`;
|
|
3534
3646
|
let cursor = "0";
|
|
3535
3647
|
do {
|
|
3536
|
-
const [nextCursor, keys] = await this.
|
|
3648
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
3649
|
+
`scan("${pattern}")`,
|
|
3650
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
3651
|
+
);
|
|
3537
3652
|
cursor = nextCursor;
|
|
3538
3653
|
if (keys.length === 0) {
|
|
3539
3654
|
continue;
|
|
3540
3655
|
}
|
|
3541
3656
|
for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
|
|
3542
3657
|
const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
|
|
3543
|
-
await this.client.del(...batch);
|
|
3658
|
+
await this.runCommand(`clear-del(${batch.length})`, () => this.client.del(...batch));
|
|
3544
3659
|
}
|
|
3545
3660
|
} while (cursor !== "0");
|
|
3546
3661
|
}
|
|
@@ -3556,7 +3671,10 @@ var RedisLayer = class {
|
|
|
3556
3671
|
const pattern = `${this.prefix}*`;
|
|
3557
3672
|
let cursor = "0";
|
|
3558
3673
|
do {
|
|
3559
|
-
const [nextCursor, keys] = await this.
|
|
3674
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
3675
|
+
`scan("${pattern}")`,
|
|
3676
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
3677
|
+
);
|
|
3560
3678
|
cursor = nextCursor;
|
|
3561
3679
|
for (const key of keys) {
|
|
3562
3680
|
await visitor(this.prefix ? key.slice(this.prefix.length) : key);
|
|
@@ -3567,7 +3685,10 @@ var RedisLayer = class {
|
|
|
3567
3685
|
const matches = [];
|
|
3568
3686
|
let cursor = "0";
|
|
3569
3687
|
do {
|
|
3570
|
-
const [nextCursor, keys] = await this.
|
|
3688
|
+
const [nextCursor, keys] = await this.runCommand(
|
|
3689
|
+
`scan("${pattern}")`,
|
|
3690
|
+
() => this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount)
|
|
3691
|
+
);
|
|
3571
3692
|
cursor = nextCursor;
|
|
3572
3693
|
matches.push(...keys);
|
|
3573
3694
|
} while (cursor !== "0");
|
|
@@ -3599,7 +3720,7 @@ var RedisLayer = class {
|
|
|
3599
3720
|
}
|
|
3600
3721
|
async deleteCorruptedKey(key) {
|
|
3601
3722
|
try {
|
|
3602
|
-
await this.client.del(this.withPrefix(key));
|
|
3723
|
+
await this.runCommand(`deleteCorrupted("${key}")`, () => this.client.del(this.withPrefix(key)));
|
|
3603
3724
|
} catch (deleteError) {
|
|
3604
3725
|
console.warn(`[layercache] RedisLayer: failed to delete corrupted key "${key}"`, deleteError);
|
|
3605
3726
|
}
|
|
@@ -3607,12 +3728,15 @@ var RedisLayer = class {
|
|
|
3607
3728
|
async rewriteWithPrimarySerializer(key, value) {
|
|
3608
3729
|
const serialized = this.primarySerializer().serialize(value);
|
|
3609
3730
|
const payload = await this.encodePayload(serialized);
|
|
3610
|
-
const ttl = await this.client.ttl(this.withPrefix(key));
|
|
3731
|
+
const ttl = await this.runCommand(`rewrite-ttl("${key}")`, () => this.client.ttl(this.withPrefix(key)));
|
|
3611
3732
|
if (ttl > 0) {
|
|
3612
|
-
await this.
|
|
3733
|
+
await this.runCommand(
|
|
3734
|
+
`rewrite-set("${key}")`,
|
|
3735
|
+
() => this.client.set(this.withPrefix(key), payload, "EX", ttl)
|
|
3736
|
+
);
|
|
3613
3737
|
return;
|
|
3614
3738
|
}
|
|
3615
|
-
await this.client.set(this.withPrefix(key), payload);
|
|
3739
|
+
await this.runCommand(`rewrite-set("${key}")`, () => this.client.set(this.withPrefix(key), payload));
|
|
3616
3740
|
}
|
|
3617
3741
|
primarySerializer() {
|
|
3618
3742
|
const serializer = this.serializers[0];
|
|
@@ -3708,10 +3832,39 @@ var RedisLayer = class {
|
|
|
3708
3832
|
source.pipe(decompressor);
|
|
3709
3833
|
});
|
|
3710
3834
|
}
|
|
3835
|
+
normalizeCommandTimeoutMs(value) {
|
|
3836
|
+
if (value === void 0) {
|
|
3837
|
+
return void 0;
|
|
3838
|
+
}
|
|
3839
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
3840
|
+
throw new Error("RedisLayer.commandTimeoutMs must be a positive number.");
|
|
3841
|
+
}
|
|
3842
|
+
return value;
|
|
3843
|
+
}
|
|
3844
|
+
async runCommand(operation, command) {
|
|
3845
|
+
const promise = command();
|
|
3846
|
+
if (!this.commandTimeoutMs) {
|
|
3847
|
+
return promise;
|
|
3848
|
+
}
|
|
3849
|
+
let timer;
|
|
3850
|
+
return Promise.race([
|
|
3851
|
+
promise,
|
|
3852
|
+
new Promise((_, reject) => {
|
|
3853
|
+
timer = setTimeout(() => {
|
|
3854
|
+
reject(new Error(`RedisLayer command ${operation} timed out after ${this.commandTimeoutMs}ms.`));
|
|
3855
|
+
}, this.commandTimeoutMs);
|
|
3856
|
+
timer.unref?.();
|
|
3857
|
+
})
|
|
3858
|
+
]).finally(() => {
|
|
3859
|
+
if (timer) {
|
|
3860
|
+
clearTimeout(timer);
|
|
3861
|
+
}
|
|
3862
|
+
});
|
|
3863
|
+
}
|
|
3711
3864
|
};
|
|
3712
3865
|
|
|
3713
3866
|
// src/layers/DiskLayer.ts
|
|
3714
|
-
import { createHash } from "crypto";
|
|
3867
|
+
import { createHash, randomBytes as randomBytes2 } from "crypto";
|
|
3715
3868
|
import { promises as fs2 } from "fs";
|
|
3716
3869
|
import { join, resolve } from "path";
|
|
3717
3870
|
var FILE_SCAN_CONCURRENCY = 32;
|
|
@@ -3764,7 +3917,7 @@ var DiskLayer = class {
|
|
|
3764
3917
|
};
|
|
3765
3918
|
const payload = this.serializer.serialize(entry);
|
|
3766
3919
|
const targetPath = this.keyToPath(key);
|
|
3767
|
-
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${
|
|
3920
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.${randomBytes2(8).toString("hex")}.tmp`;
|
|
3768
3921
|
try {
|
|
3769
3922
|
await fs2.writeFile(tempPath, payload);
|
|
3770
3923
|
await fs2.rename(tempPath, targetPath);
|
|
@@ -4158,14 +4311,19 @@ return 0
|
|
|
4158
4311
|
var RedisSingleFlightCoordinator = class {
|
|
4159
4312
|
client;
|
|
4160
4313
|
prefix;
|
|
4314
|
+
commandTimeoutMs;
|
|
4161
4315
|
constructor(options) {
|
|
4162
4316
|
this.client = options.client;
|
|
4163
4317
|
this.prefix = options.prefix ?? "layercache:singleflight";
|
|
4318
|
+
this.commandTimeoutMs = this.normalizeCommandTimeoutMs(options.commandTimeoutMs);
|
|
4164
4319
|
}
|
|
4165
4320
|
async execute(key, options, worker, waiter) {
|
|
4166
4321
|
const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
|
|
4167
4322
|
const token = randomUUID();
|
|
4168
|
-
const acquired = await this.
|
|
4323
|
+
const acquired = await this.runCommand(
|
|
4324
|
+
`acquire("${key}")`,
|
|
4325
|
+
() => this.client.set(lockKey, token, "PX", options.leaseMs, "NX")
|
|
4326
|
+
);
|
|
4169
4327
|
if (acquired === "OK") {
|
|
4170
4328
|
const renewTimer = this.startLeaseRenewal(lockKey, token, options);
|
|
4171
4329
|
try {
|
|
@@ -4174,7 +4332,7 @@ var RedisSingleFlightCoordinator = class {
|
|
|
4174
4332
|
if (renewTimer) {
|
|
4175
4333
|
clearInterval(renewTimer);
|
|
4176
4334
|
}
|
|
4177
|
-
await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
|
|
4335
|
+
await this.runCommand(`release("${key}")`, () => this.client.eval(RELEASE_SCRIPT, 1, lockKey, token));
|
|
4178
4336
|
}
|
|
4179
4337
|
}
|
|
4180
4338
|
return waiter();
|
|
@@ -4185,11 +4343,45 @@ var RedisSingleFlightCoordinator = class {
|
|
|
4185
4343
|
return void 0;
|
|
4186
4344
|
}
|
|
4187
4345
|
const timer = setInterval(() => {
|
|
4188
|
-
void this.
|
|
4346
|
+
void this.runCommand(
|
|
4347
|
+
`renew("${lockKey}")`,
|
|
4348
|
+
() => this.client.eval(RENEW_SCRIPT, 1, lockKey, token, String(options.leaseMs))
|
|
4349
|
+
).catch(() => void 0);
|
|
4189
4350
|
}, renewIntervalMs);
|
|
4190
4351
|
timer.unref?.();
|
|
4191
4352
|
return timer;
|
|
4192
4353
|
}
|
|
4354
|
+
normalizeCommandTimeoutMs(value) {
|
|
4355
|
+
if (value === void 0) {
|
|
4356
|
+
return void 0;
|
|
4357
|
+
}
|
|
4358
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
4359
|
+
throw new Error("RedisSingleFlightCoordinator.commandTimeoutMs must be a positive number.");
|
|
4360
|
+
}
|
|
4361
|
+
return value;
|
|
4362
|
+
}
|
|
4363
|
+
async runCommand(operation, command) {
|
|
4364
|
+
const promise = command();
|
|
4365
|
+
if (!this.commandTimeoutMs) {
|
|
4366
|
+
return promise;
|
|
4367
|
+
}
|
|
4368
|
+
let timer;
|
|
4369
|
+
return Promise.race([
|
|
4370
|
+
promise,
|
|
4371
|
+
new Promise((_, reject) => {
|
|
4372
|
+
timer = setTimeout(() => {
|
|
4373
|
+
reject(
|
|
4374
|
+
new Error(`RedisSingleFlightCoordinator command ${operation} timed out after ${this.commandTimeoutMs}ms.`)
|
|
4375
|
+
);
|
|
4376
|
+
}, this.commandTimeoutMs);
|
|
4377
|
+
timer.unref?.();
|
|
4378
|
+
})
|
|
4379
|
+
]).finally(() => {
|
|
4380
|
+
if (timer) {
|
|
4381
|
+
clearTimeout(timer);
|
|
4382
|
+
}
|
|
4383
|
+
});
|
|
4384
|
+
}
|
|
4193
4385
|
};
|
|
4194
4386
|
|
|
4195
4387
|
// src/metrics/PrometheusExporter.ts
|