layercache 1.0.2 → 1.1.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/benchmarks/latency.ts +1 -1
- package/benchmarks/stampede.ts +1 -4
- package/dist/{chunk-IILH5XTS.js → chunk-QUB5VZFZ.js} +33 -4
- package/dist/cli.cjs +75 -7
- package/dist/cli.js +43 -4
- package/dist/index.cjs +894 -240
- package/dist/index.d.cts +291 -11
- package/dist/index.d.ts +291 -11
- package/dist/index.js +858 -236
- 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 +552 -220
- package/packages/nestjs/dist/index.d.cts +151 -10
- package/packages/nestjs/dist/index.d.ts +151 -10
- package/packages/nestjs/dist/index.js +552 -220
package/dist/index.cjs
CHANGED
|
@@ -22,7 +22,9 @@ var index_exports = {};
|
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
CacheNamespace: () => CacheNamespace,
|
|
24
24
|
CacheStack: () => CacheStack,
|
|
25
|
+
DiskLayer: () => DiskLayer,
|
|
25
26
|
JsonSerializer: () => JsonSerializer,
|
|
27
|
+
MemcachedLayer: () => MemcachedLayer,
|
|
26
28
|
MemoryLayer: () => MemoryLayer,
|
|
27
29
|
MsgpackSerializer: () => MsgpackSerializer,
|
|
28
30
|
PatternMatcher: () => PatternMatcher,
|
|
@@ -36,14 +38,236 @@ __export(index_exports, {
|
|
|
36
38
|
createCacheStatsHandler: () => createCacheStatsHandler,
|
|
37
39
|
createCachedMethodDecorator: () => createCachedMethodDecorator,
|
|
38
40
|
createFastifyLayercachePlugin: () => createFastifyLayercachePlugin,
|
|
41
|
+
createPrometheusMetricsExporter: () => createPrometheusMetricsExporter,
|
|
39
42
|
createTrpcCacheMiddleware: () => createTrpcCacheMiddleware
|
|
40
43
|
});
|
|
41
44
|
module.exports = __toCommonJS(index_exports);
|
|
42
45
|
|
|
43
46
|
// src/CacheStack.ts
|
|
44
47
|
var import_node_crypto = require("crypto");
|
|
45
|
-
var import_node_fs = require("fs");
|
|
46
48
|
var import_node_events = require("events");
|
|
49
|
+
var import_node_fs = require("fs");
|
|
50
|
+
|
|
51
|
+
// src/CacheNamespace.ts
|
|
52
|
+
var CacheNamespace = class {
|
|
53
|
+
constructor(cache, prefix) {
|
|
54
|
+
this.cache = cache;
|
|
55
|
+
this.prefix = prefix;
|
|
56
|
+
}
|
|
57
|
+
cache;
|
|
58
|
+
prefix;
|
|
59
|
+
async get(key, fetcher, options) {
|
|
60
|
+
return this.cache.get(this.qualify(key), fetcher, options);
|
|
61
|
+
}
|
|
62
|
+
async getOrSet(key, fetcher, options) {
|
|
63
|
+
return this.cache.getOrSet(this.qualify(key), fetcher, options);
|
|
64
|
+
}
|
|
65
|
+
async has(key) {
|
|
66
|
+
return this.cache.has(this.qualify(key));
|
|
67
|
+
}
|
|
68
|
+
async ttl(key) {
|
|
69
|
+
return this.cache.ttl(this.qualify(key));
|
|
70
|
+
}
|
|
71
|
+
async set(key, value, options) {
|
|
72
|
+
await this.cache.set(this.qualify(key), value, options);
|
|
73
|
+
}
|
|
74
|
+
async delete(key) {
|
|
75
|
+
await this.cache.delete(this.qualify(key));
|
|
76
|
+
}
|
|
77
|
+
async mdelete(keys) {
|
|
78
|
+
await this.cache.mdelete(keys.map((k) => this.qualify(k)));
|
|
79
|
+
}
|
|
80
|
+
async clear() {
|
|
81
|
+
await this.cache.invalidateByPattern(`${this.prefix}:*`);
|
|
82
|
+
}
|
|
83
|
+
async mget(entries) {
|
|
84
|
+
return this.cache.mget(
|
|
85
|
+
entries.map((entry) => ({
|
|
86
|
+
...entry,
|
|
87
|
+
key: this.qualify(entry.key)
|
|
88
|
+
}))
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
async mset(entries) {
|
|
92
|
+
await this.cache.mset(
|
|
93
|
+
entries.map((entry) => ({
|
|
94
|
+
...entry,
|
|
95
|
+
key: this.qualify(entry.key)
|
|
96
|
+
}))
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
async invalidateByTag(tag) {
|
|
100
|
+
await this.cache.invalidateByTag(tag);
|
|
101
|
+
}
|
|
102
|
+
async invalidateByPattern(pattern) {
|
|
103
|
+
await this.cache.invalidateByPattern(this.qualify(pattern));
|
|
104
|
+
}
|
|
105
|
+
wrap(keyPrefix, fetcher, options) {
|
|
106
|
+
return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
|
|
107
|
+
}
|
|
108
|
+
warm(entries, options) {
|
|
109
|
+
return this.cache.warm(
|
|
110
|
+
entries.map((entry) => ({
|
|
111
|
+
...entry,
|
|
112
|
+
key: this.qualify(entry.key)
|
|
113
|
+
})),
|
|
114
|
+
options
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
getMetrics() {
|
|
118
|
+
return this.cache.getMetrics();
|
|
119
|
+
}
|
|
120
|
+
getHitRate() {
|
|
121
|
+
return this.cache.getHitRate();
|
|
122
|
+
}
|
|
123
|
+
qualify(key) {
|
|
124
|
+
return `${this.prefix}:${key}`;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// src/internal/CircuitBreakerManager.ts
|
|
129
|
+
var CircuitBreakerManager = class {
|
|
130
|
+
breakers = /* @__PURE__ */ new Map();
|
|
131
|
+
maxEntries;
|
|
132
|
+
constructor(options) {
|
|
133
|
+
this.maxEntries = options.maxEntries;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Throws if the circuit is open for the given key.
|
|
137
|
+
* Automatically resets if the cooldown has elapsed.
|
|
138
|
+
*/
|
|
139
|
+
assertClosed(key, options) {
|
|
140
|
+
const state = this.breakers.get(key);
|
|
141
|
+
if (!state?.openUntil) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
if (state.openUntil <= now) {
|
|
146
|
+
state.openUntil = null;
|
|
147
|
+
state.failures = 0;
|
|
148
|
+
this.breakers.set(key, state);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const remainingMs = state.openUntil - now;
|
|
152
|
+
const remainingSecs = Math.ceil(remainingMs / 1e3);
|
|
153
|
+
throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
|
|
154
|
+
}
|
|
155
|
+
recordFailure(key, options) {
|
|
156
|
+
if (!options) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const failureThreshold = options.failureThreshold ?? 3;
|
|
160
|
+
const cooldownMs = options.cooldownMs ?? 3e4;
|
|
161
|
+
const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
|
|
162
|
+
state.failures += 1;
|
|
163
|
+
if (state.failures >= failureThreshold) {
|
|
164
|
+
state.openUntil = Date.now() + cooldownMs;
|
|
165
|
+
}
|
|
166
|
+
this.breakers.set(key, state);
|
|
167
|
+
this.pruneIfNeeded();
|
|
168
|
+
}
|
|
169
|
+
recordSuccess(key) {
|
|
170
|
+
this.breakers.delete(key);
|
|
171
|
+
}
|
|
172
|
+
isOpen(key) {
|
|
173
|
+
const state = this.breakers.get(key);
|
|
174
|
+
if (!state?.openUntil) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (state.openUntil <= Date.now()) {
|
|
178
|
+
state.openUntil = null;
|
|
179
|
+
state.failures = 0;
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
delete(key) {
|
|
185
|
+
this.breakers.delete(key);
|
|
186
|
+
}
|
|
187
|
+
clear() {
|
|
188
|
+
this.breakers.clear();
|
|
189
|
+
}
|
|
190
|
+
tripCount() {
|
|
191
|
+
let count = 0;
|
|
192
|
+
for (const state of this.breakers.values()) {
|
|
193
|
+
if (state.openUntil !== null) {
|
|
194
|
+
count += 1;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return count;
|
|
198
|
+
}
|
|
199
|
+
pruneIfNeeded() {
|
|
200
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
for (const [key, state] of this.breakers.entries()) {
|
|
204
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
if (!state.openUntil || state.openUntil <= Date.now()) {
|
|
208
|
+
this.breakers.delete(key);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const key of this.breakers.keys()) {
|
|
212
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
this.breakers.delete(key);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// src/internal/MetricsCollector.ts
|
|
221
|
+
var MetricsCollector = class {
|
|
222
|
+
data = this.empty();
|
|
223
|
+
get snapshot() {
|
|
224
|
+
return { ...this.data };
|
|
225
|
+
}
|
|
226
|
+
increment(field, amount = 1) {
|
|
227
|
+
;
|
|
228
|
+
this.data[field] += amount;
|
|
229
|
+
}
|
|
230
|
+
incrementLayer(map, layerName) {
|
|
231
|
+
this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
|
|
232
|
+
}
|
|
233
|
+
reset() {
|
|
234
|
+
this.data = this.empty();
|
|
235
|
+
}
|
|
236
|
+
hitRate() {
|
|
237
|
+
const total = this.data.hits + this.data.misses;
|
|
238
|
+
const overall = total === 0 ? 0 : this.data.hits / total;
|
|
239
|
+
const byLayer = {};
|
|
240
|
+
const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
|
|
241
|
+
for (const layer of allLayers) {
|
|
242
|
+
const h = this.data.hitsByLayer[layer] ?? 0;
|
|
243
|
+
const m = this.data.missesByLayer[layer] ?? 0;
|
|
244
|
+
byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
|
|
245
|
+
}
|
|
246
|
+
return { overall, byLayer };
|
|
247
|
+
}
|
|
248
|
+
empty() {
|
|
249
|
+
return {
|
|
250
|
+
hits: 0,
|
|
251
|
+
misses: 0,
|
|
252
|
+
fetches: 0,
|
|
253
|
+
sets: 0,
|
|
254
|
+
deletes: 0,
|
|
255
|
+
backfills: 0,
|
|
256
|
+
invalidations: 0,
|
|
257
|
+
staleHits: 0,
|
|
258
|
+
refreshes: 0,
|
|
259
|
+
refreshErrors: 0,
|
|
260
|
+
writeFailures: 0,
|
|
261
|
+
singleFlightWaits: 0,
|
|
262
|
+
negativeCacheHits: 0,
|
|
263
|
+
circuitBreakerTrips: 0,
|
|
264
|
+
degradedOperations: 0,
|
|
265
|
+
hitsByLayer: {},
|
|
266
|
+
missesByLayer: {},
|
|
267
|
+
resetAt: Date.now()
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
};
|
|
47
271
|
|
|
48
272
|
// src/internal/StoredValue.ts
|
|
49
273
|
function isStoredValueEnvelope(value) {
|
|
@@ -146,67 +370,129 @@ function normalizePositiveSeconds(value) {
|
|
|
146
370
|
return value;
|
|
147
371
|
}
|
|
148
372
|
|
|
149
|
-
// src/
|
|
150
|
-
var
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
prefix;
|
|
157
|
-
async get(key, fetcher, options) {
|
|
158
|
-
return this.cache.get(this.qualify(key), fetcher, options);
|
|
159
|
-
}
|
|
160
|
-
async set(key, value, options) {
|
|
161
|
-
await this.cache.set(this.qualify(key), value, options);
|
|
162
|
-
}
|
|
163
|
-
async delete(key) {
|
|
164
|
-
await this.cache.delete(this.qualify(key));
|
|
373
|
+
// src/internal/TtlResolver.ts
|
|
374
|
+
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
375
|
+
var TtlResolver = class {
|
|
376
|
+
accessProfiles = /* @__PURE__ */ new Map();
|
|
377
|
+
maxProfileEntries;
|
|
378
|
+
constructor(options) {
|
|
379
|
+
this.maxProfileEntries = options.maxProfileEntries;
|
|
165
380
|
}
|
|
166
|
-
|
|
167
|
-
|
|
381
|
+
recordAccess(key) {
|
|
382
|
+
const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
|
|
383
|
+
profile.hits += 1;
|
|
384
|
+
profile.lastAccessAt = Date.now();
|
|
385
|
+
this.accessProfiles.set(key, profile);
|
|
386
|
+
this.pruneIfNeeded();
|
|
168
387
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
...entry,
|
|
172
|
-
key: this.qualify(entry.key)
|
|
173
|
-
})));
|
|
388
|
+
deleteProfile(key) {
|
|
389
|
+
this.accessProfiles.delete(key);
|
|
174
390
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
...entry,
|
|
178
|
-
key: this.qualify(entry.key)
|
|
179
|
-
})));
|
|
391
|
+
clearProfiles() {
|
|
392
|
+
this.accessProfiles.clear();
|
|
180
393
|
}
|
|
181
|
-
|
|
182
|
-
|
|
394
|
+
resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
|
|
395
|
+
const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
|
|
396
|
+
layerName,
|
|
397
|
+
options?.negativeTtl,
|
|
398
|
+
globalNegativeTtl,
|
|
399
|
+
this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
400
|
+
) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
|
|
401
|
+
const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
|
|
402
|
+
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
|
|
403
|
+
return this.applyJitter(adaptiveTtl, jitter);
|
|
183
404
|
}
|
|
184
|
-
|
|
185
|
-
|
|
405
|
+
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
406
|
+
if (override !== void 0) {
|
|
407
|
+
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
408
|
+
}
|
|
409
|
+
if (globalDefault !== void 0) {
|
|
410
|
+
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
411
|
+
}
|
|
412
|
+
return fallback;
|
|
186
413
|
}
|
|
187
|
-
|
|
188
|
-
|
|
414
|
+
applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
|
|
415
|
+
if (!ttl || !adaptiveTtl) {
|
|
416
|
+
return ttl;
|
|
417
|
+
}
|
|
418
|
+
const profile = this.accessProfiles.get(key);
|
|
419
|
+
if (!profile) {
|
|
420
|
+
return ttl;
|
|
421
|
+
}
|
|
422
|
+
const config = adaptiveTtl === true ? {} : adaptiveTtl;
|
|
423
|
+
const hotAfter = config.hotAfter ?? 3;
|
|
424
|
+
if (profile.hits < hotAfter) {
|
|
425
|
+
return ttl;
|
|
426
|
+
}
|
|
427
|
+
const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
|
|
428
|
+
const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
|
|
429
|
+
const multiplier = Math.floor(profile.hits / hotAfter);
|
|
430
|
+
return Math.min(maxTtl, ttl + step * multiplier);
|
|
189
431
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
432
|
+
applyJitter(ttl, jitter) {
|
|
433
|
+
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
434
|
+
return ttl;
|
|
435
|
+
}
|
|
436
|
+
const delta = (Math.random() * 2 - 1) * jitter;
|
|
437
|
+
return Math.max(1, Math.round(ttl + delta));
|
|
195
438
|
}
|
|
196
|
-
|
|
197
|
-
|
|
439
|
+
readLayerNumber(layerName, value) {
|
|
440
|
+
if (typeof value === "number") {
|
|
441
|
+
return value;
|
|
442
|
+
}
|
|
443
|
+
return value[layerName];
|
|
198
444
|
}
|
|
199
|
-
|
|
200
|
-
|
|
445
|
+
pruneIfNeeded() {
|
|
446
|
+
if (this.accessProfiles.size <= this.maxProfileEntries) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
|
|
450
|
+
let removed = 0;
|
|
451
|
+
for (const key of this.accessProfiles.keys()) {
|
|
452
|
+
if (removed >= toRemove) {
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
this.accessProfiles.delete(key);
|
|
456
|
+
removed += 1;
|
|
457
|
+
}
|
|
201
458
|
}
|
|
202
459
|
};
|
|
203
460
|
|
|
204
461
|
// src/invalidation/PatternMatcher.ts
|
|
205
|
-
var PatternMatcher = class {
|
|
462
|
+
var PatternMatcher = class _PatternMatcher {
|
|
463
|
+
/**
|
|
464
|
+
* Tests whether a glob-style pattern matches a value.
|
|
465
|
+
* Supports `*` (any sequence of characters) and `?` (any single character).
|
|
466
|
+
* Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
|
|
467
|
+
*/
|
|
206
468
|
static matches(pattern, value) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
469
|
+
return _PatternMatcher.matchLinear(pattern, value);
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Linear-time glob matching using dynamic programming.
|
|
473
|
+
* Avoids catastrophic backtracking that RegExp-based glob matching can cause.
|
|
474
|
+
*/
|
|
475
|
+
static matchLinear(pattern, value) {
|
|
476
|
+
const m = pattern.length;
|
|
477
|
+
const n = value.length;
|
|
478
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
|
|
479
|
+
dp[0][0] = true;
|
|
480
|
+
for (let i = 1; i <= m; i++) {
|
|
481
|
+
if (pattern[i - 1] === "*") {
|
|
482
|
+
dp[i][0] = dp[i - 1]?.[0];
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
for (let i = 1; i <= m; i++) {
|
|
486
|
+
for (let j = 1; j <= n; j++) {
|
|
487
|
+
const pc = pattern[i - 1];
|
|
488
|
+
if (pc === "*") {
|
|
489
|
+
dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
|
|
490
|
+
} else if (pc === "?" || pc === value[j - 1]) {
|
|
491
|
+
dp[i][j] = dp[i - 1]?.[j - 1];
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return dp[m]?.[n];
|
|
210
496
|
}
|
|
211
497
|
};
|
|
212
498
|
|
|
@@ -295,30 +581,11 @@ var StampedeGuard = class {
|
|
|
295
581
|
};
|
|
296
582
|
|
|
297
583
|
// src/CacheStack.ts
|
|
298
|
-
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
299
584
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
300
585
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
301
586
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
302
587
|
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
303
|
-
var
|
|
304
|
-
hits: 0,
|
|
305
|
-
misses: 0,
|
|
306
|
-
fetches: 0,
|
|
307
|
-
sets: 0,
|
|
308
|
-
deletes: 0,
|
|
309
|
-
backfills: 0,
|
|
310
|
-
invalidations: 0,
|
|
311
|
-
staleHits: 0,
|
|
312
|
-
refreshes: 0,
|
|
313
|
-
refreshErrors: 0,
|
|
314
|
-
writeFailures: 0,
|
|
315
|
-
singleFlightWaits: 0,
|
|
316
|
-
negativeCacheHits: 0,
|
|
317
|
-
circuitBreakerTrips: 0,
|
|
318
|
-
degradedOperations: 0,
|
|
319
|
-
hitsByLayer: {},
|
|
320
|
-
missesByLayer: {}
|
|
321
|
-
});
|
|
588
|
+
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
322
589
|
var DebugLogger = class {
|
|
323
590
|
enabled;
|
|
324
591
|
constructor(enabled) {
|
|
@@ -353,6 +620,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
353
620
|
throw new Error("CacheStack requires at least one cache layer.");
|
|
354
621
|
}
|
|
355
622
|
this.validateConfiguration();
|
|
623
|
+
const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
|
|
624
|
+
this.ttlResolver = new TtlResolver({ maxProfileEntries });
|
|
625
|
+
this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
|
|
626
|
+
if (options.publishSetInvalidation !== void 0) {
|
|
627
|
+
console.warn(
|
|
628
|
+
"[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
|
|
629
|
+
);
|
|
630
|
+
}
|
|
356
631
|
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
357
632
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
358
633
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
@@ -361,36 +636,42 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
361
636
|
layers;
|
|
362
637
|
options;
|
|
363
638
|
stampedeGuard = new StampedeGuard();
|
|
364
|
-
|
|
639
|
+
metricsCollector = new MetricsCollector();
|
|
365
640
|
instanceId = (0, import_node_crypto.randomUUID)();
|
|
366
641
|
startup;
|
|
367
642
|
unsubscribeInvalidation;
|
|
368
643
|
logger;
|
|
369
644
|
tagIndex;
|
|
370
645
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
371
|
-
accessProfiles = /* @__PURE__ */ new Map();
|
|
372
646
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
373
|
-
|
|
647
|
+
ttlResolver;
|
|
648
|
+
circuitBreakerManager;
|
|
374
649
|
isDisconnecting = false;
|
|
375
650
|
disconnectPromise;
|
|
651
|
+
/**
|
|
652
|
+
* Read-through cache get.
|
|
653
|
+
* Returns the cached value if present and fresh, or invokes `fetcher` on a miss
|
|
654
|
+
* and stores the result across all layers. Returns `null` if the key is not found
|
|
655
|
+
* and no `fetcher` is provided.
|
|
656
|
+
*/
|
|
376
657
|
async get(key, fetcher, options) {
|
|
377
658
|
const normalizedKey = this.validateCacheKey(key);
|
|
378
659
|
this.validateWriteOptions(options);
|
|
379
660
|
await this.startup;
|
|
380
661
|
const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
|
|
381
662
|
if (hit.found) {
|
|
382
|
-
this.recordAccess(normalizedKey);
|
|
663
|
+
this.ttlResolver.recordAccess(normalizedKey);
|
|
383
664
|
if (this.isNegativeStoredValue(hit.stored)) {
|
|
384
|
-
this.
|
|
665
|
+
this.metricsCollector.increment("negativeCacheHits");
|
|
385
666
|
}
|
|
386
667
|
if (hit.state === "fresh") {
|
|
387
|
-
this.
|
|
668
|
+
this.metricsCollector.increment("hits");
|
|
388
669
|
await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
|
|
389
670
|
return hit.value;
|
|
390
671
|
}
|
|
391
672
|
if (hit.state === "stale-while-revalidate") {
|
|
392
|
-
this.
|
|
393
|
-
this.
|
|
673
|
+
this.metricsCollector.increment("hits");
|
|
674
|
+
this.metricsCollector.increment("staleHits");
|
|
394
675
|
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
395
676
|
if (fetcher) {
|
|
396
677
|
this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
|
|
@@ -398,47 +679,136 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
398
679
|
return hit.value;
|
|
399
680
|
}
|
|
400
681
|
if (!fetcher) {
|
|
401
|
-
this.
|
|
402
|
-
this.
|
|
682
|
+
this.metricsCollector.increment("hits");
|
|
683
|
+
this.metricsCollector.increment("staleHits");
|
|
403
684
|
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
404
685
|
return hit.value;
|
|
405
686
|
}
|
|
406
687
|
try {
|
|
407
688
|
return await this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
408
689
|
} catch (error) {
|
|
409
|
-
this.
|
|
410
|
-
this.
|
|
690
|
+
this.metricsCollector.increment("staleHits");
|
|
691
|
+
this.metricsCollector.increment("refreshErrors");
|
|
411
692
|
this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
|
|
412
693
|
return hit.value;
|
|
413
694
|
}
|
|
414
695
|
}
|
|
415
|
-
this.
|
|
696
|
+
this.metricsCollector.increment("misses");
|
|
416
697
|
if (!fetcher) {
|
|
417
698
|
return null;
|
|
418
699
|
}
|
|
419
700
|
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
420
701
|
}
|
|
702
|
+
/**
|
|
703
|
+
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
704
|
+
* Fetches and caches the value if not already present.
|
|
705
|
+
*/
|
|
706
|
+
async getOrSet(key, fetcher, options) {
|
|
707
|
+
return this.get(key, fetcher, options);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Returns true if the given key exists and is not expired in any layer.
|
|
711
|
+
*/
|
|
712
|
+
async has(key) {
|
|
713
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
714
|
+
await this.startup;
|
|
715
|
+
for (const layer of this.layers) {
|
|
716
|
+
if (this.shouldSkipLayer(layer)) {
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
if (layer.has) {
|
|
720
|
+
try {
|
|
721
|
+
const exists = await layer.has(normalizedKey);
|
|
722
|
+
if (exists) {
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
} catch {
|
|
726
|
+
}
|
|
727
|
+
} else {
|
|
728
|
+
try {
|
|
729
|
+
const value = await layer.get(normalizedKey);
|
|
730
|
+
if (value !== null) {
|
|
731
|
+
return true;
|
|
732
|
+
}
|
|
733
|
+
} catch {
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return false;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Returns the remaining TTL in seconds for the key in the fastest layer
|
|
741
|
+
* that has it, or null if the key is not found / has no TTL.
|
|
742
|
+
*/
|
|
743
|
+
async ttl(key) {
|
|
744
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
745
|
+
await this.startup;
|
|
746
|
+
for (const layer of this.layers) {
|
|
747
|
+
if (this.shouldSkipLayer(layer)) {
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
if (layer.ttl) {
|
|
751
|
+
try {
|
|
752
|
+
const remaining = await layer.ttl(normalizedKey);
|
|
753
|
+
if (remaining !== null) {
|
|
754
|
+
return remaining;
|
|
755
|
+
}
|
|
756
|
+
} catch {
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Stores a value in all cache layers. Overwrites any existing value.
|
|
764
|
+
*/
|
|
421
765
|
async set(key, value, options) {
|
|
422
766
|
const normalizedKey = this.validateCacheKey(key);
|
|
423
767
|
this.validateWriteOptions(options);
|
|
424
768
|
await this.startup;
|
|
425
769
|
await this.storeEntry(normalizedKey, "value", value, options);
|
|
426
770
|
}
|
|
771
|
+
/**
|
|
772
|
+
* Deletes the key from all layers and publishes an invalidation message.
|
|
773
|
+
*/
|
|
427
774
|
async delete(key) {
|
|
428
775
|
const normalizedKey = this.validateCacheKey(key);
|
|
429
776
|
await this.startup;
|
|
430
777
|
await this.deleteKeys([normalizedKey]);
|
|
431
|
-
await this.publishInvalidation({
|
|
778
|
+
await this.publishInvalidation({
|
|
779
|
+
scope: "key",
|
|
780
|
+
keys: [normalizedKey],
|
|
781
|
+
sourceId: this.instanceId,
|
|
782
|
+
operation: "delete"
|
|
783
|
+
});
|
|
432
784
|
}
|
|
433
785
|
async clear() {
|
|
434
786
|
await this.startup;
|
|
435
787
|
await Promise.all(this.layers.map((layer) => layer.clear()));
|
|
436
788
|
await this.tagIndex.clear();
|
|
437
|
-
this.
|
|
438
|
-
this.
|
|
789
|
+
this.ttlResolver.clearProfiles();
|
|
790
|
+
this.circuitBreakerManager.clear();
|
|
791
|
+
this.metricsCollector.increment("invalidations");
|
|
439
792
|
this.logger.debug?.("clear");
|
|
440
793
|
await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
|
|
441
794
|
}
|
|
795
|
+
/**
|
|
796
|
+
* Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
|
|
797
|
+
*/
|
|
798
|
+
async mdelete(keys) {
|
|
799
|
+
if (keys.length === 0) {
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
await this.startup;
|
|
803
|
+
const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
|
|
804
|
+
await this.deleteKeys(normalizedKeys);
|
|
805
|
+
await this.publishInvalidation({
|
|
806
|
+
scope: "keys",
|
|
807
|
+
keys: normalizedKeys,
|
|
808
|
+
sourceId: this.instanceId,
|
|
809
|
+
operation: "delete"
|
|
810
|
+
});
|
|
811
|
+
}
|
|
442
812
|
async mget(entries) {
|
|
443
813
|
if (entries.length === 0) {
|
|
444
814
|
return [];
|
|
@@ -476,7 +846,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
476
846
|
const indexesByKey = /* @__PURE__ */ new Map();
|
|
477
847
|
const resultsByKey = /* @__PURE__ */ new Map();
|
|
478
848
|
for (let index = 0; index < normalizedEntries.length; index += 1) {
|
|
479
|
-
const
|
|
849
|
+
const entry = normalizedEntries[index];
|
|
850
|
+
if (!entry) continue;
|
|
851
|
+
const key = entry.key;
|
|
480
852
|
const indexes = indexesByKey.get(key) ?? [];
|
|
481
853
|
indexes.push(index);
|
|
482
854
|
indexesByKey.set(key, indexes);
|
|
@@ -484,6 +856,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
484
856
|
}
|
|
485
857
|
for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
|
|
486
858
|
const layer = this.layers[layerIndex];
|
|
859
|
+
if (!layer) continue;
|
|
487
860
|
const keys = [...pending];
|
|
488
861
|
if (keys.length === 0) {
|
|
489
862
|
break;
|
|
@@ -492,7 +865,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
492
865
|
for (let offset = 0; offset < values.length; offset += 1) {
|
|
493
866
|
const key = keys[offset];
|
|
494
867
|
const stored = values[offset];
|
|
495
|
-
if (stored === null) {
|
|
868
|
+
if (!key || stored === null) {
|
|
496
869
|
continue;
|
|
497
870
|
}
|
|
498
871
|
const resolved = resolveStoredValue(stored);
|
|
@@ -504,13 +877,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
504
877
|
await this.backfill(key, stored, layerIndex - 1);
|
|
505
878
|
resultsByKey.set(key, resolved.value);
|
|
506
879
|
pending.delete(key);
|
|
507
|
-
this.
|
|
880
|
+
this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
|
|
508
881
|
}
|
|
509
882
|
}
|
|
510
883
|
if (pending.size > 0) {
|
|
511
884
|
for (const key of pending) {
|
|
512
885
|
await this.tagIndex.remove(key);
|
|
513
|
-
this.
|
|
886
|
+
this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
|
|
514
887
|
}
|
|
515
888
|
}
|
|
516
889
|
return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
|
|
@@ -525,26 +898,38 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
525
898
|
}
|
|
526
899
|
async warm(entries, options = {}) {
|
|
527
900
|
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
901
|
+
const total = entries.length;
|
|
902
|
+
let completed = 0;
|
|
528
903
|
const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
|
|
529
|
-
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
904
|
+
const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
|
|
530
905
|
while (queue.length > 0) {
|
|
531
906
|
const entry = queue.shift();
|
|
532
907
|
if (!entry) {
|
|
533
908
|
return;
|
|
534
909
|
}
|
|
910
|
+
let success = false;
|
|
535
911
|
try {
|
|
536
912
|
await this.get(entry.key, entry.fetcher, entry.options);
|
|
537
913
|
this.emit("warm", { key: entry.key });
|
|
914
|
+
success = true;
|
|
538
915
|
} catch (error) {
|
|
539
916
|
this.emitError("warm", { key: entry.key, error: this.formatError(error) });
|
|
540
917
|
if (!options.continueOnError) {
|
|
541
918
|
throw error;
|
|
542
919
|
}
|
|
920
|
+
} finally {
|
|
921
|
+
completed += 1;
|
|
922
|
+
const progress = { completed, total, key: entry.key, success };
|
|
923
|
+
options.onProgress?.(progress);
|
|
543
924
|
}
|
|
544
925
|
}
|
|
545
926
|
});
|
|
546
927
|
await Promise.all(workers);
|
|
547
928
|
}
|
|
929
|
+
/**
|
|
930
|
+
* Returns a cached version of `fetcher`. The cache key is derived from
|
|
931
|
+
* `prefix` plus the serialized arguments unless a `keyResolver` is provided.
|
|
932
|
+
*/
|
|
548
933
|
wrap(prefix, fetcher, options = {}) {
|
|
549
934
|
return (...args) => {
|
|
550
935
|
const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
|
|
@@ -552,6 +937,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
552
937
|
return this.get(key, () => fetcher(...args), options);
|
|
553
938
|
};
|
|
554
939
|
}
|
|
940
|
+
/**
|
|
941
|
+
* Creates a `CacheNamespace` that automatically prefixes all keys with
|
|
942
|
+
* `prefix:`. Useful for multi-tenant or module-level isolation.
|
|
943
|
+
*/
|
|
555
944
|
namespace(prefix) {
|
|
556
945
|
return new CacheNamespace(this, prefix);
|
|
557
946
|
}
|
|
@@ -568,7 +957,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
568
957
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
569
958
|
}
|
|
570
959
|
getMetrics() {
|
|
571
|
-
return
|
|
960
|
+
return this.metricsCollector.snapshot;
|
|
572
961
|
}
|
|
573
962
|
getStats() {
|
|
574
963
|
return {
|
|
@@ -582,7 +971,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
582
971
|
};
|
|
583
972
|
}
|
|
584
973
|
resetMetrics() {
|
|
585
|
-
|
|
974
|
+
this.metricsCollector.reset();
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Returns computed hit-rate statistics (overall and per-layer).
|
|
978
|
+
*/
|
|
979
|
+
getHitRate() {
|
|
980
|
+
return this.metricsCollector.hitRate();
|
|
586
981
|
}
|
|
587
982
|
async exportState() {
|
|
588
983
|
await this.startup;
|
|
@@ -611,10 +1006,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
611
1006
|
}
|
|
612
1007
|
async importState(entries) {
|
|
613
1008
|
await this.startup;
|
|
614
|
-
await Promise.all(
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
1009
|
+
await Promise.all(
|
|
1010
|
+
entries.map(async (entry) => {
|
|
1011
|
+
await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
|
|
1012
|
+
await this.tagIndex.touch(entry.key);
|
|
1013
|
+
})
|
|
1014
|
+
);
|
|
618
1015
|
}
|
|
619
1016
|
async persistToFile(filePath) {
|
|
620
1017
|
const snapshot = await this.exportState();
|
|
@@ -622,11 +1019,21 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
622
1019
|
}
|
|
623
1020
|
async restoreFromFile(filePath) {
|
|
624
1021
|
const raw = await import_node_fs.promises.readFile(filePath, "utf8");
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1022
|
+
let parsed;
|
|
1023
|
+
try {
|
|
1024
|
+
parsed = JSON.parse(raw, (_key, value) => {
|
|
1025
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
1026
|
+
return Object.assign(/* @__PURE__ */ Object.create(null), value);
|
|
1027
|
+
}
|
|
1028
|
+
return value;
|
|
1029
|
+
});
|
|
1030
|
+
} catch (cause) {
|
|
1031
|
+
throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
|
|
1032
|
+
}
|
|
1033
|
+
if (!this.isCacheSnapshotEntries(parsed)) {
|
|
1034
|
+
throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
|
|
628
1035
|
}
|
|
629
|
-
await this.importState(
|
|
1036
|
+
await this.importState(parsed);
|
|
630
1037
|
}
|
|
631
1038
|
async disconnect() {
|
|
632
1039
|
if (!this.disconnectPromise) {
|
|
@@ -651,7 +1058,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
651
1058
|
const fetchTask = async () => {
|
|
652
1059
|
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
653
1060
|
if (secondHit.found) {
|
|
654
|
-
this.
|
|
1061
|
+
this.metricsCollector.increment("hits");
|
|
655
1062
|
return secondHit.value;
|
|
656
1063
|
}
|
|
657
1064
|
return this.fetchAndPopulate(key, fetcher, options);
|
|
@@ -676,12 +1083,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
676
1083
|
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
677
1084
|
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
678
1085
|
const deadline = Date.now() + timeoutMs;
|
|
679
|
-
this.
|
|
1086
|
+
this.metricsCollector.increment("singleFlightWaits");
|
|
680
1087
|
this.emit("stampede-dedupe", { key });
|
|
681
1088
|
while (Date.now() < deadline) {
|
|
682
1089
|
const hit = await this.readFromLayers(key, options, "fresh-only");
|
|
683
1090
|
if (hit.found) {
|
|
684
|
-
this.
|
|
1091
|
+
this.metricsCollector.increment("hits");
|
|
685
1092
|
return hit.value;
|
|
686
1093
|
}
|
|
687
1094
|
await this.sleep(pollIntervalMs);
|
|
@@ -689,12 +1096,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
689
1096
|
return this.fetchAndPopulate(key, fetcher, options);
|
|
690
1097
|
}
|
|
691
1098
|
async fetchAndPopulate(key, fetcher, options) {
|
|
692
|
-
this.
|
|
693
|
-
this.
|
|
1099
|
+
this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
|
|
1100
|
+
this.metricsCollector.increment("fetches");
|
|
1101
|
+
const fetchStart = Date.now();
|
|
694
1102
|
let fetched;
|
|
695
1103
|
try {
|
|
696
1104
|
fetched = await fetcher();
|
|
697
|
-
this.
|
|
1105
|
+
this.circuitBreakerManager.recordSuccess(key);
|
|
1106
|
+
this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
|
|
698
1107
|
} catch (error) {
|
|
699
1108
|
this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
|
|
700
1109
|
throw error;
|
|
@@ -716,7 +1125,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
716
1125
|
} else {
|
|
717
1126
|
await this.tagIndex.touch(key);
|
|
718
1127
|
}
|
|
719
|
-
this.
|
|
1128
|
+
this.metricsCollector.increment("sets");
|
|
720
1129
|
this.logger.debug?.("set", { key, kind, tags: options?.tags });
|
|
721
1130
|
this.emit("set", { key, kind, tags: options?.tags });
|
|
722
1131
|
if (this.shouldBroadcastL1Invalidation()) {
|
|
@@ -727,9 +1136,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
727
1136
|
let sawRetainableValue = false;
|
|
728
1137
|
for (let index = 0; index < this.layers.length; index += 1) {
|
|
729
1138
|
const layer = this.layers[index];
|
|
1139
|
+
if (!layer) continue;
|
|
730
1140
|
const stored = await this.readLayerEntry(layer, key);
|
|
731
1141
|
if (stored === null) {
|
|
732
|
-
this.
|
|
1142
|
+
this.metricsCollector.incrementLayer("missesByLayer", layer.name);
|
|
733
1143
|
continue;
|
|
734
1144
|
}
|
|
735
1145
|
const resolved = resolveStoredValue(stored);
|
|
@@ -743,10 +1153,17 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
743
1153
|
}
|
|
744
1154
|
await this.tagIndex.touch(key);
|
|
745
1155
|
await this.backfill(key, stored, index - 1, options);
|
|
746
|
-
this.
|
|
1156
|
+
this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
|
|
747
1157
|
this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
|
|
748
1158
|
this.emit("hit", { key, layer: layer.name, state: resolved.state });
|
|
749
|
-
return {
|
|
1159
|
+
return {
|
|
1160
|
+
found: true,
|
|
1161
|
+
value: resolved.value,
|
|
1162
|
+
stored,
|
|
1163
|
+
state: resolved.state,
|
|
1164
|
+
layerIndex: index,
|
|
1165
|
+
layerName: layer.name
|
|
1166
|
+
};
|
|
750
1167
|
}
|
|
751
1168
|
if (!sawRetainableValue) {
|
|
752
1169
|
await this.tagIndex.remove(key);
|
|
@@ -778,7 +1195,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
778
1195
|
}
|
|
779
1196
|
for (let index = 0; index <= upToIndex; index += 1) {
|
|
780
1197
|
const layer = this.layers[index];
|
|
781
|
-
if (this.shouldSkipLayer(layer)) {
|
|
1198
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
782
1199
|
continue;
|
|
783
1200
|
}
|
|
784
1201
|
const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
|
|
@@ -788,7 +1205,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
788
1205
|
await this.handleLayerFailure(layer, "backfill", error);
|
|
789
1206
|
continue;
|
|
790
1207
|
}
|
|
791
|
-
this.
|
|
1208
|
+
this.metricsCollector.increment("backfills");
|
|
792
1209
|
this.logger.debug?.("backfill", { key, layer: layer.name });
|
|
793
1210
|
this.emit("backfill", { key, layer: layer.name });
|
|
794
1211
|
}
|
|
@@ -805,11 +1222,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
805
1222
|
options?.staleWhileRevalidate,
|
|
806
1223
|
this.options.staleWhileRevalidate
|
|
807
1224
|
);
|
|
808
|
-
const staleIfError = this.resolveLayerSeconds(
|
|
809
|
-
layer.name,
|
|
810
|
-
options?.staleIfError,
|
|
811
|
-
this.options.staleIfError
|
|
812
|
-
);
|
|
1225
|
+
const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
|
|
813
1226
|
const payload = createStoredValueEnvelope({
|
|
814
1227
|
kind,
|
|
815
1228
|
value,
|
|
@@ -837,7 +1250,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
837
1250
|
if (failures.length === 0) {
|
|
838
1251
|
return;
|
|
839
1252
|
}
|
|
840
|
-
this.
|
|
1253
|
+
this.metricsCollector.increment("writeFailures", failures.length);
|
|
841
1254
|
this.logger.debug?.("write-failure", {
|
|
842
1255
|
...context,
|
|
843
1256
|
failures: failures.map((failure) => this.formatError(failure.reason))
|
|
@@ -850,42 +1263,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
850
1263
|
}
|
|
851
1264
|
}
|
|
852
1265
|
resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
|
|
853
|
-
|
|
854
|
-
layerName,
|
|
855
|
-
options?.negativeTtl,
|
|
856
|
-
this.options.negativeTtl,
|
|
857
|
-
this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
858
|
-
) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
|
|
859
|
-
const adaptiveTtl = this.applyAdaptiveTtl(
|
|
860
|
-
key,
|
|
861
|
-
layerName,
|
|
862
|
-
baseTtl,
|
|
863
|
-
options?.adaptiveTtl ?? this.options.adaptiveTtl
|
|
864
|
-
);
|
|
865
|
-
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
|
|
866
|
-
return this.applyJitter(adaptiveTtl, jitter);
|
|
1266
|
+
return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
|
|
867
1267
|
}
|
|
868
1268
|
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
869
|
-
|
|
870
|
-
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
871
|
-
}
|
|
872
|
-
if (globalDefault !== void 0) {
|
|
873
|
-
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
874
|
-
}
|
|
875
|
-
return fallback;
|
|
876
|
-
}
|
|
877
|
-
readLayerNumber(layerName, value) {
|
|
878
|
-
if (typeof value === "number") {
|
|
879
|
-
return value;
|
|
880
|
-
}
|
|
881
|
-
return value[layerName];
|
|
882
|
-
}
|
|
883
|
-
applyJitter(ttl, jitter) {
|
|
884
|
-
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
885
|
-
return ttl;
|
|
886
|
-
}
|
|
887
|
-
const delta = (Math.random() * 2 - 1) * jitter;
|
|
888
|
-
return Math.max(1, Math.round(ttl + delta));
|
|
1269
|
+
return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
|
|
889
1270
|
}
|
|
890
1271
|
shouldNegativeCache(options) {
|
|
891
1272
|
return options?.negativeCache ?? this.options.negativeCaching ?? false;
|
|
@@ -895,11 +1276,11 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
895
1276
|
return;
|
|
896
1277
|
}
|
|
897
1278
|
const refresh = (async () => {
|
|
898
|
-
this.
|
|
1279
|
+
this.metricsCollector.increment("refreshes");
|
|
899
1280
|
try {
|
|
900
1281
|
await this.fetchWithGuards(key, fetcher, options);
|
|
901
1282
|
} catch (error) {
|
|
902
|
-
this.
|
|
1283
|
+
this.metricsCollector.increment("refreshErrors");
|
|
903
1284
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
904
1285
|
} finally {
|
|
905
1286
|
this.backgroundRefreshes.delete(key);
|
|
@@ -921,10 +1302,11 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
921
1302
|
await this.deleteKeysFromLayers(this.layers, keys);
|
|
922
1303
|
for (const key of keys) {
|
|
923
1304
|
await this.tagIndex.remove(key);
|
|
924
|
-
this.
|
|
1305
|
+
this.ttlResolver.deleteProfile(key);
|
|
1306
|
+
this.circuitBreakerManager.delete(key);
|
|
925
1307
|
}
|
|
926
|
-
this.
|
|
927
|
-
this.
|
|
1308
|
+
this.metricsCollector.increment("deletes", keys.length);
|
|
1309
|
+
this.metricsCollector.increment("invalidations");
|
|
928
1310
|
this.logger.debug?.("delete", { keys });
|
|
929
1311
|
this.emit("delete", { keys });
|
|
930
1312
|
}
|
|
@@ -945,7 +1327,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
945
1327
|
if (message.scope === "clear") {
|
|
946
1328
|
await Promise.all(localLayers.map((layer) => layer.clear()));
|
|
947
1329
|
await this.tagIndex.clear();
|
|
948
|
-
this.
|
|
1330
|
+
this.ttlResolver.clearProfiles();
|
|
949
1331
|
return;
|
|
950
1332
|
}
|
|
951
1333
|
const keys = message.keys ?? [];
|
|
@@ -953,7 +1335,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
953
1335
|
if (message.operation !== "write") {
|
|
954
1336
|
for (const key of keys) {
|
|
955
1337
|
await this.tagIndex.remove(key);
|
|
956
|
-
this.
|
|
1338
|
+
this.ttlResolver.deleteProfile(key);
|
|
957
1339
|
}
|
|
958
1340
|
}
|
|
959
1341
|
}
|
|
@@ -983,13 +1365,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
983
1365
|
}
|
|
984
1366
|
return;
|
|
985
1367
|
}
|
|
986
|
-
await Promise.all(
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1368
|
+
await Promise.all(
|
|
1369
|
+
keys.map(async (key) => {
|
|
1370
|
+
try {
|
|
1371
|
+
await layer.delete(key);
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
await this.handleLayerFailure(layer, "delete", error);
|
|
1374
|
+
}
|
|
1375
|
+
})
|
|
1376
|
+
);
|
|
993
1377
|
})
|
|
994
1378
|
);
|
|
995
1379
|
}
|
|
@@ -1090,7 +1474,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1090
1474
|
const ttl = remainingStoredTtlSeconds(refreshed);
|
|
1091
1475
|
for (let index = 0; index <= hit.layerIndex; index += 1) {
|
|
1092
1476
|
const layer = this.layers[index];
|
|
1093
|
-
if (this.shouldSkipLayer(layer)) {
|
|
1477
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
1094
1478
|
continue;
|
|
1095
1479
|
}
|
|
1096
1480
|
try {
|
|
@@ -1104,33 +1488,6 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1104
1488
|
this.scheduleBackgroundRefresh(key, fetcher, options);
|
|
1105
1489
|
}
|
|
1106
1490
|
}
|
|
1107
|
-
applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
|
|
1108
|
-
if (!ttl || !adaptiveTtl) {
|
|
1109
|
-
return ttl;
|
|
1110
|
-
}
|
|
1111
|
-
const profile = this.accessProfiles.get(key);
|
|
1112
|
-
if (!profile) {
|
|
1113
|
-
return ttl;
|
|
1114
|
-
}
|
|
1115
|
-
const config = adaptiveTtl === true ? {} : adaptiveTtl;
|
|
1116
|
-
const hotAfter = config.hotAfter ?? 3;
|
|
1117
|
-
if (profile.hits < hotAfter) {
|
|
1118
|
-
return ttl;
|
|
1119
|
-
}
|
|
1120
|
-
const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
|
|
1121
|
-
const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
|
|
1122
|
-
const multiplier = Math.floor(profile.hits / hotAfter);
|
|
1123
|
-
return Math.min(maxTtl, ttl + step * multiplier);
|
|
1124
|
-
}
|
|
1125
|
-
recordAccess(key) {
|
|
1126
|
-
const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
|
|
1127
|
-
profile.hits += 1;
|
|
1128
|
-
profile.lastAccessAt = Date.now();
|
|
1129
|
-
this.accessProfiles.set(key, profile);
|
|
1130
|
-
}
|
|
1131
|
-
incrementMetricMap(target, key) {
|
|
1132
|
-
target[key] = (target[key] ?? 0) + 1;
|
|
1133
|
-
}
|
|
1134
1491
|
shouldSkipLayer(layer) {
|
|
1135
1492
|
const degradedUntil = this.layerDegradedUntil.get(layer.name);
|
|
1136
1493
|
return degradedUntil !== void 0 && degradedUntil > Date.now();
|
|
@@ -1141,7 +1498,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1141
1498
|
}
|
|
1142
1499
|
const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
|
|
1143
1500
|
this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
|
|
1144
|
-
this.
|
|
1501
|
+
this.metricsCollector.increment("degradedOperations");
|
|
1145
1502
|
this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
|
|
1146
1503
|
this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
|
|
1147
1504
|
return null;
|
|
@@ -1149,37 +1506,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1149
1506
|
isGracefulDegradationEnabled() {
|
|
1150
1507
|
return Boolean(this.options.gracefulDegradation);
|
|
1151
1508
|
}
|
|
1152
|
-
assertCircuitClosed(key, options) {
|
|
1153
|
-
const state = this.circuitBreakers.get(key);
|
|
1154
|
-
if (!state?.openUntil) {
|
|
1155
|
-
return;
|
|
1156
|
-
}
|
|
1157
|
-
if (state.openUntil <= Date.now()) {
|
|
1158
|
-
state.openUntil = null;
|
|
1159
|
-
state.failures = 0;
|
|
1160
|
-
this.circuitBreakers.set(key, state);
|
|
1161
|
-
return;
|
|
1162
|
-
}
|
|
1163
|
-
this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
|
|
1164
|
-
throw new Error(`Circuit breaker is open for key "${key}".`);
|
|
1165
|
-
}
|
|
1166
1509
|
recordCircuitFailure(key, options, error) {
|
|
1167
1510
|
if (!options) {
|
|
1168
1511
|
return;
|
|
1169
1512
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
state.failures += 1;
|
|
1174
|
-
if (state.failures >= failureThreshold) {
|
|
1175
|
-
state.openUntil = Date.now() + cooldownMs;
|
|
1176
|
-
this.metrics.circuitBreakerTrips += 1;
|
|
1513
|
+
this.circuitBreakerManager.recordFailure(key, options);
|
|
1514
|
+
if (this.circuitBreakerManager.isOpen(key)) {
|
|
1515
|
+
this.metricsCollector.increment("circuitBreakerTrips");
|
|
1177
1516
|
}
|
|
1178
|
-
this.
|
|
1179
|
-
this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
|
|
1180
|
-
}
|
|
1181
|
-
resetCircuitBreaker(key) {
|
|
1182
|
-
this.circuitBreakers.delete(key);
|
|
1517
|
+
this.emitError("fetch", { key, error: this.formatError(error) });
|
|
1183
1518
|
}
|
|
1184
1519
|
isNegativeStoredValue(stored) {
|
|
1185
1520
|
return isStoredValueEnvelope(stored) && stored.kind === "empty";
|
|
@@ -1459,11 +1794,13 @@ var MemoryLayer = class {
|
|
|
1459
1794
|
defaultTtl;
|
|
1460
1795
|
isLocal = true;
|
|
1461
1796
|
maxSize;
|
|
1797
|
+
evictionPolicy;
|
|
1462
1798
|
entries = /* @__PURE__ */ new Map();
|
|
1463
1799
|
constructor(options = {}) {
|
|
1464
1800
|
this.name = options.name ?? "memory";
|
|
1465
1801
|
this.defaultTtl = options.ttl;
|
|
1466
1802
|
this.maxSize = options.maxSize ?? 1e3;
|
|
1803
|
+
this.evictionPolicy = options.evictionPolicy ?? "lru";
|
|
1467
1804
|
}
|
|
1468
1805
|
async get(key) {
|
|
1469
1806
|
const value = await this.getEntry(key);
|
|
@@ -1478,8 +1815,13 @@ var MemoryLayer = class {
|
|
|
1478
1815
|
this.entries.delete(key);
|
|
1479
1816
|
return null;
|
|
1480
1817
|
}
|
|
1481
|
-
this.
|
|
1482
|
-
|
|
1818
|
+
if (this.evictionPolicy === "lru") {
|
|
1819
|
+
this.entries.delete(key);
|
|
1820
|
+
entry.frequency += 1;
|
|
1821
|
+
this.entries.set(key, entry);
|
|
1822
|
+
} else {
|
|
1823
|
+
entry.frequency += 1;
|
|
1824
|
+
}
|
|
1483
1825
|
return entry.value;
|
|
1484
1826
|
}
|
|
1485
1827
|
async getMany(keys) {
|
|
@@ -1493,15 +1835,42 @@ var MemoryLayer = class {
|
|
|
1493
1835
|
this.entries.delete(key);
|
|
1494
1836
|
this.entries.set(key, {
|
|
1495
1837
|
value,
|
|
1496
|
-
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
1838
|
+
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
|
|
1839
|
+
frequency: 0,
|
|
1840
|
+
insertedAt: Date.now()
|
|
1497
1841
|
});
|
|
1498
1842
|
while (this.entries.size > this.maxSize) {
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1843
|
+
this.evict();
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
async has(key) {
|
|
1847
|
+
const entry = this.entries.get(key);
|
|
1848
|
+
if (!entry) {
|
|
1849
|
+
return false;
|
|
1850
|
+
}
|
|
1851
|
+
if (this.isExpired(entry)) {
|
|
1852
|
+
this.entries.delete(key);
|
|
1853
|
+
return false;
|
|
1854
|
+
}
|
|
1855
|
+
return true;
|
|
1856
|
+
}
|
|
1857
|
+
async ttl(key) {
|
|
1858
|
+
const entry = this.entries.get(key);
|
|
1859
|
+
if (!entry) {
|
|
1860
|
+
return null;
|
|
1861
|
+
}
|
|
1862
|
+
if (this.isExpired(entry)) {
|
|
1863
|
+
this.entries.delete(key);
|
|
1864
|
+
return null;
|
|
1865
|
+
}
|
|
1866
|
+
if (entry.expiresAt === null) {
|
|
1867
|
+
return null;
|
|
1504
1868
|
}
|
|
1869
|
+
return Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 1e3));
|
|
1870
|
+
}
|
|
1871
|
+
async size() {
|
|
1872
|
+
this.pruneExpired();
|
|
1873
|
+
return this.entries.size;
|
|
1505
1874
|
}
|
|
1506
1875
|
async delete(key) {
|
|
1507
1876
|
this.entries.delete(key);
|
|
@@ -1533,15 +1902,35 @@ var MemoryLayer = class {
|
|
|
1533
1902
|
}
|
|
1534
1903
|
this.entries.set(entry.key, {
|
|
1535
1904
|
value: entry.value,
|
|
1536
|
-
expiresAt: entry.expiresAt
|
|
1905
|
+
expiresAt: entry.expiresAt,
|
|
1906
|
+
frequency: 0,
|
|
1907
|
+
insertedAt: Date.now()
|
|
1537
1908
|
});
|
|
1538
1909
|
}
|
|
1539
1910
|
while (this.entries.size > this.maxSize) {
|
|
1911
|
+
this.evict();
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
evict() {
|
|
1915
|
+
if (this.evictionPolicy === "lru" || this.evictionPolicy === "fifo") {
|
|
1540
1916
|
const oldestKey = this.entries.keys().next().value;
|
|
1541
|
-
if (
|
|
1542
|
-
|
|
1917
|
+
if (oldestKey !== void 0) {
|
|
1918
|
+
this.entries.delete(oldestKey);
|
|
1543
1919
|
}
|
|
1544
|
-
|
|
1920
|
+
return;
|
|
1921
|
+
}
|
|
1922
|
+
let victimKey;
|
|
1923
|
+
let minFreq = Number.POSITIVE_INFINITY;
|
|
1924
|
+
let minInsertedAt = Number.POSITIVE_INFINITY;
|
|
1925
|
+
for (const [key, entry] of this.entries.entries()) {
|
|
1926
|
+
if (entry.frequency < minFreq || entry.frequency === minFreq && entry.insertedAt < minInsertedAt) {
|
|
1927
|
+
minFreq = entry.frequency;
|
|
1928
|
+
minInsertedAt = entry.insertedAt;
|
|
1929
|
+
victimKey = key;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
if (victimKey !== void 0) {
|
|
1933
|
+
this.entries.delete(victimKey);
|
|
1545
1934
|
}
|
|
1546
1935
|
}
|
|
1547
1936
|
pruneExpired() {
|
|
@@ -1571,6 +1960,7 @@ var JsonSerializer = class {
|
|
|
1571
1960
|
};
|
|
1572
1961
|
|
|
1573
1962
|
// src/layers/RedisLayer.ts
|
|
1963
|
+
var BATCH_DELETE_SIZE = 500;
|
|
1574
1964
|
var RedisLayer = class {
|
|
1575
1965
|
name;
|
|
1576
1966
|
defaultTtl;
|
|
@@ -1622,7 +2012,7 @@ var RedisLayer = class {
|
|
|
1622
2012
|
if (error || payload === null || !this.isSerializablePayload(payload)) {
|
|
1623
2013
|
return null;
|
|
1624
2014
|
}
|
|
1625
|
-
return this.deserializeOrDelete(keys[index], payload);
|
|
2015
|
+
return this.deserializeOrDelete(keys[index] ?? "", payload);
|
|
1626
2016
|
})
|
|
1627
2017
|
);
|
|
1628
2018
|
}
|
|
@@ -1644,14 +2034,44 @@ var RedisLayer = class {
|
|
|
1644
2034
|
}
|
|
1645
2035
|
await this.client.del(...keys.map((key) => this.withPrefix(key)));
|
|
1646
2036
|
}
|
|
1647
|
-
async
|
|
1648
|
-
|
|
1649
|
-
|
|
2037
|
+
async has(key) {
|
|
2038
|
+
const exists = await this.client.exists(this.withPrefix(key));
|
|
2039
|
+
return exists > 0;
|
|
2040
|
+
}
|
|
2041
|
+
async ttl(key) {
|
|
2042
|
+
const remaining = await this.client.ttl(this.withPrefix(key));
|
|
2043
|
+
if (remaining < 0) {
|
|
2044
|
+
return null;
|
|
1650
2045
|
}
|
|
2046
|
+
return remaining;
|
|
2047
|
+
}
|
|
2048
|
+
async size() {
|
|
1651
2049
|
const keys = await this.keys();
|
|
1652
|
-
|
|
1653
|
-
|
|
2050
|
+
return keys.length;
|
|
2051
|
+
}
|
|
2052
|
+
/**
|
|
2053
|
+
* Deletes all keys matching the layer's prefix in batches to avoid
|
|
2054
|
+
* loading millions of keys into memory at once.
|
|
2055
|
+
*/
|
|
2056
|
+
async clear() {
|
|
2057
|
+
if (!this.prefix && !this.allowUnprefixedClear) {
|
|
2058
|
+
throw new Error(
|
|
2059
|
+
"RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys."
|
|
2060
|
+
);
|
|
1654
2061
|
}
|
|
2062
|
+
const pattern = `${this.prefix}*`;
|
|
2063
|
+
let cursor = "0";
|
|
2064
|
+
do {
|
|
2065
|
+
const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
|
|
2066
|
+
cursor = nextCursor;
|
|
2067
|
+
if (keys.length === 0) {
|
|
2068
|
+
continue;
|
|
2069
|
+
}
|
|
2070
|
+
for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
|
|
2071
|
+
const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
|
|
2072
|
+
await this.client.del(...batch);
|
|
2073
|
+
}
|
|
2074
|
+
} while (cursor !== "0");
|
|
1655
2075
|
}
|
|
1656
2076
|
async keys() {
|
|
1657
2077
|
const pattern = `${this.prefix}*`;
|
|
@@ -1711,6 +2131,170 @@ var RedisLayer = class {
|
|
|
1711
2131
|
}
|
|
1712
2132
|
};
|
|
1713
2133
|
|
|
2134
|
+
// src/layers/DiskLayer.ts
|
|
2135
|
+
var import_node_crypto2 = require("crypto");
|
|
2136
|
+
var import_node_fs2 = require("fs");
|
|
2137
|
+
var import_node_path = require("path");
|
|
2138
|
+
var DiskLayer = class {
|
|
2139
|
+
name;
|
|
2140
|
+
defaultTtl;
|
|
2141
|
+
isLocal = true;
|
|
2142
|
+
directory;
|
|
2143
|
+
serializer;
|
|
2144
|
+
constructor(options) {
|
|
2145
|
+
this.directory = options.directory;
|
|
2146
|
+
this.defaultTtl = options.ttl;
|
|
2147
|
+
this.name = options.name ?? "disk";
|
|
2148
|
+
this.serializer = options.serializer ?? new JsonSerializer();
|
|
2149
|
+
}
|
|
2150
|
+
async get(key) {
|
|
2151
|
+
return unwrapStoredValue(await this.getEntry(key));
|
|
2152
|
+
}
|
|
2153
|
+
async getEntry(key) {
|
|
2154
|
+
const filePath = this.keyToPath(key);
|
|
2155
|
+
let raw;
|
|
2156
|
+
try {
|
|
2157
|
+
raw = await import_node_fs2.promises.readFile(filePath);
|
|
2158
|
+
} catch {
|
|
2159
|
+
return null;
|
|
2160
|
+
}
|
|
2161
|
+
let entry;
|
|
2162
|
+
try {
|
|
2163
|
+
entry = this.serializer.deserialize(raw);
|
|
2164
|
+
} catch {
|
|
2165
|
+
await this.safeDelete(filePath);
|
|
2166
|
+
return null;
|
|
2167
|
+
}
|
|
2168
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
2169
|
+
await this.safeDelete(filePath);
|
|
2170
|
+
return null;
|
|
2171
|
+
}
|
|
2172
|
+
return entry.value;
|
|
2173
|
+
}
|
|
2174
|
+
async set(key, value, ttl = this.defaultTtl) {
|
|
2175
|
+
await import_node_fs2.promises.mkdir(this.directory, { recursive: true });
|
|
2176
|
+
const entry = {
|
|
2177
|
+
value,
|
|
2178
|
+
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
2179
|
+
};
|
|
2180
|
+
const payload = this.serializer.serialize(entry);
|
|
2181
|
+
await import_node_fs2.promises.writeFile(this.keyToPath(key), payload);
|
|
2182
|
+
}
|
|
2183
|
+
async has(key) {
|
|
2184
|
+
const value = await this.getEntry(key);
|
|
2185
|
+
return value !== null;
|
|
2186
|
+
}
|
|
2187
|
+
async ttl(key) {
|
|
2188
|
+
const filePath = this.keyToPath(key);
|
|
2189
|
+
let raw;
|
|
2190
|
+
try {
|
|
2191
|
+
raw = await import_node_fs2.promises.readFile(filePath);
|
|
2192
|
+
} catch {
|
|
2193
|
+
return null;
|
|
2194
|
+
}
|
|
2195
|
+
let entry;
|
|
2196
|
+
try {
|
|
2197
|
+
entry = this.serializer.deserialize(raw);
|
|
2198
|
+
} catch {
|
|
2199
|
+
return null;
|
|
2200
|
+
}
|
|
2201
|
+
if (entry.expiresAt === null) {
|
|
2202
|
+
return null;
|
|
2203
|
+
}
|
|
2204
|
+
const remaining = Math.ceil((entry.expiresAt - Date.now()) / 1e3);
|
|
2205
|
+
if (remaining <= 0) {
|
|
2206
|
+
return null;
|
|
2207
|
+
}
|
|
2208
|
+
return remaining;
|
|
2209
|
+
}
|
|
2210
|
+
async delete(key) {
|
|
2211
|
+
await this.safeDelete(this.keyToPath(key));
|
|
2212
|
+
}
|
|
2213
|
+
async deleteMany(keys) {
|
|
2214
|
+
await Promise.all(keys.map((key) => this.delete(key)));
|
|
2215
|
+
}
|
|
2216
|
+
async clear() {
|
|
2217
|
+
let entries;
|
|
2218
|
+
try {
|
|
2219
|
+
entries = await import_node_fs2.promises.readdir(this.directory);
|
|
2220
|
+
} catch {
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
await Promise.all(
|
|
2224
|
+
entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete((0, import_node_path.join)(this.directory, name)))
|
|
2225
|
+
);
|
|
2226
|
+
}
|
|
2227
|
+
async keys() {
|
|
2228
|
+
let entries;
|
|
2229
|
+
try {
|
|
2230
|
+
entries = await import_node_fs2.promises.readdir(this.directory);
|
|
2231
|
+
} catch {
|
|
2232
|
+
return [];
|
|
2233
|
+
}
|
|
2234
|
+
return entries.filter((name) => name.endsWith(".lc")).map((name) => name.slice(0, -3));
|
|
2235
|
+
}
|
|
2236
|
+
async size() {
|
|
2237
|
+
const keys = await this.keys();
|
|
2238
|
+
return keys.length;
|
|
2239
|
+
}
|
|
2240
|
+
keyToPath(key) {
|
|
2241
|
+
const hash = (0, import_node_crypto2.createHash)("sha256").update(key).digest("hex");
|
|
2242
|
+
return (0, import_node_path.join)(this.directory, `${hash}.lc`);
|
|
2243
|
+
}
|
|
2244
|
+
async safeDelete(filePath) {
|
|
2245
|
+
try {
|
|
2246
|
+
await import_node_fs2.promises.unlink(filePath);
|
|
2247
|
+
} catch {
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
};
|
|
2251
|
+
|
|
2252
|
+
// src/layers/MemcachedLayer.ts
|
|
2253
|
+
var MemcachedLayer = class {
|
|
2254
|
+
name;
|
|
2255
|
+
defaultTtl;
|
|
2256
|
+
isLocal = false;
|
|
2257
|
+
client;
|
|
2258
|
+
keyPrefix;
|
|
2259
|
+
constructor(options) {
|
|
2260
|
+
this.client = options.client;
|
|
2261
|
+
this.defaultTtl = options.ttl;
|
|
2262
|
+
this.name = options.name ?? "memcached";
|
|
2263
|
+
this.keyPrefix = options.keyPrefix ?? "";
|
|
2264
|
+
}
|
|
2265
|
+
async get(key) {
|
|
2266
|
+
const result = await this.client.get(this.withPrefix(key));
|
|
2267
|
+
if (!result || result.value === null) {
|
|
2268
|
+
return null;
|
|
2269
|
+
}
|
|
2270
|
+
try {
|
|
2271
|
+
return JSON.parse(result.value.toString("utf8"));
|
|
2272
|
+
} catch {
|
|
2273
|
+
return null;
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
async set(key, value, ttl = this.defaultTtl) {
|
|
2277
|
+
const payload = JSON.stringify(value);
|
|
2278
|
+
await this.client.set(this.withPrefix(key), payload, {
|
|
2279
|
+
expires: ttl && ttl > 0 ? ttl : void 0
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
async delete(key) {
|
|
2283
|
+
await this.client.delete(this.withPrefix(key));
|
|
2284
|
+
}
|
|
2285
|
+
async deleteMany(keys) {
|
|
2286
|
+
await Promise.all(keys.map((key) => this.delete(key)));
|
|
2287
|
+
}
|
|
2288
|
+
async clear() {
|
|
2289
|
+
throw new Error(
|
|
2290
|
+
"MemcachedLayer.clear() is not supported. Use a key prefix and rotate it to effectively invalidate all keys."
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
withPrefix(key) {
|
|
2294
|
+
return `${this.keyPrefix}${key}`;
|
|
2295
|
+
}
|
|
2296
|
+
};
|
|
2297
|
+
|
|
1714
2298
|
// src/serialization/MsgpackSerializer.ts
|
|
1715
2299
|
var import_msgpack = require("@msgpack/msgpack");
|
|
1716
2300
|
var MsgpackSerializer = class {
|
|
@@ -1724,7 +2308,7 @@ var MsgpackSerializer = class {
|
|
|
1724
2308
|
};
|
|
1725
2309
|
|
|
1726
2310
|
// src/singleflight/RedisSingleFlightCoordinator.ts
|
|
1727
|
-
var
|
|
2311
|
+
var import_node_crypto3 = require("crypto");
|
|
1728
2312
|
var RELEASE_SCRIPT = `
|
|
1729
2313
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
1730
2314
|
return redis.call("del", KEYS[1])
|
|
@@ -1740,7 +2324,7 @@ var RedisSingleFlightCoordinator = class {
|
|
|
1740
2324
|
}
|
|
1741
2325
|
async execute(key, options, worker, waiter) {
|
|
1742
2326
|
const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
|
|
1743
|
-
const token = (0,
|
|
2327
|
+
const token = (0, import_node_crypto3.randomUUID)();
|
|
1744
2328
|
const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
|
|
1745
2329
|
if (acquired === "OK") {
|
|
1746
2330
|
try {
|
|
@@ -1752,11 +2336,80 @@ var RedisSingleFlightCoordinator = class {
|
|
|
1752
2336
|
return waiter();
|
|
1753
2337
|
}
|
|
1754
2338
|
};
|
|
2339
|
+
|
|
2340
|
+
// src/metrics/PrometheusExporter.ts
|
|
2341
|
+
function createPrometheusMetricsExporter(stacks) {
|
|
2342
|
+
return () => {
|
|
2343
|
+
const entries = Array.isArray(stacks) ? stacks : [{ stack: stacks, name: "default" }];
|
|
2344
|
+
const lines = [];
|
|
2345
|
+
lines.push("# HELP layercache_hits_total Total number of cache hits");
|
|
2346
|
+
lines.push("# TYPE layercache_hits_total counter");
|
|
2347
|
+
lines.push("# HELP layercache_misses_total Total number of cache misses");
|
|
2348
|
+
lines.push("# TYPE layercache_misses_total counter");
|
|
2349
|
+
lines.push("# HELP layercache_fetches_total Total fetcher invocations (full misses)");
|
|
2350
|
+
lines.push("# TYPE layercache_fetches_total counter");
|
|
2351
|
+
lines.push("# HELP layercache_sets_total Total number of cache sets");
|
|
2352
|
+
lines.push("# TYPE layercache_sets_total counter");
|
|
2353
|
+
lines.push("# HELP layercache_deletes_total Total number of cache deletes");
|
|
2354
|
+
lines.push("# TYPE layercache_deletes_total counter");
|
|
2355
|
+
lines.push("# HELP layercache_backfills_total Total number of backfill operations");
|
|
2356
|
+
lines.push("# TYPE layercache_backfills_total counter");
|
|
2357
|
+
lines.push("# HELP layercache_stale_hits_total Total number of stale hits served");
|
|
2358
|
+
lines.push("# TYPE layercache_stale_hits_total counter");
|
|
2359
|
+
lines.push("# HELP layercache_refreshes_total Background refreshes triggered");
|
|
2360
|
+
lines.push("# TYPE layercache_refreshes_total counter");
|
|
2361
|
+
lines.push("# HELP layercache_refresh_errors_total Background refresh errors");
|
|
2362
|
+
lines.push("# TYPE layercache_refresh_errors_total counter");
|
|
2363
|
+
lines.push("# HELP layercache_negative_cache_hits_total Negative cache hits");
|
|
2364
|
+
lines.push("# TYPE layercache_negative_cache_hits_total counter");
|
|
2365
|
+
lines.push("# HELP layercache_circuit_breaker_trips_total Circuit breaker trips");
|
|
2366
|
+
lines.push("# TYPE layercache_circuit_breaker_trips_total counter");
|
|
2367
|
+
lines.push("# HELP layercache_degraded_operations_total Operations run in degraded mode");
|
|
2368
|
+
lines.push("# TYPE layercache_degraded_operations_total counter");
|
|
2369
|
+
lines.push("# HELP layercache_hit_rate Overall cache hit rate (0-1)");
|
|
2370
|
+
lines.push("# TYPE layercache_hit_rate gauge");
|
|
2371
|
+
lines.push("# HELP layercache_hits_by_layer_total Hits broken down by layer");
|
|
2372
|
+
lines.push("# TYPE layercache_hits_by_layer_total counter");
|
|
2373
|
+
lines.push("# HELP layercache_misses_by_layer_total Misses broken down by layer");
|
|
2374
|
+
lines.push("# TYPE layercache_misses_by_layer_total counter");
|
|
2375
|
+
for (const { stack, name } of entries) {
|
|
2376
|
+
const m = stack.getMetrics();
|
|
2377
|
+
const hr = stack.getHitRate();
|
|
2378
|
+
const label = `cache="${sanitizeLabel(name)}"`;
|
|
2379
|
+
lines.push(`layercache_hits_total{${label}} ${m.hits}`);
|
|
2380
|
+
lines.push(`layercache_misses_total{${label}} ${m.misses}`);
|
|
2381
|
+
lines.push(`layercache_fetches_total{${label}} ${m.fetches}`);
|
|
2382
|
+
lines.push(`layercache_sets_total{${label}} ${m.sets}`);
|
|
2383
|
+
lines.push(`layercache_deletes_total{${label}} ${m.deletes}`);
|
|
2384
|
+
lines.push(`layercache_backfills_total{${label}} ${m.backfills}`);
|
|
2385
|
+
lines.push(`layercache_stale_hits_total{${label}} ${m.staleHits}`);
|
|
2386
|
+
lines.push(`layercache_refreshes_total{${label}} ${m.refreshes}`);
|
|
2387
|
+
lines.push(`layercache_refresh_errors_total{${label}} ${m.refreshErrors}`);
|
|
2388
|
+
lines.push(`layercache_negative_cache_hits_total{${label}} ${m.negativeCacheHits}`);
|
|
2389
|
+
lines.push(`layercache_circuit_breaker_trips_total{${label}} ${m.circuitBreakerTrips}`);
|
|
2390
|
+
lines.push(`layercache_degraded_operations_total{${label}} ${m.degradedOperations}`);
|
|
2391
|
+
lines.push(`layercache_hit_rate{${label}} ${hr.overall.toFixed(6)}`);
|
|
2392
|
+
for (const [layerName, count] of Object.entries(m.hitsByLayer)) {
|
|
2393
|
+
lines.push(`layercache_hits_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
|
|
2394
|
+
}
|
|
2395
|
+
for (const [layerName, count] of Object.entries(m.missesByLayer)) {
|
|
2396
|
+
lines.push(`layercache_misses_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
lines.push("");
|
|
2400
|
+
return lines.join("\n");
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
function sanitizeLabel(value) {
|
|
2404
|
+
return value.replace(/["\\\n]/g, "_");
|
|
2405
|
+
}
|
|
1755
2406
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1756
2407
|
0 && (module.exports = {
|
|
1757
2408
|
CacheNamespace,
|
|
1758
2409
|
CacheStack,
|
|
2410
|
+
DiskLayer,
|
|
1759
2411
|
JsonSerializer,
|
|
2412
|
+
MemcachedLayer,
|
|
1760
2413
|
MemoryLayer,
|
|
1761
2414
|
MsgpackSerializer,
|
|
1762
2415
|
PatternMatcher,
|
|
@@ -1770,5 +2423,6 @@ var RedisSingleFlightCoordinator = class {
|
|
|
1770
2423
|
createCacheStatsHandler,
|
|
1771
2424
|
createCachedMethodDecorator,
|
|
1772
2425
|
createFastifyLayercachePlugin,
|
|
2426
|
+
createPrometheusMetricsExporter,
|
|
1773
2427
|
createTrpcCacheMiddleware
|
|
1774
2428
|
});
|