layercache 1.0.2 → 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/benchmarks/latency.ts +1 -1
- package/benchmarks/stampede.ts +1 -4
- package/dist/{chunk-IILH5XTS.js → chunk-BWM4MU2X.js} +36 -4
- package/dist/cli.cjs +87 -9
- package/dist/cli.js +52 -6
- package/dist/index.cjs +1219 -272
- package/dist/index.d.cts +469 -13
- package/dist/index.d.ts +469 -13
- package/dist/index.js +1181 -271
- package/examples/express-api/index.ts +12 -8
- package/examples/nestjs-module/app.module.ts +2 -5
- package/examples/nextjs-api-routes/route.ts +1 -4
- package/package.json +6 -1
- package/packages/nestjs/dist/index.cjs +712 -220
- package/packages/nestjs/dist/index.d.cts +243 -11
- package/packages/nestjs/dist/index.d.ts +243 -11
- package/packages/nestjs/dist/index.js +712 -220
package/dist/index.js
CHANGED
|
@@ -1,12 +1,279 @@
|
|
|
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";
|
|
8
|
-
import { promises as fs } from "fs";
|
|
9
8
|
import { EventEmitter } from "events";
|
|
9
|
+
import { promises as fs } from "fs";
|
|
10
|
+
|
|
11
|
+
// src/CacheNamespace.ts
|
|
12
|
+
var CacheNamespace = class _CacheNamespace {
|
|
13
|
+
constructor(cache, prefix) {
|
|
14
|
+
this.cache = cache;
|
|
15
|
+
this.prefix = prefix;
|
|
16
|
+
}
|
|
17
|
+
cache;
|
|
18
|
+
prefix;
|
|
19
|
+
async get(key, fetcher, options) {
|
|
20
|
+
return this.cache.get(this.qualify(key), fetcher, options);
|
|
21
|
+
}
|
|
22
|
+
async getOrSet(key, fetcher, options) {
|
|
23
|
+
return this.cache.getOrSet(this.qualify(key), fetcher, options);
|
|
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
|
+
}
|
|
31
|
+
async has(key) {
|
|
32
|
+
return this.cache.has(this.qualify(key));
|
|
33
|
+
}
|
|
34
|
+
async ttl(key) {
|
|
35
|
+
return this.cache.ttl(this.qualify(key));
|
|
36
|
+
}
|
|
37
|
+
async set(key, value, options) {
|
|
38
|
+
await this.cache.set(this.qualify(key), value, options);
|
|
39
|
+
}
|
|
40
|
+
async delete(key) {
|
|
41
|
+
await this.cache.delete(this.qualify(key));
|
|
42
|
+
}
|
|
43
|
+
async mdelete(keys) {
|
|
44
|
+
await this.cache.mdelete(keys.map((k) => this.qualify(k)));
|
|
45
|
+
}
|
|
46
|
+
async clear() {
|
|
47
|
+
await this.cache.invalidateByPattern(`${this.prefix}:*`);
|
|
48
|
+
}
|
|
49
|
+
async mget(entries) {
|
|
50
|
+
return this.cache.mget(
|
|
51
|
+
entries.map((entry) => ({
|
|
52
|
+
...entry,
|
|
53
|
+
key: this.qualify(entry.key)
|
|
54
|
+
}))
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
async mset(entries) {
|
|
58
|
+
await this.cache.mset(
|
|
59
|
+
entries.map((entry) => ({
|
|
60
|
+
...entry,
|
|
61
|
+
key: this.qualify(entry.key)
|
|
62
|
+
}))
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
async invalidateByTag(tag) {
|
|
66
|
+
await this.cache.invalidateByTag(tag);
|
|
67
|
+
}
|
|
68
|
+
async invalidateByPattern(pattern) {
|
|
69
|
+
await this.cache.invalidateByPattern(this.qualify(pattern));
|
|
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
|
+
}
|
|
77
|
+
wrap(keyPrefix, fetcher, options) {
|
|
78
|
+
return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
|
|
79
|
+
}
|
|
80
|
+
warm(entries, options) {
|
|
81
|
+
return this.cache.warm(
|
|
82
|
+
entries.map((entry) => ({
|
|
83
|
+
...entry,
|
|
84
|
+
key: this.qualify(entry.key)
|
|
85
|
+
})),
|
|
86
|
+
options
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
getMetrics() {
|
|
90
|
+
return this.cache.getMetrics();
|
|
91
|
+
}
|
|
92
|
+
getHitRate() {
|
|
93
|
+
return this.cache.getHitRate();
|
|
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
|
+
}
|
|
107
|
+
qualify(key) {
|
|
108
|
+
return `${this.prefix}:${key}`;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// src/internal/CircuitBreakerManager.ts
|
|
113
|
+
var CircuitBreakerManager = class {
|
|
114
|
+
breakers = /* @__PURE__ */ new Map();
|
|
115
|
+
maxEntries;
|
|
116
|
+
constructor(options) {
|
|
117
|
+
this.maxEntries = options.maxEntries;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Throws if the circuit is open for the given key.
|
|
121
|
+
* Automatically resets if the cooldown has elapsed.
|
|
122
|
+
*/
|
|
123
|
+
assertClosed(key, options) {
|
|
124
|
+
const state = this.breakers.get(key);
|
|
125
|
+
if (!state?.openUntil) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
if (state.openUntil <= now) {
|
|
130
|
+
state.openUntil = null;
|
|
131
|
+
state.failures = 0;
|
|
132
|
+
this.breakers.set(key, state);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const remainingMs = state.openUntil - now;
|
|
136
|
+
const remainingSecs = Math.ceil(remainingMs / 1e3);
|
|
137
|
+
throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
|
|
138
|
+
}
|
|
139
|
+
recordFailure(key, options) {
|
|
140
|
+
if (!options) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const failureThreshold = options.failureThreshold ?? 3;
|
|
144
|
+
const cooldownMs = options.cooldownMs ?? 3e4;
|
|
145
|
+
const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
|
|
146
|
+
state.failures += 1;
|
|
147
|
+
if (state.failures >= failureThreshold) {
|
|
148
|
+
state.openUntil = Date.now() + cooldownMs;
|
|
149
|
+
}
|
|
150
|
+
this.breakers.set(key, state);
|
|
151
|
+
this.pruneIfNeeded();
|
|
152
|
+
}
|
|
153
|
+
recordSuccess(key) {
|
|
154
|
+
this.breakers.delete(key);
|
|
155
|
+
}
|
|
156
|
+
isOpen(key) {
|
|
157
|
+
const state = this.breakers.get(key);
|
|
158
|
+
if (!state?.openUntil) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
if (state.openUntil <= Date.now()) {
|
|
162
|
+
state.openUntil = null;
|
|
163
|
+
state.failures = 0;
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
delete(key) {
|
|
169
|
+
this.breakers.delete(key);
|
|
170
|
+
}
|
|
171
|
+
clear() {
|
|
172
|
+
this.breakers.clear();
|
|
173
|
+
}
|
|
174
|
+
tripCount() {
|
|
175
|
+
let count = 0;
|
|
176
|
+
for (const state of this.breakers.values()) {
|
|
177
|
+
if (state.openUntil !== null) {
|
|
178
|
+
count += 1;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return count;
|
|
182
|
+
}
|
|
183
|
+
pruneIfNeeded() {
|
|
184
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
for (const [key, state] of this.breakers.entries()) {
|
|
188
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
if (!state.openUntil || state.openUntil <= Date.now()) {
|
|
192
|
+
this.breakers.delete(key);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const key of this.breakers.keys()) {
|
|
196
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
this.breakers.delete(key);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// src/internal/MetricsCollector.ts
|
|
205
|
+
var MetricsCollector = class {
|
|
206
|
+
data = this.empty();
|
|
207
|
+
get snapshot() {
|
|
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
|
+
};
|
|
214
|
+
}
|
|
215
|
+
increment(field, amount = 1) {
|
|
216
|
+
;
|
|
217
|
+
this.data[field] += amount;
|
|
218
|
+
}
|
|
219
|
+
incrementLayer(map, layerName) {
|
|
220
|
+
this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
|
|
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
|
+
}
|
|
238
|
+
reset() {
|
|
239
|
+
this.data = this.empty();
|
|
240
|
+
}
|
|
241
|
+
hitRate() {
|
|
242
|
+
const total = this.data.hits + this.data.misses;
|
|
243
|
+
const overall = total === 0 ? 0 : this.data.hits / total;
|
|
244
|
+
const byLayer = {};
|
|
245
|
+
const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
|
|
246
|
+
for (const layer of allLayers) {
|
|
247
|
+
const h = this.data.hitsByLayer[layer] ?? 0;
|
|
248
|
+
const m = this.data.missesByLayer[layer] ?? 0;
|
|
249
|
+
byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
|
|
250
|
+
}
|
|
251
|
+
return { overall, byLayer };
|
|
252
|
+
}
|
|
253
|
+
empty() {
|
|
254
|
+
return {
|
|
255
|
+
hits: 0,
|
|
256
|
+
misses: 0,
|
|
257
|
+
fetches: 0,
|
|
258
|
+
sets: 0,
|
|
259
|
+
deletes: 0,
|
|
260
|
+
backfills: 0,
|
|
261
|
+
invalidations: 0,
|
|
262
|
+
staleHits: 0,
|
|
263
|
+
refreshes: 0,
|
|
264
|
+
refreshErrors: 0,
|
|
265
|
+
writeFailures: 0,
|
|
266
|
+
singleFlightWaits: 0,
|
|
267
|
+
negativeCacheHits: 0,
|
|
268
|
+
circuitBreakerTrips: 0,
|
|
269
|
+
degradedOperations: 0,
|
|
270
|
+
hitsByLayer: {},
|
|
271
|
+
missesByLayer: {},
|
|
272
|
+
latencyByLayer: {},
|
|
273
|
+
resetAt: Date.now()
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
};
|
|
10
277
|
|
|
11
278
|
// src/internal/StoredValue.ts
|
|
12
279
|
function isStoredValueEnvelope(value) {
|
|
@@ -109,58 +376,91 @@ function normalizePositiveSeconds(value) {
|
|
|
109
376
|
return value;
|
|
110
377
|
}
|
|
111
378
|
|
|
112
|
-
// src/
|
|
113
|
-
var
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
prefix;
|
|
120
|
-
async get(key, fetcher, options) {
|
|
121
|
-
return this.cache.get(this.qualify(key), fetcher, options);
|
|
122
|
-
}
|
|
123
|
-
async set(key, value, options) {
|
|
124
|
-
await this.cache.set(this.qualify(key), value, options);
|
|
125
|
-
}
|
|
126
|
-
async delete(key) {
|
|
127
|
-
await this.cache.delete(this.qualify(key));
|
|
379
|
+
// src/internal/TtlResolver.ts
|
|
380
|
+
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
381
|
+
var TtlResolver = class {
|
|
382
|
+
accessProfiles = /* @__PURE__ */ new Map();
|
|
383
|
+
maxProfileEntries;
|
|
384
|
+
constructor(options) {
|
|
385
|
+
this.maxProfileEntries = options.maxProfileEntries;
|
|
128
386
|
}
|
|
129
|
-
|
|
130
|
-
|
|
387
|
+
recordAccess(key) {
|
|
388
|
+
const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
|
|
389
|
+
profile.hits += 1;
|
|
390
|
+
profile.lastAccessAt = Date.now();
|
|
391
|
+
this.accessProfiles.set(key, profile);
|
|
392
|
+
this.pruneIfNeeded();
|
|
131
393
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
...entry,
|
|
135
|
-
key: this.qualify(entry.key)
|
|
136
|
-
})));
|
|
394
|
+
deleteProfile(key) {
|
|
395
|
+
this.accessProfiles.delete(key);
|
|
137
396
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
...entry,
|
|
141
|
-
key: this.qualify(entry.key)
|
|
142
|
-
})));
|
|
397
|
+
clearProfiles() {
|
|
398
|
+
this.accessProfiles.clear();
|
|
143
399
|
}
|
|
144
|
-
|
|
145
|
-
|
|
400
|
+
resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
|
|
401
|
+
const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
|
|
402
|
+
layerName,
|
|
403
|
+
options?.negativeTtl,
|
|
404
|
+
globalNegativeTtl,
|
|
405
|
+
this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
406
|
+
) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
|
|
407
|
+
const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
|
|
408
|
+
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
|
|
409
|
+
return this.applyJitter(adaptiveTtl, jitter);
|
|
146
410
|
}
|
|
147
|
-
|
|
148
|
-
|
|
411
|
+
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
412
|
+
if (override !== void 0) {
|
|
413
|
+
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
414
|
+
}
|
|
415
|
+
if (globalDefault !== void 0) {
|
|
416
|
+
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
417
|
+
}
|
|
418
|
+
return fallback;
|
|
149
419
|
}
|
|
150
|
-
|
|
151
|
-
|
|
420
|
+
applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
|
|
421
|
+
if (!ttl || !adaptiveTtl) {
|
|
422
|
+
return ttl;
|
|
423
|
+
}
|
|
424
|
+
const profile = this.accessProfiles.get(key);
|
|
425
|
+
if (!profile) {
|
|
426
|
+
return ttl;
|
|
427
|
+
}
|
|
428
|
+
const config = adaptiveTtl === true ? {} : adaptiveTtl;
|
|
429
|
+
const hotAfter = config.hotAfter ?? 3;
|
|
430
|
+
if (profile.hits < hotAfter) {
|
|
431
|
+
return ttl;
|
|
432
|
+
}
|
|
433
|
+
const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
|
|
434
|
+
const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
|
|
435
|
+
const multiplier = Math.floor(profile.hits / hotAfter);
|
|
436
|
+
return Math.min(maxTtl, ttl + step * multiplier);
|
|
152
437
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
438
|
+
applyJitter(ttl, jitter) {
|
|
439
|
+
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
440
|
+
return ttl;
|
|
441
|
+
}
|
|
442
|
+
const delta = (Math.random() * 2 - 1) * jitter;
|
|
443
|
+
return Math.max(1, Math.round(ttl + delta));
|
|
158
444
|
}
|
|
159
|
-
|
|
160
|
-
|
|
445
|
+
readLayerNumber(layerName, value) {
|
|
446
|
+
if (typeof value === "number") {
|
|
447
|
+
return value;
|
|
448
|
+
}
|
|
449
|
+
return value[layerName];
|
|
161
450
|
}
|
|
162
|
-
|
|
163
|
-
|
|
451
|
+
pruneIfNeeded() {
|
|
452
|
+
if (this.accessProfiles.size <= this.maxProfileEntries) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
|
|
456
|
+
let removed = 0;
|
|
457
|
+
for (const key of this.accessProfiles.keys()) {
|
|
458
|
+
if (removed >= toRemove) {
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
this.accessProfiles.delete(key);
|
|
462
|
+
removed += 1;
|
|
463
|
+
}
|
|
164
464
|
}
|
|
165
465
|
};
|
|
166
466
|
|
|
@@ -169,11 +469,17 @@ var TagIndex = class {
|
|
|
169
469
|
tagToKeys = /* @__PURE__ */ new Map();
|
|
170
470
|
keyToTags = /* @__PURE__ */ new Map();
|
|
171
471
|
knownKeys = /* @__PURE__ */ new Set();
|
|
472
|
+
maxKnownKeys;
|
|
473
|
+
constructor(options = {}) {
|
|
474
|
+
this.maxKnownKeys = options.maxKnownKeys;
|
|
475
|
+
}
|
|
172
476
|
async touch(key) {
|
|
173
477
|
this.knownKeys.add(key);
|
|
478
|
+
this.pruneKnownKeysIfNeeded();
|
|
174
479
|
}
|
|
175
480
|
async track(key, tags) {
|
|
176
481
|
this.knownKeys.add(key);
|
|
482
|
+
this.pruneKnownKeysIfNeeded();
|
|
177
483
|
if (tags.length === 0) {
|
|
178
484
|
return;
|
|
179
485
|
}
|
|
@@ -212,6 +518,9 @@ var TagIndex = class {
|
|
|
212
518
|
async keysForTag(tag) {
|
|
213
519
|
return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
|
|
214
520
|
}
|
|
521
|
+
async tagsForKey(key) {
|
|
522
|
+
return [...this.keyToTags.get(key) ?? /* @__PURE__ */ new Set()];
|
|
523
|
+
}
|
|
215
524
|
async matchPattern(pattern) {
|
|
216
525
|
return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
|
|
217
526
|
}
|
|
@@ -220,6 +529,21 @@ var TagIndex = class {
|
|
|
220
529
|
this.keyToTags.clear();
|
|
221
530
|
this.knownKeys.clear();
|
|
222
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
|
+
}
|
|
223
547
|
};
|
|
224
548
|
|
|
225
549
|
// src/stampede/StampedeGuard.ts
|
|
@@ -248,31 +572,22 @@ var StampedeGuard = class {
|
|
|
248
572
|
}
|
|
249
573
|
};
|
|
250
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
|
+
|
|
251
585
|
// src/CacheStack.ts
|
|
252
|
-
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
253
586
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
254
587
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
255
588
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
256
589
|
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
257
|
-
var
|
|
258
|
-
hits: 0,
|
|
259
|
-
misses: 0,
|
|
260
|
-
fetches: 0,
|
|
261
|
-
sets: 0,
|
|
262
|
-
deletes: 0,
|
|
263
|
-
backfills: 0,
|
|
264
|
-
invalidations: 0,
|
|
265
|
-
staleHits: 0,
|
|
266
|
-
refreshes: 0,
|
|
267
|
-
refreshErrors: 0,
|
|
268
|
-
writeFailures: 0,
|
|
269
|
-
singleFlightWaits: 0,
|
|
270
|
-
negativeCacheHits: 0,
|
|
271
|
-
circuitBreakerTrips: 0,
|
|
272
|
-
degradedOperations: 0,
|
|
273
|
-
hitsByLayer: {},
|
|
274
|
-
missesByLayer: {}
|
|
275
|
-
});
|
|
590
|
+
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
276
591
|
var DebugLogger = class {
|
|
277
592
|
enabled;
|
|
278
593
|
constructor(enabled) {
|
|
@@ -307,6 +622,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
307
622
|
throw new Error("CacheStack requires at least one cache layer.");
|
|
308
623
|
}
|
|
309
624
|
this.validateConfiguration();
|
|
625
|
+
const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
|
|
626
|
+
this.ttlResolver = new TtlResolver({ maxProfileEntries });
|
|
627
|
+
this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
|
|
628
|
+
if (options.publishSetInvalidation !== void 0) {
|
|
629
|
+
console.warn(
|
|
630
|
+
"[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
|
|
631
|
+
);
|
|
632
|
+
}
|
|
310
633
|
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
311
634
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
312
635
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
@@ -315,36 +638,42 @@ var CacheStack = class extends EventEmitter {
|
|
|
315
638
|
layers;
|
|
316
639
|
options;
|
|
317
640
|
stampedeGuard = new StampedeGuard();
|
|
318
|
-
|
|
641
|
+
metricsCollector = new MetricsCollector();
|
|
319
642
|
instanceId = randomUUID();
|
|
320
643
|
startup;
|
|
321
644
|
unsubscribeInvalidation;
|
|
322
645
|
logger;
|
|
323
646
|
tagIndex;
|
|
324
647
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
325
|
-
accessProfiles = /* @__PURE__ */ new Map();
|
|
326
648
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
327
|
-
|
|
649
|
+
ttlResolver;
|
|
650
|
+
circuitBreakerManager;
|
|
328
651
|
isDisconnecting = false;
|
|
329
652
|
disconnectPromise;
|
|
653
|
+
/**
|
|
654
|
+
* Read-through cache get.
|
|
655
|
+
* Returns the cached value if present and fresh, or invokes `fetcher` on a miss
|
|
656
|
+
* and stores the result across all layers. Returns `null` if the key is not found
|
|
657
|
+
* and no `fetcher` is provided.
|
|
658
|
+
*/
|
|
330
659
|
async get(key, fetcher, options) {
|
|
331
660
|
const normalizedKey = this.validateCacheKey(key);
|
|
332
661
|
this.validateWriteOptions(options);
|
|
333
662
|
await this.startup;
|
|
334
663
|
const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
|
|
335
664
|
if (hit.found) {
|
|
336
|
-
this.recordAccess(normalizedKey);
|
|
665
|
+
this.ttlResolver.recordAccess(normalizedKey);
|
|
337
666
|
if (this.isNegativeStoredValue(hit.stored)) {
|
|
338
|
-
this.
|
|
667
|
+
this.metricsCollector.increment("negativeCacheHits");
|
|
339
668
|
}
|
|
340
669
|
if (hit.state === "fresh") {
|
|
341
|
-
this.
|
|
670
|
+
this.metricsCollector.increment("hits");
|
|
342
671
|
await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
|
|
343
672
|
return hit.value;
|
|
344
673
|
}
|
|
345
674
|
if (hit.state === "stale-while-revalidate") {
|
|
346
|
-
this.
|
|
347
|
-
this.
|
|
675
|
+
this.metricsCollector.increment("hits");
|
|
676
|
+
this.metricsCollector.increment("staleHits");
|
|
348
677
|
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
349
678
|
if (fetcher) {
|
|
350
679
|
this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
|
|
@@ -352,47 +681,148 @@ var CacheStack = class extends EventEmitter {
|
|
|
352
681
|
return hit.value;
|
|
353
682
|
}
|
|
354
683
|
if (!fetcher) {
|
|
355
|
-
this.
|
|
356
|
-
this.
|
|
684
|
+
this.metricsCollector.increment("hits");
|
|
685
|
+
this.metricsCollector.increment("staleHits");
|
|
357
686
|
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
358
687
|
return hit.value;
|
|
359
688
|
}
|
|
360
689
|
try {
|
|
361
690
|
return await this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
362
691
|
} catch (error) {
|
|
363
|
-
this.
|
|
364
|
-
this.
|
|
692
|
+
this.metricsCollector.increment("staleHits");
|
|
693
|
+
this.metricsCollector.increment("refreshErrors");
|
|
365
694
|
this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
|
|
366
695
|
return hit.value;
|
|
367
696
|
}
|
|
368
697
|
}
|
|
369
|
-
this.
|
|
698
|
+
this.metricsCollector.increment("misses");
|
|
370
699
|
if (!fetcher) {
|
|
371
700
|
return null;
|
|
372
701
|
}
|
|
373
702
|
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
374
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
706
|
+
* Fetches and caches the value if not already present.
|
|
707
|
+
*/
|
|
708
|
+
async getOrSet(key, fetcher, options) {
|
|
709
|
+
return this.get(key, fetcher, options);
|
|
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
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Returns true if the given key exists and is not expired in any layer.
|
|
725
|
+
*/
|
|
726
|
+
async has(key) {
|
|
727
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
728
|
+
await this.startup;
|
|
729
|
+
for (const layer of this.layers) {
|
|
730
|
+
if (this.shouldSkipLayer(layer)) {
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (layer.has) {
|
|
734
|
+
try {
|
|
735
|
+
const exists = await layer.has(normalizedKey);
|
|
736
|
+
if (exists) {
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
} catch {
|
|
740
|
+
}
|
|
741
|
+
} else {
|
|
742
|
+
try {
|
|
743
|
+
const value = await layer.get(normalizedKey);
|
|
744
|
+
if (value !== null) {
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
} catch {
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Returns the remaining TTL in seconds for the key in the fastest layer
|
|
755
|
+
* that has it, or null if the key is not found / has no TTL.
|
|
756
|
+
*/
|
|
757
|
+
async ttl(key) {
|
|
758
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
759
|
+
await this.startup;
|
|
760
|
+
for (const layer of this.layers) {
|
|
761
|
+
if (this.shouldSkipLayer(layer)) {
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (layer.ttl) {
|
|
765
|
+
try {
|
|
766
|
+
const remaining = await layer.ttl(normalizedKey);
|
|
767
|
+
if (remaining !== null) {
|
|
768
|
+
return remaining;
|
|
769
|
+
}
|
|
770
|
+
} catch {
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Stores a value in all cache layers. Overwrites any existing value.
|
|
778
|
+
*/
|
|
375
779
|
async set(key, value, options) {
|
|
376
780
|
const normalizedKey = this.validateCacheKey(key);
|
|
377
781
|
this.validateWriteOptions(options);
|
|
378
782
|
await this.startup;
|
|
379
783
|
await this.storeEntry(normalizedKey, "value", value, options);
|
|
380
784
|
}
|
|
785
|
+
/**
|
|
786
|
+
* Deletes the key from all layers and publishes an invalidation message.
|
|
787
|
+
*/
|
|
381
788
|
async delete(key) {
|
|
382
789
|
const normalizedKey = this.validateCacheKey(key);
|
|
383
790
|
await this.startup;
|
|
384
791
|
await this.deleteKeys([normalizedKey]);
|
|
385
|
-
await this.publishInvalidation({
|
|
792
|
+
await this.publishInvalidation({
|
|
793
|
+
scope: "key",
|
|
794
|
+
keys: [normalizedKey],
|
|
795
|
+
sourceId: this.instanceId,
|
|
796
|
+
operation: "delete"
|
|
797
|
+
});
|
|
386
798
|
}
|
|
387
799
|
async clear() {
|
|
388
800
|
await this.startup;
|
|
389
801
|
await Promise.all(this.layers.map((layer) => layer.clear()));
|
|
390
802
|
await this.tagIndex.clear();
|
|
391
|
-
this.
|
|
392
|
-
this.
|
|
803
|
+
this.ttlResolver.clearProfiles();
|
|
804
|
+
this.circuitBreakerManager.clear();
|
|
805
|
+
this.metricsCollector.increment("invalidations");
|
|
393
806
|
this.logger.debug?.("clear");
|
|
394
807
|
await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
|
|
395
808
|
}
|
|
809
|
+
/**
|
|
810
|
+
* Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
|
|
811
|
+
*/
|
|
812
|
+
async mdelete(keys) {
|
|
813
|
+
if (keys.length === 0) {
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
await this.startup;
|
|
817
|
+
const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
|
|
818
|
+
await this.deleteKeys(normalizedKeys);
|
|
819
|
+
await this.publishInvalidation({
|
|
820
|
+
scope: "keys",
|
|
821
|
+
keys: normalizedKeys,
|
|
822
|
+
sourceId: this.instanceId,
|
|
823
|
+
operation: "delete"
|
|
824
|
+
});
|
|
825
|
+
}
|
|
396
826
|
async mget(entries) {
|
|
397
827
|
if (entries.length === 0) {
|
|
398
828
|
return [];
|
|
@@ -430,7 +860,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
430
860
|
const indexesByKey = /* @__PURE__ */ new Map();
|
|
431
861
|
const resultsByKey = /* @__PURE__ */ new Map();
|
|
432
862
|
for (let index = 0; index < normalizedEntries.length; index += 1) {
|
|
433
|
-
const
|
|
863
|
+
const entry = normalizedEntries[index];
|
|
864
|
+
if (!entry) continue;
|
|
865
|
+
const key = entry.key;
|
|
434
866
|
const indexes = indexesByKey.get(key) ?? [];
|
|
435
867
|
indexes.push(index);
|
|
436
868
|
indexesByKey.set(key, indexes);
|
|
@@ -438,6 +870,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
438
870
|
}
|
|
439
871
|
for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
|
|
440
872
|
const layer = this.layers[layerIndex];
|
|
873
|
+
if (!layer) continue;
|
|
441
874
|
const keys = [...pending];
|
|
442
875
|
if (keys.length === 0) {
|
|
443
876
|
break;
|
|
@@ -446,7 +879,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
446
879
|
for (let offset = 0; offset < values.length; offset += 1) {
|
|
447
880
|
const key = keys[offset];
|
|
448
881
|
const stored = values[offset];
|
|
449
|
-
if (stored === null) {
|
|
882
|
+
if (!key || stored === null) {
|
|
450
883
|
continue;
|
|
451
884
|
}
|
|
452
885
|
const resolved = resolveStoredValue(stored);
|
|
@@ -458,13 +891,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
458
891
|
await this.backfill(key, stored, layerIndex - 1);
|
|
459
892
|
resultsByKey.set(key, resolved.value);
|
|
460
893
|
pending.delete(key);
|
|
461
|
-
this.
|
|
894
|
+
this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
|
|
462
895
|
}
|
|
463
896
|
}
|
|
464
897
|
if (pending.size > 0) {
|
|
465
898
|
for (const key of pending) {
|
|
466
899
|
await this.tagIndex.remove(key);
|
|
467
|
-
this.
|
|
900
|
+
this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
|
|
468
901
|
}
|
|
469
902
|
}
|
|
470
903
|
return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
|
|
@@ -479,26 +912,38 @@ var CacheStack = class extends EventEmitter {
|
|
|
479
912
|
}
|
|
480
913
|
async warm(entries, options = {}) {
|
|
481
914
|
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
915
|
+
const total = entries.length;
|
|
916
|
+
let completed = 0;
|
|
482
917
|
const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
|
|
483
|
-
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
918
|
+
const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
|
|
484
919
|
while (queue.length > 0) {
|
|
485
920
|
const entry = queue.shift();
|
|
486
921
|
if (!entry) {
|
|
487
922
|
return;
|
|
488
923
|
}
|
|
924
|
+
let success = false;
|
|
489
925
|
try {
|
|
490
926
|
await this.get(entry.key, entry.fetcher, entry.options);
|
|
491
927
|
this.emit("warm", { key: entry.key });
|
|
928
|
+
success = true;
|
|
492
929
|
} catch (error) {
|
|
493
930
|
this.emitError("warm", { key: entry.key, error: this.formatError(error) });
|
|
494
931
|
if (!options.continueOnError) {
|
|
495
932
|
throw error;
|
|
496
933
|
}
|
|
934
|
+
} finally {
|
|
935
|
+
completed += 1;
|
|
936
|
+
const progress = { completed, total, key: entry.key, success };
|
|
937
|
+
options.onProgress?.(progress);
|
|
497
938
|
}
|
|
498
939
|
}
|
|
499
940
|
});
|
|
500
941
|
await Promise.all(workers);
|
|
501
942
|
}
|
|
943
|
+
/**
|
|
944
|
+
* Returns a cached version of `fetcher`. The cache key is derived from
|
|
945
|
+
* `prefix` plus the serialized arguments unless a `keyResolver` is provided.
|
|
946
|
+
*/
|
|
502
947
|
wrap(prefix, fetcher, options = {}) {
|
|
503
948
|
return (...args) => {
|
|
504
949
|
const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
|
|
@@ -506,6 +951,10 @@ var CacheStack = class extends EventEmitter {
|
|
|
506
951
|
return this.get(key, () => fetcher(...args), options);
|
|
507
952
|
};
|
|
508
953
|
}
|
|
954
|
+
/**
|
|
955
|
+
* Creates a `CacheNamespace` that automatically prefixes all keys with
|
|
956
|
+
* `prefix:`. Useful for multi-tenant or module-level isolation.
|
|
957
|
+
*/
|
|
509
958
|
namespace(prefix) {
|
|
510
959
|
return new CacheNamespace(this, prefix);
|
|
511
960
|
}
|
|
@@ -522,7 +971,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
522
971
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
523
972
|
}
|
|
524
973
|
getMetrics() {
|
|
525
|
-
return
|
|
974
|
+
return this.metricsCollector.snapshot;
|
|
526
975
|
}
|
|
527
976
|
getStats() {
|
|
528
977
|
return {
|
|
@@ -536,7 +985,53 @@ var CacheStack = class extends EventEmitter {
|
|
|
536
985
|
};
|
|
537
986
|
}
|
|
538
987
|
resetMetrics() {
|
|
539
|
-
|
|
988
|
+
this.metricsCollector.reset();
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Returns computed hit-rate statistics (overall and per-layer).
|
|
992
|
+
*/
|
|
993
|
+
getHitRate() {
|
|
994
|
+
return this.metricsCollector.hitRate();
|
|
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 };
|
|
540
1035
|
}
|
|
541
1036
|
async exportState() {
|
|
542
1037
|
await this.startup;
|
|
@@ -565,10 +1060,12 @@ var CacheStack = class extends EventEmitter {
|
|
|
565
1060
|
}
|
|
566
1061
|
async importState(entries) {
|
|
567
1062
|
await this.startup;
|
|
568
|
-
await Promise.all(
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
1063
|
+
await Promise.all(
|
|
1064
|
+
entries.map(async (entry) => {
|
|
1065
|
+
await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
|
|
1066
|
+
await this.tagIndex.touch(entry.key);
|
|
1067
|
+
})
|
|
1068
|
+
);
|
|
572
1069
|
}
|
|
573
1070
|
async persistToFile(filePath) {
|
|
574
1071
|
const snapshot = await this.exportState();
|
|
@@ -576,11 +1073,21 @@ var CacheStack = class extends EventEmitter {
|
|
|
576
1073
|
}
|
|
577
1074
|
async restoreFromFile(filePath) {
|
|
578
1075
|
const raw = await fs.readFile(filePath, "utf8");
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
1076
|
+
let parsed;
|
|
1077
|
+
try {
|
|
1078
|
+
parsed = JSON.parse(raw, (_key, value) => {
|
|
1079
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
1080
|
+
return Object.assign(/* @__PURE__ */ Object.create(null), value);
|
|
1081
|
+
}
|
|
1082
|
+
return value;
|
|
1083
|
+
});
|
|
1084
|
+
} catch (cause) {
|
|
1085
|
+
throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
|
|
582
1086
|
}
|
|
583
|
-
|
|
1087
|
+
if (!this.isCacheSnapshotEntries(parsed)) {
|
|
1088
|
+
throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
|
|
1089
|
+
}
|
|
1090
|
+
await this.importState(parsed);
|
|
584
1091
|
}
|
|
585
1092
|
async disconnect() {
|
|
586
1093
|
if (!this.disconnectPromise) {
|
|
@@ -605,7 +1112,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
605
1112
|
const fetchTask = async () => {
|
|
606
1113
|
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
607
1114
|
if (secondHit.found) {
|
|
608
|
-
this.
|
|
1115
|
+
this.metricsCollector.increment("hits");
|
|
609
1116
|
return secondHit.value;
|
|
610
1117
|
}
|
|
611
1118
|
return this.fetchAndPopulate(key, fetcher, options);
|
|
@@ -630,12 +1137,12 @@ var CacheStack = class extends EventEmitter {
|
|
|
630
1137
|
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
631
1138
|
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
632
1139
|
const deadline = Date.now() + timeoutMs;
|
|
633
|
-
this.
|
|
1140
|
+
this.metricsCollector.increment("singleFlightWaits");
|
|
634
1141
|
this.emit("stampede-dedupe", { key });
|
|
635
1142
|
while (Date.now() < deadline) {
|
|
636
1143
|
const hit = await this.readFromLayers(key, options, "fresh-only");
|
|
637
1144
|
if (hit.found) {
|
|
638
|
-
this.
|
|
1145
|
+
this.metricsCollector.increment("hits");
|
|
639
1146
|
return hit.value;
|
|
640
1147
|
}
|
|
641
1148
|
await this.sleep(pollIntervalMs);
|
|
@@ -643,12 +1150,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
643
1150
|
return this.fetchAndPopulate(key, fetcher, options);
|
|
644
1151
|
}
|
|
645
1152
|
async fetchAndPopulate(key, fetcher, options) {
|
|
646
|
-
this.
|
|
647
|
-
this.
|
|
1153
|
+
this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
|
|
1154
|
+
this.metricsCollector.increment("fetches");
|
|
1155
|
+
const fetchStart = Date.now();
|
|
648
1156
|
let fetched;
|
|
649
1157
|
try {
|
|
650
1158
|
fetched = await fetcher();
|
|
651
|
-
this.
|
|
1159
|
+
this.circuitBreakerManager.recordSuccess(key);
|
|
1160
|
+
this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
|
|
652
1161
|
} catch (error) {
|
|
653
1162
|
this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
|
|
654
1163
|
throw error;
|
|
@@ -660,6 +1169,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
660
1169
|
await this.storeEntry(key, "empty", null, options);
|
|
661
1170
|
return null;
|
|
662
1171
|
}
|
|
1172
|
+
if (options?.shouldCache && !options.shouldCache(fetched)) {
|
|
1173
|
+
return fetched;
|
|
1174
|
+
}
|
|
663
1175
|
await this.storeEntry(key, "value", fetched, options);
|
|
664
1176
|
return fetched;
|
|
665
1177
|
}
|
|
@@ -670,7 +1182,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
670
1182
|
} else {
|
|
671
1183
|
await this.tagIndex.touch(key);
|
|
672
1184
|
}
|
|
673
|
-
this.
|
|
1185
|
+
this.metricsCollector.increment("sets");
|
|
674
1186
|
this.logger.debug?.("set", { key, kind, tags: options?.tags });
|
|
675
1187
|
this.emit("set", { key, kind, tags: options?.tags });
|
|
676
1188
|
if (this.shouldBroadcastL1Invalidation()) {
|
|
@@ -681,9 +1193,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
681
1193
|
let sawRetainableValue = false;
|
|
682
1194
|
for (let index = 0; index < this.layers.length; index += 1) {
|
|
683
1195
|
const layer = this.layers[index];
|
|
1196
|
+
if (!layer) continue;
|
|
1197
|
+
const readStart = performance.now();
|
|
684
1198
|
const stored = await this.readLayerEntry(layer, key);
|
|
1199
|
+
const readDuration = performance.now() - readStart;
|
|
1200
|
+
this.metricsCollector.recordLatency(layer.name, readDuration);
|
|
685
1201
|
if (stored === null) {
|
|
686
|
-
this.
|
|
1202
|
+
this.metricsCollector.incrementLayer("missesByLayer", layer.name);
|
|
687
1203
|
continue;
|
|
688
1204
|
}
|
|
689
1205
|
const resolved = resolveStoredValue(stored);
|
|
@@ -697,10 +1213,17 @@ var CacheStack = class extends EventEmitter {
|
|
|
697
1213
|
}
|
|
698
1214
|
await this.tagIndex.touch(key);
|
|
699
1215
|
await this.backfill(key, stored, index - 1, options);
|
|
700
|
-
this.
|
|
1216
|
+
this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
|
|
701
1217
|
this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
|
|
702
1218
|
this.emit("hit", { key, layer: layer.name, state: resolved.state });
|
|
703
|
-
return {
|
|
1219
|
+
return {
|
|
1220
|
+
found: true,
|
|
1221
|
+
value: resolved.value,
|
|
1222
|
+
stored,
|
|
1223
|
+
state: resolved.state,
|
|
1224
|
+
layerIndex: index,
|
|
1225
|
+
layerName: layer.name
|
|
1226
|
+
};
|
|
704
1227
|
}
|
|
705
1228
|
if (!sawRetainableValue) {
|
|
706
1229
|
await this.tagIndex.remove(key);
|
|
@@ -732,7 +1255,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
732
1255
|
}
|
|
733
1256
|
for (let index = 0; index <= upToIndex; index += 1) {
|
|
734
1257
|
const layer = this.layers[index];
|
|
735
|
-
if (this.shouldSkipLayer(layer)) {
|
|
1258
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
736
1259
|
continue;
|
|
737
1260
|
}
|
|
738
1261
|
const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
|
|
@@ -742,7 +1265,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
742
1265
|
await this.handleLayerFailure(layer, "backfill", error);
|
|
743
1266
|
continue;
|
|
744
1267
|
}
|
|
745
|
-
this.
|
|
1268
|
+
this.metricsCollector.increment("backfills");
|
|
746
1269
|
this.logger.debug?.("backfill", { key, layer: layer.name });
|
|
747
1270
|
this.emit("backfill", { key, layer: layer.name });
|
|
748
1271
|
}
|
|
@@ -759,11 +1282,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
759
1282
|
options?.staleWhileRevalidate,
|
|
760
1283
|
this.options.staleWhileRevalidate
|
|
761
1284
|
);
|
|
762
|
-
const staleIfError = this.resolveLayerSeconds(
|
|
763
|
-
layer.name,
|
|
764
|
-
options?.staleIfError,
|
|
765
|
-
this.options.staleIfError
|
|
766
|
-
);
|
|
1285
|
+
const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
|
|
767
1286
|
const payload = createStoredValueEnvelope({
|
|
768
1287
|
kind,
|
|
769
1288
|
value,
|
|
@@ -791,7 +1310,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
791
1310
|
if (failures.length === 0) {
|
|
792
1311
|
return;
|
|
793
1312
|
}
|
|
794
|
-
this.
|
|
1313
|
+
this.metricsCollector.increment("writeFailures", failures.length);
|
|
795
1314
|
this.logger.debug?.("write-failure", {
|
|
796
1315
|
...context,
|
|
797
1316
|
failures: failures.map((failure) => this.formatError(failure.reason))
|
|
@@ -804,42 +1323,10 @@ var CacheStack = class extends EventEmitter {
|
|
|
804
1323
|
}
|
|
805
1324
|
}
|
|
806
1325
|
resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
|
|
807
|
-
|
|
808
|
-
layerName,
|
|
809
|
-
options?.negativeTtl,
|
|
810
|
-
this.options.negativeTtl,
|
|
811
|
-
this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
812
|
-
) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
|
|
813
|
-
const adaptiveTtl = this.applyAdaptiveTtl(
|
|
814
|
-
key,
|
|
815
|
-
layerName,
|
|
816
|
-
baseTtl,
|
|
817
|
-
options?.adaptiveTtl ?? this.options.adaptiveTtl
|
|
818
|
-
);
|
|
819
|
-
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
|
|
820
|
-
return this.applyJitter(adaptiveTtl, jitter);
|
|
1326
|
+
return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
|
|
821
1327
|
}
|
|
822
1328
|
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
823
|
-
|
|
824
|
-
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
825
|
-
}
|
|
826
|
-
if (globalDefault !== void 0) {
|
|
827
|
-
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
828
|
-
}
|
|
829
|
-
return fallback;
|
|
830
|
-
}
|
|
831
|
-
readLayerNumber(layerName, value) {
|
|
832
|
-
if (typeof value === "number") {
|
|
833
|
-
return value;
|
|
834
|
-
}
|
|
835
|
-
return value[layerName];
|
|
836
|
-
}
|
|
837
|
-
applyJitter(ttl, jitter) {
|
|
838
|
-
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
839
|
-
return ttl;
|
|
840
|
-
}
|
|
841
|
-
const delta = (Math.random() * 2 - 1) * jitter;
|
|
842
|
-
return Math.max(1, Math.round(ttl + delta));
|
|
1329
|
+
return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
|
|
843
1330
|
}
|
|
844
1331
|
shouldNegativeCache(options) {
|
|
845
1332
|
return options?.negativeCache ?? this.options.negativeCaching ?? false;
|
|
@@ -849,11 +1336,11 @@ var CacheStack = class extends EventEmitter {
|
|
|
849
1336
|
return;
|
|
850
1337
|
}
|
|
851
1338
|
const refresh = (async () => {
|
|
852
|
-
this.
|
|
1339
|
+
this.metricsCollector.increment("refreshes");
|
|
853
1340
|
try {
|
|
854
1341
|
await this.fetchWithGuards(key, fetcher, options);
|
|
855
1342
|
} catch (error) {
|
|
856
|
-
this.
|
|
1343
|
+
this.metricsCollector.increment("refreshErrors");
|
|
857
1344
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
858
1345
|
} finally {
|
|
859
1346
|
this.backgroundRefreshes.delete(key);
|
|
@@ -875,10 +1362,11 @@ var CacheStack = class extends EventEmitter {
|
|
|
875
1362
|
await this.deleteKeysFromLayers(this.layers, keys);
|
|
876
1363
|
for (const key of keys) {
|
|
877
1364
|
await this.tagIndex.remove(key);
|
|
878
|
-
this.
|
|
1365
|
+
this.ttlResolver.deleteProfile(key);
|
|
1366
|
+
this.circuitBreakerManager.delete(key);
|
|
879
1367
|
}
|
|
880
|
-
this.
|
|
881
|
-
this.
|
|
1368
|
+
this.metricsCollector.increment("deletes", keys.length);
|
|
1369
|
+
this.metricsCollector.increment("invalidations");
|
|
882
1370
|
this.logger.debug?.("delete", { keys });
|
|
883
1371
|
this.emit("delete", { keys });
|
|
884
1372
|
}
|
|
@@ -899,7 +1387,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
899
1387
|
if (message.scope === "clear") {
|
|
900
1388
|
await Promise.all(localLayers.map((layer) => layer.clear()));
|
|
901
1389
|
await this.tagIndex.clear();
|
|
902
|
-
this.
|
|
1390
|
+
this.ttlResolver.clearProfiles();
|
|
903
1391
|
return;
|
|
904
1392
|
}
|
|
905
1393
|
const keys = message.keys ?? [];
|
|
@@ -907,10 +1395,16 @@ var CacheStack = class extends EventEmitter {
|
|
|
907
1395
|
if (message.operation !== "write") {
|
|
908
1396
|
for (const key of keys) {
|
|
909
1397
|
await this.tagIndex.remove(key);
|
|
910
|
-
this.
|
|
1398
|
+
this.ttlResolver.deleteProfile(key);
|
|
911
1399
|
}
|
|
912
1400
|
}
|
|
913
1401
|
}
|
|
1402
|
+
async getTagsForKey(key) {
|
|
1403
|
+
if (this.tagIndex.tagsForKey) {
|
|
1404
|
+
return this.tagIndex.tagsForKey(key);
|
|
1405
|
+
}
|
|
1406
|
+
return [];
|
|
1407
|
+
}
|
|
914
1408
|
formatError(error) {
|
|
915
1409
|
if (error instanceof Error) {
|
|
916
1410
|
return error.message;
|
|
@@ -937,13 +1431,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
937
1431
|
}
|
|
938
1432
|
return;
|
|
939
1433
|
}
|
|
940
|
-
await Promise.all(
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1434
|
+
await Promise.all(
|
|
1435
|
+
keys.map(async (key) => {
|
|
1436
|
+
try {
|
|
1437
|
+
await layer.delete(key);
|
|
1438
|
+
} catch (error) {
|
|
1439
|
+
await this.handleLayerFailure(layer, "delete", error);
|
|
1440
|
+
}
|
|
1441
|
+
})
|
|
1442
|
+
);
|
|
947
1443
|
})
|
|
948
1444
|
);
|
|
949
1445
|
}
|
|
@@ -1044,46 +1540,19 @@ var CacheStack = class extends EventEmitter {
|
|
|
1044
1540
|
const ttl = remainingStoredTtlSeconds(refreshed);
|
|
1045
1541
|
for (let index = 0; index <= hit.layerIndex; index += 1) {
|
|
1046
1542
|
const layer = this.layers[index];
|
|
1047
|
-
if (this.shouldSkipLayer(layer)) {
|
|
1543
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
1048
1544
|
continue;
|
|
1049
1545
|
}
|
|
1050
1546
|
try {
|
|
1051
|
-
await layer.set(key, refreshed, ttl);
|
|
1052
|
-
} catch (error) {
|
|
1053
|
-
await this.handleLayerFailure(layer, "sliding-ttl", error);
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
|
|
1058
|
-
this.scheduleBackgroundRefresh(key, fetcher, options);
|
|
1059
|
-
}
|
|
1060
|
-
}
|
|
1061
|
-
applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
|
|
1062
|
-
if (!ttl || !adaptiveTtl) {
|
|
1063
|
-
return ttl;
|
|
1064
|
-
}
|
|
1065
|
-
const profile = this.accessProfiles.get(key);
|
|
1066
|
-
if (!profile) {
|
|
1067
|
-
return ttl;
|
|
1547
|
+
await layer.set(key, refreshed, ttl);
|
|
1548
|
+
} catch (error) {
|
|
1549
|
+
await this.handleLayerFailure(layer, "sliding-ttl", error);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1068
1552
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
if (profile.hits < hotAfter) {
|
|
1072
|
-
return ttl;
|
|
1553
|
+
if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
|
|
1554
|
+
this.scheduleBackgroundRefresh(key, fetcher, options);
|
|
1073
1555
|
}
|
|
1074
|
-
const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
|
|
1075
|
-
const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
|
|
1076
|
-
const multiplier = Math.floor(profile.hits / hotAfter);
|
|
1077
|
-
return Math.min(maxTtl, ttl + step * multiplier);
|
|
1078
|
-
}
|
|
1079
|
-
recordAccess(key) {
|
|
1080
|
-
const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
|
|
1081
|
-
profile.hits += 1;
|
|
1082
|
-
profile.lastAccessAt = Date.now();
|
|
1083
|
-
this.accessProfiles.set(key, profile);
|
|
1084
|
-
}
|
|
1085
|
-
incrementMetricMap(target, key) {
|
|
1086
|
-
target[key] = (target[key] ?? 0) + 1;
|
|
1087
1556
|
}
|
|
1088
1557
|
shouldSkipLayer(layer) {
|
|
1089
1558
|
const degradedUntil = this.layerDegradedUntil.get(layer.name);
|
|
@@ -1095,7 +1564,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1095
1564
|
}
|
|
1096
1565
|
const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
|
|
1097
1566
|
this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
|
|
1098
|
-
this.
|
|
1567
|
+
this.metricsCollector.increment("degradedOperations");
|
|
1099
1568
|
this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
|
|
1100
1569
|
this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
|
|
1101
1570
|
return null;
|
|
@@ -1103,37 +1572,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
1103
1572
|
isGracefulDegradationEnabled() {
|
|
1104
1573
|
return Boolean(this.options.gracefulDegradation);
|
|
1105
1574
|
}
|
|
1106
|
-
assertCircuitClosed(key, options) {
|
|
1107
|
-
const state = this.circuitBreakers.get(key);
|
|
1108
|
-
if (!state?.openUntil) {
|
|
1109
|
-
return;
|
|
1110
|
-
}
|
|
1111
|
-
if (state.openUntil <= Date.now()) {
|
|
1112
|
-
state.openUntil = null;
|
|
1113
|
-
state.failures = 0;
|
|
1114
|
-
this.circuitBreakers.set(key, state);
|
|
1115
|
-
return;
|
|
1116
|
-
}
|
|
1117
|
-
this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
|
|
1118
|
-
throw new Error(`Circuit breaker is open for key "${key}".`);
|
|
1119
|
-
}
|
|
1120
1575
|
recordCircuitFailure(key, options, error) {
|
|
1121
1576
|
if (!options) {
|
|
1122
1577
|
return;
|
|
1123
1578
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
state.failures += 1;
|
|
1128
|
-
if (state.failures >= failureThreshold) {
|
|
1129
|
-
state.openUntil = Date.now() + cooldownMs;
|
|
1130
|
-
this.metrics.circuitBreakerTrips += 1;
|
|
1579
|
+
this.circuitBreakerManager.recordFailure(key, options);
|
|
1580
|
+
if (this.circuitBreakerManager.isOpen(key)) {
|
|
1581
|
+
this.metricsCollector.increment("circuitBreakerTrips");
|
|
1131
1582
|
}
|
|
1132
|
-
this.
|
|
1133
|
-
this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
|
|
1134
|
-
}
|
|
1135
|
-
resetCircuitBreaker(key) {
|
|
1136
|
-
this.circuitBreakers.delete(key);
|
|
1583
|
+
this.emitError("fetch", { key, error: this.formatError(error) });
|
|
1137
1584
|
}
|
|
1138
1585
|
isNegativeStoredValue(stored) {
|
|
1139
1586
|
return isStoredValueEnvelope(stored) && stored.kind === "empty";
|
|
@@ -1178,35 +1625,36 @@ var RedisInvalidationBus = class {
|
|
|
1178
1625
|
channel;
|
|
1179
1626
|
publisher;
|
|
1180
1627
|
subscriber;
|
|
1181
|
-
|
|
1628
|
+
handlers = /* @__PURE__ */ new Set();
|
|
1629
|
+
sharedListener;
|
|
1182
1630
|
constructor(options) {
|
|
1183
1631
|
this.publisher = options.publisher;
|
|
1184
1632
|
this.subscriber = options.subscriber ?? options.publisher.duplicate();
|
|
1185
1633
|
this.channel = options.channel ?? "layercache:invalidation";
|
|
1186
1634
|
}
|
|
1187
1635
|
async subscribe(handler) {
|
|
1188
|
-
if (this.
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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);
|
|
1643
|
+
}
|
|
1644
|
+
this.handlers.add(handler);
|
|
1197
1645
|
return async () => {
|
|
1198
|
-
|
|
1199
|
-
|
|
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);
|
|
1200
1651
|
}
|
|
1201
|
-
this.activeListener = void 0;
|
|
1202
|
-
this.subscriber.off("message", listener);
|
|
1203
|
-
await this.subscriber.unsubscribe(this.channel);
|
|
1204
1652
|
};
|
|
1205
1653
|
}
|
|
1206
1654
|
async publish(message) {
|
|
1207
1655
|
await this.publisher.publish(this.channel, JSON.stringify(message));
|
|
1208
1656
|
}
|
|
1209
|
-
async
|
|
1657
|
+
async dispatchToHandlers(payload) {
|
|
1210
1658
|
let message;
|
|
1211
1659
|
try {
|
|
1212
1660
|
const parsed = JSON.parse(payload);
|
|
@@ -1218,11 +1666,15 @@ var RedisInvalidationBus = class {
|
|
|
1218
1666
|
this.reportError("invalid invalidation payload", error);
|
|
1219
1667
|
return;
|
|
1220
1668
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
+
);
|
|
1226
1678
|
}
|
|
1227
1679
|
isInvalidationMessage(value) {
|
|
1228
1680
|
if (!value || typeof value !== "object") {
|
|
@@ -1283,6 +1735,39 @@ function createFastifyLayercachePlugin(cache, options = {}) {
|
|
|
1283
1735
|
};
|
|
1284
1736
|
}
|
|
1285
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
|
+
|
|
1286
1771
|
// src/integrations/graphql.ts
|
|
1287
1772
|
function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
|
|
1288
1773
|
const wrapped = cache.wrap(prefix, resolver, {
|
|
@@ -1323,11 +1808,13 @@ var MemoryLayer = class {
|
|
|
1323
1808
|
defaultTtl;
|
|
1324
1809
|
isLocal = true;
|
|
1325
1810
|
maxSize;
|
|
1811
|
+
evictionPolicy;
|
|
1326
1812
|
entries = /* @__PURE__ */ new Map();
|
|
1327
1813
|
constructor(options = {}) {
|
|
1328
1814
|
this.name = options.name ?? "memory";
|
|
1329
1815
|
this.defaultTtl = options.ttl;
|
|
1330
1816
|
this.maxSize = options.maxSize ?? 1e3;
|
|
1817
|
+
this.evictionPolicy = options.evictionPolicy ?? "lru";
|
|
1331
1818
|
}
|
|
1332
1819
|
async get(key) {
|
|
1333
1820
|
const value = await this.getEntry(key);
|
|
@@ -1342,8 +1829,13 @@ var MemoryLayer = class {
|
|
|
1342
1829
|
this.entries.delete(key);
|
|
1343
1830
|
return null;
|
|
1344
1831
|
}
|
|
1345
|
-
this.
|
|
1346
|
-
|
|
1832
|
+
if (this.evictionPolicy === "lru") {
|
|
1833
|
+
this.entries.delete(key);
|
|
1834
|
+
entry.accessCount += 1;
|
|
1835
|
+
this.entries.set(key, entry);
|
|
1836
|
+
} else if (this.evictionPolicy === "lfu") {
|
|
1837
|
+
entry.accessCount += 1;
|
|
1838
|
+
}
|
|
1347
1839
|
return entry.value;
|
|
1348
1840
|
}
|
|
1349
1841
|
async getMany(keys) {
|
|
@@ -1357,15 +1849,42 @@ var MemoryLayer = class {
|
|
|
1357
1849
|
this.entries.delete(key);
|
|
1358
1850
|
this.entries.set(key, {
|
|
1359
1851
|
value,
|
|
1360
|
-
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
1852
|
+
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
|
|
1853
|
+
accessCount: 0,
|
|
1854
|
+
insertedAt: Date.now()
|
|
1361
1855
|
});
|
|
1362
1856
|
while (this.entries.size > this.maxSize) {
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1857
|
+
this.evict();
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
async has(key) {
|
|
1861
|
+
const entry = this.entries.get(key);
|
|
1862
|
+
if (!entry) {
|
|
1863
|
+
return false;
|
|
1864
|
+
}
|
|
1865
|
+
if (this.isExpired(entry)) {
|
|
1866
|
+
this.entries.delete(key);
|
|
1867
|
+
return false;
|
|
1868
|
+
}
|
|
1869
|
+
return true;
|
|
1870
|
+
}
|
|
1871
|
+
async ttl(key) {
|
|
1872
|
+
const entry = this.entries.get(key);
|
|
1873
|
+
if (!entry) {
|
|
1874
|
+
return null;
|
|
1368
1875
|
}
|
|
1876
|
+
if (this.isExpired(entry)) {
|
|
1877
|
+
this.entries.delete(key);
|
|
1878
|
+
return null;
|
|
1879
|
+
}
|
|
1880
|
+
if (entry.expiresAt === null) {
|
|
1881
|
+
return null;
|
|
1882
|
+
}
|
|
1883
|
+
return Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 1e3));
|
|
1884
|
+
}
|
|
1885
|
+
async size() {
|
|
1886
|
+
this.pruneExpired();
|
|
1887
|
+
return this.entries.size;
|
|
1369
1888
|
}
|
|
1370
1889
|
async delete(key) {
|
|
1371
1890
|
this.entries.delete(key);
|
|
@@ -1397,15 +1916,35 @@ var MemoryLayer = class {
|
|
|
1397
1916
|
}
|
|
1398
1917
|
this.entries.set(entry.key, {
|
|
1399
1918
|
value: entry.value,
|
|
1400
|
-
expiresAt: entry.expiresAt
|
|
1919
|
+
expiresAt: entry.expiresAt,
|
|
1920
|
+
accessCount: 0,
|
|
1921
|
+
insertedAt: Date.now()
|
|
1401
1922
|
});
|
|
1402
1923
|
}
|
|
1403
1924
|
while (this.entries.size > this.maxSize) {
|
|
1925
|
+
this.evict();
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
evict() {
|
|
1929
|
+
if (this.evictionPolicy === "lru" || this.evictionPolicy === "fifo") {
|
|
1404
1930
|
const oldestKey = this.entries.keys().next().value;
|
|
1405
|
-
if (
|
|
1406
|
-
|
|
1931
|
+
if (oldestKey !== void 0) {
|
|
1932
|
+
this.entries.delete(oldestKey);
|
|
1933
|
+
}
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
let victimKey;
|
|
1937
|
+
let minCount = Number.POSITIVE_INFINITY;
|
|
1938
|
+
let minInsertedAt = Number.POSITIVE_INFINITY;
|
|
1939
|
+
for (const [key, entry] of this.entries.entries()) {
|
|
1940
|
+
if (entry.accessCount < minCount || entry.accessCount === minCount && entry.insertedAt < minInsertedAt) {
|
|
1941
|
+
minCount = entry.accessCount;
|
|
1942
|
+
minInsertedAt = entry.insertedAt;
|
|
1943
|
+
victimKey = key;
|
|
1407
1944
|
}
|
|
1408
|
-
|
|
1945
|
+
}
|
|
1946
|
+
if (victimKey !== void 0) {
|
|
1947
|
+
this.entries.delete(victimKey);
|
|
1409
1948
|
}
|
|
1410
1949
|
}
|
|
1411
1950
|
pruneExpired() {
|
|
@@ -1421,7 +1960,8 @@ var MemoryLayer = class {
|
|
|
1421
1960
|
};
|
|
1422
1961
|
|
|
1423
1962
|
// src/layers/RedisLayer.ts
|
|
1424
|
-
import {
|
|
1963
|
+
import { promisify } from "util";
|
|
1964
|
+
import { brotliCompress, brotliDecompress, gunzip, gzip } from "zlib";
|
|
1425
1965
|
|
|
1426
1966
|
// src/serialization/JsonSerializer.ts
|
|
1427
1967
|
var JsonSerializer = class {
|
|
@@ -1435,6 +1975,11 @@ var JsonSerializer = class {
|
|
|
1435
1975
|
};
|
|
1436
1976
|
|
|
1437
1977
|
// src/layers/RedisLayer.ts
|
|
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);
|
|
1438
1983
|
var RedisLayer = class {
|
|
1439
1984
|
name;
|
|
1440
1985
|
defaultTtl;
|
|
@@ -1486,12 +2031,13 @@ var RedisLayer = class {
|
|
|
1486
2031
|
if (error || payload === null || !this.isSerializablePayload(payload)) {
|
|
1487
2032
|
return null;
|
|
1488
2033
|
}
|
|
1489
|
-
return this.deserializeOrDelete(keys[index], payload);
|
|
2034
|
+
return this.deserializeOrDelete(keys[index] ?? "", payload);
|
|
1490
2035
|
})
|
|
1491
2036
|
);
|
|
1492
2037
|
}
|
|
1493
2038
|
async set(key, value, ttl = this.defaultTtl) {
|
|
1494
|
-
const
|
|
2039
|
+
const serialized = this.serializer.serialize(value);
|
|
2040
|
+
const payload = await this.encodePayload(serialized);
|
|
1495
2041
|
const normalizedKey = this.withPrefix(key);
|
|
1496
2042
|
if (ttl && ttl > 0) {
|
|
1497
2043
|
await this.client.set(normalizedKey, payload, "EX", ttl);
|
|
@@ -1508,14 +2054,44 @@ var RedisLayer = class {
|
|
|
1508
2054
|
}
|
|
1509
2055
|
await this.client.del(...keys.map((key) => this.withPrefix(key)));
|
|
1510
2056
|
}
|
|
1511
|
-
async
|
|
1512
|
-
|
|
1513
|
-
|
|
2057
|
+
async has(key) {
|
|
2058
|
+
const exists = await this.client.exists(this.withPrefix(key));
|
|
2059
|
+
return exists > 0;
|
|
2060
|
+
}
|
|
2061
|
+
async ttl(key) {
|
|
2062
|
+
const remaining = await this.client.ttl(this.withPrefix(key));
|
|
2063
|
+
if (remaining < 0) {
|
|
2064
|
+
return null;
|
|
1514
2065
|
}
|
|
2066
|
+
return remaining;
|
|
2067
|
+
}
|
|
2068
|
+
async size() {
|
|
1515
2069
|
const keys = await this.keys();
|
|
1516
|
-
|
|
1517
|
-
|
|
2070
|
+
return keys.length;
|
|
2071
|
+
}
|
|
2072
|
+
/**
|
|
2073
|
+
* Deletes all keys matching the layer's prefix in batches to avoid
|
|
2074
|
+
* loading millions of keys into memory at once.
|
|
2075
|
+
*/
|
|
2076
|
+
async clear() {
|
|
2077
|
+
if (!this.prefix && !this.allowUnprefixedClear) {
|
|
2078
|
+
throw new Error(
|
|
2079
|
+
"RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys."
|
|
2080
|
+
);
|
|
1518
2081
|
}
|
|
2082
|
+
const pattern = `${this.prefix}*`;
|
|
2083
|
+
let cursor = "0";
|
|
2084
|
+
do {
|
|
2085
|
+
const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
|
|
2086
|
+
cursor = nextCursor;
|
|
2087
|
+
if (keys.length === 0) {
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
|
|
2091
|
+
const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
|
|
2092
|
+
await this.client.del(...batch);
|
|
2093
|
+
}
|
|
2094
|
+
} while (cursor !== "0");
|
|
1519
2095
|
}
|
|
1520
2096
|
async keys() {
|
|
1521
2097
|
const pattern = `${this.prefix}*`;
|
|
@@ -1540,7 +2116,7 @@ var RedisLayer = class {
|
|
|
1540
2116
|
}
|
|
1541
2117
|
async deserializeOrDelete(key, payload) {
|
|
1542
2118
|
try {
|
|
1543
|
-
return this.serializer.deserialize(this.decodePayload(payload));
|
|
2119
|
+
return this.serializer.deserialize(await this.decodePayload(payload));
|
|
1544
2120
|
} catch {
|
|
1545
2121
|
await this.client.del(this.withPrefix(key)).catch(() => void 0);
|
|
1546
2122
|
return null;
|
|
@@ -1549,7 +2125,11 @@ var RedisLayer = class {
|
|
|
1549
2125
|
isSerializablePayload(payload) {
|
|
1550
2126
|
return typeof payload === "string" || Buffer.isBuffer(payload);
|
|
1551
2127
|
}
|
|
1552
|
-
|
|
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) {
|
|
1553
2133
|
if (!this.compression) {
|
|
1554
2134
|
return payload;
|
|
1555
2135
|
}
|
|
@@ -1558,23 +2138,269 @@ var RedisLayer = class {
|
|
|
1558
2138
|
return payload;
|
|
1559
2139
|
}
|
|
1560
2140
|
const header = Buffer.from(`LCZ1:${this.compression}:`);
|
|
1561
|
-
const compressed = this.compression === "gzip" ?
|
|
2141
|
+
const compressed = this.compression === "gzip" ? await gzipAsync(source) : await brotliCompressAsync(source);
|
|
1562
2142
|
return Buffer.concat([header, compressed]);
|
|
1563
2143
|
}
|
|
1564
|
-
|
|
2144
|
+
/**
|
|
2145
|
+
* Decompresses the payload asynchronously if a compression header is present.
|
|
2146
|
+
*/
|
|
2147
|
+
async decodePayload(payload) {
|
|
1565
2148
|
if (!Buffer.isBuffer(payload)) {
|
|
1566
2149
|
return payload;
|
|
1567
2150
|
}
|
|
1568
2151
|
if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
|
|
1569
|
-
return
|
|
2152
|
+
return gunzipAsync(payload.subarray(10));
|
|
1570
2153
|
}
|
|
1571
2154
|
if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
|
|
1572
|
-
return
|
|
2155
|
+
return brotliDecompressAsync(payload.subarray(12));
|
|
1573
2156
|
}
|
|
1574
2157
|
return payload;
|
|
1575
2158
|
}
|
|
1576
2159
|
};
|
|
1577
2160
|
|
|
2161
|
+
// src/layers/DiskLayer.ts
|
|
2162
|
+
import { createHash } from "crypto";
|
|
2163
|
+
import { promises as fs2 } from "fs";
|
|
2164
|
+
import { join } from "path";
|
|
2165
|
+
var DiskLayer = class {
|
|
2166
|
+
name;
|
|
2167
|
+
defaultTtl;
|
|
2168
|
+
isLocal = true;
|
|
2169
|
+
directory;
|
|
2170
|
+
serializer;
|
|
2171
|
+
maxFiles;
|
|
2172
|
+
constructor(options) {
|
|
2173
|
+
this.directory = options.directory;
|
|
2174
|
+
this.defaultTtl = options.ttl;
|
|
2175
|
+
this.name = options.name ?? "disk";
|
|
2176
|
+
this.serializer = options.serializer ?? new JsonSerializer();
|
|
2177
|
+
this.maxFiles = options.maxFiles;
|
|
2178
|
+
}
|
|
2179
|
+
async get(key) {
|
|
2180
|
+
return unwrapStoredValue(await this.getEntry(key));
|
|
2181
|
+
}
|
|
2182
|
+
async getEntry(key) {
|
|
2183
|
+
const filePath = this.keyToPath(key);
|
|
2184
|
+
let raw;
|
|
2185
|
+
try {
|
|
2186
|
+
raw = await fs2.readFile(filePath);
|
|
2187
|
+
} catch {
|
|
2188
|
+
return null;
|
|
2189
|
+
}
|
|
2190
|
+
let entry;
|
|
2191
|
+
try {
|
|
2192
|
+
entry = this.serializer.deserialize(raw);
|
|
2193
|
+
} catch {
|
|
2194
|
+
await this.safeDelete(filePath);
|
|
2195
|
+
return null;
|
|
2196
|
+
}
|
|
2197
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
2198
|
+
await this.safeDelete(filePath);
|
|
2199
|
+
return null;
|
|
2200
|
+
}
|
|
2201
|
+
return entry.value;
|
|
2202
|
+
}
|
|
2203
|
+
async set(key, value, ttl = this.defaultTtl) {
|
|
2204
|
+
await fs2.mkdir(this.directory, { recursive: true });
|
|
2205
|
+
const entry = {
|
|
2206
|
+
key,
|
|
2207
|
+
value,
|
|
2208
|
+
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
2209
|
+
};
|
|
2210
|
+
const payload = this.serializer.serialize(entry);
|
|
2211
|
+
await fs2.writeFile(this.keyToPath(key), payload);
|
|
2212
|
+
if (this.maxFiles !== void 0) {
|
|
2213
|
+
await this.enforceMaxFiles();
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
async has(key) {
|
|
2217
|
+
const value = await this.getEntry(key);
|
|
2218
|
+
return value !== null;
|
|
2219
|
+
}
|
|
2220
|
+
async ttl(key) {
|
|
2221
|
+
const filePath = this.keyToPath(key);
|
|
2222
|
+
let raw;
|
|
2223
|
+
try {
|
|
2224
|
+
raw = await fs2.readFile(filePath);
|
|
2225
|
+
} catch {
|
|
2226
|
+
return null;
|
|
2227
|
+
}
|
|
2228
|
+
let entry;
|
|
2229
|
+
try {
|
|
2230
|
+
entry = this.serializer.deserialize(raw);
|
|
2231
|
+
} catch {
|
|
2232
|
+
return null;
|
|
2233
|
+
}
|
|
2234
|
+
if (entry.expiresAt === null) {
|
|
2235
|
+
return null;
|
|
2236
|
+
}
|
|
2237
|
+
const remaining = Math.ceil((entry.expiresAt - Date.now()) / 1e3);
|
|
2238
|
+
if (remaining <= 0) {
|
|
2239
|
+
return null;
|
|
2240
|
+
}
|
|
2241
|
+
return remaining;
|
|
2242
|
+
}
|
|
2243
|
+
async delete(key) {
|
|
2244
|
+
await this.safeDelete(this.keyToPath(key));
|
|
2245
|
+
}
|
|
2246
|
+
async deleteMany(keys) {
|
|
2247
|
+
await Promise.all(keys.map((key) => this.delete(key)));
|
|
2248
|
+
}
|
|
2249
|
+
async clear() {
|
|
2250
|
+
let entries;
|
|
2251
|
+
try {
|
|
2252
|
+
entries = await fs2.readdir(this.directory);
|
|
2253
|
+
} catch {
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
await Promise.all(
|
|
2257
|
+
entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete(join(this.directory, name)))
|
|
2258
|
+
);
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Returns the original cache key strings stored on disk.
|
|
2262
|
+
* Expired entries are skipped and cleaned up during the scan.
|
|
2263
|
+
*/
|
|
2264
|
+
async keys() {
|
|
2265
|
+
let entries;
|
|
2266
|
+
try {
|
|
2267
|
+
entries = await fs2.readdir(this.directory);
|
|
2268
|
+
} catch {
|
|
2269
|
+
return [];
|
|
2270
|
+
}
|
|
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;
|
|
2297
|
+
}
|
|
2298
|
+
async size() {
|
|
2299
|
+
const keys = await this.keys();
|
|
2300
|
+
return keys.length;
|
|
2301
|
+
}
|
|
2302
|
+
keyToPath(key) {
|
|
2303
|
+
const hash = createHash("sha256").update(key).digest("hex");
|
|
2304
|
+
return join(this.directory, `${hash}.lc`);
|
|
2305
|
+
}
|
|
2306
|
+
async safeDelete(filePath) {
|
|
2307
|
+
try {
|
|
2308
|
+
await fs2.unlink(filePath);
|
|
2309
|
+
} catch {
|
|
2310
|
+
}
|
|
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
|
+
}
|
|
2344
|
+
};
|
|
2345
|
+
|
|
2346
|
+
// src/layers/MemcachedLayer.ts
|
|
2347
|
+
var MemcachedLayer = class {
|
|
2348
|
+
name;
|
|
2349
|
+
defaultTtl;
|
|
2350
|
+
isLocal = false;
|
|
2351
|
+
client;
|
|
2352
|
+
keyPrefix;
|
|
2353
|
+
serializer;
|
|
2354
|
+
constructor(options) {
|
|
2355
|
+
this.client = options.client;
|
|
2356
|
+
this.defaultTtl = options.ttl;
|
|
2357
|
+
this.name = options.name ?? "memcached";
|
|
2358
|
+
this.keyPrefix = options.keyPrefix ?? "";
|
|
2359
|
+
this.serializer = options.serializer ?? new JsonSerializer();
|
|
2360
|
+
}
|
|
2361
|
+
async get(key) {
|
|
2362
|
+
return unwrapStoredValue(await this.getEntry(key));
|
|
2363
|
+
}
|
|
2364
|
+
async getEntry(key) {
|
|
2365
|
+
const result = await this.client.get(this.withPrefix(key));
|
|
2366
|
+
if (!result || result.value === null) {
|
|
2367
|
+
return null;
|
|
2368
|
+
}
|
|
2369
|
+
try {
|
|
2370
|
+
return this.serializer.deserialize(result.value);
|
|
2371
|
+
} catch {
|
|
2372
|
+
return null;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
async getMany(keys) {
|
|
2376
|
+
return Promise.all(keys.map((key) => this.getEntry(key)));
|
|
2377
|
+
}
|
|
2378
|
+
async set(key, value, ttl = this.defaultTtl) {
|
|
2379
|
+
const payload = this.serializer.serialize(value);
|
|
2380
|
+
await this.client.set(this.withPrefix(key), payload, {
|
|
2381
|
+
expires: ttl && ttl > 0 ? ttl : void 0
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
async has(key) {
|
|
2385
|
+
const result = await this.client.get(this.withPrefix(key));
|
|
2386
|
+
return result !== null && result.value !== null;
|
|
2387
|
+
}
|
|
2388
|
+
async delete(key) {
|
|
2389
|
+
await this.client.delete(this.withPrefix(key));
|
|
2390
|
+
}
|
|
2391
|
+
async deleteMany(keys) {
|
|
2392
|
+
await Promise.all(keys.map((key) => this.delete(key)));
|
|
2393
|
+
}
|
|
2394
|
+
async clear() {
|
|
2395
|
+
throw new Error(
|
|
2396
|
+
"MemcachedLayer.clear() is not supported. Use a key prefix and rotate it to effectively invalidate all keys."
|
|
2397
|
+
);
|
|
2398
|
+
}
|
|
2399
|
+
withPrefix(key) {
|
|
2400
|
+
return `${this.keyPrefix}${key}`;
|
|
2401
|
+
}
|
|
2402
|
+
};
|
|
2403
|
+
|
|
1578
2404
|
// src/serialization/MsgpackSerializer.ts
|
|
1579
2405
|
import { decode, encode } from "@msgpack/msgpack";
|
|
1580
2406
|
var MsgpackSerializer = class {
|
|
@@ -1616,10 +2442,92 @@ var RedisSingleFlightCoordinator = class {
|
|
|
1616
2442
|
return waiter();
|
|
1617
2443
|
}
|
|
1618
2444
|
};
|
|
2445
|
+
|
|
2446
|
+
// src/metrics/PrometheusExporter.ts
|
|
2447
|
+
function createPrometheusMetricsExporter(stacks) {
|
|
2448
|
+
return () => {
|
|
2449
|
+
const entries = Array.isArray(stacks) ? stacks : [{ stack: stacks, name: "default" }];
|
|
2450
|
+
const lines = [];
|
|
2451
|
+
lines.push("# HELP layercache_hits_total Total number of cache hits");
|
|
2452
|
+
lines.push("# TYPE layercache_hits_total counter");
|
|
2453
|
+
lines.push("# HELP layercache_misses_total Total number of cache misses");
|
|
2454
|
+
lines.push("# TYPE layercache_misses_total counter");
|
|
2455
|
+
lines.push("# HELP layercache_fetches_total Total fetcher invocations (full misses)");
|
|
2456
|
+
lines.push("# TYPE layercache_fetches_total counter");
|
|
2457
|
+
lines.push("# HELP layercache_sets_total Total number of cache sets");
|
|
2458
|
+
lines.push("# TYPE layercache_sets_total counter");
|
|
2459
|
+
lines.push("# HELP layercache_deletes_total Total number of cache deletes");
|
|
2460
|
+
lines.push("# TYPE layercache_deletes_total counter");
|
|
2461
|
+
lines.push("# HELP layercache_backfills_total Total number of backfill operations");
|
|
2462
|
+
lines.push("# TYPE layercache_backfills_total counter");
|
|
2463
|
+
lines.push("# HELP layercache_stale_hits_total Total number of stale hits served");
|
|
2464
|
+
lines.push("# TYPE layercache_stale_hits_total counter");
|
|
2465
|
+
lines.push("# HELP layercache_refreshes_total Background refreshes triggered");
|
|
2466
|
+
lines.push("# TYPE layercache_refreshes_total counter");
|
|
2467
|
+
lines.push("# HELP layercache_refresh_errors_total Background refresh errors");
|
|
2468
|
+
lines.push("# TYPE layercache_refresh_errors_total counter");
|
|
2469
|
+
lines.push("# HELP layercache_negative_cache_hits_total Negative cache hits");
|
|
2470
|
+
lines.push("# TYPE layercache_negative_cache_hits_total counter");
|
|
2471
|
+
lines.push("# HELP layercache_circuit_breaker_trips_total Circuit breaker trips");
|
|
2472
|
+
lines.push("# TYPE layercache_circuit_breaker_trips_total counter");
|
|
2473
|
+
lines.push("# HELP layercache_degraded_operations_total Operations run in degraded mode");
|
|
2474
|
+
lines.push("# TYPE layercache_degraded_operations_total counter");
|
|
2475
|
+
lines.push("# HELP layercache_hit_rate Overall cache hit rate (0-1)");
|
|
2476
|
+
lines.push("# TYPE layercache_hit_rate gauge");
|
|
2477
|
+
lines.push("# HELP layercache_hits_by_layer_total Hits broken down by layer");
|
|
2478
|
+
lines.push("# TYPE layercache_hits_by_layer_total counter");
|
|
2479
|
+
lines.push("# HELP layercache_misses_by_layer_total Misses broken down by layer");
|
|
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");
|
|
2487
|
+
for (const { stack, name } of entries) {
|
|
2488
|
+
const m = stack.getMetrics();
|
|
2489
|
+
const hr = stack.getHitRate();
|
|
2490
|
+
const label = `cache="${sanitizeLabel(name)}"`;
|
|
2491
|
+
lines.push(`layercache_hits_total{${label}} ${m.hits}`);
|
|
2492
|
+
lines.push(`layercache_misses_total{${label}} ${m.misses}`);
|
|
2493
|
+
lines.push(`layercache_fetches_total{${label}} ${m.fetches}`);
|
|
2494
|
+
lines.push(`layercache_sets_total{${label}} ${m.sets}`);
|
|
2495
|
+
lines.push(`layercache_deletes_total{${label}} ${m.deletes}`);
|
|
2496
|
+
lines.push(`layercache_backfills_total{${label}} ${m.backfills}`);
|
|
2497
|
+
lines.push(`layercache_stale_hits_total{${label}} ${m.staleHits}`);
|
|
2498
|
+
lines.push(`layercache_refreshes_total{${label}} ${m.refreshes}`);
|
|
2499
|
+
lines.push(`layercache_refresh_errors_total{${label}} ${m.refreshErrors}`);
|
|
2500
|
+
lines.push(`layercache_negative_cache_hits_total{${label}} ${m.negativeCacheHits}`);
|
|
2501
|
+
lines.push(`layercache_circuit_breaker_trips_total{${label}} ${m.circuitBreakerTrips}`);
|
|
2502
|
+
lines.push(`layercache_degraded_operations_total{${label}} ${m.degradedOperations}`);
|
|
2503
|
+
lines.push(`layercache_hit_rate{${label}} ${hr.overall.toFixed(6)}`);
|
|
2504
|
+
for (const [layerName, count] of Object.entries(m.hitsByLayer)) {
|
|
2505
|
+
lines.push(`layercache_hits_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
|
|
2506
|
+
}
|
|
2507
|
+
for (const [layerName, count] of Object.entries(m.missesByLayer)) {
|
|
2508
|
+
lines.push(`layercache_misses_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
|
|
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
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
lines.push("");
|
|
2518
|
+
return lines.join("\n");
|
|
2519
|
+
};
|
|
2520
|
+
}
|
|
2521
|
+
function sanitizeLabel(value) {
|
|
2522
|
+
return value.replace(/["\\\n]/g, "_");
|
|
2523
|
+
}
|
|
1619
2524
|
export {
|
|
2525
|
+
CacheMissError,
|
|
1620
2526
|
CacheNamespace,
|
|
1621
2527
|
CacheStack,
|
|
2528
|
+
DiskLayer,
|
|
1622
2529
|
JsonSerializer,
|
|
2530
|
+
MemcachedLayer,
|
|
1623
2531
|
MemoryLayer,
|
|
1624
2532
|
MsgpackSerializer,
|
|
1625
2533
|
PatternMatcher,
|
|
@@ -1632,6 +2540,8 @@ export {
|
|
|
1632
2540
|
cacheGraphqlResolver,
|
|
1633
2541
|
createCacheStatsHandler,
|
|
1634
2542
|
createCachedMethodDecorator,
|
|
2543
|
+
createExpressCacheMiddleware,
|
|
1635
2544
|
createFastifyLayercachePlugin,
|
|
2545
|
+
createPrometheusMetricsExporter,
|
|
1636
2546
|
createTrpcCacheMiddleware
|
|
1637
2547
|
};
|