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.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PatternMatcher,
|
|
3
3
|
RedisTagIndex
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-BWM4MU2X.js";
|
|
5
5
|
|
|
6
6
|
// src/CacheStack.ts
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
@@ -9,7 +9,7 @@ import { EventEmitter } from "events";
|
|
|
9
9
|
import { promises as fs } from "fs";
|
|
10
10
|
|
|
11
11
|
// src/CacheNamespace.ts
|
|
12
|
-
var CacheNamespace = class {
|
|
12
|
+
var CacheNamespace = class _CacheNamespace {
|
|
13
13
|
constructor(cache, prefix) {
|
|
14
14
|
this.cache = cache;
|
|
15
15
|
this.prefix = prefix;
|
|
@@ -22,6 +22,12 @@ var CacheNamespace = class {
|
|
|
22
22
|
async getOrSet(key, fetcher, options) {
|
|
23
23
|
return this.cache.getOrSet(this.qualify(key), fetcher, options);
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Like `get()`, but throws `CacheMissError` instead of returning `null`.
|
|
27
|
+
*/
|
|
28
|
+
async getOrThrow(key, fetcher, options) {
|
|
29
|
+
return this.cache.getOrThrow(this.qualify(key), fetcher, options);
|
|
30
|
+
}
|
|
25
31
|
async has(key) {
|
|
26
32
|
return this.cache.has(this.qualify(key));
|
|
27
33
|
}
|
|
@@ -62,6 +68,12 @@ var CacheNamespace = class {
|
|
|
62
68
|
async invalidateByPattern(pattern) {
|
|
63
69
|
await this.cache.invalidateByPattern(this.qualify(pattern));
|
|
64
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Returns detailed metadata about a single cache key within this namespace.
|
|
73
|
+
*/
|
|
74
|
+
async inspect(key) {
|
|
75
|
+
return this.cache.inspect(this.qualify(key));
|
|
76
|
+
}
|
|
65
77
|
wrap(keyPrefix, fetcher, options) {
|
|
66
78
|
return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
|
|
67
79
|
}
|
|
@@ -80,6 +92,18 @@ var CacheNamespace = class {
|
|
|
80
92
|
getHitRate() {
|
|
81
93
|
return this.cache.getHitRate();
|
|
82
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Creates a nested namespace. Keys are prefixed with `parentPrefix:childPrefix:`.
|
|
97
|
+
*
|
|
98
|
+
* ```ts
|
|
99
|
+
* const tenant = cache.namespace('tenant:abc')
|
|
100
|
+
* const posts = tenant.namespace('posts')
|
|
101
|
+
* // keys become: "tenant:abc:posts:mykey"
|
|
102
|
+
* ```
|
|
103
|
+
*/
|
|
104
|
+
namespace(childPrefix) {
|
|
105
|
+
return new _CacheNamespace(this.cache, `${this.prefix}:${childPrefix}`);
|
|
106
|
+
}
|
|
83
107
|
qualify(key) {
|
|
84
108
|
return `${this.prefix}:${key}`;
|
|
85
109
|
}
|
|
@@ -181,7 +205,12 @@ var CircuitBreakerManager = class {
|
|
|
181
205
|
var MetricsCollector = class {
|
|
182
206
|
data = this.empty();
|
|
183
207
|
get snapshot() {
|
|
184
|
-
return {
|
|
208
|
+
return {
|
|
209
|
+
...this.data,
|
|
210
|
+
hitsByLayer: { ...this.data.hitsByLayer },
|
|
211
|
+
missesByLayer: { ...this.data.missesByLayer },
|
|
212
|
+
latencyByLayer: Object.fromEntries(Object.entries(this.data.latencyByLayer).map(([k, v]) => [k, { ...v }]))
|
|
213
|
+
};
|
|
185
214
|
}
|
|
186
215
|
increment(field, amount = 1) {
|
|
187
216
|
;
|
|
@@ -190,6 +219,22 @@ var MetricsCollector = class {
|
|
|
190
219
|
incrementLayer(map, layerName) {
|
|
191
220
|
this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
|
|
192
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Records a read latency sample for the given layer.
|
|
224
|
+
* Maintains a rolling average and max using Welford's online algorithm.
|
|
225
|
+
*/
|
|
226
|
+
recordLatency(layerName, durationMs) {
|
|
227
|
+
const existing = this.data.latencyByLayer[layerName];
|
|
228
|
+
if (!existing) {
|
|
229
|
+
this.data.latencyByLayer[layerName] = { avgMs: durationMs, maxMs: durationMs, count: 1 };
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
existing.count += 1;
|
|
233
|
+
existing.avgMs += (durationMs - existing.avgMs) / existing.count;
|
|
234
|
+
if (durationMs > existing.maxMs) {
|
|
235
|
+
existing.maxMs = durationMs;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
193
238
|
reset() {
|
|
194
239
|
this.data = this.empty();
|
|
195
240
|
}
|
|
@@ -224,6 +269,7 @@ var MetricsCollector = class {
|
|
|
224
269
|
degradedOperations: 0,
|
|
225
270
|
hitsByLayer: {},
|
|
226
271
|
missesByLayer: {},
|
|
272
|
+
latencyByLayer: {},
|
|
227
273
|
resetAt: Date.now()
|
|
228
274
|
};
|
|
229
275
|
}
|
|
@@ -423,11 +469,17 @@ var TagIndex = class {
|
|
|
423
469
|
tagToKeys = /* @__PURE__ */ new Map();
|
|
424
470
|
keyToTags = /* @__PURE__ */ new Map();
|
|
425
471
|
knownKeys = /* @__PURE__ */ new Set();
|
|
472
|
+
maxKnownKeys;
|
|
473
|
+
constructor(options = {}) {
|
|
474
|
+
this.maxKnownKeys = options.maxKnownKeys;
|
|
475
|
+
}
|
|
426
476
|
async touch(key) {
|
|
427
477
|
this.knownKeys.add(key);
|
|
478
|
+
this.pruneKnownKeysIfNeeded();
|
|
428
479
|
}
|
|
429
480
|
async track(key, tags) {
|
|
430
481
|
this.knownKeys.add(key);
|
|
482
|
+
this.pruneKnownKeysIfNeeded();
|
|
431
483
|
if (tags.length === 0) {
|
|
432
484
|
return;
|
|
433
485
|
}
|
|
@@ -466,6 +518,9 @@ var TagIndex = class {
|
|
|
466
518
|
async keysForTag(tag) {
|
|
467
519
|
return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
|
|
468
520
|
}
|
|
521
|
+
async tagsForKey(key) {
|
|
522
|
+
return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
|
|
523
|
+
}
|
|
469
524
|
async matchPattern(pattern) {
|
|
470
525
|
return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
|
|
471
526
|
}
|
|
@@ -474,6 +529,21 @@ var TagIndex = class {
|
|
|
474
529
|
this.keyToTags.clear();
|
|
475
530
|
this.knownKeys.clear();
|
|
476
531
|
}
|
|
532
|
+
pruneKnownKeysIfNeeded() {
|
|
533
|
+
if (this.maxKnownKeys === void 0 || this.knownKeys.size <= this.maxKnownKeys) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const toRemove = Math.ceil(this.maxKnownKeys * 0.1);
|
|
537
|
+
let removed = 0;
|
|
538
|
+
for (const key of this.knownKeys) {
|
|
539
|
+
if (removed >= toRemove) {
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
this.knownKeys.delete(key);
|
|
543
|
+
this.keyToTags.delete(key);
|
|
544
|
+
removed += 1;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
477
547
|
};
|
|
478
548
|
|
|
479
549
|
// src/stampede/StampedeGuard.ts
|
|
@@ -502,6 +572,16 @@ var StampedeGuard = class {
|
|
|
502
572
|
}
|
|
503
573
|
};
|
|
504
574
|
|
|
575
|
+
// src/types.ts
|
|
576
|
+
var CacheMissError = class extends Error {
|
|
577
|
+
key;
|
|
578
|
+
constructor(key) {
|
|
579
|
+
super(`Cache miss for key "${key}".`);
|
|
580
|
+
this.name = "CacheMissError";
|
|
581
|
+
this.key = key;
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
|
|
505
585
|
// src/CacheStack.ts
|
|
506
586
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
507
587
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
@@ -628,6 +708,18 @@ var CacheStack = class extends EventEmitter {
|
|
|
628
708
|
async getOrSet(key, fetcher, options) {
|
|
629
709
|
return this.get(key, fetcher, options);
|
|
630
710
|
}
|
|
711
|
+
/**
|
|
712
|
+
* Like `get()`, but throws `CacheMissError` instead of returning `null`.
|
|
713
|
+
* Useful when the value is expected to exist or the fetcher is expected to
|
|
714
|
+
* return non-null.
|
|
715
|
+
*/
|
|
716
|
+
async getOrThrow(key, fetcher, options) {
|
|
717
|
+
const value = await this.get(key, fetcher, options);
|
|
718
|
+
if (value === null) {
|
|
719
|
+
throw new CacheMissError(key);
|
|
720
|
+
}
|
|
721
|
+
return value;
|
|
722
|
+
}
|
|
631
723
|
/**
|
|
632
724
|
* Returns true if the given key exists and is not expired in any layer.
|
|
633
725
|
*/
|
|
@@ -901,6 +993,46 @@ var CacheStack = class extends EventEmitter {
|
|
|
901
993
|
getHitRate() {
|
|
902
994
|
return this.metricsCollector.hitRate();
|
|
903
995
|
}
|
|
996
|
+
/**
|
|
997
|
+
* Returns detailed metadata about a single cache key: which layers contain it,
|
|
998
|
+
* remaining fresh/stale/error TTLs, and associated tags.
|
|
999
|
+
* Returns `null` if the key does not exist in any layer.
|
|
1000
|
+
*/
|
|
1001
|
+
async inspect(key) {
|
|
1002
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
1003
|
+
await this.startup;
|
|
1004
|
+
const foundInLayers = [];
|
|
1005
|
+
let freshTtlSeconds = null;
|
|
1006
|
+
let staleTtlSeconds = null;
|
|
1007
|
+
let errorTtlSeconds = null;
|
|
1008
|
+
let isStale = false;
|
|
1009
|
+
for (const layer of this.layers) {
|
|
1010
|
+
if (this.shouldSkipLayer(layer)) {
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
const stored = await this.readLayerEntry(layer, normalizedKey);
|
|
1014
|
+
if (stored === null) {
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
const resolved = resolveStoredValue(stored);
|
|
1018
|
+
if (resolved.state === "expired") {
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
foundInLayers.push(layer.name);
|
|
1022
|
+
if (foundInLayers.length === 1 && resolved.envelope) {
|
|
1023
|
+
const now = Date.now();
|
|
1024
|
+
freshTtlSeconds = resolved.envelope.freshUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.freshUntil - now) / 1e3)) : null;
|
|
1025
|
+
staleTtlSeconds = resolved.envelope.staleUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.staleUntil - now) / 1e3)) : null;
|
|
1026
|
+
errorTtlSeconds = resolved.envelope.errorUntil !== null ? Math.max(0, Math.ceil((resolved.envelope.errorUntil - now) / 1e3)) : null;
|
|
1027
|
+
isStale = resolved.state === "stale-while-revalidate" || resolved.state === "stale-if-error";
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (foundInLayers.length === 0) {
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
const tags = await this.getTagsForKey(normalizedKey);
|
|
1034
|
+
return { key: normalizedKey, foundInLayers, freshTtlSeconds, staleTtlSeconds, errorTtlSeconds, isStale, tags };
|
|
1035
|
+
}
|
|
904
1036
|
async exportState() {
|
|
905
1037
|
await this.startup;
|
|
906
1038
|
const exported = /* @__PURE__ */ new Map();
|
|
@@ -1037,6 +1169,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
1037
1169
|
await this.storeEntry(key, "empty", null, options);
|
|
1038
1170
|
return null;
|
|
1039
1171
|
}
|
|
1172
|
+
if (options?.shouldCache && !options.shouldCache(fetched)) {
|
|
1173
|
+
return fetched;
|
|
1174
|
+
}
|
|
1040
1175
|
await this.storeEntry(key, "value", fetched, options);
|
|
1041
1176
|
return fetched;
|
|
1042
1177
|
}
|
|
@@ -1059,7 +1194,10 @@ var CacheStack = class extends EventEmitter {
|
|
|
1059
1194
|
for (let index = 0; index < this.layers.length; index += 1) {
|
|
1060
1195
|
const layer = this.layers[index];
|
|
1061
1196
|
if (!layer) continue;
|
|
1197
|
+
const readStart = performance.now();
|
|
1062
1198
|
const stored = await this.readLayerEntry(layer, key);
|
|
1199
|
+
const readDuration = performance.now() - readStart;
|
|
1200
|
+
this.metricsCollector.recordLatency(layer.name, readDuration);
|
|
1063
1201
|
if (stored === null) {
|
|
1064
1202
|
this.metricsCollector.incrementLayer("missesByLayer", layer.name);
|
|
1065
1203
|
continue;
|
|
@@ -1261,6 +1399,12 @@ var CacheStack = class extends EventEmitter {
|
|
|
1261
1399
|
}
|
|
1262
1400
|
}
|
|
1263
1401
|
}
|
|
1402
|
+
async getTagsForKey(key) {
|
|
1403
|
+
if (this.tagIndex.tagsForKey) {
|
|
1404
|
+
return this.tagIndex.tagsForKey(key);
|
|
1405
|
+
}
|
|
1406
|
+
return [];
|
|
1407
|
+
}
|
|
1264
1408
|
formatError(error) {
|
|
1265
1409
|
if (error instanceof Error) {
|
|
1266
1410
|
return error.message;
|
|
@@ -1481,35 +1625,36 @@ var RedisInvalidationBus = class {
|
|
|
1481
1625
|
channel;
|
|
1482
1626
|
publisher;
|
|
1483
1627
|
subscriber;
|
|
1484
|
-
|
|
1628
|
+
handlers = /* @__PURE__ */ new Set();
|
|
1629
|
+
sharedListener;
|
|
1485
1630
|
constructor(options) {
|
|
1486
1631
|
this.publisher = options.publisher;
|
|
1487
1632
|
this.subscriber = options.subscriber ?? options.publisher.duplicate();
|
|
1488
1633
|
this.channel = options.channel ?? "layercache:invalidation";
|
|
1489
1634
|
}
|
|
1490
1635
|
async subscribe(handler) {
|
|
1491
|
-
if (this.
|
|
1492
|
-
|
|
1636
|
+
if (this.handlers.size === 0) {
|
|
1637
|
+
const listener = (_channel, payload) => {
|
|
1638
|
+
void this.dispatchToHandlers(payload);
|
|
1639
|
+
};
|
|
1640
|
+
this.sharedListener = listener;
|
|
1641
|
+
this.subscriber.on("message", listener);
|
|
1642
|
+
await this.subscriber.subscribe(this.channel);
|
|
1493
1643
|
}
|
|
1494
|
-
|
|
1495
|
-
void this.handleMessage(payload, handler);
|
|
1496
|
-
};
|
|
1497
|
-
this.activeListener = listener;
|
|
1498
|
-
this.subscriber.on("message", listener);
|
|
1499
|
-
await this.subscriber.subscribe(this.channel);
|
|
1644
|
+
this.handlers.add(handler);
|
|
1500
1645
|
return async () => {
|
|
1501
|
-
|
|
1502
|
-
|
|
1646
|
+
this.handlers.delete(handler);
|
|
1647
|
+
if (this.handlers.size === 0 && this.sharedListener) {
|
|
1648
|
+
this.subscriber.off("message", this.sharedListener);
|
|
1649
|
+
this.sharedListener = void 0;
|
|
1650
|
+
await this.subscriber.unsubscribe(this.channel);
|
|
1503
1651
|
}
|
|
1504
|
-
this.activeListener = void 0;
|
|
1505
|
-
this.subscriber.off("message", listener);
|
|
1506
|
-
await this.subscriber.unsubscribe(this.channel);
|
|
1507
1652
|
};
|
|
1508
1653
|
}
|
|
1509
1654
|
async publish(message) {
|
|
1510
1655
|
await this.publisher.publish(this.channel, JSON.stringify(message));
|
|
1511
1656
|
}
|
|
1512
|
-
async
|
|
1657
|
+
async dispatchToHandlers(payload) {
|
|
1513
1658
|
let message;
|
|
1514
1659
|
try {
|
|
1515
1660
|
const parsed = JSON.parse(payload);
|
|
@@ -1521,11 +1666,15 @@ var RedisInvalidationBus = class {
|
|
|
1521
1666
|
this.reportError("invalid invalidation payload", error);
|
|
1522
1667
|
return;
|
|
1523
1668
|
}
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1669
|
+
await Promise.all(
|
|
1670
|
+
[...this.handlers].map(async (handler) => {
|
|
1671
|
+
try {
|
|
1672
|
+
await handler(message);
|
|
1673
|
+
} catch (error) {
|
|
1674
|
+
this.reportError("invalidation handler failed", error);
|
|
1675
|
+
}
|
|
1676
|
+
})
|
|
1677
|
+
);
|
|
1529
1678
|
}
|
|
1530
1679
|
isInvalidationMessage(value) {
|
|
1531
1680
|
if (!value || typeof value !== "object") {
|
|
@@ -1586,6 +1735,39 @@ function createFastifyLayercachePlugin(cache, options = {}) {
|
|
|
1586
1735
|
};
|
|
1587
1736
|
}
|
|
1588
1737
|
|
|
1738
|
+
// src/integrations/express.ts
|
|
1739
|
+
function createExpressCacheMiddleware(cache, options = {}) {
|
|
1740
|
+
const allowedMethods = new Set((options.methods ?? ["GET"]).map((m) => m.toUpperCase()));
|
|
1741
|
+
return async (req, res, next) => {
|
|
1742
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
1743
|
+
if (!allowedMethods.has(method)) {
|
|
1744
|
+
next();
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
const key = options.keyResolver ? options.keyResolver(req) : `${method}:${req.originalUrl ?? req.url ?? "/"}`;
|
|
1748
|
+
const cached = await cache.get(key, void 0, options);
|
|
1749
|
+
if (cached !== null) {
|
|
1750
|
+
res.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
1751
|
+
res.setHeader?.("x-cache", "HIT");
|
|
1752
|
+
if (res.json) {
|
|
1753
|
+
res.json(cached);
|
|
1754
|
+
} else {
|
|
1755
|
+
res.end?.(JSON.stringify(cached));
|
|
1756
|
+
}
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const originalJson = res.json?.bind(res);
|
|
1760
|
+
if (originalJson) {
|
|
1761
|
+
res.json = (body) => {
|
|
1762
|
+
res.setHeader?.("x-cache", "MISS");
|
|
1763
|
+
void cache.set(key, body, options);
|
|
1764
|
+
return originalJson(body);
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
next();
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1589
1771
|
// src/integrations/graphql.ts
|
|
1590
1772
|
function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
|
|
1591
1773
|
const wrapped = cache.wrap(prefix, resolver, {
|
|
@@ -1649,10 +1831,10 @@ var MemoryLayer = class {
|
|
|
1649
1831
|
}
|
|
1650
1832
|
if (this.evictionPolicy === "lru") {
|
|
1651
1833
|
this.entries.delete(key);
|
|
1652
|
-
entry.
|
|
1834
|
+
entry.accessCount += 1;
|
|
1653
1835
|
this.entries.set(key, entry);
|
|
1654
|
-
} else {
|
|
1655
|
-
entry.
|
|
1836
|
+
} else if (this.evictionPolicy === "lfu") {
|
|
1837
|
+
entry.accessCount += 1;
|
|
1656
1838
|
}
|
|
1657
1839
|
return entry.value;
|
|
1658
1840
|
}
|
|
@@ -1668,7 +1850,7 @@ var MemoryLayer = class {
|
|
|
1668
1850
|
this.entries.set(key, {
|
|
1669
1851
|
value,
|
|
1670
1852
|
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
|
|
1671
|
-
|
|
1853
|
+
accessCount: 0,
|
|
1672
1854
|
insertedAt: Date.now()
|
|
1673
1855
|
});
|
|
1674
1856
|
while (this.entries.size > this.maxSize) {
|
|
@@ -1735,7 +1917,7 @@ var MemoryLayer = class {
|
|
|
1735
1917
|
this.entries.set(entry.key, {
|
|
1736
1918
|
value: entry.value,
|
|
1737
1919
|
expiresAt: entry.expiresAt,
|
|
1738
|
-
|
|
1920
|
+
accessCount: 0,
|
|
1739
1921
|
insertedAt: Date.now()
|
|
1740
1922
|
});
|
|
1741
1923
|
}
|
|
@@ -1752,11 +1934,11 @@ var MemoryLayer = class {
|
|
|
1752
1934
|
return;
|
|
1753
1935
|
}
|
|
1754
1936
|
let victimKey;
|
|
1755
|
-
let
|
|
1937
|
+
let minCount = Number.POSITIVE_INFINITY;
|
|
1756
1938
|
let minInsertedAt = Number.POSITIVE_INFINITY;
|
|
1757
1939
|
for (const [key, entry] of this.entries.entries()) {
|
|
1758
|
-
if (entry.
|
|
1759
|
-
|
|
1940
|
+
if (entry.accessCount < minCount || entry.accessCount === minCount && entry.insertedAt < minInsertedAt) {
|
|
1941
|
+
minCount = entry.accessCount;
|
|
1760
1942
|
minInsertedAt = entry.insertedAt;
|
|
1761
1943
|
victimKey = key;
|
|
1762
1944
|
}
|
|
@@ -1778,7 +1960,8 @@ var MemoryLayer = class {
|
|
|
1778
1960
|
};
|
|
1779
1961
|
|
|
1780
1962
|
// src/layers/RedisLayer.ts
|
|
1781
|
-
import {
|
|
1963
|
+
import { promisify } from "util";
|
|
1964
|
+
import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
|
|
1782
1965
|
|
|
1783
1966
|
// src/serialization/JsonSerializer.ts
|
|
1784
1967
|
var JsonSerializer = class {
|
|
@@ -1793,6 +1976,10 @@ var JsonSerializer = class {
|
|
|
1793
1976
|
|
|
1794
1977
|
// src/layers/RedisLayer.ts
|
|
1795
1978
|
var BATCH_DELETE_SIZE = 500;
|
|
1979
|
+
var gzipAsync = promisify(gzip);
|
|
1980
|
+
var gunzipAsync = promisify(gunzip);
|
|
1981
|
+
var brotliCompressAsync = promisify(brotliCompress);
|
|
1982
|
+
var brotliDecompressAsync = promisify(brotliDecompress);
|
|
1796
1983
|
var RedisLayer = class {
|
|
1797
1984
|
name;
|
|
1798
1985
|
defaultTtl;
|
|
@@ -1849,7 +2036,8 @@ var RedisLayer = class {
|
|
|
1849
2036
|
);
|
|
1850
2037
|
}
|
|
1851
2038
|
async set(key, value, ttl = this.defaultTtl) {
|
|
1852
|
-
const
|
|
2039
|
+
const serialized = this.serializer.serialize(value);
|
|
2040
|
+
const payload = await this.encodePayload(serialized);
|
|
1853
2041
|
const normalizedKey = this.withPrefix(key);
|
|
1854
2042
|
if (ttl && ttl > 0) {
|
|
1855
2043
|
await this.client.set(normalizedKey, payload, "EX", ttl);
|
|
@@ -1928,7 +2116,7 @@ var RedisLayer = class {
|
|
|
1928
2116
|
}
|
|
1929
2117
|
async deserializeOrDelete(key, payload) {
|
|
1930
2118
|
try {
|
|
1931
|
-
return this.serializer.deserialize(this.decodePayload(payload));
|
|
2119
|
+
return this.serializer.deserialize(await this.decodePayload(payload));
|
|
1932
2120
|
} catch {
|
|
1933
2121
|
await this.client.del(this.withPrefix(key)).catch(() => void 0);
|
|
1934
2122
|
return null;
|
|
@@ -1937,7 +2125,11 @@ var RedisLayer = class {
|
|
|
1937
2125
|
isSerializablePayload(payload) {
|
|
1938
2126
|
return typeof payload === "string" || Buffer.isBuffer(payload);
|
|
1939
2127
|
}
|
|
1940
|
-
|
|
2128
|
+
/**
|
|
2129
|
+
* Compresses the payload asynchronously if compression is enabled and the
|
|
2130
|
+
* payload exceeds the threshold. This avoids blocking the event loop.
|
|
2131
|
+
*/
|
|
2132
|
+
async encodePayload(payload) {
|
|
1941
2133
|
if (!this.compression) {
|
|
1942
2134
|
return payload;
|
|
1943
2135
|
}
|
|
@@ -1946,18 +2138,21 @@ var RedisLayer = class {
|
|
|
1946
2138
|
return payload;
|
|
1947
2139
|
}
|
|
1948
2140
|
const header = Buffer.from(`LCZ1:${this.compression}:`);
|
|
1949
|
-
const compressed = this.compression === "gzip" ?
|
|
2141
|
+
const compressed = this.compression === "gzip" ? await gzipAsync(source) : await brotliCompressAsync(source);
|
|
1950
2142
|
return Buffer.concat([header, compressed]);
|
|
1951
2143
|
}
|
|
1952
|
-
|
|
2144
|
+
/**
|
|
2145
|
+
* Decompresses the payload asynchronously if a compression header is present.
|
|
2146
|
+
*/
|
|
2147
|
+
async decodePayload(payload) {
|
|
1953
2148
|
if (!Buffer.isBuffer(payload)) {
|
|
1954
2149
|
return payload;
|
|
1955
2150
|
}
|
|
1956
2151
|
if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
|
|
1957
|
-
return
|
|
2152
|
+
return gunzipAsync(payload.subarray(10));
|
|
1958
2153
|
}
|
|
1959
2154
|
if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
|
|
1960
|
-
return
|
|
2155
|
+
return brotliDecompressAsync(payload.subarray(12));
|
|
1961
2156
|
}
|
|
1962
2157
|
return payload;
|
|
1963
2158
|
}
|
|
@@ -1973,11 +2168,13 @@ var DiskLayer = class {
|
|
|
1973
2168
|
isLocal = true;
|
|
1974
2169
|
directory;
|
|
1975
2170
|
serializer;
|
|
2171
|
+
maxFiles;
|
|
1976
2172
|
constructor(options) {
|
|
1977
2173
|
this.directory = options.directory;
|
|
1978
2174
|
this.defaultTtl = options.ttl;
|
|
1979
2175
|
this.name = options.name ?? "disk";
|
|
1980
2176
|
this.serializer = options.serializer ?? new JsonSerializer();
|
|
2177
|
+
this.maxFiles = options.maxFiles;
|
|
1981
2178
|
}
|
|
1982
2179
|
async get(key) {
|
|
1983
2180
|
return unwrapStoredValue(await this.getEntry(key));
|
|
@@ -2006,11 +2203,15 @@ var DiskLayer = class {
|
|
|
2006
2203
|
async set(key, value, ttl = this.defaultTtl) {
|
|
2007
2204
|
await fs2.mkdir(this.directory, { recursive: true });
|
|
2008
2205
|
const entry = {
|
|
2206
|
+
key,
|
|
2009
2207
|
value,
|
|
2010
2208
|
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
2011
2209
|
};
|
|
2012
2210
|
const payload = this.serializer.serialize(entry);
|
|
2013
2211
|
await fs2.writeFile(this.keyToPath(key), payload);
|
|
2212
|
+
if (this.maxFiles !== void 0) {
|
|
2213
|
+
await this.enforceMaxFiles();
|
|
2214
|
+
}
|
|
2014
2215
|
}
|
|
2015
2216
|
async has(key) {
|
|
2016
2217
|
const value = await this.getEntry(key);
|
|
@@ -2056,6 +2257,10 @@ var DiskLayer = class {
|
|
|
2056
2257
|
entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete(join(this.directory, name)))
|
|
2057
2258
|
);
|
|
2058
2259
|
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Returns the original cache key strings stored on disk.
|
|
2262
|
+
* Expired entries are skipped and cleaned up during the scan.
|
|
2263
|
+
*/
|
|
2059
2264
|
async keys() {
|
|
2060
2265
|
let entries;
|
|
2061
2266
|
try {
|
|
@@ -2063,7 +2268,32 @@ var DiskLayer = class {
|
|
|
2063
2268
|
} catch {
|
|
2064
2269
|
return [];
|
|
2065
2270
|
}
|
|
2066
|
-
|
|
2271
|
+
const lcFiles = entries.filter((name) => name.endsWith(".lc"));
|
|
2272
|
+
const keys = [];
|
|
2273
|
+
await Promise.all(
|
|
2274
|
+
lcFiles.map(async (name) => {
|
|
2275
|
+
const filePath = join(this.directory, name);
|
|
2276
|
+
let raw;
|
|
2277
|
+
try {
|
|
2278
|
+
raw = await fs2.readFile(filePath);
|
|
2279
|
+
} catch {
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
let entry;
|
|
2283
|
+
try {
|
|
2284
|
+
entry = this.serializer.deserialize(raw);
|
|
2285
|
+
} catch {
|
|
2286
|
+
await this.safeDelete(filePath);
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
2290
|
+
await this.safeDelete(filePath);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
keys.push(entry.key);
|
|
2294
|
+
})
|
|
2295
|
+
);
|
|
2296
|
+
return keys;
|
|
2067
2297
|
}
|
|
2068
2298
|
async size() {
|
|
2069
2299
|
const keys = await this.keys();
|
|
@@ -2079,6 +2309,38 @@ var DiskLayer = class {
|
|
|
2079
2309
|
} catch {
|
|
2080
2310
|
}
|
|
2081
2311
|
}
|
|
2312
|
+
/**
|
|
2313
|
+
* Removes the oldest files (by mtime) when the directory exceeds maxFiles.
|
|
2314
|
+
*/
|
|
2315
|
+
async enforceMaxFiles() {
|
|
2316
|
+
if (this.maxFiles === void 0) {
|
|
2317
|
+
return;
|
|
2318
|
+
}
|
|
2319
|
+
let entries;
|
|
2320
|
+
try {
|
|
2321
|
+
entries = await fs2.readdir(this.directory);
|
|
2322
|
+
} catch {
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
const lcFiles = entries.filter((name) => name.endsWith(".lc"));
|
|
2326
|
+
if (lcFiles.length <= this.maxFiles) {
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
const withStats = await Promise.all(
|
|
2330
|
+
lcFiles.map(async (name) => {
|
|
2331
|
+
const filePath = join(this.directory, name);
|
|
2332
|
+
try {
|
|
2333
|
+
const stat = await fs2.stat(filePath);
|
|
2334
|
+
return { filePath, mtimeMs: stat.mtimeMs };
|
|
2335
|
+
} catch {
|
|
2336
|
+
return { filePath, mtimeMs: 0 };
|
|
2337
|
+
}
|
|
2338
|
+
})
|
|
2339
|
+
);
|
|
2340
|
+
withStats.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
2341
|
+
const toEvict = withStats.slice(0, lcFiles.length - this.maxFiles);
|
|
2342
|
+
await Promise.all(toEvict.map(({ filePath }) => this.safeDelete(filePath)));
|
|
2343
|
+
}
|
|
2082
2344
|
};
|
|
2083
2345
|
|
|
2084
2346
|
// src/layers/MemcachedLayer.ts
|
|
@@ -2088,29 +2350,41 @@ var MemcachedLayer = class {
|
|
|
2088
2350
|
isLocal = false;
|
|
2089
2351
|
client;
|
|
2090
2352
|
keyPrefix;
|
|
2353
|
+
serializer;
|
|
2091
2354
|
constructor(options) {
|
|
2092
2355
|
this.client = options.client;
|
|
2093
2356
|
this.defaultTtl = options.ttl;
|
|
2094
2357
|
this.name = options.name ?? "memcached";
|
|
2095
2358
|
this.keyPrefix = options.keyPrefix ?? "";
|
|
2359
|
+
this.serializer = options.serializer ?? new JsonSerializer();
|
|
2096
2360
|
}
|
|
2097
2361
|
async get(key) {
|
|
2362
|
+
return unwrapStoredValue(await this.getEntry(key));
|
|
2363
|
+
}
|
|
2364
|
+
async getEntry(key) {
|
|
2098
2365
|
const result = await this.client.get(this.withPrefix(key));
|
|
2099
2366
|
if (!result || result.value === null) {
|
|
2100
2367
|
return null;
|
|
2101
2368
|
}
|
|
2102
2369
|
try {
|
|
2103
|
-
return
|
|
2370
|
+
return this.serializer.deserialize(result.value);
|
|
2104
2371
|
} catch {
|
|
2105
2372
|
return null;
|
|
2106
2373
|
}
|
|
2107
2374
|
}
|
|
2375
|
+
async getMany(keys) {
|
|
2376
|
+
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
2377
|
+
}
|
|
2108
2378
|
async set(key, value, ttl = this.defaultTtl) {
|
|
2109
|
-
const payload =
|
|
2379
|
+
const payload = this.serializer.serialize(value);
|
|
2110
2380
|
await this.client.set(this.withPrefix(key), payload, {
|
|
2111
2381
|
expires: ttl && ttl > 0 ? ttl : void 0
|
|
2112
2382
|
});
|
|
2113
2383
|
}
|
|
2384
|
+
async has(key) {
|
|
2385
|
+
const result = await this.client.get(this.withPrefix(key));
|
|
2386
|
+
return result !== null && result.value !== null;
|
|
2387
|
+
}
|
|
2114
2388
|
async delete(key) {
|
|
2115
2389
|
await this.client.delete(this.withPrefix(key));
|
|
2116
2390
|
}
|
|
@@ -2204,6 +2478,12 @@ function createPrometheusMetricsExporter(stacks) {
|
|
|
2204
2478
|
lines.push("# TYPE layercache_hits_by_layer_total counter");
|
|
2205
2479
|
lines.push("# HELP layercache_misses_by_layer_total Misses broken down by layer");
|
|
2206
2480
|
lines.push("# TYPE layercache_misses_by_layer_total counter");
|
|
2481
|
+
lines.push("# HELP layercache_layer_latency_avg_ms Average read latency per layer in milliseconds");
|
|
2482
|
+
lines.push("# TYPE layercache_layer_latency_avg_ms gauge");
|
|
2483
|
+
lines.push("# HELP layercache_layer_latency_max_ms Maximum read latency per layer in milliseconds");
|
|
2484
|
+
lines.push("# TYPE layercache_layer_latency_max_ms gauge");
|
|
2485
|
+
lines.push("# HELP layercache_layer_latency_count Number of read latency samples per layer");
|
|
2486
|
+
lines.push("# TYPE layercache_layer_latency_count counter");
|
|
2207
2487
|
for (const { stack, name } of entries) {
|
|
2208
2488
|
const m = stack.getMetrics();
|
|
2209
2489
|
const hr = stack.getHitRate();
|
|
@@ -2227,6 +2507,12 @@ function createPrometheusMetricsExporter(stacks) {
|
|
|
2227
2507
|
for (const [layerName, count] of Object.entries(m.missesByLayer)) {
|
|
2228
2508
|
lines.push(`layercache_misses_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
|
|
2229
2509
|
}
|
|
2510
|
+
for (const [layerName, latency] of Object.entries(m.latencyByLayer)) {
|
|
2511
|
+
const layerLabel = `${label},layer="${sanitizeLabel(layerName)}"`;
|
|
2512
|
+
lines.push(`layercache_layer_latency_avg_ms{${layerLabel}} ${latency.avgMs.toFixed(4)}`);
|
|
2513
|
+
lines.push(`layercache_layer_latency_max_ms{${layerLabel}} ${latency.maxMs.toFixed(4)}`);
|
|
2514
|
+
lines.push(`layercache_layer_latency_count{${layerLabel}} ${latency.count}`);
|
|
2515
|
+
}
|
|
2230
2516
|
}
|
|
2231
2517
|
lines.push("");
|
|
2232
2518
|
return lines.join("\n");
|
|
@@ -2236,6 +2522,7 @@ function sanitizeLabel(value) {
|
|
|
2236
2522
|
return value.replace(/["\\\n]/g, "_");
|
|
2237
2523
|
}
|
|
2238
2524
|
export {
|
|
2525
|
+
CacheMissError,
|
|
2239
2526
|
CacheNamespace,
|
|
2240
2527
|
CacheStack,
|
|
2241
2528
|
DiskLayer,
|
|
@@ -2253,6 +2540,7 @@ export {
|
|
|
2253
2540
|
cacheGraphqlResolver,
|
|
2254
2541
|
createCacheStatsHandler,
|
|
2255
2542
|
createCachedMethodDecorator,
|
|
2543
|
+
createExpressCacheMiddleware,
|
|
2256
2544
|
createFastifyLayercachePlugin,
|
|
2257
2545
|
createPrometheusMetricsExporter,
|
|
2258
2546
|
createTrpcCacheMiddleware
|