layercache 1.1.0 → 1.2.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 +97 -5
- package/dist/{chunk-QUB5VZFZ.js → chunk-BWM4MU2X.js} +3 -0
- package/dist/cli.cjs +12 -2
- package/dist/cli.js +10 -3
- package/dist/index.cjs +333 -40
- package/dist/index.d.cts +179 -3
- package/dist/index.d.ts +179 -3
- package/dist/index.js +330 -42
- package/package.json +1 -1
- package/packages/nestjs/dist/index.cjs +162 -2
- package/packages/nestjs/dist/index.d.cts +92 -1
- package/packages/nestjs/dist/index.d.ts +92 -1
- package/packages/nestjs/dist/index.js +162 -2
package/dist/index.cjs
CHANGED
|
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
CacheMissError: () => CacheMissError,
|
|
23
24
|
CacheNamespace: () => CacheNamespace,
|
|
24
25
|
CacheStack: () => CacheStack,
|
|
25
26
|
DiskLayer: () => DiskLayer,
|
|
@@ -37,6 +38,7 @@ __export(index_exports, {
|
|
|
37
38
|
cacheGraphqlResolver: () => cacheGraphqlResolver,
|
|
38
39
|
createCacheStatsHandler: () => createCacheStatsHandler,
|
|
39
40
|
createCachedMethodDecorator: () => createCachedMethodDecorator,
|
|
41
|
+
createExpressCacheMiddleware: () => createExpressCacheMiddleware,
|
|
40
42
|
createFastifyLayercachePlugin: () => createFastifyLayercachePlugin,
|
|
41
43
|
createPrometheusMetricsExporter: () => createPrometheusMetricsExporter,
|
|
42
44
|
createTrpcCacheMiddleware: () => createTrpcCacheMiddleware
|
|
@@ -49,7 +51,7 @@ var import_node_events = require("events");
|
|
|
49
51
|
var import_node_fs = require("fs");
|
|
50
52
|
|
|
51
53
|
// src/CacheNamespace.ts
|
|
52
|
-
var CacheNamespace = class {
|
|
54
|
+
var CacheNamespace = class _CacheNamespace {
|
|
53
55
|
constructor(cache, prefix) {
|
|
54
56
|
this.cache = cache;
|
|
55
57
|
this.prefix = prefix;
|
|
@@ -62,6 +64,12 @@ var CacheNamespace = class {
|
|
|
62
64
|
async getOrSet(key, fetcher, options) {
|
|
63
65
|
return this.cache.getOrSet(this.qualify(key), fetcher, options);
|
|
64
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Like `get()`, but throws `CacheMissError` instead of returning `null`.
|
|
69
|
+
*/
|
|
70
|
+
async getOrThrow(key, fetcher, options) {
|
|
71
|
+
return this.cache.getOrThrow(this.qualify(key), fetcher, options);
|
|
72
|
+
}
|
|
65
73
|
async has(key) {
|
|
66
74
|
return this.cache.has(this.qualify(key));
|
|
67
75
|
}
|
|
@@ -102,6 +110,12 @@ var CacheNamespace = class {
|
|
|
102
110
|
async invalidateByPattern(pattern) {
|
|
103
111
|
await this.cache.invalidateByPattern(this.qualify(pattern));
|
|
104
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* Returns detailed metadata about a single cache key within this namespace.
|
|
115
|
+
*/
|
|
116
|
+
async inspect(key) {
|
|
117
|
+
return this.cache.inspect(this.qualify(key));
|
|
118
|
+
}
|
|
105
119
|
wrap(keyPrefix, fetcher, options) {
|
|
106
120
|
return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
|
|
107
121
|
}
|
|
@@ -120,6 +134,18 @@ var CacheNamespace = class {
|
|
|
120
134
|
getHitRate() {
|
|
121
135
|
return this.cache.getHitRate();
|
|
122
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Creates a nested namespace. Keys are prefixed with `parentPrefix:childPrefix:`.
|
|
139
|
+
*
|
|
140
|
+
* ```ts
|
|
141
|
+
* const tenant = cache.namespace('tenant:abc')
|
|
142
|
+
* const posts = tenant.namespace('posts')
|
|
143
|
+
* // keys become: "tenant:abc:posts:mykey"
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
namespace(childPrefix) {
|
|
147
|
+
return new _CacheNamespace(this.cache, `${this.prefix}:${childPrefix}`);
|
|
148
|
+
}
|
|
123
149
|
qualify(key) {
|
|
124
150
|
return `${this.prefix}:${key}`;
|
|
125
151
|
}
|
|
@@ -221,7 +247,12 @@ var CircuitBreakerManager = class {
|
|
|
221
247
|
var MetricsCollector = class {
|
|
222
248
|
data = this.empty();
|
|
223
249
|
get snapshot() {
|
|
224
|
-
return {
|
|
250
|
+
return {
|
|
251
|
+
...this.data,
|
|
252
|
+
hitsByLayer: { ...this.data.hitsByLayer },
|
|
253
|
+
missesByLayer: { ...this.data.missesByLayer },
|
|
254
|
+
latencyByLayer: Object.fromEntries(Object.entries(this.data.latencyByLayer).map(([k, v]) => [k, { ...v }]))
|
|
255
|
+
};
|
|
225
256
|
}
|
|
226
257
|
increment(field, amount = 1) {
|
|
227
258
|
;
|
|
@@ -230,6 +261,22 @@ var MetricsCollector = class {
|
|
|
230
261
|
incrementLayer(map, layerName) {
|
|
231
262
|
this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
|
|
232
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Records a read latency sample for the given layer.
|
|
266
|
+
* Maintains a rolling average and max using Welford's online algorithm.
|
|
267
|
+
*/
|
|
268
|
+
recordLatency(layerName, durationMs) {
|
|
269
|
+
const existing = this.data.latencyByLayer[layerName];
|
|
270
|
+
if (!existing) {
|
|
271
|
+
this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
existing.count += 1;
|
|
275
|
+
existing.avgMs += (durationMs - existing.avgMs) / existing.count;
|
|
276
|
+
if (durationMs > existing.maxMs) {
|
|
277
|
+
existing.maxMs = durationMs;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
233
280
|
reset() {
|
|
234
281
|
this.data = this.empty();
|
|
235
282
|
}
|
|
@@ -264,6 +311,7 @@ var MetricsCollector = class {
|
|
|
264
311
|
degradedOperations: 0,
|
|
265
312
|
hitsByLayer: {},
|
|
266
313
|
missesByLayer: {},
|
|
314
|
+
latencyByLayer: {},
|
|
267
315
|
resetAt: Date.now()
|
|
268
316
|
};
|
|
269
317
|
}
|
|
@@ -501,11 +549,17 @@ var TagIndex = class {
|
|
|
501
549
|
tagToKeys = /* @__PURE__ */ new Map();
|
|
502
550
|
keyToTags = /* @__PURE__ */ new Map();
|
|
503
551
|
knownKeys = /* @__PURE__ */ new Set();
|
|
552
|
+
maxKnownKeys;
|
|
553
|
+
constructor(options = {}) {
|
|
554
|
+
this.maxKnownKeys = options.maxKnownKeys;
|
|
555
|
+
}
|
|
504
556
|
async touch(key) {
|
|
505
557
|
this.knownKeys.add(key);
|
|
558
|
+
this.pruneKnownKeysIfNeeded();
|
|
506
559
|
}
|
|
507
560
|
async track(key, tags) {
|
|
508
561
|
this.knownKeys.add(key);
|
|
562
|
+
this.pruneKnownKeysIfNeeded();
|
|
509
563
|
if (tags.length === 0) {
|
|
510
564
|
return;
|
|
511
565
|
}
|
|
@@ -544,6 +598,9 @@ var TagIndex = class {
|
|
|
544
598
|
async keysForTag(tag) {
|
|
545
599
|
return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
|
|
546
600
|
}
|
|
601
|
+
async tagsForKey(key) {
|
|
602
|
+
return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
|
|
603
|
+
}
|
|
547
604
|
async matchPattern(pattern) {
|
|
548
605
|
return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
|
|
549
606
|
}
|
|
@@ -552,6 +609,21 @@ var TagIndex = class {
|
|
|
552
609
|
this.keyToTags.clear();
|
|
553
610
|
this.knownKeys.clear();
|
|
554
611
|
}
|
|
612
|
+
pruneKnownKeysIfNeeded() {
|
|
613
|
+
if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
|
|
617
|
+
let removed = 0;
|
|
618
|
+
for (const key of this.knownKeys) {
|
|
619
|
+
if (removed >= toRemove) {
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
this.knownKeys.delete(key);
|
|
623
|
+
this.keyToTags.delete(key);
|
|
624
|
+
removed += 1;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
555
627
|
};
|
|
556
628
|
|
|
557
629
|
// src/stampede/StampedeGuard.ts
|
|
@@ -580,6 +652,16 @@ var StampedeGuard = class {
|
|
|
580
652
|
}
|
|
581
653
|
};
|
|
582
654
|
|
|
655
|
+
// src/types.ts
|
|
656
|
+
var CacheMissError = class extends Error {
|
|
657
|
+
key;
|
|
658
|
+
constructor(key) {
|
|
659
|
+
super(`Cache miss for key "${key}".`);
|
|
660
|
+
this.name = "CacheMissError";
|
|
661
|
+
this.key = key;
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
|
|
583
665
|
// src/CacheStack.ts
|
|
584
666
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
585
667
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
@@ -706,6 +788,18 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
706
788
|
async getOrSet(key, fetcher, options) {
|
|
707
789
|
return this.get(key, fetcher, options);
|
|
708
790
|
}
|
|
791
|
+
/**
|
|
792
|
+
* Like `get()`, but throws `CacheMissError` instead of returning `null`.
|
|
793
|
+
* Useful when the value is expected to exist or the fetcher is expected to
|
|
794
|
+
* return non-null.
|
|
795
|
+
*/
|
|
796
|
+
async getOrThrow(key, fetcher, options) {
|
|
797
|
+
const value = await this.get(key, fetcher, options);
|
|
798
|
+
if (value === null) {
|
|
799
|
+
throw new CacheMissError(key);
|
|
800
|
+
}
|
|
801
|
+
return value;
|
|
802
|
+
}
|
|
709
803
|
/**
|
|
710
804
|
* Returns true if the given key exists and is not expired in any layer.
|
|
711
805
|
*/
|
|
@@ -979,6 +1073,46 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
979
1073
|
getHitRate() {
|
|
980
1074
|
return this.metricsCollector.hitRate();
|
|
981
1075
|
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Returns detailed metadata about a single cache key: which layers contain it,
|
|
1078
|
+
* remaining fresh/stale/error TTLs, and associated tags.
|
|
1079
|
+
* Returns `null` if the key does not exist in any layer.
|
|
1080
|
+
*/
|
|
1081
|
+
async inspect(key) {
|
|
1082
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
1083
|
+
await this.startup;
|
|
1084
|
+
const foundInLayers = [];
|
|
1085
|
+
let freshTtlSeconds = null;
|
|
1086
|
+
let staleTtlSeconds = null;
|
|
1087
|
+
let errorTtlSeconds = null;
|
|
1088
|
+
let isStale = false;
|
|
1089
|
+
for (const layer of this.layers) {
|
|
1090
|
+
if (this.shouldSkipLayer(layer)) {
|
|
1091
|
+
continue;
|
|
1092
|
+
}
|
|
1093
|
+
const stored = await this.readLayerEntry(layer, normalizedKey);
|
|
1094
|
+
if (stored === null) {
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
const resolved = resolveStoredValue(stored);
|
|
1098
|
+
if (resolved.state === "expired") {
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
foundInLayers.push(layer.name);
|
|
1102
|
+
if (foundInLayers.length === 1 && resolved.envelope) {
|
|
1103
|
+
const now = Date.now();
|
|
1104
|
+
freshTtlSeconds = resolved.envelope.freshUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.freshUntil - now) / 1e3)) : null;
|
|
1105
|
+
staleTtlSeconds = resolved.envelope.staleUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1e3)) : null;
|
|
1106
|
+
errorTtlSeconds = resolved.envelope.errorUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1e3)) : null;
|
|
1107
|
+
isStale = resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error";
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (foundInLayers.length === 0) {
|
|
1111
|
+
return null;
|
|
1112
|
+
}
|
|
1113
|
+
const tags = await this.getTagsForKey(normalizedKey);
|
|
1114
|
+
return { key: normalizedKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags };
|
|
1115
|
+
}
|
|
982
1116
|
async exportState() {
|
|
983
1117
|
await this.startup;
|
|
984
1118
|
const exported = /* @__PURE__ */ new Map();
|
|
@@ -1115,6 +1249,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1115
1249
|
await this.storeEntry(key, "empty", null, options);
|
|
1116
1250
|
return null;
|
|
1117
1251
|
}
|
|
1252
|
+
if (options?.shouldCache && !options.shouldCache(fetched)) {
|
|
1253
|
+
return fetched;
|
|
1254
|
+
}
|
|
1118
1255
|
await this.storeEntry(key, "value", fetched, options);
|
|
1119
1256
|
return fetched;
|
|
1120
1257
|
}
|
|
@@ -1137,7 +1274,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1137
1274
|
for (let index = 0; index < this.layers.length; index += 1) {
|
|
1138
1275
|
const layer = this.layers[index];
|
|
1139
1276
|
if (!layer) continue;
|
|
1277
|
+
const readStart = performance.now();
|
|
1140
1278
|
const stored = await this.readLayerEntry(layer, key);
|
|
1279
|
+
const readDuration = performance.now() - readStart;
|
|
1280
|
+
this.metricsCollector.recordLatency(layer.name, readDuration);
|
|
1141
1281
|
if (stored === null) {
|
|
1142
1282
|
this.metricsCollector.incrementLayer("missesByLayer", layer.name);
|
|
1143
1283
|
continue;
|
|
@@ -1339,6 +1479,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1339
1479
|
}
|
|
1340
1480
|
}
|
|
1341
1481
|
}
|
|
1482
|
+
async getTagsForKey(key) {
|
|
1483
|
+
if (this.tagIndex.tagsForKey) {
|
|
1484
|
+
return this.tagIndex.tagsForKey(key);
|
|
1485
|
+
}
|
|
1486
|
+
return [];
|
|
1487
|
+
}
|
|
1342
1488
|
formatError(error) {
|
|
1343
1489
|
if (error instanceof Error) {
|
|
1344
1490
|
return error.message;
|
|
@@ -1559,35 +1705,36 @@ var RedisInvalidationBus = class {
|
|
|
1559
1705
|
channel;
|
|
1560
1706
|
publisher;
|
|
1561
1707
|
subscriber;
|
|
1562
|
-
|
|
1708
|
+
handlers = /* @__PURE__ */ new Set();
|
|
1709
|
+
sharedListener;
|
|
1563
1710
|
constructor(options) {
|
|
1564
1711
|
this.publisher = options.publisher;
|
|
1565
1712
|
this.subscriber = options.subscriber ?? options.publisher.duplicate();
|
|
1566
1713
|
this.channel = options.channel ?? "layercache:invalidation";
|
|
1567
1714
|
}
|
|
1568
1715
|
async subscribe(handler) {
|
|
1569
|
-
if (this.
|
|
1570
|
-
|
|
1716
|
+
if (this.handlers.size === 0) {
|
|
1717
|
+
const listener = (_channel, payload) => {
|
|
1718
|
+
void this.dispatchToHandlers(payload);
|
|
1719
|
+
};
|
|
1720
|
+
this.sharedListener = listener;
|
|
1721
|
+
this.subscriber.on("message", listener);
|
|
1722
|
+
await this.subscriber.subscribe(this.channel);
|
|
1571
1723
|
}
|
|
1572
|
-
|
|
1573
|
-
void this.handleMessage(payload, handler);
|
|
1574
|
-
};
|
|
1575
|
-
this.activeListener = listener;
|
|
1576
|
-
this.subscriber.on("message", listener);
|
|
1577
|
-
await this.subscriber.subscribe(this.channel);
|
|
1724
|
+
this.handlers.add(handler);
|
|
1578
1725
|
return async () => {
|
|
1579
|
-
|
|
1580
|
-
|
|
1726
|
+
this.handlers.delete(handler);
|
|
1727
|
+
if (this.handlers.size === 0 && this.sharedListener) {
|
|
1728
|
+
this.subscriber.off("message", this.sharedListener);
|
|
1729
|
+
this.sharedListener = void 0;
|
|
1730
|
+
await this.subscriber.unsubscribe(this.channel);
|
|
1581
1731
|
}
|
|
1582
|
-
this.activeListener = void 0;
|
|
1583
|
-
this.subscriber.off("message", listener);
|
|
1584
|
-
await this.subscriber.unsubscribe(this.channel);
|
|
1585
1732
|
};
|
|
1586
1733
|
}
|
|
1587
1734
|
async publish(message) {
|
|
1588
1735
|
await this.publisher.publish(this.channel, JSON.stringify(message));
|
|
1589
1736
|
}
|
|
1590
|
-
async
|
|
1737
|
+
async dispatchToHandlers(payload) {
|
|
1591
1738
|
let message;
|
|
1592
1739
|
try {
|
|
1593
1740
|
const parsed = JSON.parse(payload);
|
|
@@ -1599,11 +1746,15 @@ var RedisInvalidationBus = class {
|
|
|
1599
1746
|
this.reportError("invalid invalidation payload", error);
|
|
1600
1747
|
return;
|
|
1601
1748
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1749
|
+
await Promise.all(
|
|
1750
|
+
[...this.handlers].map(async (handler) => {
|
|
1751
|
+
try {
|
|
1752
|
+
await handler(message);
|
|
1753
|
+
} catch (error) {
|
|
1754
|
+
this.reportError("invalidation handler failed", error);
|
|
1755
|
+
}
|
|
1756
|
+
})
|
|
1757
|
+
);
|
|
1607
1758
|
}
|
|
1608
1759
|
isInvalidationMessage(value) {
|
|
1609
1760
|
if (!value || typeof value !== "object") {
|
|
@@ -1664,6 +1815,9 @@ var RedisTagIndex = class {
|
|
|
1664
1815
|
async keysForTag(tag) {
|
|
1665
1816
|
return this.client.smembers(this.tagKeysKey(tag));
|
|
1666
1817
|
}
|
|
1818
|
+
async tagsForKey(key) {
|
|
1819
|
+
return this.client.smembers(this.keyTagsKey(key));
|
|
1820
|
+
}
|
|
1667
1821
|
async matchPattern(pattern) {
|
|
1668
1822
|
const matches = [];
|
|
1669
1823
|
let cursor = "0";
|
|
@@ -1754,6 +1908,39 @@ function createFastifyLayercachePlugin(cache, options = {}) {
|
|
|
1754
1908
|
};
|
|
1755
1909
|
}
|
|
1756
1910
|
|
|
1911
|
+
// src/integrations/express.ts
|
|
1912
|
+
function createExpressCacheMiddleware(cache, options = {}) {
|
|
1913
|
+
const allowedMethods = new Set((options.methods ?? ["GET"]).map((m) => m.toUpperCase()));
|
|
1914
|
+
return async (req, res, next) => {
|
|
1915
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
1916
|
+
if (!allowedMethods.has(method)) {
|
|
1917
|
+
next();
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
const key = options.keyResolver ? options.keyResolver(req) : `${method}:${req.originalUrl ?? req.url ?? "/"}`;
|
|
1921
|
+
const cached = await cache.get(key, void 0, options);
|
|
1922
|
+
if (cached !== null) {
|
|
1923
|
+
res.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
1924
|
+
res.setHeader?.("x-cache", "HIT");
|
|
1925
|
+
if (res.json) {
|
|
1926
|
+
res.json(cached);
|
|
1927
|
+
} else {
|
|
1928
|
+
res.end?.(JSON.stringify(cached));
|
|
1929
|
+
}
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
const originalJson = res.json?.bind(res);
|
|
1933
|
+
if (originalJson) {
|
|
1934
|
+
res.json = (body) => {
|
|
1935
|
+
res.setHeader?.("x-cache", "MISS");
|
|
1936
|
+
void cache.set(key, body, options);
|
|
1937
|
+
return originalJson(body);
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
next();
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1757
1944
|
// src/integrations/graphql.ts
|
|
1758
1945
|
function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
|
|
1759
1946
|
const wrapped = cache.wrap(prefix, resolver, {
|
|
@@ -1817,10 +2004,10 @@ var MemoryLayer = class {
|
|
|
1817
2004
|
}
|
|
1818
2005
|
if (this.evictionPolicy === "lru") {
|
|
1819
2006
|
this.entries.delete(key);
|
|
1820
|
-
entry.
|
|
2007
|
+
entry.accessCount += 1;
|
|
1821
2008
|
this.entries.set(key, entry);
|
|
1822
|
-
} else {
|
|
1823
|
-
entry.
|
|
2009
|
+
} else if (this.evictionPolicy === "lfu") {
|
|
2010
|
+
entry.accessCount += 1;
|
|
1824
2011
|
}
|
|
1825
2012
|
return entry.value;
|
|
1826
2013
|
}
|
|
@@ -1836,7 +2023,7 @@ var MemoryLayer = class {
|
|
|
1836
2023
|
this.entries.set(key, {
|
|
1837
2024
|
value,
|
|
1838
2025
|
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
|
|
1839
|
-
|
|
2026
|
+
accessCount: 0,
|
|
1840
2027
|
insertedAt: Date.now()
|
|
1841
2028
|
});
|
|
1842
2029
|
while (this.entries.size > this.maxSize) {
|
|
@@ -1903,7 +2090,7 @@ var MemoryLayer = class {
|
|
|
1903
2090
|
this.entries.set(entry.key, {
|
|
1904
2091
|
value: entry.value,
|
|
1905
2092
|
expiresAt: entry.expiresAt,
|
|
1906
|
-
|
|
2093
|
+
accessCount: 0,
|
|
1907
2094
|
insertedAt: Date.now()
|
|
1908
2095
|
});
|
|
1909
2096
|
}
|
|
@@ -1920,11 +2107,11 @@ var MemoryLayer = class {
|
|
|
1920
2107
|
return;
|
|
1921
2108
|
}
|
|
1922
2109
|
let victimKey;
|
|
1923
|
-
let
|
|
2110
|
+
let minCount = Number.POSITIVE_INFINITY;
|
|
1924
2111
|
let minInsertedAt = Number.POSITIVE_INFINITY;
|
|
1925
2112
|
for (const [key, entry] of this.entries.entries()) {
|
|
1926
|
-
if (entry.
|
|
1927
|
-
|
|
2113
|
+
if (entry.accessCount < minCount || entry.accessCount === minCount && entry.insertedAt < minInsertedAt) {
|
|
2114
|
+
minCount = entry.accessCount;
|
|
1928
2115
|
minInsertedAt = entry.insertedAt;
|
|
1929
2116
|
victimKey = key;
|
|
1930
2117
|
}
|
|
@@ -1946,6 +2133,7 @@ var MemoryLayer = class {
|
|
|
1946
2133
|
};
|
|
1947
2134
|
|
|
1948
2135
|
// src/layers/RedisLayer.ts
|
|
2136
|
+
var import_node_util = require("util");
|
|
1949
2137
|
var import_node_zlib = require("zlib");
|
|
1950
2138
|
|
|
1951
2139
|
// src/serialization/JsonSerializer.ts
|
|
@@ -1961,6 +2149,10 @@ var JsonSerializer = class {
|
|
|
1961
2149
|
|
|
1962
2150
|
// src/layers/RedisLayer.ts
|
|
1963
2151
|
var BATCH_DELETE_SIZE = 500;
|
|
2152
|
+
var gzipAsync = (0, import_node_util.promisify)(import_node_zlib.gzip);
|
|
2153
|
+
var gunzipAsync = (0, import_node_util.promisify)(import_node_zlib.gunzip);
|
|
2154
|
+
var brotliCompressAsync = (0, import_node_util.promisify)(import_node_zlib.brotliCompress);
|
|
2155
|
+
var brotliDecompressAsync = (0, import_node_util.promisify)(import_node_zlib.brotliDecompress);
|
|
1964
2156
|
var RedisLayer = class {
|
|
1965
2157
|
name;
|
|
1966
2158
|
defaultTtl;
|
|
@@ -2017,7 +2209,8 @@ var RedisLayer = class {
|
|
|
2017
2209
|
);
|
|
2018
2210
|
}
|
|
2019
2211
|
async set(key, value, ttl = this.defaultTtl) {
|
|
2020
|
-
const
|
|
2212
|
+
const serialized = this.serializer.serialize(value);
|
|
2213
|
+
const payload = await this.encodePayload(serialized);
|
|
2021
2214
|
const normalizedKey = this.withPrefix(key);
|
|
2022
2215
|
if (ttl && ttl > 0) {
|
|
2023
2216
|
await this.client.set(normalizedKey, payload, "EX", ttl);
|
|
@@ -2096,7 +2289,7 @@ var RedisLayer = class {
|
|
|
2096
2289
|
}
|
|
2097
2290
|
async deserializeOrDelete(key, payload) {
|
|
2098
2291
|
try {
|
|
2099
|
-
return this.serializer.deserialize(this.decodePayload(payload));
|
|
2292
|
+
return this.serializer.deserialize(await this.decodePayload(payload));
|
|
2100
2293
|
} catch {
|
|
2101
2294
|
await this.client.del(this.withPrefix(key)).catch(() => void 0);
|
|
2102
2295
|
return null;
|
|
@@ -2105,7 +2298,11 @@ var RedisLayer = class {
|
|
|
2105
2298
|
isSerializablePayload(payload) {
|
|
2106
2299
|
return typeof payload === "string" || Buffer.isBuffer(payload);
|
|
2107
2300
|
}
|
|
2108
|
-
|
|
2301
|
+
/**
|
|
2302
|
+
* Compresses the payload asynchronously if compression is enabled and the
|
|
2303
|
+
* payload exceeds the threshold. This avoids blocking the event loop.
|
|
2304
|
+
*/
|
|
2305
|
+
async encodePayload(payload) {
|
|
2109
2306
|
if (!this.compression) {
|
|
2110
2307
|
return payload;
|
|
2111
2308
|
}
|
|
@@ -2114,18 +2311,21 @@ var RedisLayer = class {
|
|
|
2114
2311
|
return payload;
|
|
2115
2312
|
}
|
|
2116
2313
|
const header = Buffer.from(`LCZ1:${this.compression}:`);
|
|
2117
|
-
const compressed = this.compression === "gzip" ?
|
|
2314
|
+
const compressed = this.compression === "gzip" ? await gzipAsync(source) : await brotliCompressAsync(source);
|
|
2118
2315
|
return Buffer.concat([header, compressed]);
|
|
2119
2316
|
}
|
|
2120
|
-
|
|
2317
|
+
/**
|
|
2318
|
+
* Decompresses the payload asynchronously if a compression header is present.
|
|
2319
|
+
*/
|
|
2320
|
+
async decodePayload(payload) {
|
|
2121
2321
|
if (!Buffer.isBuffer(payload)) {
|
|
2122
2322
|
return payload;
|
|
2123
2323
|
}
|
|
2124
2324
|
if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
|
|
2125
|
-
return (
|
|
2325
|
+
return gunzipAsync(payload.subarray(10));
|
|
2126
2326
|
}
|
|
2127
2327
|
if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
|
|
2128
|
-
return (
|
|
2328
|
+
return brotliDecompressAsync(payload.subarray(12));
|
|
2129
2329
|
}
|
|
2130
2330
|
return payload;
|
|
2131
2331
|
}
|
|
@@ -2141,11 +2341,13 @@ var DiskLayer = class {
|
|
|
2141
2341
|
isLocal = true;
|
|
2142
2342
|
directory;
|
|
2143
2343
|
serializer;
|
|
2344
|
+
maxFiles;
|
|
2144
2345
|
constructor(options) {
|
|
2145
2346
|
this.directory = options.directory;
|
|
2146
2347
|
this.defaultTtl = options.ttl;
|
|
2147
2348
|
this.name = options.name ?? "disk";
|
|
2148
2349
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
2350
|
+
this.maxFiles = options.maxFiles;
|
|
2149
2351
|
}
|
|
2150
2352
|
async get(key) {
|
|
2151
2353
|
return unwrapStoredValue(await this.getEntry(key));
|
|
@@ -2174,11 +2376,15 @@ var DiskLayer = class {
|
|
|
2174
2376
|
async set(key, value, ttl = this.defaultTtl) {
|
|
2175
2377
|
await import_node_fs2.promises.mkdir(this.directory, { recursive: true });
|
|
2176
2378
|
const entry = {
|
|
2379
|
+
key,
|
|
2177
2380
|
value,
|
|
2178
2381
|
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
2179
2382
|
};
|
|
2180
2383
|
const payload = this.serializer.serialize(entry);
|
|
2181
2384
|
await import_node_fs2.promises.writeFile(this.keyToPath(key), payload);
|
|
2385
|
+
if (this.maxFiles !== void 0) {
|
|
2386
|
+
await this.enforceMaxFiles();
|
|
2387
|
+
}
|
|
2182
2388
|
}
|
|
2183
2389
|
async has(key) {
|
|
2184
2390
|
const value = await this.getEntry(key);
|
|
@@ -2224,6 +2430,10 @@ var DiskLayer = class {
|
|
|
2224
2430
|
entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete((0, import_node_path.join)(this.directory, name)))
|
|
2225
2431
|
);
|
|
2226
2432
|
}
|
|
2433
|
+
/**
|
|
2434
|
+
* Returns the original cache key strings stored on disk.
|
|
2435
|
+
* Expired entries are skipped and cleaned up during the scan.
|
|
2436
|
+
*/
|
|
2227
2437
|
async keys() {
|
|
2228
2438
|
let entries;
|
|
2229
2439
|
try {
|
|
@@ -2231,7 +2441,32 @@ var DiskLayer = class {
|
|
|
2231
2441
|
} catch {
|
|
2232
2442
|
return [];
|
|
2233
2443
|
}
|
|
2234
|
-
|
|
2444
|
+
const lcFiles = entries.filter((name) => name.endsWith(".lc"));
|
|
2445
|
+
const keys = [];
|
|
2446
|
+
await Promise.all(
|
|
2447
|
+
lcFiles.map(async (name) => {
|
|
2448
|
+
const filePath = (0, import_node_path.join)(this.directory, name);
|
|
2449
|
+
let raw;
|
|
2450
|
+
try {
|
|
2451
|
+
raw = await import_node_fs2.promises.readFile(filePath);
|
|
2452
|
+
} catch {
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
let entry;
|
|
2456
|
+
try {
|
|
2457
|
+
entry = this.serializer.deserialize(raw);
|
|
2458
|
+
} catch {
|
|
2459
|
+
await this.safeDelete(filePath);
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
2463
|
+
await this.safeDelete(filePath);
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
keys.push(entry.key);
|
|
2467
|
+
})
|
|
2468
|
+
);
|
|
2469
|
+
return keys;
|
|
2235
2470
|
}
|
|
2236
2471
|
async size() {
|
|
2237
2472
|
const keys = await this.keys();
|
|
@@ -2247,6 +2482,38 @@ var DiskLayer = class {
|
|
|
2247
2482
|
} catch {
|
|
2248
2483
|
}
|
|
2249
2484
|
}
|
|
2485
|
+
/**
|
|
2486
|
+
* Removes the oldest files (by mtime) when the directory exceeds maxFiles.
|
|
2487
|
+
*/
|
|
2488
|
+
async enforceMaxFiles() {
|
|
2489
|
+
if (this.maxFiles === void 0) {
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
let entries;
|
|
2493
|
+
try {
|
|
2494
|
+
entries = await import_node_fs2.promises.readdir(this.directory);
|
|
2495
|
+
} catch {
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
const lcFiles = entries.filter((name) => name.endsWith(".lc"));
|
|
2499
|
+
if (lcFiles.length <= this.maxFiles) {
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
const withStats = await Promise.all(
|
|
2503
|
+
lcFiles.map(async (name) => {
|
|
2504
|
+
const filePath = (0, import_node_path.join)(this.directory, name);
|
|
2505
|
+
try {
|
|
2506
|
+
const stat = await import_node_fs2.promises.stat(filePath);
|
|
2507
|
+
return { filePath, mtimeMs: stat.mtimeMs };
|
|
2508
|
+
} catch {
|
|
2509
|
+
return { filePath, mtimeMs: 0 };
|
|
2510
|
+
}
|
|
2511
|
+
})
|
|
2512
|
+
);
|
|
2513
|
+
withStats.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
2514
|
+
const toEvict = withStats.slice(0, lcFiles.length - this.maxFiles);
|
|
2515
|
+
await Promise.all(toEvict.map(({ filePath }) => this.safeDelete(filePath)));
|
|
2516
|
+
}
|
|
2250
2517
|
};
|
|
2251
2518
|
|
|
2252
2519
|
// src/layers/MemcachedLayer.ts
|
|
@@ -2256,29 +2523,41 @@ var MemcachedLayer = class {
|
|
|
2256
2523
|
isLocal = false;
|
|
2257
2524
|
client;
|
|
2258
2525
|
keyPrefix;
|
|
2526
|
+
serializer;
|
|
2259
2527
|
constructor(options) {
|
|
2260
2528
|
this.client = options.client;
|
|
2261
2529
|
this.defaultTtl = options.ttl;
|
|
2262
2530
|
this.name = options.name ?? "memcached";
|
|
2263
2531
|
this.keyPrefix = options.keyPrefix ?? "";
|
|
2532
|
+
this.serializer = options.serializer ?? new JsonSerializer();
|
|
2264
2533
|
}
|
|
2265
2534
|
async get(key) {
|
|
2535
|
+
return unwrapStoredValue(await this.getEntry(key));
|
|
2536
|
+
}
|
|
2537
|
+
async getEntry(key) {
|
|
2266
2538
|
const result = await this.client.get(this.withPrefix(key));
|
|
2267
2539
|
if (!result || result.value === null) {
|
|
2268
2540
|
return null;
|
|
2269
2541
|
}
|
|
2270
2542
|
try {
|
|
2271
|
-
return
|
|
2543
|
+
return this.serializer.deserialize(result.value);
|
|
2272
2544
|
} catch {
|
|
2273
2545
|
return null;
|
|
2274
2546
|
}
|
|
2275
2547
|
}
|
|
2548
|
+
async getMany(keys) {
|
|
2549
|
+
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
2550
|
+
}
|
|
2276
2551
|
async set(key, value, ttl = this.defaultTtl) {
|
|
2277
|
-
const payload =
|
|
2552
|
+
const payload = this.serializer.serialize(value);
|
|
2278
2553
|
await this.client.set(this.withPrefix(key), payload, {
|
|
2279
2554
|
expires: ttl && ttl > 0 ? ttl : void 0
|
|
2280
2555
|
});
|
|
2281
2556
|
}
|
|
2557
|
+
async has(key) {
|
|
2558
|
+
const result = await this.client.get(this.withPrefix(key));
|
|
2559
|
+
return result !== null && result.value !== null;
|
|
2560
|
+
}
|
|
2282
2561
|
async delete(key) {
|
|
2283
2562
|
await this.client.delete(this.withPrefix(key));
|
|
2284
2563
|
}
|
|
@@ -2372,6 +2651,12 @@ function createPrometheusMetricsExporter(stacks) {
|
|
|
2372
2651
|
lines.push("# TYPE layercache_hits_by_layer_total counter");
|
|
2373
2652
|
lines.push("# HELP layercache_misses_by_layer_total Misses broken down by layer");
|
|
2374
2653
|
lines.push("# TYPE layercache_misses_by_layer_total counter");
|
|
2654
|
+
lines.push("# HELP layercache_layer_latency_avg_ms Average read latency per layer in milliseconds");
|
|
2655
|
+
lines.push("# TYPE layercache_layer_latency_avg_ms gauge");
|
|
2656
|
+
lines.push("# HELP layercache_layer_latency_max_ms Maximum read latency per layer in milliseconds");
|
|
2657
|
+
lines.push("# TYPE layercache_layer_latency_max_ms gauge");
|
|
2658
|
+
lines.push("# HELP layercache_layer_latency_count Number of read latency samples per layer");
|
|
2659
|
+
lines.push("# TYPE layercache_layer_latency_count counter");
|
|
2375
2660
|
for (const { stack, name } of entries) {
|
|
2376
2661
|
const m = stack.getMetrics();
|
|
2377
2662
|
const hr = stack.getHitRate();
|
|
@@ -2395,6 +2680,12 @@ function createPrometheusMetricsExporter(stacks) {
|
|
|
2395
2680
|
for (const [layerName, count] of Object.entries(m.missesByLayer)) {
|
|
2396
2681
|
lines.push(`layercache_misses_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
|
|
2397
2682
|
}
|
|
2683
|
+
for (const [layerName, latency] of Object.entries(m.latencyByLayer)) {
|
|
2684
|
+
const layerLabel = `${label},layer="${sanitizeLabel(layerName)}"`;
|
|
2685
|
+
lines.push(`layercache_layer_latency_avg_ms{${layerLabel}} ${latency.avgMs.toFixed(4)}`);
|
|
2686
|
+
lines.push(`layercache_layer_latency_max_ms{${layerLabel}} ${latency.maxMs.toFixed(4)}`);
|
|
2687
|
+
lines.push(`layercache_layer_latency_count{${layerLabel}} ${latency.count}`);
|
|
2688
|
+
}
|
|
2398
2689
|
}
|
|
2399
2690
|
lines.push("");
|
|
2400
2691
|
return lines.join("\n");
|
|
@@ -2405,6 +2696,7 @@ function sanitizeLabel(value) {
|
|
|
2405
2696
|
}
|
|
2406
2697
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2407
2698
|
0 && (module.exports = {
|
|
2699
|
+
CacheMissError,
|
|
2408
2700
|
CacheNamespace,
|
|
2409
2701
|
CacheStack,
|
|
2410
2702
|
DiskLayer,
|
|
@@ -2422,6 +2714,7 @@ function sanitizeLabel(value) {
|
|
|
2422
2714
|
cacheGraphqlResolver,
|
|
2423
2715
|
createCacheStatsHandler,
|
|
2424
2716
|
createCachedMethodDecorator,
|
|
2717
|
+
createExpressCacheMiddleware,
|
|
2425
2718
|
createFastifyLayercachePlugin,
|
|
2426
2719
|
createPrometheusMetricsExporter,
|
|
2427
2720
|
createTrpcCacheMiddleware
|