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.js
CHANGED
|
@@ -1,12 +1,233 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PatternMatcher,
|
|
3
3
|
RedisTagIndex
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-QUB5VZFZ.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 {
|
|
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
|
+
async has(key) {
|
|
26
|
+
return this.cache.has(this.qualify(key));
|
|
27
|
+
}
|
|
28
|
+
async ttl(key) {
|
|
29
|
+
return this.cache.ttl(this.qualify(key));
|
|
30
|
+
}
|
|
31
|
+
async set(key, value, options) {
|
|
32
|
+
await this.cache.set(this.qualify(key), value, options);
|
|
33
|
+
}
|
|
34
|
+
async delete(key) {
|
|
35
|
+
await this.cache.delete(this.qualify(key));
|
|
36
|
+
}
|
|
37
|
+
async mdelete(keys) {
|
|
38
|
+
await this.cache.mdelete(keys.map((k) => this.qualify(k)));
|
|
39
|
+
}
|
|
40
|
+
async clear() {
|
|
41
|
+
await this.cache.invalidateByPattern(`${this.prefix}:*`);
|
|
42
|
+
}
|
|
43
|
+
async mget(entries) {
|
|
44
|
+
return this.cache.mget(
|
|
45
|
+
entries.map((entry) => ({
|
|
46
|
+
...entry,
|
|
47
|
+
key: this.qualify(entry.key)
|
|
48
|
+
}))
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
async mset(entries) {
|
|
52
|
+
await this.cache.mset(
|
|
53
|
+
entries.map((entry) => ({
|
|
54
|
+
...entry,
|
|
55
|
+
key: this.qualify(entry.key)
|
|
56
|
+
}))
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
async invalidateByTag(tag) {
|
|
60
|
+
await this.cache.invalidateByTag(tag);
|
|
61
|
+
}
|
|
62
|
+
async invalidateByPattern(pattern) {
|
|
63
|
+
await this.cache.invalidateByPattern(this.qualify(pattern));
|
|
64
|
+
}
|
|
65
|
+
wrap(keyPrefix, fetcher, options) {
|
|
66
|
+
return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
|
|
67
|
+
}
|
|
68
|
+
warm(entries, options) {
|
|
69
|
+
return this.cache.warm(
|
|
70
|
+
entries.map((entry) => ({
|
|
71
|
+
...entry,
|
|
72
|
+
key: this.qualify(entry.key)
|
|
73
|
+
})),
|
|
74
|
+
options
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
getMetrics() {
|
|
78
|
+
return this.cache.getMetrics();
|
|
79
|
+
}
|
|
80
|
+
getHitRate() {
|
|
81
|
+
return this.cache.getHitRate();
|
|
82
|
+
}
|
|
83
|
+
qualify(key) {
|
|
84
|
+
return `${this.prefix}:${key}`;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// src/internal/CircuitBreakerManager.ts
|
|
89
|
+
var CircuitBreakerManager = class {
|
|
90
|
+
breakers = /* @__PURE__ */ new Map();
|
|
91
|
+
maxEntries;
|
|
92
|
+
constructor(options) {
|
|
93
|
+
this.maxEntries = options.maxEntries;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Throws if the circuit is open for the given key.
|
|
97
|
+
* Automatically resets if the cooldown has elapsed.
|
|
98
|
+
*/
|
|
99
|
+
assertClosed(key, options) {
|
|
100
|
+
const state = this.breakers.get(key);
|
|
101
|
+
if (!state?.openUntil) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
if (state.openUntil <= now) {
|
|
106
|
+
state.openUntil = null;
|
|
107
|
+
state.failures = 0;
|
|
108
|
+
this.breakers.set(key, state);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const remainingMs = state.openUntil - now;
|
|
112
|
+
const remainingSecs = Math.ceil(remainingMs / 1e3);
|
|
113
|
+
throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
|
|
114
|
+
}
|
|
115
|
+
recordFailure(key, options) {
|
|
116
|
+
if (!options) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const failureThreshold = options.failureThreshold ?? 3;
|
|
120
|
+
const cooldownMs = options.cooldownMs ?? 3e4;
|
|
121
|
+
const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
|
|
122
|
+
state.failures += 1;
|
|
123
|
+
if (state.failures >= failureThreshold) {
|
|
124
|
+
state.openUntil = Date.now() + cooldownMs;
|
|
125
|
+
}
|
|
126
|
+
this.breakers.set(key, state);
|
|
127
|
+
this.pruneIfNeeded();
|
|
128
|
+
}
|
|
129
|
+
recordSuccess(key) {
|
|
130
|
+
this.breakers.delete(key);
|
|
131
|
+
}
|
|
132
|
+
isOpen(key) {
|
|
133
|
+
const state = this.breakers.get(key);
|
|
134
|
+
if (!state?.openUntil) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
if (state.openUntil <= Date.now()) {
|
|
138
|
+
state.openUntil = null;
|
|
139
|
+
state.failures = 0;
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
delete(key) {
|
|
145
|
+
this.breakers.delete(key);
|
|
146
|
+
}
|
|
147
|
+
clear() {
|
|
148
|
+
this.breakers.clear();
|
|
149
|
+
}
|
|
150
|
+
tripCount() {
|
|
151
|
+
let count = 0;
|
|
152
|
+
for (const state of this.breakers.values()) {
|
|
153
|
+
if (state.openUntil !== null) {
|
|
154
|
+
count += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return count;
|
|
158
|
+
}
|
|
159
|
+
pruneIfNeeded() {
|
|
160
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
for (const [key, state] of this.breakers.entries()) {
|
|
164
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
if (!state.openUntil || state.openUntil <= Date.now()) {
|
|
168
|
+
this.breakers.delete(key);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const key of this.breakers.keys()) {
|
|
172
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
this.breakers.delete(key);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// src/internal/MetricsCollector.ts
|
|
181
|
+
var MetricsCollector = class {
|
|
182
|
+
data = this.empty();
|
|
183
|
+
get snapshot() {
|
|
184
|
+
return { ...this.data };
|
|
185
|
+
}
|
|
186
|
+
increment(field, amount = 1) {
|
|
187
|
+
;
|
|
188
|
+
this.data[field] += amount;
|
|
189
|
+
}
|
|
190
|
+
incrementLayer(map, layerName) {
|
|
191
|
+
this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
|
|
192
|
+
}
|
|
193
|
+
reset() {
|
|
194
|
+
this.data = this.empty();
|
|
195
|
+
}
|
|
196
|
+
hitRate() {
|
|
197
|
+
const total = this.data.hits + this.data.misses;
|
|
198
|
+
const overall = total === 0 ? 0 : this.data.hits / total;
|
|
199
|
+
const byLayer = {};
|
|
200
|
+
const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
|
|
201
|
+
for (const layer of allLayers) {
|
|
202
|
+
const h = this.data.hitsByLayer[layer] ?? 0;
|
|
203
|
+
const m = this.data.missesByLayer[layer] ?? 0;
|
|
204
|
+
byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
|
|
205
|
+
}
|
|
206
|
+
return { overall, byLayer };
|
|
207
|
+
}
|
|
208
|
+
empty() {
|
|
209
|
+
return {
|
|
210
|
+
hits: 0,
|
|
211
|
+
misses: 0,
|
|
212
|
+
fetches: 0,
|
|
213
|
+
sets: 0,
|
|
214
|
+
deletes: 0,
|
|
215
|
+
backfills: 0,
|
|
216
|
+
invalidations: 0,
|
|
217
|
+
staleHits: 0,
|
|
218
|
+
refreshes: 0,
|
|
219
|
+
refreshErrors: 0,
|
|
220
|
+
writeFailures: 0,
|
|
221
|
+
singleFlightWaits: 0,
|
|
222
|
+
negativeCacheHits: 0,
|
|
223
|
+
circuitBreakerTrips: 0,
|
|
224
|
+
degradedOperations: 0,
|
|
225
|
+
hitsByLayer: {},
|
|
226
|
+
missesByLayer: {},
|
|
227
|
+
resetAt: Date.now()
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
};
|
|
10
231
|
|
|
11
232
|
// src/internal/StoredValue.ts
|
|
12
233
|
function isStoredValueEnvelope(value) {
|
|
@@ -109,58 +330,91 @@ function normalizePositiveSeconds(value) {
|
|
|
109
330
|
return value;
|
|
110
331
|
}
|
|
111
332
|
|
|
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));
|
|
333
|
+
// src/internal/TtlResolver.ts
|
|
334
|
+
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
335
|
+
var TtlResolver = class {
|
|
336
|
+
accessProfiles = /* @__PURE__ */ new Map();
|
|
337
|
+
maxProfileEntries;
|
|
338
|
+
constructor(options) {
|
|
339
|
+
this.maxProfileEntries = options.maxProfileEntries;
|
|
128
340
|
}
|
|
129
|
-
|
|
130
|
-
|
|
341
|
+
recordAccess(key) {
|
|
342
|
+
const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
|
|
343
|
+
profile.hits += 1;
|
|
344
|
+
profile.lastAccessAt = Date.now();
|
|
345
|
+
this.accessProfiles.set(key, profile);
|
|
346
|
+
this.pruneIfNeeded();
|
|
131
347
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
...entry,
|
|
135
|
-
key: this.qualify(entry.key)
|
|
136
|
-
})));
|
|
348
|
+
deleteProfile(key) {
|
|
349
|
+
this.accessProfiles.delete(key);
|
|
137
350
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
...entry,
|
|
141
|
-
key: this.qualify(entry.key)
|
|
142
|
-
})));
|
|
351
|
+
clearProfiles() {
|
|
352
|
+
this.accessProfiles.clear();
|
|
143
353
|
}
|
|
144
|
-
|
|
145
|
-
|
|
354
|
+
resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
|
|
355
|
+
const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
|
|
356
|
+
layerName,
|
|
357
|
+
options?.negativeTtl,
|
|
358
|
+
globalNegativeTtl,
|
|
359
|
+
this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
360
|
+
) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
|
|
361
|
+
const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
|
|
362
|
+
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
|
|
363
|
+
return this.applyJitter(adaptiveTtl, jitter);
|
|
146
364
|
}
|
|
147
|
-
|
|
148
|
-
|
|
365
|
+
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
366
|
+
if (override !== void 0) {
|
|
367
|
+
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
368
|
+
}
|
|
369
|
+
if (globalDefault !== void 0) {
|
|
370
|
+
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
371
|
+
}
|
|
372
|
+
return fallback;
|
|
149
373
|
}
|
|
150
|
-
|
|
151
|
-
|
|
374
|
+
applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
|
|
375
|
+
if (!ttl || !adaptiveTtl) {
|
|
376
|
+
return ttl;
|
|
377
|
+
}
|
|
378
|
+
const profile = this.accessProfiles.get(key);
|
|
379
|
+
if (!profile) {
|
|
380
|
+
return ttl;
|
|
381
|
+
}
|
|
382
|
+
const config = adaptiveTtl === true ? {} : adaptiveTtl;
|
|
383
|
+
const hotAfter = config.hotAfter ?? 3;
|
|
384
|
+
if (profile.hits < hotAfter) {
|
|
385
|
+
return ttl;
|
|
386
|
+
}
|
|
387
|
+
const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
|
|
388
|
+
const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
|
|
389
|
+
const multiplier = Math.floor(profile.hits / hotAfter);
|
|
390
|
+
return Math.min(maxTtl, ttl + step * multiplier);
|
|
152
391
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
392
|
+
applyJitter(ttl, jitter) {
|
|
393
|
+
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
394
|
+
return ttl;
|
|
395
|
+
}
|
|
396
|
+
const delta = (Math.random() * 2 - 1) * jitter;
|
|
397
|
+
return Math.max(1, Math.round(ttl + delta));
|
|
158
398
|
}
|
|
159
|
-
|
|
160
|
-
|
|
399
|
+
readLayerNumber(layerName, value) {
|
|
400
|
+
if (typeof value === "number") {
|
|
401
|
+
return value;
|
|
402
|
+
}
|
|
403
|
+
return value[layerName];
|
|
161
404
|
}
|
|
162
|
-
|
|
163
|
-
|
|
405
|
+
pruneIfNeeded() {
|
|
406
|
+
if (this.accessProfiles.size <= this.maxProfileEntries) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
|
|
410
|
+
let removed = 0;
|
|
411
|
+
for (const key of this.accessProfiles.keys()) {
|
|
412
|
+
if (removed >= toRemove) {
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
this.accessProfiles.delete(key);
|
|
416
|
+
removed += 1;
|
|
417
|
+
}
|
|
164
418
|
}
|
|
165
419
|
};
|
|
166
420
|
|
|
@@ -249,30 +503,11 @@ var StampedeGuard = class {
|
|
|
249
503
|
};
|
|
250
504
|
|
|
251
505
|
// src/CacheStack.ts
|
|
252
|
-
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
253
506
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
254
507
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
255
508
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
256
509
|
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
|
-
});
|
|
510
|
+
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
276
511
|
var DebugLogger = class {
|
|
277
512
|
enabled;
|
|
278
513
|
constructor(enabled) {
|
|
@@ -307,6 +542,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
307
542
|
throw new Error("CacheStack requires at least one cache layer.");
|
|
308
543
|
}
|
|
309
544
|
this.validateConfiguration();
|
|
545
|
+
const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
|
|
546
|
+
this.ttlResolver = new TtlResolver({ maxProfileEntries });
|
|
547
|
+
this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
|
|
548
|
+
if (options.publishSetInvalidation !== void 0) {
|
|
549
|
+
console.warn(
|
|
550
|
+
"[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
|
|
551
|
+
);
|
|
552
|
+
}
|
|
310
553
|
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
311
554
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
312
555
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
@@ -315,36 +558,42 @@ var CacheStack = class extends EventEmitter {
|
|
|
315
558
|
layers;
|
|
316
559
|
options;
|
|
317
560
|
stampedeGuard = new StampedeGuard();
|
|
318
|
-
|
|
561
|
+
metricsCollector = new MetricsCollector();
|
|
319
562
|
instanceId = randomUUID();
|
|
320
563
|
startup;
|
|
321
564
|
unsubscribeInvalidation;
|
|
322
565
|
logger;
|
|
323
566
|
tagIndex;
|
|
324
567
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
325
|
-
accessProfiles = /* @__PURE__ */ new Map();
|
|
326
568
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
327
|
-
|
|
569
|
+
ttlResolver;
|
|
570
|
+
circuitBreakerManager;
|
|
328
571
|
isDisconnecting = false;
|
|
329
572
|
disconnectPromise;
|
|
573
|
+
/**
|
|
574
|
+
* Read-through cache get.
|
|
575
|
+
* Returns the cached value if present and fresh, or invokes `fetcher` on a miss
|
|
576
|
+
* and stores the result across all layers. Returns `null` if the key is not found
|
|
577
|
+
* and no `fetcher` is provided.
|
|
578
|
+
*/
|
|
330
579
|
async get(key, fetcher, options) {
|
|
331
580
|
const normalizedKey = this.validateCacheKey(key);
|
|
332
581
|
this.validateWriteOptions(options);
|
|
333
582
|
await this.startup;
|
|
334
583
|
const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
|
|
335
584
|
if (hit.found) {
|
|
336
|
-
this.recordAccess(normalizedKey);
|
|
585
|
+
this.ttlResolver.recordAccess(normalizedKey);
|
|
337
586
|
if (this.isNegativeStoredValue(hit.stored)) {
|
|
338
|
-
this.
|
|
587
|
+
this.metricsCollector.increment("negativeCacheHits");
|
|
339
588
|
}
|
|
340
589
|
if (hit.state === "fresh") {
|
|
341
|
-
this.
|
|
590
|
+
this.metricsCollector.increment("hits");
|
|
342
591
|
await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
|
|
343
592
|
return hit.value;
|
|
344
593
|
}
|
|
345
594
|
if (hit.state === "stale-while-revalidate") {
|
|
346
|
-
this.
|
|
347
|
-
this.
|
|
595
|
+
this.metricsCollector.increment("hits");
|
|
596
|
+
this.metricsCollector.increment("staleHits");
|
|
348
597
|
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
349
598
|
if (fetcher) {
|
|
350
599
|
this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
|
|
@@ -352,47 +601,136 @@ var CacheStack = class extends EventEmitter {
|
|
|
352
601
|
return hit.value;
|
|
353
602
|
}
|
|
354
603
|
if (!fetcher) {
|
|
355
|
-
this.
|
|
356
|
-
this.
|
|
604
|
+
this.metricsCollector.increment("hits");
|
|
605
|
+
this.metricsCollector.increment("staleHits");
|
|
357
606
|
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
358
607
|
return hit.value;
|
|
359
608
|
}
|
|
360
609
|
try {
|
|
361
610
|
return await this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
362
611
|
} catch (error) {
|
|
363
|
-
this.
|
|
364
|
-
this.
|
|
612
|
+
this.metricsCollector.increment("staleHits");
|
|
613
|
+
this.metricsCollector.increment("refreshErrors");
|
|
365
614
|
this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
|
|
366
615
|
return hit.value;
|
|
367
616
|
}
|
|
368
617
|
}
|
|
369
|
-
this.
|
|
618
|
+
this.metricsCollector.increment("misses");
|
|
370
619
|
if (!fetcher) {
|
|
371
620
|
return null;
|
|
372
621
|
}
|
|
373
622
|
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
374
623
|
}
|
|
624
|
+
/**
|
|
625
|
+
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
626
|
+
* Fetches and caches the value if not already present.
|
|
627
|
+
*/
|
|
628
|
+
async getOrSet(key, fetcher, options) {
|
|
629
|
+
return this.get(key, fetcher, options);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Returns true if the given key exists and is not expired in any layer.
|
|
633
|
+
*/
|
|
634
|
+
async has(key) {
|
|
635
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
636
|
+
await this.startup;
|
|
637
|
+
for (const layer of this.layers) {
|
|
638
|
+
if (this.shouldSkipLayer(layer)) {
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (layer.has) {
|
|
642
|
+
try {
|
|
643
|
+
const exists = await layer.has(normalizedKey);
|
|
644
|
+
if (exists) {
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
} catch {
|
|
648
|
+
}
|
|
649
|
+
} else {
|
|
650
|
+
try {
|
|
651
|
+
const value = await layer.get(normalizedKey);
|
|
652
|
+
if (value !== null) {
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
} catch {
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Returns the remaining TTL in seconds for the key in the fastest layer
|
|
663
|
+
* that has it, or null if the key is not found / has no TTL.
|
|
664
|
+
*/
|
|
665
|
+
async ttl(key) {
|
|
666
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
667
|
+
await this.startup;
|
|
668
|
+
for (const layer of this.layers) {
|
|
669
|
+
if (this.shouldSkipLayer(layer)) {
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (layer.ttl) {
|
|
673
|
+
try {
|
|
674
|
+
const remaining = await layer.ttl(normalizedKey);
|
|
675
|
+
if (remaining !== null) {
|
|
676
|
+
return remaining;
|
|
677
|
+
}
|
|
678
|
+
} catch {
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Stores a value in all cache layers. Overwrites any existing value.
|
|
686
|
+
*/
|
|
375
687
|
async set(key, value, options) {
|
|
376
688
|
const normalizedKey = this.validateCacheKey(key);
|
|
377
689
|
this.validateWriteOptions(options);
|
|
378
690
|
await this.startup;
|
|
379
691
|
await this.storeEntry(normalizedKey, "value", value, options);
|
|
380
692
|
}
|
|
693
|
+
/**
|
|
694
|
+
* Deletes the key from all layers and publishes an invalidation message.
|
|
695
|
+
*/
|
|
381
696
|
async delete(key) {
|
|
382
697
|
const normalizedKey = this.validateCacheKey(key);
|
|
383
698
|
await this.startup;
|
|
384
699
|
await this.deleteKeys([normalizedKey]);
|
|
385
|
-
await this.publishInvalidation({
|
|
700
|
+
await this.publishInvalidation({
|
|
701
|
+
scope: "key",
|
|
702
|
+
keys: [normalizedKey],
|
|
703
|
+
sourceId: this.instanceId,
|
|
704
|
+
operation: "delete"
|
|
705
|
+
});
|
|
386
706
|
}
|
|
387
707
|
async clear() {
|
|
388
708
|
await this.startup;
|
|
389
709
|
await Promise.all(this.layers.map((layer) => layer.clear()));
|
|
390
710
|
await this.tagIndex.clear();
|
|
391
|
-
this.
|
|
392
|
-
this.
|
|
711
|
+
this.ttlResolver.clearProfiles();
|
|
712
|
+
this.circuitBreakerManager.clear();
|
|
713
|
+
this.metricsCollector.increment("invalidations");
|
|
393
714
|
this.logger.debug?.("clear");
|
|
394
715
|
await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
|
|
395
716
|
}
|
|
717
|
+
/**
|
|
718
|
+
* Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
|
|
719
|
+
*/
|
|
720
|
+
async mdelete(keys) {
|
|
721
|
+
if (keys.length === 0) {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
await this.startup;
|
|
725
|
+
const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
|
|
726
|
+
await this.deleteKeys(normalizedKeys);
|
|
727
|
+
await this.publishInvalidation({
|
|
728
|
+
scope: "keys",
|
|
729
|
+
keys: normalizedKeys,
|
|
730
|
+
sourceId: this.instanceId,
|
|
731
|
+
operation: "delete"
|
|
732
|
+
});
|
|
733
|
+
}
|
|
396
734
|
async mget(entries) {
|
|
397
735
|
if (entries.length === 0) {
|
|
398
736
|
return [];
|
|
@@ -430,7 +768,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
430
768
|
const indexesByKey = /* @__PURE__ */ new Map();
|
|
431
769
|
const resultsByKey = /* @__PURE__ */ new Map();
|
|
432
770
|
for (let index = 0; index < normalizedEntries.length; index += 1) {
|
|
433
|
-
const
|
|
771
|
+
const entry = normalizedEntries[index];
|
|
772
|
+
if (!entry) continue;
|
|
773
|
+
const key = entry.key;
|
|
434
774
|
const indexes = indexesByKey.get(key) ?? [];
|
|
435
775
|
indexes.push(index);
|
|
436
776
|
indexesByKey.set(key, indexes);
|
|
@@ -438,6 +778,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
438
778
|
}
|
|
439
779
|
for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
|
|
440
780
|
const layer = this.layers[layerIndex];
|
|
781
|
+
if (!layer) continue;
|
|
441
782
|
const keys = [...pending];
|
|
442
783
|
if (keys.length === 0) {
|
|
443
784
|
break;
|
|
@@ -446,7 +787,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
446
787
|
for (let offset = 0; offset < values.length; offset += 1) {
|
|
447
788
|
const key = keys[offset];
|
|
448
789
|
const stored = values[offset];
|
|
449
|
-
if (stored === null) {
|
|
790
|
+
if (!key || stored === null) {
|
|
450
791
|
continue;
|
|
451
792
|
}
|
|
452
793
|
const resolved = resolveStoredValue(stored);
|
|
@@ -458,13 +799,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
458
799
|
await this.backfill(key, stored, layerIndex - 1);
|
|
459
800
|
resultsByKey.set(key, resolved.value);
|
|
460
801
|
pending.delete(key);
|
|
461
|
-
this.
|
|
802
|
+
this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
|
|
462
803
|
}
|
|
463
804
|
}
|
|
464
805
|
if (pending.size > 0) {
|
|
465
806
|
for (const key of pending) {
|
|
466
807
|
await this.tagIndex.remove(key);
|
|
467
|
-
this.
|
|
808
|
+
this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
|
|
468
809
|
}
|
|
469
810
|
}
|
|
470
811
|
return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
|
|
@@ -479,26 +820,38 @@ var CacheStack = class extends EventEmitter {
|
|
|
479
820
|
}
|
|
480
821
|
async warm(entries, options = {}) {
|
|
481
822
|
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
823
|
+
const total = entries.length;
|
|
824
|
+
let completed = 0;
|
|
482
825
|
const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
|
|
483
|
-
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
826
|
+
const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
|
|
484
827
|
while (queue.length > 0) {
|
|
485
828
|
const entry = queue.shift();
|
|
486
829
|
if (!entry) {
|
|
487
830
|
return;
|
|
488
831
|
}
|
|
832
|
+
let success = false;
|
|
489
833
|
try {
|
|
490
834
|
await this.get(entry.key, entry.fetcher, entry.options);
|
|
491
835
|
this.emit("warm", { key: entry.key });
|
|
836
|
+
success = true;
|
|
492
837
|
} catch (error) {
|
|
493
838
|
this.emitError("warm", { key: entry.key, error: this.formatError(error) });
|
|
494
839
|
if (!options.continueOnError) {
|
|
495
840
|
throw error;
|
|
496
841
|
}
|
|
842
|
+
} finally {
|
|
843
|
+
completed += 1;
|
|
844
|
+
const progress = { completed, total, key: entry.key, success };
|
|
845
|
+
options.onProgress?.(progress);
|
|
497
846
|
}
|
|
498
847
|
}
|
|
499
848
|
});
|
|
500
849
|
await Promise.all(workers);
|
|
501
850
|
}
|
|
851
|
+
/**
|
|
852
|
+
* Returns a cached version of `fetcher`. The cache key is derived from
|
|
853
|
+
* `prefix` plus the serialized arguments unless a `keyResolver` is provided.
|
|
854
|
+
*/
|
|
502
855
|
wrap(prefix, fetcher, options = {}) {
|
|
503
856
|
return (...args) => {
|
|
504
857
|
const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
|
|
@@ -506,6 +859,10 @@ var CacheStack = class extends EventEmitter {
|
|
|
506
859
|
return this.get(key, () => fetcher(...args), options);
|
|
507
860
|
};
|
|
508
861
|
}
|
|
862
|
+
/**
|
|
863
|
+
* Creates a `CacheNamespace` that automatically prefixes all keys with
|
|
864
|
+
* `prefix:`. Useful for multi-tenant or module-level isolation.
|
|
865
|
+
*/
|
|
509
866
|
namespace(prefix) {
|
|
510
867
|
return new CacheNamespace(this, prefix);
|
|
511
868
|
}
|
|
@@ -522,7 +879,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
522
879
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
523
880
|
}
|
|
524
881
|
getMetrics() {
|
|
525
|
-
return
|
|
882
|
+
return this.metricsCollector.snapshot;
|
|
526
883
|
}
|
|
527
884
|
getStats() {
|
|
528
885
|
return {
|
|
@@ -536,7 +893,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
536
893
|
};
|
|
537
894
|
}
|
|
538
895
|
resetMetrics() {
|
|
539
|
-
|
|
896
|
+
this.metricsCollector.reset();
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Returns computed hit-rate statistics (overall and per-layer).
|
|
900
|
+
*/
|
|
901
|
+
getHitRate() {
|
|
902
|
+
return this.metricsCollector.hitRate();
|
|
540
903
|
}
|
|
541
904
|
async exportState() {
|
|
542
905
|
await this.startup;
|
|
@@ -565,10 +928,12 @@ var CacheStack = class extends EventEmitter {
|
|
|
565
928
|
}
|
|
566
929
|
async importState(entries) {
|
|
567
930
|
await this.startup;
|
|
568
|
-
await Promise.all(
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
931
|
+
await Promise.all(
|
|
932
|
+
entries.map(async (entry) => {
|
|
933
|
+
await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
|
|
934
|
+
await this.tagIndex.touch(entry.key);
|
|
935
|
+
})
|
|
936
|
+
);
|
|
572
937
|
}
|
|
573
938
|
async persistToFile(filePath) {
|
|
574
939
|
const snapshot = await this.exportState();
|
|
@@ -576,11 +941,21 @@ var CacheStack = class extends EventEmitter {
|
|
|
576
941
|
}
|
|
577
942
|
async restoreFromFile(filePath) {
|
|
578
943
|
const raw = await fs.readFile(filePath, "utf8");
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
944
|
+
let parsed;
|
|
945
|
+
try {
|
|
946
|
+
parsed = JSON.parse(raw, (_key, value) => {
|
|
947
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
948
|
+
return Object.assign(/* @__PURE__ */ Object.create(null), value);
|
|
949
|
+
}
|
|
950
|
+
return value;
|
|
951
|
+
});
|
|
952
|
+
} catch (cause) {
|
|
953
|
+
throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
|
|
954
|
+
}
|
|
955
|
+
if (!this.isCacheSnapshotEntries(parsed)) {
|
|
956
|
+
throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
|
|
582
957
|
}
|
|
583
|
-
await this.importState(
|
|
958
|
+
await this.importState(parsed);
|
|
584
959
|
}
|
|
585
960
|
async disconnect() {
|
|
586
961
|
if (!this.disconnectPromise) {
|
|
@@ -605,7 +980,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
605
980
|
const fetchTask = async () => {
|
|
606
981
|
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
607
982
|
if (secondHit.found) {
|
|
608
|
-
this.
|
|
983
|
+
this.metricsCollector.increment("hits");
|
|
609
984
|
return secondHit.value;
|
|
610
985
|
}
|
|
611
986
|
return this.fetchAndPopulate(key, fetcher, options);
|
|
@@ -630,12 +1005,12 @@ var CacheStack = class extends EventEmitter {
|
|
|
630
1005
|
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
631
1006
|
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
632
1007
|
const deadline = Date.now() + timeoutMs;
|
|
633
|
-
this.
|
|
1008
|
+
this.metricsCollector.increment("singleFlightWaits");
|
|
634
1009
|
this.emit("stampede-dedupe", { key });
|
|
635
1010
|
while (Date.now() < deadline) {
|
|
636
1011
|
const hit = await this.readFromLayers(key, options, "fresh-only");
|
|
637
1012
|
if (hit.found) {
|
|
638
|
-
this.
|
|
1013
|
+
this.metricsCollector.increment("hits");
|
|
639
1014
|
return hit.value;
|
|
640
1015
|
}
|
|
641
1016
|
await this.sleep(pollIntervalMs);
|
|
@@ -643,12 +1018,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
643
1018
|
return this.fetchAndPopulate(key, fetcher, options);
|
|
644
1019
|
}
|
|
645
1020
|
async fetchAndPopulate(key, fetcher, options) {
|
|
646
|
-
this.
|
|
647
|
-
this.
|
|
1021
|
+
this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
|
|
1022
|
+
this.metricsCollector.increment("fetches");
|
|
1023
|
+
const fetchStart = Date.now();
|
|
648
1024
|
let fetched;
|
|
649
1025
|
try {
|
|
650
1026
|
fetched = await fetcher();
|
|
651
|
-
this.
|
|
1027
|
+
this.circuitBreakerManager.recordSuccess(key);
|
|
1028
|
+
this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
|
|
652
1029
|
} catch (error) {
|
|
653
1030
|
this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
|
|
654
1031
|
throw error;
|
|
@@ -670,7 +1047,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
670
1047
|
} else {
|
|
671
1048
|
await this.tagIndex.touch(key);
|
|
672
1049
|
}
|
|
673
|
-
this.
|
|
1050
|
+
this.metricsCollector.increment("sets");
|
|
674
1051
|
this.logger.debug?.("set", { key, kind, tags: options?.tags });
|
|
675
1052
|
this.emit("set", { key, kind, tags: options?.tags });
|
|
676
1053
|
if (this.shouldBroadcastL1Invalidation()) {
|
|
@@ -681,9 +1058,10 @@ var CacheStack = class extends EventEmitter {
|
|
|
681
1058
|
let sawRetainableValue = false;
|
|
682
1059
|
for (let index = 0; index < this.layers.length; index += 1) {
|
|
683
1060
|
const layer = this.layers[index];
|
|
1061
|
+
if (!layer) continue;
|
|
684
1062
|
const stored = await this.readLayerEntry(layer, key);
|
|
685
1063
|
if (stored === null) {
|
|
686
|
-
this.
|
|
1064
|
+
this.metricsCollector.incrementLayer("missesByLayer", layer.name);
|
|
687
1065
|
continue;
|
|
688
1066
|
}
|
|
689
1067
|
const resolved = resolveStoredValue(stored);
|
|
@@ -697,10 +1075,17 @@ var CacheStack = class extends EventEmitter {
|
|
|
697
1075
|
}
|
|
698
1076
|
await this.tagIndex.touch(key);
|
|
699
1077
|
await this.backfill(key, stored, index - 1, options);
|
|
700
|
-
this.
|
|
1078
|
+
this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
|
|
701
1079
|
this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
|
|
702
1080
|
this.emit("hit", { key, layer: layer.name, state: resolved.state });
|
|
703
|
-
return {
|
|
1081
|
+
return {
|
|
1082
|
+
found: true,
|
|
1083
|
+
value: resolved.value,
|
|
1084
|
+
stored,
|
|
1085
|
+
state: resolved.state,
|
|
1086
|
+
layerIndex: index,
|
|
1087
|
+
layerName: layer.name
|
|
1088
|
+
};
|
|
704
1089
|
}
|
|
705
1090
|
if (!sawRetainableValue) {
|
|
706
1091
|
await this.tagIndex.remove(key);
|
|
@@ -732,7 +1117,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
732
1117
|
}
|
|
733
1118
|
for (let index = 0; index <= upToIndex; index += 1) {
|
|
734
1119
|
const layer = this.layers[index];
|
|
735
|
-
if (this.shouldSkipLayer(layer)) {
|
|
1120
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
736
1121
|
continue;
|
|
737
1122
|
}
|
|
738
1123
|
const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
|
|
@@ -742,7 +1127,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
742
1127
|
await this.handleLayerFailure(layer, "backfill", error);
|
|
743
1128
|
continue;
|
|
744
1129
|
}
|
|
745
|
-
this.
|
|
1130
|
+
this.metricsCollector.increment("backfills");
|
|
746
1131
|
this.logger.debug?.("backfill", { key, layer: layer.name });
|
|
747
1132
|
this.emit("backfill", { key, layer: layer.name });
|
|
748
1133
|
}
|
|
@@ -759,11 +1144,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
759
1144
|
options?.staleWhileRevalidate,
|
|
760
1145
|
this.options.staleWhileRevalidate
|
|
761
1146
|
);
|
|
762
|
-
const staleIfError = this.resolveLayerSeconds(
|
|
763
|
-
layer.name,
|
|
764
|
-
options?.staleIfError,
|
|
765
|
-
this.options.staleIfError
|
|
766
|
-
);
|
|
1147
|
+
const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
|
|
767
1148
|
const payload = createStoredValueEnvelope({
|
|
768
1149
|
kind,
|
|
769
1150
|
value,
|
|
@@ -791,7 +1172,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
791
1172
|
if (failures.length === 0) {
|
|
792
1173
|
return;
|
|
793
1174
|
}
|
|
794
|
-
this.
|
|
1175
|
+
this.metricsCollector.increment("writeFailures", failures.length);
|
|
795
1176
|
this.logger.debug?.("write-failure", {
|
|
796
1177
|
...context,
|
|
797
1178
|
failures: failures.map((failure) => this.formatError(failure.reason))
|
|
@@ -804,42 +1185,10 @@ var CacheStack = class extends EventEmitter {
|
|
|
804
1185
|
}
|
|
805
1186
|
}
|
|
806
1187
|
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);
|
|
1188
|
+
return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
|
|
821
1189
|
}
|
|
822
1190
|
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));
|
|
1191
|
+
return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
|
|
843
1192
|
}
|
|
844
1193
|
shouldNegativeCache(options) {
|
|
845
1194
|
return options?.negativeCache ?? this.options.negativeCaching ?? false;
|
|
@@ -849,11 +1198,11 @@ var CacheStack = class extends EventEmitter {
|
|
|
849
1198
|
return;
|
|
850
1199
|
}
|
|
851
1200
|
const refresh = (async () => {
|
|
852
|
-
this.
|
|
1201
|
+
this.metricsCollector.increment("refreshes");
|
|
853
1202
|
try {
|
|
854
1203
|
await this.fetchWithGuards(key, fetcher, options);
|
|
855
1204
|
} catch (error) {
|
|
856
|
-
this.
|
|
1205
|
+
this.metricsCollector.increment("refreshErrors");
|
|
857
1206
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
858
1207
|
} finally {
|
|
859
1208
|
this.backgroundRefreshes.delete(key);
|
|
@@ -875,10 +1224,11 @@ var CacheStack = class extends EventEmitter {
|
|
|
875
1224
|
await this.deleteKeysFromLayers(this.layers, keys);
|
|
876
1225
|
for (const key of keys) {
|
|
877
1226
|
await this.tagIndex.remove(key);
|
|
878
|
-
this.
|
|
1227
|
+
this.ttlResolver.deleteProfile(key);
|
|
1228
|
+
this.circuitBreakerManager.delete(key);
|
|
879
1229
|
}
|
|
880
|
-
this.
|
|
881
|
-
this.
|
|
1230
|
+
this.metricsCollector.increment("deletes", keys.length);
|
|
1231
|
+
this.metricsCollector.increment("invalidations");
|
|
882
1232
|
this.logger.debug?.("delete", { keys });
|
|
883
1233
|
this.emit("delete", { keys });
|
|
884
1234
|
}
|
|
@@ -899,7 +1249,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
899
1249
|
if (message.scope === "clear") {
|
|
900
1250
|
await Promise.all(localLayers.map((layer) => layer.clear()));
|
|
901
1251
|
await this.tagIndex.clear();
|
|
902
|
-
this.
|
|
1252
|
+
this.ttlResolver.clearProfiles();
|
|
903
1253
|
return;
|
|
904
1254
|
}
|
|
905
1255
|
const keys = message.keys ?? [];
|
|
@@ -907,7 +1257,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
907
1257
|
if (message.operation !== "write") {
|
|
908
1258
|
for (const key of keys) {
|
|
909
1259
|
await this.tagIndex.remove(key);
|
|
910
|
-
this.
|
|
1260
|
+
this.ttlResolver.deleteProfile(key);
|
|
911
1261
|
}
|
|
912
1262
|
}
|
|
913
1263
|
}
|
|
@@ -937,13 +1287,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
937
1287
|
}
|
|
938
1288
|
return;
|
|
939
1289
|
}
|
|
940
|
-
await Promise.all(
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1290
|
+
await Promise.all(
|
|
1291
|
+
keys.map(async (key) => {
|
|
1292
|
+
try {
|
|
1293
|
+
await layer.delete(key);
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
await this.handleLayerFailure(layer, "delete", error);
|
|
1296
|
+
}
|
|
1297
|
+
})
|
|
1298
|
+
);
|
|
947
1299
|
})
|
|
948
1300
|
);
|
|
949
1301
|
}
|
|
@@ -1044,7 +1396,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1044
1396
|
const ttl = remainingStoredTtlSeconds(refreshed);
|
|
1045
1397
|
for (let index = 0; index <= hit.layerIndex; index += 1) {
|
|
1046
1398
|
const layer = this.layers[index];
|
|
1047
|
-
if (this.shouldSkipLayer(layer)) {
|
|
1399
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
1048
1400
|
continue;
|
|
1049
1401
|
}
|
|
1050
1402
|
try {
|
|
@@ -1058,33 +1410,6 @@ var CacheStack = class extends EventEmitter {
|
|
|
1058
1410
|
this.scheduleBackgroundRefresh(key, fetcher, options);
|
|
1059
1411
|
}
|
|
1060
1412
|
}
|
|
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;
|
|
1068
|
-
}
|
|
1069
|
-
const config = adaptiveTtl === true ? {} : adaptiveTtl;
|
|
1070
|
-
const hotAfter = config.hotAfter ?? 3;
|
|
1071
|
-
if (profile.hits < hotAfter) {
|
|
1072
|
-
return ttl;
|
|
1073
|
-
}
|
|
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
|
-
}
|
|
1088
1413
|
shouldSkipLayer(layer) {
|
|
1089
1414
|
const degradedUntil = this.layerDegradedUntil.get(layer.name);
|
|
1090
1415
|
return degradedUntil !== void 0 && degradedUntil > Date.now();
|
|
@@ -1095,7 +1420,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1095
1420
|
}
|
|
1096
1421
|
const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
|
|
1097
1422
|
this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
|
|
1098
|
-
this.
|
|
1423
|
+
this.metricsCollector.increment("degradedOperations");
|
|
1099
1424
|
this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
|
|
1100
1425
|
this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
|
|
1101
1426
|
return null;
|
|
@@ -1103,37 +1428,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
1103
1428
|
isGracefulDegradationEnabled() {
|
|
1104
1429
|
return Boolean(this.options.gracefulDegradation);
|
|
1105
1430
|
}
|
|
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
1431
|
recordCircuitFailure(key, options, error) {
|
|
1121
1432
|
if (!options) {
|
|
1122
1433
|
return;
|
|
1123
1434
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
state.failures += 1;
|
|
1128
|
-
if (state.failures >= failureThreshold) {
|
|
1129
|
-
state.openUntil = Date.now() + cooldownMs;
|
|
1130
|
-
this.metrics.circuitBreakerTrips += 1;
|
|
1435
|
+
this.circuitBreakerManager.recordFailure(key, options);
|
|
1436
|
+
if (this.circuitBreakerManager.isOpen(key)) {
|
|
1437
|
+
this.metricsCollector.increment("circuitBreakerTrips");
|
|
1131
1438
|
}
|
|
1132
|
-
this.
|
|
1133
|
-
this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
|
|
1134
|
-
}
|
|
1135
|
-
resetCircuitBreaker(key) {
|
|
1136
|
-
this.circuitBreakers.delete(key);
|
|
1439
|
+
this.emitError("fetch", { key, error: this.formatError(error) });
|
|
1137
1440
|
}
|
|
1138
1441
|
isNegativeStoredValue(stored) {
|
|
1139
1442
|
return isStoredValueEnvelope(stored) && stored.kind === "empty";
|
|
@@ -1323,11 +1626,13 @@ var MemoryLayer = class {
|
|
|
1323
1626
|
defaultTtl;
|
|
1324
1627
|
isLocal = true;
|
|
1325
1628
|
maxSize;
|
|
1629
|
+
evictionPolicy;
|
|
1326
1630
|
entries = /* @__PURE__ */ new Map();
|
|
1327
1631
|
constructor(options = {}) {
|
|
1328
1632
|
this.name = options.name ?? "memory";
|
|
1329
1633
|
this.defaultTtl = options.ttl;
|
|
1330
1634
|
this.maxSize = options.maxSize ?? 1e3;
|
|
1635
|
+
this.evictionPolicy = options.evictionPolicy ?? "lru";
|
|
1331
1636
|
}
|
|
1332
1637
|
async get(key) {
|
|
1333
1638
|
const value = await this.getEntry(key);
|
|
@@ -1342,8 +1647,13 @@ var MemoryLayer = class {
|
|
|
1342
1647
|
this.entries.delete(key);
|
|
1343
1648
|
return null;
|
|
1344
1649
|
}
|
|
1345
|
-
this.
|
|
1346
|
-
|
|
1650
|
+
if (this.evictionPolicy === "lru") {
|
|
1651
|
+
this.entries.delete(key);
|
|
1652
|
+
entry.frequency += 1;
|
|
1653
|
+
this.entries.set(key, entry);
|
|
1654
|
+
} else {
|
|
1655
|
+
entry.frequency += 1;
|
|
1656
|
+
}
|
|
1347
1657
|
return entry.value;
|
|
1348
1658
|
}
|
|
1349
1659
|
async getMany(keys) {
|
|
@@ -1357,16 +1667,43 @@ var MemoryLayer = class {
|
|
|
1357
1667
|
this.entries.delete(key);
|
|
1358
1668
|
this.entries.set(key, {
|
|
1359
1669
|
value,
|
|
1360
|
-
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
1670
|
+
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null,
|
|
1671
|
+
frequency: 0,
|
|
1672
|
+
insertedAt: Date.now()
|
|
1361
1673
|
});
|
|
1362
1674
|
while (this.entries.size > this.maxSize) {
|
|
1363
|
-
|
|
1364
|
-
if (!oldestKey) {
|
|
1365
|
-
break;
|
|
1366
|
-
}
|
|
1367
|
-
this.entries.delete(oldestKey);
|
|
1675
|
+
this.evict();
|
|
1368
1676
|
}
|
|
1369
1677
|
}
|
|
1678
|
+
async has(key) {
|
|
1679
|
+
const entry = this.entries.get(key);
|
|
1680
|
+
if (!entry) {
|
|
1681
|
+
return false;
|
|
1682
|
+
}
|
|
1683
|
+
if (this.isExpired(entry)) {
|
|
1684
|
+
this.entries.delete(key);
|
|
1685
|
+
return false;
|
|
1686
|
+
}
|
|
1687
|
+
return true;
|
|
1688
|
+
}
|
|
1689
|
+
async ttl(key) {
|
|
1690
|
+
const entry = this.entries.get(key);
|
|
1691
|
+
if (!entry) {
|
|
1692
|
+
return null;
|
|
1693
|
+
}
|
|
1694
|
+
if (this.isExpired(entry)) {
|
|
1695
|
+
this.entries.delete(key);
|
|
1696
|
+
return null;
|
|
1697
|
+
}
|
|
1698
|
+
if (entry.expiresAt === null) {
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
return Math.max(0, Math.ceil((entry.expiresAt - Date.now()) / 1e3));
|
|
1702
|
+
}
|
|
1703
|
+
async size() {
|
|
1704
|
+
this.pruneExpired();
|
|
1705
|
+
return this.entries.size;
|
|
1706
|
+
}
|
|
1370
1707
|
async delete(key) {
|
|
1371
1708
|
this.entries.delete(key);
|
|
1372
1709
|
}
|
|
@@ -1397,15 +1734,35 @@ var MemoryLayer = class {
|
|
|
1397
1734
|
}
|
|
1398
1735
|
this.entries.set(entry.key, {
|
|
1399
1736
|
value: entry.value,
|
|
1400
|
-
expiresAt: entry.expiresAt
|
|
1737
|
+
expiresAt: entry.expiresAt,
|
|
1738
|
+
frequency: 0,
|
|
1739
|
+
insertedAt: Date.now()
|
|
1401
1740
|
});
|
|
1402
1741
|
}
|
|
1403
1742
|
while (this.entries.size > this.maxSize) {
|
|
1743
|
+
this.evict();
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
evict() {
|
|
1747
|
+
if (this.evictionPolicy === "lru" || this.evictionPolicy === "fifo") {
|
|
1404
1748
|
const oldestKey = this.entries.keys().next().value;
|
|
1405
|
-
if (
|
|
1406
|
-
|
|
1749
|
+
if (oldestKey !== void 0) {
|
|
1750
|
+
this.entries.delete(oldestKey);
|
|
1407
1751
|
}
|
|
1408
|
-
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
let victimKey;
|
|
1755
|
+
let minFreq = Number.POSITIVE_INFINITY;
|
|
1756
|
+
let minInsertedAt = Number.POSITIVE_INFINITY;
|
|
1757
|
+
for (const [key, entry] of this.entries.entries()) {
|
|
1758
|
+
if (entry.frequency < minFreq || entry.frequency === minFreq && entry.insertedAt < minInsertedAt) {
|
|
1759
|
+
minFreq = entry.frequency;
|
|
1760
|
+
minInsertedAt = entry.insertedAt;
|
|
1761
|
+
victimKey = key;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
if (victimKey !== void 0) {
|
|
1765
|
+
this.entries.delete(victimKey);
|
|
1409
1766
|
}
|
|
1410
1767
|
}
|
|
1411
1768
|
pruneExpired() {
|
|
@@ -1421,7 +1778,7 @@ var MemoryLayer = class {
|
|
|
1421
1778
|
};
|
|
1422
1779
|
|
|
1423
1780
|
// src/layers/RedisLayer.ts
|
|
1424
|
-
import { brotliCompressSync, brotliDecompressSync,
|
|
1781
|
+
import { brotliCompressSync, brotliDecompressSync, gunzipSync, gzipSync } from "zlib";
|
|
1425
1782
|
|
|
1426
1783
|
// src/serialization/JsonSerializer.ts
|
|
1427
1784
|
var JsonSerializer = class {
|
|
@@ -1435,6 +1792,7 @@ var JsonSerializer = class {
|
|
|
1435
1792
|
};
|
|
1436
1793
|
|
|
1437
1794
|
// src/layers/RedisLayer.ts
|
|
1795
|
+
var BATCH_DELETE_SIZE = 500;
|
|
1438
1796
|
var RedisLayer = class {
|
|
1439
1797
|
name;
|
|
1440
1798
|
defaultTtl;
|
|
@@ -1486,7 +1844,7 @@ var RedisLayer = class {
|
|
|
1486
1844
|
if (error || payload === null || !this.isSerializablePayload(payload)) {
|
|
1487
1845
|
return null;
|
|
1488
1846
|
}
|
|
1489
|
-
return this.deserializeOrDelete(keys[index], payload);
|
|
1847
|
+
return this.deserializeOrDelete(keys[index] ?? "", payload);
|
|
1490
1848
|
})
|
|
1491
1849
|
);
|
|
1492
1850
|
}
|
|
@@ -1508,14 +1866,44 @@ var RedisLayer = class {
|
|
|
1508
1866
|
}
|
|
1509
1867
|
await this.client.del(...keys.map((key) => this.withPrefix(key)));
|
|
1510
1868
|
}
|
|
1511
|
-
async
|
|
1512
|
-
|
|
1513
|
-
|
|
1869
|
+
async has(key) {
|
|
1870
|
+
const exists = await this.client.exists(this.withPrefix(key));
|
|
1871
|
+
return exists > 0;
|
|
1872
|
+
}
|
|
1873
|
+
async ttl(key) {
|
|
1874
|
+
const remaining = await this.client.ttl(this.withPrefix(key));
|
|
1875
|
+
if (remaining < 0) {
|
|
1876
|
+
return null;
|
|
1514
1877
|
}
|
|
1878
|
+
return remaining;
|
|
1879
|
+
}
|
|
1880
|
+
async size() {
|
|
1515
1881
|
const keys = await this.keys();
|
|
1516
|
-
|
|
1517
|
-
|
|
1882
|
+
return keys.length;
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Deletes all keys matching the layer's prefix in batches to avoid
|
|
1886
|
+
* loading millions of keys into memory at once.
|
|
1887
|
+
*/
|
|
1888
|
+
async clear() {
|
|
1889
|
+
if (!this.prefix && !this.allowUnprefixedClear) {
|
|
1890
|
+
throw new Error(
|
|
1891
|
+
"RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys."
|
|
1892
|
+
);
|
|
1518
1893
|
}
|
|
1894
|
+
const pattern = `${this.prefix}*`;
|
|
1895
|
+
let cursor = "0";
|
|
1896
|
+
do {
|
|
1897
|
+
const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
|
|
1898
|
+
cursor = nextCursor;
|
|
1899
|
+
if (keys.length === 0) {
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
for (let i = 0; i < keys.length; i += BATCH_DELETE_SIZE) {
|
|
1903
|
+
const batch = keys.slice(i, i + BATCH_DELETE_SIZE);
|
|
1904
|
+
await this.client.del(...batch);
|
|
1905
|
+
}
|
|
1906
|
+
} while (cursor !== "0");
|
|
1519
1907
|
}
|
|
1520
1908
|
async keys() {
|
|
1521
1909
|
const pattern = `${this.prefix}*`;
|
|
@@ -1575,6 +1963,170 @@ var RedisLayer = class {
|
|
|
1575
1963
|
}
|
|
1576
1964
|
};
|
|
1577
1965
|
|
|
1966
|
+
// src/layers/DiskLayer.ts
|
|
1967
|
+
import { createHash } from "crypto";
|
|
1968
|
+
import { promises as fs2 } from "fs";
|
|
1969
|
+
import { join } from "path";
|
|
1970
|
+
var DiskLayer = class {
|
|
1971
|
+
name;
|
|
1972
|
+
defaultTtl;
|
|
1973
|
+
isLocal = true;
|
|
1974
|
+
directory;
|
|
1975
|
+
serializer;
|
|
1976
|
+
constructor(options) {
|
|
1977
|
+
this.directory = options.directory;
|
|
1978
|
+
this.defaultTtl = options.ttl;
|
|
1979
|
+
this.name = options.name ?? "disk";
|
|
1980
|
+
this.serializer = options.serializer ?? new JsonSerializer();
|
|
1981
|
+
}
|
|
1982
|
+
async get(key) {
|
|
1983
|
+
return unwrapStoredValue(await this.getEntry(key));
|
|
1984
|
+
}
|
|
1985
|
+
async getEntry(key) {
|
|
1986
|
+
const filePath = this.keyToPath(key);
|
|
1987
|
+
let raw;
|
|
1988
|
+
try {
|
|
1989
|
+
raw = await fs2.readFile(filePath);
|
|
1990
|
+
} catch {
|
|
1991
|
+
return null;
|
|
1992
|
+
}
|
|
1993
|
+
let entry;
|
|
1994
|
+
try {
|
|
1995
|
+
entry = this.serializer.deserialize(raw);
|
|
1996
|
+
} catch {
|
|
1997
|
+
await this.safeDelete(filePath);
|
|
1998
|
+
return null;
|
|
1999
|
+
}
|
|
2000
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
2001
|
+
await this.safeDelete(filePath);
|
|
2002
|
+
return null;
|
|
2003
|
+
}
|
|
2004
|
+
return entry.value;
|
|
2005
|
+
}
|
|
2006
|
+
async set(key, value, ttl = this.defaultTtl) {
|
|
2007
|
+
await fs2.mkdir(this.directory, { recursive: true });
|
|
2008
|
+
const entry = {
|
|
2009
|
+
value,
|
|
2010
|
+
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
2011
|
+
};
|
|
2012
|
+
const payload = this.serializer.serialize(entry);
|
|
2013
|
+
await fs2.writeFile(this.keyToPath(key), payload);
|
|
2014
|
+
}
|
|
2015
|
+
async has(key) {
|
|
2016
|
+
const value = await this.getEntry(key);
|
|
2017
|
+
return value !== null;
|
|
2018
|
+
}
|
|
2019
|
+
async ttl(key) {
|
|
2020
|
+
const filePath = this.keyToPath(key);
|
|
2021
|
+
let raw;
|
|
2022
|
+
try {
|
|
2023
|
+
raw = await fs2.readFile(filePath);
|
|
2024
|
+
} catch {
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
let entry;
|
|
2028
|
+
try {
|
|
2029
|
+
entry = this.serializer.deserialize(raw);
|
|
2030
|
+
} catch {
|
|
2031
|
+
return null;
|
|
2032
|
+
}
|
|
2033
|
+
if (entry.expiresAt === null) {
|
|
2034
|
+
return null;
|
|
2035
|
+
}
|
|
2036
|
+
const remaining = Math.ceil((entry.expiresAt - Date.now()) / 1e3);
|
|
2037
|
+
if (remaining <= 0) {
|
|
2038
|
+
return null;
|
|
2039
|
+
}
|
|
2040
|
+
return remaining;
|
|
2041
|
+
}
|
|
2042
|
+
async delete(key) {
|
|
2043
|
+
await this.safeDelete(this.keyToPath(key));
|
|
2044
|
+
}
|
|
2045
|
+
async deleteMany(keys) {
|
|
2046
|
+
await Promise.all(keys.map((key) => this.delete(key)));
|
|
2047
|
+
}
|
|
2048
|
+
async clear() {
|
|
2049
|
+
let entries;
|
|
2050
|
+
try {
|
|
2051
|
+
entries = await fs2.readdir(this.directory);
|
|
2052
|
+
} catch {
|
|
2053
|
+
return;
|
|
2054
|
+
}
|
|
2055
|
+
await Promise.all(
|
|
2056
|
+
entries.filter((name) => name.endsWith(".lc")).map((name) => this.safeDelete(join(this.directory, name)))
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
async keys() {
|
|
2060
|
+
let entries;
|
|
2061
|
+
try {
|
|
2062
|
+
entries = await fs2.readdir(this.directory);
|
|
2063
|
+
} catch {
|
|
2064
|
+
return [];
|
|
2065
|
+
}
|
|
2066
|
+
return entries.filter((name) => name.endsWith(".lc")).map((name) => name.slice(0, -3));
|
|
2067
|
+
}
|
|
2068
|
+
async size() {
|
|
2069
|
+
const keys = await this.keys();
|
|
2070
|
+
return keys.length;
|
|
2071
|
+
}
|
|
2072
|
+
keyToPath(key) {
|
|
2073
|
+
const hash = createHash("sha256").update(key).digest("hex");
|
|
2074
|
+
return join(this.directory, `${hash}.lc`);
|
|
2075
|
+
}
|
|
2076
|
+
async safeDelete(filePath) {
|
|
2077
|
+
try {
|
|
2078
|
+
await fs2.unlink(filePath);
|
|
2079
|
+
} catch {
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
};
|
|
2083
|
+
|
|
2084
|
+
// src/layers/MemcachedLayer.ts
|
|
2085
|
+
var MemcachedLayer = class {
|
|
2086
|
+
name;
|
|
2087
|
+
defaultTtl;
|
|
2088
|
+
isLocal = false;
|
|
2089
|
+
client;
|
|
2090
|
+
keyPrefix;
|
|
2091
|
+
constructor(options) {
|
|
2092
|
+
this.client = options.client;
|
|
2093
|
+
this.defaultTtl = options.ttl;
|
|
2094
|
+
this.name = options.name ?? "memcached";
|
|
2095
|
+
this.keyPrefix = options.keyPrefix ?? "";
|
|
2096
|
+
}
|
|
2097
|
+
async get(key) {
|
|
2098
|
+
const result = await this.client.get(this.withPrefix(key));
|
|
2099
|
+
if (!result || result.value === null) {
|
|
2100
|
+
return null;
|
|
2101
|
+
}
|
|
2102
|
+
try {
|
|
2103
|
+
return JSON.parse(result.value.toString("utf8"));
|
|
2104
|
+
} catch {
|
|
2105
|
+
return null;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
async set(key, value, ttl = this.defaultTtl) {
|
|
2109
|
+
const payload = JSON.stringify(value);
|
|
2110
|
+
await this.client.set(this.withPrefix(key), payload, {
|
|
2111
|
+
expires: ttl && ttl > 0 ? ttl : void 0
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
async delete(key) {
|
|
2115
|
+
await this.client.delete(this.withPrefix(key));
|
|
2116
|
+
}
|
|
2117
|
+
async deleteMany(keys) {
|
|
2118
|
+
await Promise.all(keys.map((key) => this.delete(key)));
|
|
2119
|
+
}
|
|
2120
|
+
async clear() {
|
|
2121
|
+
throw new Error(
|
|
2122
|
+
"MemcachedLayer.clear() is not supported. Use a key prefix and rotate it to effectively invalidate all keys."
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
withPrefix(key) {
|
|
2126
|
+
return `${this.keyPrefix}${key}`;
|
|
2127
|
+
}
|
|
2128
|
+
};
|
|
2129
|
+
|
|
1578
2130
|
// src/serialization/MsgpackSerializer.ts
|
|
1579
2131
|
import { decode, encode } from "@msgpack/msgpack";
|
|
1580
2132
|
var MsgpackSerializer = class {
|
|
@@ -1616,10 +2168,79 @@ var RedisSingleFlightCoordinator = class {
|
|
|
1616
2168
|
return waiter();
|
|
1617
2169
|
}
|
|
1618
2170
|
};
|
|
2171
|
+
|
|
2172
|
+
// src/metrics/PrometheusExporter.ts
|
|
2173
|
+
function createPrometheusMetricsExporter(stacks) {
|
|
2174
|
+
return () => {
|
|
2175
|
+
const entries = Array.isArray(stacks) ? stacks : [{ stack: stacks, name: "default" }];
|
|
2176
|
+
const lines = [];
|
|
2177
|
+
lines.push("# HELP layercache_hits_total Total number of cache hits");
|
|
2178
|
+
lines.push("# TYPE layercache_hits_total counter");
|
|
2179
|
+
lines.push("# HELP layercache_misses_total Total number of cache misses");
|
|
2180
|
+
lines.push("# TYPE layercache_misses_total counter");
|
|
2181
|
+
lines.push("# HELP layercache_fetches_total Total fetcher invocations (full misses)");
|
|
2182
|
+
lines.push("# TYPE layercache_fetches_total counter");
|
|
2183
|
+
lines.push("# HELP layercache_sets_total Total number of cache sets");
|
|
2184
|
+
lines.push("# TYPE layercache_sets_total counter");
|
|
2185
|
+
lines.push("# HELP layercache_deletes_total Total number of cache deletes");
|
|
2186
|
+
lines.push("# TYPE layercache_deletes_total counter");
|
|
2187
|
+
lines.push("# HELP layercache_backfills_total Total number of backfill operations");
|
|
2188
|
+
lines.push("# TYPE layercache_backfills_total counter");
|
|
2189
|
+
lines.push("# HELP layercache_stale_hits_total Total number of stale hits served");
|
|
2190
|
+
lines.push("# TYPE layercache_stale_hits_total counter");
|
|
2191
|
+
lines.push("# HELP layercache_refreshes_total Background refreshes triggered");
|
|
2192
|
+
lines.push("# TYPE layercache_refreshes_total counter");
|
|
2193
|
+
lines.push("# HELP layercache_refresh_errors_total Background refresh errors");
|
|
2194
|
+
lines.push("# TYPE layercache_refresh_errors_total counter");
|
|
2195
|
+
lines.push("# HELP layercache_negative_cache_hits_total Negative cache hits");
|
|
2196
|
+
lines.push("# TYPE layercache_negative_cache_hits_total counter");
|
|
2197
|
+
lines.push("# HELP layercache_circuit_breaker_trips_total Circuit breaker trips");
|
|
2198
|
+
lines.push("# TYPE layercache_circuit_breaker_trips_total counter");
|
|
2199
|
+
lines.push("# HELP layercache_degraded_operations_total Operations run in degraded mode");
|
|
2200
|
+
lines.push("# TYPE layercache_degraded_operations_total counter");
|
|
2201
|
+
lines.push("# HELP layercache_hit_rate Overall cache hit rate (0-1)");
|
|
2202
|
+
lines.push("# TYPE layercache_hit_rate gauge");
|
|
2203
|
+
lines.push("# HELP layercache_hits_by_layer_total Hits broken down by layer");
|
|
2204
|
+
lines.push("# TYPE layercache_hits_by_layer_total counter");
|
|
2205
|
+
lines.push("# HELP layercache_misses_by_layer_total Misses broken down by layer");
|
|
2206
|
+
lines.push("# TYPE layercache_misses_by_layer_total counter");
|
|
2207
|
+
for (const { stack, name } of entries) {
|
|
2208
|
+
const m = stack.getMetrics();
|
|
2209
|
+
const hr = stack.getHitRate();
|
|
2210
|
+
const label = `cache="${sanitizeLabel(name)}"`;
|
|
2211
|
+
lines.push(`layercache_hits_total{${label}} ${m.hits}`);
|
|
2212
|
+
lines.push(`layercache_misses_total{${label}} ${m.misses}`);
|
|
2213
|
+
lines.push(`layercache_fetches_total{${label}} ${m.fetches}`);
|
|
2214
|
+
lines.push(`layercache_sets_total{${label}} ${m.sets}`);
|
|
2215
|
+
lines.push(`layercache_deletes_total{${label}} ${m.deletes}`);
|
|
2216
|
+
lines.push(`layercache_backfills_total{${label}} ${m.backfills}`);
|
|
2217
|
+
lines.push(`layercache_stale_hits_total{${label}} ${m.staleHits}`);
|
|
2218
|
+
lines.push(`layercache_refreshes_total{${label}} ${m.refreshes}`);
|
|
2219
|
+
lines.push(`layercache_refresh_errors_total{${label}} ${m.refreshErrors}`);
|
|
2220
|
+
lines.push(`layercache_negative_cache_hits_total{${label}} ${m.negativeCacheHits}`);
|
|
2221
|
+
lines.push(`layercache_circuit_breaker_trips_total{${label}} ${m.circuitBreakerTrips}`);
|
|
2222
|
+
lines.push(`layercache_degraded_operations_total{${label}} ${m.degradedOperations}`);
|
|
2223
|
+
lines.push(`layercache_hit_rate{${label}} ${hr.overall.toFixed(6)}`);
|
|
2224
|
+
for (const [layerName, count] of Object.entries(m.hitsByLayer)) {
|
|
2225
|
+
lines.push(`layercache_hits_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
|
|
2226
|
+
}
|
|
2227
|
+
for (const [layerName, count] of Object.entries(m.missesByLayer)) {
|
|
2228
|
+
lines.push(`layercache_misses_by_layer_total{${label},layer="${sanitizeLabel(layerName)}"} ${count}`);
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
lines.push("");
|
|
2232
|
+
return lines.join("\n");
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
function sanitizeLabel(value) {
|
|
2236
|
+
return value.replace(/["\\\n]/g, "_");
|
|
2237
|
+
}
|
|
1619
2238
|
export {
|
|
1620
2239
|
CacheNamespace,
|
|
1621
2240
|
CacheStack,
|
|
2241
|
+
DiskLayer,
|
|
1622
2242
|
JsonSerializer,
|
|
2243
|
+
MemcachedLayer,
|
|
1623
2244
|
MemoryLayer,
|
|
1624
2245
|
MsgpackSerializer,
|
|
1625
2246
|
PatternMatcher,
|
|
@@ -1633,5 +2254,6 @@ export {
|
|
|
1633
2254
|
createCacheStatsHandler,
|
|
1634
2255
|
createCachedMethodDecorator,
|
|
1635
2256
|
createFastifyLayercachePlugin,
|
|
2257
|
+
createPrometheusMetricsExporter,
|
|
1636
2258
|
createTrpcCacheMiddleware
|
|
1637
2259
|
};
|