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
|
@@ -73,8 +73,229 @@ var import_common = require("@nestjs/common");
|
|
|
73
73
|
|
|
74
74
|
// ../../src/CacheStack.ts
|
|
75
75
|
var import_node_crypto = require("crypto");
|
|
76
|
-
var import_node_fs = require("fs");
|
|
77
76
|
var import_node_events = require("events");
|
|
77
|
+
var import_node_fs = require("fs");
|
|
78
|
+
|
|
79
|
+
// ../../src/CacheNamespace.ts
|
|
80
|
+
var CacheNamespace = class {
|
|
81
|
+
constructor(cache, prefix) {
|
|
82
|
+
this.cache = cache;
|
|
83
|
+
this.prefix = prefix;
|
|
84
|
+
}
|
|
85
|
+
cache;
|
|
86
|
+
prefix;
|
|
87
|
+
async get(key, fetcher, options) {
|
|
88
|
+
return this.cache.get(this.qualify(key), fetcher, options);
|
|
89
|
+
}
|
|
90
|
+
async getOrSet(key, fetcher, options) {
|
|
91
|
+
return this.cache.getOrSet(this.qualify(key), fetcher, options);
|
|
92
|
+
}
|
|
93
|
+
async has(key) {
|
|
94
|
+
return this.cache.has(this.qualify(key));
|
|
95
|
+
}
|
|
96
|
+
async ttl(key) {
|
|
97
|
+
return this.cache.ttl(this.qualify(key));
|
|
98
|
+
}
|
|
99
|
+
async set(key, value, options) {
|
|
100
|
+
await this.cache.set(this.qualify(key), value, options);
|
|
101
|
+
}
|
|
102
|
+
async delete(key) {
|
|
103
|
+
await this.cache.delete(this.qualify(key));
|
|
104
|
+
}
|
|
105
|
+
async mdelete(keys) {
|
|
106
|
+
await this.cache.mdelete(keys.map((k) => this.qualify(k)));
|
|
107
|
+
}
|
|
108
|
+
async clear() {
|
|
109
|
+
await this.cache.invalidateByPattern(`${this.prefix}:*`);
|
|
110
|
+
}
|
|
111
|
+
async mget(entries) {
|
|
112
|
+
return this.cache.mget(
|
|
113
|
+
entries.map((entry) => ({
|
|
114
|
+
...entry,
|
|
115
|
+
key: this.qualify(entry.key)
|
|
116
|
+
}))
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
async mset(entries) {
|
|
120
|
+
await this.cache.mset(
|
|
121
|
+
entries.map((entry) => ({
|
|
122
|
+
...entry,
|
|
123
|
+
key: this.qualify(entry.key)
|
|
124
|
+
}))
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
async invalidateByTag(tag) {
|
|
128
|
+
await this.cache.invalidateByTag(tag);
|
|
129
|
+
}
|
|
130
|
+
async invalidateByPattern(pattern) {
|
|
131
|
+
await this.cache.invalidateByPattern(this.qualify(pattern));
|
|
132
|
+
}
|
|
133
|
+
wrap(keyPrefix, fetcher, options) {
|
|
134
|
+
return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
|
|
135
|
+
}
|
|
136
|
+
warm(entries, options) {
|
|
137
|
+
return this.cache.warm(
|
|
138
|
+
entries.map((entry) => ({
|
|
139
|
+
...entry,
|
|
140
|
+
key: this.qualify(entry.key)
|
|
141
|
+
})),
|
|
142
|
+
options
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
getMetrics() {
|
|
146
|
+
return this.cache.getMetrics();
|
|
147
|
+
}
|
|
148
|
+
getHitRate() {
|
|
149
|
+
return this.cache.getHitRate();
|
|
150
|
+
}
|
|
151
|
+
qualify(key) {
|
|
152
|
+
return `${this.prefix}:${key}`;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// ../../src/internal/CircuitBreakerManager.ts
|
|
157
|
+
var CircuitBreakerManager = class {
|
|
158
|
+
breakers = /* @__PURE__ */ new Map();
|
|
159
|
+
maxEntries;
|
|
160
|
+
constructor(options) {
|
|
161
|
+
this.maxEntries = options.maxEntries;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Throws if the circuit is open for the given key.
|
|
165
|
+
* Automatically resets if the cooldown has elapsed.
|
|
166
|
+
*/
|
|
167
|
+
assertClosed(key, options) {
|
|
168
|
+
const state = this.breakers.get(key);
|
|
169
|
+
if (!state?.openUntil) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
if (state.openUntil <= now) {
|
|
174
|
+
state.openUntil = null;
|
|
175
|
+
state.failures = 0;
|
|
176
|
+
this.breakers.set(key, state);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const remainingMs = state.openUntil - now;
|
|
180
|
+
const remainingSecs = Math.ceil(remainingMs / 1e3);
|
|
181
|
+
throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
|
|
182
|
+
}
|
|
183
|
+
recordFailure(key, options) {
|
|
184
|
+
if (!options) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const failureThreshold = options.failureThreshold ?? 3;
|
|
188
|
+
const cooldownMs = options.cooldownMs ?? 3e4;
|
|
189
|
+
const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
|
|
190
|
+
state.failures += 1;
|
|
191
|
+
if (state.failures >= failureThreshold) {
|
|
192
|
+
state.openUntil = Date.now() + cooldownMs;
|
|
193
|
+
}
|
|
194
|
+
this.breakers.set(key, state);
|
|
195
|
+
this.pruneIfNeeded();
|
|
196
|
+
}
|
|
197
|
+
recordSuccess(key) {
|
|
198
|
+
this.breakers.delete(key);
|
|
199
|
+
}
|
|
200
|
+
isOpen(key) {
|
|
201
|
+
const state = this.breakers.get(key);
|
|
202
|
+
if (!state?.openUntil) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
if (state.openUntil <= Date.now()) {
|
|
206
|
+
state.openUntil = null;
|
|
207
|
+
state.failures = 0;
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
delete(key) {
|
|
213
|
+
this.breakers.delete(key);
|
|
214
|
+
}
|
|
215
|
+
clear() {
|
|
216
|
+
this.breakers.clear();
|
|
217
|
+
}
|
|
218
|
+
tripCount() {
|
|
219
|
+
let count = 0;
|
|
220
|
+
for (const state of this.breakers.values()) {
|
|
221
|
+
if (state.openUntil !== null) {
|
|
222
|
+
count += 1;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return count;
|
|
226
|
+
}
|
|
227
|
+
pruneIfNeeded() {
|
|
228
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
for (const [key, state] of this.breakers.entries()) {
|
|
232
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
if (!state.openUntil || state.openUntil <= Date.now()) {
|
|
236
|
+
this.breakers.delete(key);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const key of this.breakers.keys()) {
|
|
240
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
this.breakers.delete(key);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// ../../src/internal/MetricsCollector.ts
|
|
249
|
+
var MetricsCollector = class {
|
|
250
|
+
data = this.empty();
|
|
251
|
+
get snapshot() {
|
|
252
|
+
return { ...this.data };
|
|
253
|
+
}
|
|
254
|
+
increment(field, amount = 1) {
|
|
255
|
+
;
|
|
256
|
+
this.data[field] += amount;
|
|
257
|
+
}
|
|
258
|
+
incrementLayer(map, layerName) {
|
|
259
|
+
this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
|
|
260
|
+
}
|
|
261
|
+
reset() {
|
|
262
|
+
this.data = this.empty();
|
|
263
|
+
}
|
|
264
|
+
hitRate() {
|
|
265
|
+
const total = this.data.hits + this.data.misses;
|
|
266
|
+
const overall = total === 0 ? 0 : this.data.hits / total;
|
|
267
|
+
const byLayer = {};
|
|
268
|
+
const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
|
|
269
|
+
for (const layer of allLayers) {
|
|
270
|
+
const h = this.data.hitsByLayer[layer] ?? 0;
|
|
271
|
+
const m = this.data.missesByLayer[layer] ?? 0;
|
|
272
|
+
byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
|
|
273
|
+
}
|
|
274
|
+
return { overall, byLayer };
|
|
275
|
+
}
|
|
276
|
+
empty() {
|
|
277
|
+
return {
|
|
278
|
+
hits: 0,
|
|
279
|
+
misses: 0,
|
|
280
|
+
fetches: 0,
|
|
281
|
+
sets: 0,
|
|
282
|
+
deletes: 0,
|
|
283
|
+
backfills: 0,
|
|
284
|
+
invalidations: 0,
|
|
285
|
+
staleHits: 0,
|
|
286
|
+
refreshes: 0,
|
|
287
|
+
refreshErrors: 0,
|
|
288
|
+
writeFailures: 0,
|
|
289
|
+
singleFlightWaits: 0,
|
|
290
|
+
negativeCacheHits: 0,
|
|
291
|
+
circuitBreakerTrips: 0,
|
|
292
|
+
degradedOperations: 0,
|
|
293
|
+
hitsByLayer: {},
|
|
294
|
+
missesByLayer: {},
|
|
295
|
+
resetAt: Date.now()
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
};
|
|
78
299
|
|
|
79
300
|
// ../../src/internal/StoredValue.ts
|
|
80
301
|
function isStoredValueEnvelope(value) {
|
|
@@ -177,67 +398,129 @@ function normalizePositiveSeconds(value) {
|
|
|
177
398
|
return value;
|
|
178
399
|
}
|
|
179
400
|
|
|
180
|
-
// ../../src/
|
|
181
|
-
var
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
prefix;
|
|
188
|
-
async get(key, fetcher, options) {
|
|
189
|
-
return this.cache.get(this.qualify(key), fetcher, options);
|
|
190
|
-
}
|
|
191
|
-
async set(key, value, options) {
|
|
192
|
-
await this.cache.set(this.qualify(key), value, options);
|
|
193
|
-
}
|
|
194
|
-
async delete(key) {
|
|
195
|
-
await this.cache.delete(this.qualify(key));
|
|
401
|
+
// ../../src/internal/TtlResolver.ts
|
|
402
|
+
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
403
|
+
var TtlResolver = class {
|
|
404
|
+
accessProfiles = /* @__PURE__ */ new Map();
|
|
405
|
+
maxProfileEntries;
|
|
406
|
+
constructor(options) {
|
|
407
|
+
this.maxProfileEntries = options.maxProfileEntries;
|
|
196
408
|
}
|
|
197
|
-
|
|
198
|
-
|
|
409
|
+
recordAccess(key) {
|
|
410
|
+
const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
|
|
411
|
+
profile.hits += 1;
|
|
412
|
+
profile.lastAccessAt = Date.now();
|
|
413
|
+
this.accessProfiles.set(key, profile);
|
|
414
|
+
this.pruneIfNeeded();
|
|
199
415
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
...entry,
|
|
203
|
-
key: this.qualify(entry.key)
|
|
204
|
-
})));
|
|
416
|
+
deleteProfile(key) {
|
|
417
|
+
this.accessProfiles.delete(key);
|
|
205
418
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
...entry,
|
|
209
|
-
key: this.qualify(entry.key)
|
|
210
|
-
})));
|
|
419
|
+
clearProfiles() {
|
|
420
|
+
this.accessProfiles.clear();
|
|
211
421
|
}
|
|
212
|
-
|
|
213
|
-
|
|
422
|
+
resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
|
|
423
|
+
const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
|
|
424
|
+
layerName,
|
|
425
|
+
options?.negativeTtl,
|
|
426
|
+
globalNegativeTtl,
|
|
427
|
+
this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
428
|
+
) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
|
|
429
|
+
const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
|
|
430
|
+
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
|
|
431
|
+
return this.applyJitter(adaptiveTtl, jitter);
|
|
214
432
|
}
|
|
215
|
-
|
|
216
|
-
|
|
433
|
+
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
434
|
+
if (override !== void 0) {
|
|
435
|
+
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
436
|
+
}
|
|
437
|
+
if (globalDefault !== void 0) {
|
|
438
|
+
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
439
|
+
}
|
|
440
|
+
return fallback;
|
|
217
441
|
}
|
|
218
|
-
|
|
219
|
-
|
|
442
|
+
applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
|
|
443
|
+
if (!ttl || !adaptiveTtl) {
|
|
444
|
+
return ttl;
|
|
445
|
+
}
|
|
446
|
+
const profile = this.accessProfiles.get(key);
|
|
447
|
+
if (!profile) {
|
|
448
|
+
return ttl;
|
|
449
|
+
}
|
|
450
|
+
const config = adaptiveTtl === true ? {} : adaptiveTtl;
|
|
451
|
+
const hotAfter = config.hotAfter ?? 3;
|
|
452
|
+
if (profile.hits < hotAfter) {
|
|
453
|
+
return ttl;
|
|
454
|
+
}
|
|
455
|
+
const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
|
|
456
|
+
const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
|
|
457
|
+
const multiplier = Math.floor(profile.hits / hotAfter);
|
|
458
|
+
return Math.min(maxTtl, ttl + step * multiplier);
|
|
220
459
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
460
|
+
applyJitter(ttl, jitter) {
|
|
461
|
+
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
462
|
+
return ttl;
|
|
463
|
+
}
|
|
464
|
+
const delta = (Math.random() * 2 - 1) * jitter;
|
|
465
|
+
return Math.max(1, Math.round(ttl + delta));
|
|
226
466
|
}
|
|
227
|
-
|
|
228
|
-
|
|
467
|
+
readLayerNumber(layerName, value) {
|
|
468
|
+
if (typeof value === "number") {
|
|
469
|
+
return value;
|
|
470
|
+
}
|
|
471
|
+
return value[layerName];
|
|
229
472
|
}
|
|
230
|
-
|
|
231
|
-
|
|
473
|
+
pruneIfNeeded() {
|
|
474
|
+
if (this.accessProfiles.size <= this.maxProfileEntries) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
|
|
478
|
+
let removed = 0;
|
|
479
|
+
for (const key of this.accessProfiles.keys()) {
|
|
480
|
+
if (removed >= toRemove) {
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
this.accessProfiles.delete(key);
|
|
484
|
+
removed += 1;
|
|
485
|
+
}
|
|
232
486
|
}
|
|
233
487
|
};
|
|
234
488
|
|
|
235
489
|
// ../../src/invalidation/PatternMatcher.ts
|
|
236
|
-
var PatternMatcher = class {
|
|
490
|
+
var PatternMatcher = class _PatternMatcher {
|
|
491
|
+
/**
|
|
492
|
+
* Tests whether a glob-style pattern matches a value.
|
|
493
|
+
* Supports `*` (any sequence of characters) and `?` (any single character).
|
|
494
|
+
* Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
|
|
495
|
+
*/
|
|
237
496
|
static matches(pattern, value) {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
497
|
+
return _PatternMatcher.matchLinear(pattern, value);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Linear-time glob matching using dynamic programming.
|
|
501
|
+
* Avoids catastrophic backtracking that RegExp-based glob matching can cause.
|
|
502
|
+
*/
|
|
503
|
+
static matchLinear(pattern, value) {
|
|
504
|
+
const m = pattern.length;
|
|
505
|
+
const n = value.length;
|
|
506
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
|
|
507
|
+
dp[0][0] = true;
|
|
508
|
+
for (let i = 1; i <= m; i++) {
|
|
509
|
+
if (pattern[i - 1] === "*") {
|
|
510
|
+
dp[i][0] = dp[i - 1]?.[0];
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (let i = 1; i <= m; i++) {
|
|
514
|
+
for (let j = 1; j <= n; j++) {
|
|
515
|
+
const pc = pattern[i - 1];
|
|
516
|
+
if (pc === "*") {
|
|
517
|
+
dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
|
|
518
|
+
} else if (pc === "?" || pc === value[j - 1]) {
|
|
519
|
+
dp[i][j] = dp[i - 1]?.[j - 1];
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return dp[m]?.[n];
|
|
241
524
|
}
|
|
242
525
|
};
|
|
243
526
|
|
|
@@ -500,30 +783,11 @@ var StampedeGuard = class {
|
|
|
500
783
|
};
|
|
501
784
|
|
|
502
785
|
// ../../src/CacheStack.ts
|
|
503
|
-
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
504
786
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
505
787
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
506
788
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
507
789
|
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
508
|
-
var
|
|
509
|
-
hits: 0,
|
|
510
|
-
misses: 0,
|
|
511
|
-
fetches: 0,
|
|
512
|
-
sets: 0,
|
|
513
|
-
deletes: 0,
|
|
514
|
-
backfills: 0,
|
|
515
|
-
invalidations: 0,
|
|
516
|
-
staleHits: 0,
|
|
517
|
-
refreshes: 0,
|
|
518
|
-
refreshErrors: 0,
|
|
519
|
-
writeFailures: 0,
|
|
520
|
-
singleFlightWaits: 0,
|
|
521
|
-
negativeCacheHits: 0,
|
|
522
|
-
circuitBreakerTrips: 0,
|
|
523
|
-
degradedOperations: 0,
|
|
524
|
-
hitsByLayer: {},
|
|
525
|
-
missesByLayer: {}
|
|
526
|
-
});
|
|
790
|
+
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
527
791
|
var DebugLogger = class {
|
|
528
792
|
enabled;
|
|
529
793
|
constructor(enabled) {
|
|
@@ -558,6 +822,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
558
822
|
throw new Error("CacheStack requires at least one cache layer.");
|
|
559
823
|
}
|
|
560
824
|
this.validateConfiguration();
|
|
825
|
+
const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
|
|
826
|
+
this.ttlResolver = new TtlResolver({ maxProfileEntries });
|
|
827
|
+
this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
|
|
828
|
+
if (options.publishSetInvalidation !== void 0) {
|
|
829
|
+
console.warn(
|
|
830
|
+
"[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
|
|
831
|
+
);
|
|
832
|
+
}
|
|
561
833
|
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
562
834
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
563
835
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
@@ -566,36 +838,42 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
566
838
|
layers;
|
|
567
839
|
options;
|
|
568
840
|
stampedeGuard = new StampedeGuard();
|
|
569
|
-
|
|
841
|
+
metricsCollector = new MetricsCollector();
|
|
570
842
|
instanceId = (0, import_node_crypto.randomUUID)();
|
|
571
843
|
startup;
|
|
572
844
|
unsubscribeInvalidation;
|
|
573
845
|
logger;
|
|
574
846
|
tagIndex;
|
|
575
847
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
576
|
-
accessProfiles = /* @__PURE__ */ new Map();
|
|
577
848
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
578
|
-
|
|
849
|
+
ttlResolver;
|
|
850
|
+
circuitBreakerManager;
|
|
579
851
|
isDisconnecting = false;
|
|
580
852
|
disconnectPromise;
|
|
853
|
+
/**
|
|
854
|
+
* Read-through cache get.
|
|
855
|
+
* Returns the cached value if present and fresh, or invokes `fetcher` on a miss
|
|
856
|
+
* and stores the result across all layers. Returns `null` if the key is not found
|
|
857
|
+
* and no `fetcher` is provided.
|
|
858
|
+
*/
|
|
581
859
|
async get(key, fetcher, options) {
|
|
582
860
|
const normalizedKey = this.validateCacheKey(key);
|
|
583
861
|
this.validateWriteOptions(options);
|
|
584
862
|
await this.startup;
|
|
585
863
|
const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
|
|
586
864
|
if (hit.found) {
|
|
587
|
-
this.recordAccess(normalizedKey);
|
|
865
|
+
this.ttlResolver.recordAccess(normalizedKey);
|
|
588
866
|
if (this.isNegativeStoredValue(hit.stored)) {
|
|
589
|
-
this.
|
|
867
|
+
this.metricsCollector.increment("negativeCacheHits");
|
|
590
868
|
}
|
|
591
869
|
if (hit.state === "fresh") {
|
|
592
|
-
this.
|
|
870
|
+
this.metricsCollector.increment("hits");
|
|
593
871
|
await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
|
|
594
872
|
return hit.value;
|
|
595
873
|
}
|
|
596
874
|
if (hit.state === "stale-while-revalidate") {
|
|
597
|
-
this.
|
|
598
|
-
this.
|
|
875
|
+
this.metricsCollector.increment("hits");
|
|
876
|
+
this.metricsCollector.increment("staleHits");
|
|
599
877
|
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
600
878
|
if (fetcher) {
|
|
601
879
|
this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
|
|
@@ -603,47 +881,136 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
603
881
|
return hit.value;
|
|
604
882
|
}
|
|
605
883
|
if (!fetcher) {
|
|
606
|
-
this.
|
|
607
|
-
this.
|
|
884
|
+
this.metricsCollector.increment("hits");
|
|
885
|
+
this.metricsCollector.increment("staleHits");
|
|
608
886
|
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
609
887
|
return hit.value;
|
|
610
888
|
}
|
|
611
889
|
try {
|
|
612
890
|
return await this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
613
891
|
} catch (error) {
|
|
614
|
-
this.
|
|
615
|
-
this.
|
|
892
|
+
this.metricsCollector.increment("staleHits");
|
|
893
|
+
this.metricsCollector.increment("refreshErrors");
|
|
616
894
|
this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
|
|
617
895
|
return hit.value;
|
|
618
896
|
}
|
|
619
897
|
}
|
|
620
|
-
this.
|
|
898
|
+
this.metricsCollector.increment("misses");
|
|
621
899
|
if (!fetcher) {
|
|
622
900
|
return null;
|
|
623
901
|
}
|
|
624
902
|
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
625
903
|
}
|
|
904
|
+
/**
|
|
905
|
+
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
906
|
+
* Fetches and caches the value if not already present.
|
|
907
|
+
*/
|
|
908
|
+
async getOrSet(key, fetcher, options) {
|
|
909
|
+
return this.get(key, fetcher, options);
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Returns true if the given key exists and is not expired in any layer.
|
|
913
|
+
*/
|
|
914
|
+
async has(key) {
|
|
915
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
916
|
+
await this.startup;
|
|
917
|
+
for (const layer of this.layers) {
|
|
918
|
+
if (this.shouldSkipLayer(layer)) {
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
if (layer.has) {
|
|
922
|
+
try {
|
|
923
|
+
const exists = await layer.has(normalizedKey);
|
|
924
|
+
if (exists) {
|
|
925
|
+
return true;
|
|
926
|
+
}
|
|
927
|
+
} catch {
|
|
928
|
+
}
|
|
929
|
+
} else {
|
|
930
|
+
try {
|
|
931
|
+
const value = await layer.get(normalizedKey);
|
|
932
|
+
if (value !== null) {
|
|
933
|
+
return true;
|
|
934
|
+
}
|
|
935
|
+
} catch {
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Returns the remaining TTL in seconds for the key in the fastest layer
|
|
943
|
+
* that has it, or null if the key is not found / has no TTL.
|
|
944
|
+
*/
|
|
945
|
+
async ttl(key) {
|
|
946
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
947
|
+
await this.startup;
|
|
948
|
+
for (const layer of this.layers) {
|
|
949
|
+
if (this.shouldSkipLayer(layer)) {
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
if (layer.ttl) {
|
|
953
|
+
try {
|
|
954
|
+
const remaining = await layer.ttl(normalizedKey);
|
|
955
|
+
if (remaining !== null) {
|
|
956
|
+
return remaining;
|
|
957
|
+
}
|
|
958
|
+
} catch {
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return null;
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Stores a value in all cache layers. Overwrites any existing value.
|
|
966
|
+
*/
|
|
626
967
|
async set(key, value, options) {
|
|
627
968
|
const normalizedKey = this.validateCacheKey(key);
|
|
628
969
|
this.validateWriteOptions(options);
|
|
629
970
|
await this.startup;
|
|
630
971
|
await this.storeEntry(normalizedKey, "value", value, options);
|
|
631
972
|
}
|
|
973
|
+
/**
|
|
974
|
+
* Deletes the key from all layers and publishes an invalidation message.
|
|
975
|
+
*/
|
|
632
976
|
async delete(key) {
|
|
633
977
|
const normalizedKey = this.validateCacheKey(key);
|
|
634
978
|
await this.startup;
|
|
635
979
|
await this.deleteKeys([normalizedKey]);
|
|
636
|
-
await this.publishInvalidation({
|
|
980
|
+
await this.publishInvalidation({
|
|
981
|
+
scope: "key",
|
|
982
|
+
keys: [normalizedKey],
|
|
983
|
+
sourceId: this.instanceId,
|
|
984
|
+
operation: "delete"
|
|
985
|
+
});
|
|
637
986
|
}
|
|
638
987
|
async clear() {
|
|
639
988
|
await this.startup;
|
|
640
989
|
await Promise.all(this.layers.map((layer) => layer.clear()));
|
|
641
990
|
await this.tagIndex.clear();
|
|
642
|
-
this.
|
|
643
|
-
this.
|
|
991
|
+
this.ttlResolver.clearProfiles();
|
|
992
|
+
this.circuitBreakerManager.clear();
|
|
993
|
+
this.metricsCollector.increment("invalidations");
|
|
644
994
|
this.logger.debug?.("clear");
|
|
645
995
|
await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
|
|
646
996
|
}
|
|
997
|
+
/**
|
|
998
|
+
* Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
|
|
999
|
+
*/
|
|
1000
|
+
async mdelete(keys) {
|
|
1001
|
+
if (keys.length === 0) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
await this.startup;
|
|
1005
|
+
const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
|
|
1006
|
+
await this.deleteKeys(normalizedKeys);
|
|
1007
|
+
await this.publishInvalidation({
|
|
1008
|
+
scope: "keys",
|
|
1009
|
+
keys: normalizedKeys,
|
|
1010
|
+
sourceId: this.instanceId,
|
|
1011
|
+
operation: "delete"
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
647
1014
|
async mget(entries) {
|
|
648
1015
|
if (entries.length === 0) {
|
|
649
1016
|
return [];
|
|
@@ -681,7 +1048,9 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
681
1048
|
const indexesByKey = /* @__PURE__ */ new Map();
|
|
682
1049
|
const resultsByKey = /* @__PURE__ */ new Map();
|
|
683
1050
|
for (let index = 0; index < normalizedEntries.length; index += 1) {
|
|
684
|
-
const
|
|
1051
|
+
const entry = normalizedEntries[index];
|
|
1052
|
+
if (!entry) continue;
|
|
1053
|
+
const key = entry.key;
|
|
685
1054
|
const indexes = indexesByKey.get(key) ?? [];
|
|
686
1055
|
indexes.push(index);
|
|
687
1056
|
indexesByKey.set(key, indexes);
|
|
@@ -689,6 +1058,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
689
1058
|
}
|
|
690
1059
|
for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
|
|
691
1060
|
const layer = this.layers[layerIndex];
|
|
1061
|
+
if (!layer) continue;
|
|
692
1062
|
const keys = [...pending];
|
|
693
1063
|
if (keys.length === 0) {
|
|
694
1064
|
break;
|
|
@@ -697,7 +1067,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
697
1067
|
for (let offset = 0; offset < values.length; offset += 1) {
|
|
698
1068
|
const key = keys[offset];
|
|
699
1069
|
const stored = values[offset];
|
|
700
|
-
if (stored === null) {
|
|
1070
|
+
if (!key || stored === null) {
|
|
701
1071
|
continue;
|
|
702
1072
|
}
|
|
703
1073
|
const resolved = resolveStoredValue(stored);
|
|
@@ -709,13 +1079,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
709
1079
|
await this.backfill(key, stored, layerIndex - 1);
|
|
710
1080
|
resultsByKey.set(key, resolved.value);
|
|
711
1081
|
pending.delete(key);
|
|
712
|
-
this.
|
|
1082
|
+
this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
|
|
713
1083
|
}
|
|
714
1084
|
}
|
|
715
1085
|
if (pending.size > 0) {
|
|
716
1086
|
for (const key of pending) {
|
|
717
1087
|
await this.tagIndex.remove(key);
|
|
718
|
-
this.
|
|
1088
|
+
this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
|
|
719
1089
|
}
|
|
720
1090
|
}
|
|
721
1091
|
return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
|
|
@@ -730,26 +1100,38 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
730
1100
|
}
|
|
731
1101
|
async warm(entries, options = {}) {
|
|
732
1102
|
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
1103
|
+
const total = entries.length;
|
|
1104
|
+
let completed = 0;
|
|
733
1105
|
const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
|
|
734
|
-
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
1106
|
+
const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
|
|
735
1107
|
while (queue.length > 0) {
|
|
736
1108
|
const entry = queue.shift();
|
|
737
1109
|
if (!entry) {
|
|
738
1110
|
return;
|
|
739
1111
|
}
|
|
1112
|
+
let success = false;
|
|
740
1113
|
try {
|
|
741
1114
|
await this.get(entry.key, entry.fetcher, entry.options);
|
|
742
1115
|
this.emit("warm", { key: entry.key });
|
|
1116
|
+
success = true;
|
|
743
1117
|
} catch (error) {
|
|
744
1118
|
this.emitError("warm", { key: entry.key, error: this.formatError(error) });
|
|
745
1119
|
if (!options.continueOnError) {
|
|
746
1120
|
throw error;
|
|
747
1121
|
}
|
|
1122
|
+
} finally {
|
|
1123
|
+
completed += 1;
|
|
1124
|
+
const progress = { completed, total, key: entry.key, success };
|
|
1125
|
+
options.onProgress?.(progress);
|
|
748
1126
|
}
|
|
749
1127
|
}
|
|
750
1128
|
});
|
|
751
1129
|
await Promise.all(workers);
|
|
752
1130
|
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Returns a cached version of `fetcher`. The cache key is derived from
|
|
1133
|
+
* `prefix` plus the serialized arguments unless a `keyResolver` is provided.
|
|
1134
|
+
*/
|
|
753
1135
|
wrap(prefix, fetcher, options = {}) {
|
|
754
1136
|
return (...args) => {
|
|
755
1137
|
const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
|
|
@@ -757,6 +1139,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
757
1139
|
return this.get(key, () => fetcher(...args), options);
|
|
758
1140
|
};
|
|
759
1141
|
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Creates a `CacheNamespace` that automatically prefixes all keys with
|
|
1144
|
+
* `prefix:`. Useful for multi-tenant or module-level isolation.
|
|
1145
|
+
*/
|
|
760
1146
|
namespace(prefix) {
|
|
761
1147
|
return new CacheNamespace(this, prefix);
|
|
762
1148
|
}
|
|
@@ -773,7 +1159,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
773
1159
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
774
1160
|
}
|
|
775
1161
|
getMetrics() {
|
|
776
|
-
return
|
|
1162
|
+
return this.metricsCollector.snapshot;
|
|
777
1163
|
}
|
|
778
1164
|
getStats() {
|
|
779
1165
|
return {
|
|
@@ -787,7 +1173,13 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
787
1173
|
};
|
|
788
1174
|
}
|
|
789
1175
|
resetMetrics() {
|
|
790
|
-
|
|
1176
|
+
this.metricsCollector.reset();
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Returns computed hit-rate statistics (overall and per-layer).
|
|
1180
|
+
*/
|
|
1181
|
+
getHitRate() {
|
|
1182
|
+
return this.metricsCollector.hitRate();
|
|
791
1183
|
}
|
|
792
1184
|
async exportState() {
|
|
793
1185
|
await this.startup;
|
|
@@ -816,10 +1208,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
816
1208
|
}
|
|
817
1209
|
async importState(entries) {
|
|
818
1210
|
await this.startup;
|
|
819
|
-
await Promise.all(
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1211
|
+
await Promise.all(
|
|
1212
|
+
entries.map(async (entry) => {
|
|
1213
|
+
await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
|
|
1214
|
+
await this.tagIndex.touch(entry.key);
|
|
1215
|
+
})
|
|
1216
|
+
);
|
|
823
1217
|
}
|
|
824
1218
|
async persistToFile(filePath) {
|
|
825
1219
|
const snapshot = await this.exportState();
|
|
@@ -827,11 +1221,21 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
827
1221
|
}
|
|
828
1222
|
async restoreFromFile(filePath) {
|
|
829
1223
|
const raw = await import_node_fs.promises.readFile(filePath, "utf8");
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
1224
|
+
let parsed;
|
|
1225
|
+
try {
|
|
1226
|
+
parsed = JSON.parse(raw, (_key, value) => {
|
|
1227
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
1228
|
+
return Object.assign(/* @__PURE__ */ Object.create(null), value);
|
|
1229
|
+
}
|
|
1230
|
+
return value;
|
|
1231
|
+
});
|
|
1232
|
+
} catch (cause) {
|
|
1233
|
+
throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
|
|
833
1234
|
}
|
|
834
|
-
|
|
1235
|
+
if (!this.isCacheSnapshotEntries(parsed)) {
|
|
1236
|
+
throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
|
|
1237
|
+
}
|
|
1238
|
+
await this.importState(parsed);
|
|
835
1239
|
}
|
|
836
1240
|
async disconnect() {
|
|
837
1241
|
if (!this.disconnectPromise) {
|
|
@@ -856,7 +1260,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
856
1260
|
const fetchTask = async () => {
|
|
857
1261
|
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
858
1262
|
if (secondHit.found) {
|
|
859
|
-
this.
|
|
1263
|
+
this.metricsCollector.increment("hits");
|
|
860
1264
|
return secondHit.value;
|
|
861
1265
|
}
|
|
862
1266
|
return this.fetchAndPopulate(key, fetcher, options);
|
|
@@ -881,12 +1285,12 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
881
1285
|
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
882
1286
|
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
883
1287
|
const deadline = Date.now() + timeoutMs;
|
|
884
|
-
this.
|
|
1288
|
+
this.metricsCollector.increment("singleFlightWaits");
|
|
885
1289
|
this.emit("stampede-dedupe", { key });
|
|
886
1290
|
while (Date.now() < deadline) {
|
|
887
1291
|
const hit = await this.readFromLayers(key, options, "fresh-only");
|
|
888
1292
|
if (hit.found) {
|
|
889
|
-
this.
|
|
1293
|
+
this.metricsCollector.increment("hits");
|
|
890
1294
|
return hit.value;
|
|
891
1295
|
}
|
|
892
1296
|
await this.sleep(pollIntervalMs);
|
|
@@ -894,12 +1298,14 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
894
1298
|
return this.fetchAndPopulate(key, fetcher, options);
|
|
895
1299
|
}
|
|
896
1300
|
async fetchAndPopulate(key, fetcher, options) {
|
|
897
|
-
this.
|
|
898
|
-
this.
|
|
1301
|
+
this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
|
|
1302
|
+
this.metricsCollector.increment("fetches");
|
|
1303
|
+
const fetchStart = Date.now();
|
|
899
1304
|
let fetched;
|
|
900
1305
|
try {
|
|
901
1306
|
fetched = await fetcher();
|
|
902
|
-
this.
|
|
1307
|
+
this.circuitBreakerManager.recordSuccess(key);
|
|
1308
|
+
this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
|
|
903
1309
|
} catch (error) {
|
|
904
1310
|
this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
|
|
905
1311
|
throw error;
|
|
@@ -921,7 +1327,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
921
1327
|
} else {
|
|
922
1328
|
await this.tagIndex.touch(key);
|
|
923
1329
|
}
|
|
924
|
-
this.
|
|
1330
|
+
this.metricsCollector.increment("sets");
|
|
925
1331
|
this.logger.debug?.("set", { key, kind, tags: options?.tags });
|
|
926
1332
|
this.emit("set", { key, kind, tags: options?.tags });
|
|
927
1333
|
if (this.shouldBroadcastL1Invalidation()) {
|
|
@@ -932,9 +1338,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
932
1338
|
let sawRetainableValue = false;
|
|
933
1339
|
for (let index = 0; index < this.layers.length; index += 1) {
|
|
934
1340
|
const layer = this.layers[index];
|
|
1341
|
+
if (!layer) continue;
|
|
935
1342
|
const stored = await this.readLayerEntry(layer, key);
|
|
936
1343
|
if (stored === null) {
|
|
937
|
-
this.
|
|
1344
|
+
this.metricsCollector.incrementLayer("missesByLayer", layer.name);
|
|
938
1345
|
continue;
|
|
939
1346
|
}
|
|
940
1347
|
const resolved = resolveStoredValue(stored);
|
|
@@ -948,10 +1355,17 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
948
1355
|
}
|
|
949
1356
|
await this.tagIndex.touch(key);
|
|
950
1357
|
await this.backfill(key, stored, index - 1, options);
|
|
951
|
-
this.
|
|
1358
|
+
this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
|
|
952
1359
|
this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
|
|
953
1360
|
this.emit("hit", { key, layer: layer.name, state: resolved.state });
|
|
954
|
-
return {
|
|
1361
|
+
return {
|
|
1362
|
+
found: true,
|
|
1363
|
+
value: resolved.value,
|
|
1364
|
+
stored,
|
|
1365
|
+
state: resolved.state,
|
|
1366
|
+
layerIndex: index,
|
|
1367
|
+
layerName: layer.name
|
|
1368
|
+
};
|
|
955
1369
|
}
|
|
956
1370
|
if (!sawRetainableValue) {
|
|
957
1371
|
await this.tagIndex.remove(key);
|
|
@@ -983,7 +1397,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
983
1397
|
}
|
|
984
1398
|
for (let index = 0; index <= upToIndex; index += 1) {
|
|
985
1399
|
const layer = this.layers[index];
|
|
986
|
-
if (this.shouldSkipLayer(layer)) {
|
|
1400
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
987
1401
|
continue;
|
|
988
1402
|
}
|
|
989
1403
|
const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
|
|
@@ -993,7 +1407,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
993
1407
|
await this.handleLayerFailure(layer, "backfill", error);
|
|
994
1408
|
continue;
|
|
995
1409
|
}
|
|
996
|
-
this.
|
|
1410
|
+
this.metricsCollector.increment("backfills");
|
|
997
1411
|
this.logger.debug?.("backfill", { key, layer: layer.name });
|
|
998
1412
|
this.emit("backfill", { key, layer: layer.name });
|
|
999
1413
|
}
|
|
@@ -1010,11 +1424,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1010
1424
|
options?.staleWhileRevalidate,
|
|
1011
1425
|
this.options.staleWhileRevalidate
|
|
1012
1426
|
);
|
|
1013
|
-
const staleIfError = this.resolveLayerSeconds(
|
|
1014
|
-
layer.name,
|
|
1015
|
-
options?.staleIfError,
|
|
1016
|
-
this.options.staleIfError
|
|
1017
|
-
);
|
|
1427
|
+
const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
|
|
1018
1428
|
const payload = createStoredValueEnvelope({
|
|
1019
1429
|
kind,
|
|
1020
1430
|
value,
|
|
@@ -1042,7 +1452,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1042
1452
|
if (failures.length === 0) {
|
|
1043
1453
|
return;
|
|
1044
1454
|
}
|
|
1045
|
-
this.
|
|
1455
|
+
this.metricsCollector.increment("writeFailures", failures.length);
|
|
1046
1456
|
this.logger.debug?.("write-failure", {
|
|
1047
1457
|
...context,
|
|
1048
1458
|
failures: failures.map((failure) => this.formatError(failure.reason))
|
|
@@ -1055,42 +1465,10 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1055
1465
|
}
|
|
1056
1466
|
}
|
|
1057
1467
|
resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
|
|
1058
|
-
|
|
1059
|
-
layerName,
|
|
1060
|
-
options?.negativeTtl,
|
|
1061
|
-
this.options.negativeTtl,
|
|
1062
|
-
this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
1063
|
-
) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
|
|
1064
|
-
const adaptiveTtl = this.applyAdaptiveTtl(
|
|
1065
|
-
key,
|
|
1066
|
-
layerName,
|
|
1067
|
-
baseTtl,
|
|
1068
|
-
options?.adaptiveTtl ?? this.options.adaptiveTtl
|
|
1069
|
-
);
|
|
1070
|
-
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
|
|
1071
|
-
return this.applyJitter(adaptiveTtl, jitter);
|
|
1468
|
+
return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
|
|
1072
1469
|
}
|
|
1073
1470
|
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
1074
|
-
|
|
1075
|
-
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
1076
|
-
}
|
|
1077
|
-
if (globalDefault !== void 0) {
|
|
1078
|
-
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
1079
|
-
}
|
|
1080
|
-
return fallback;
|
|
1081
|
-
}
|
|
1082
|
-
readLayerNumber(layerName, value) {
|
|
1083
|
-
if (typeof value === "number") {
|
|
1084
|
-
return value;
|
|
1085
|
-
}
|
|
1086
|
-
return value[layerName];
|
|
1087
|
-
}
|
|
1088
|
-
applyJitter(ttl, jitter) {
|
|
1089
|
-
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
1090
|
-
return ttl;
|
|
1091
|
-
}
|
|
1092
|
-
const delta = (Math.random() * 2 - 1) * jitter;
|
|
1093
|
-
return Math.max(1, Math.round(ttl + delta));
|
|
1471
|
+
return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
|
|
1094
1472
|
}
|
|
1095
1473
|
shouldNegativeCache(options) {
|
|
1096
1474
|
return options?.negativeCache ?? this.options.negativeCaching ?? false;
|
|
@@ -1100,11 +1478,11 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1100
1478
|
return;
|
|
1101
1479
|
}
|
|
1102
1480
|
const refresh = (async () => {
|
|
1103
|
-
this.
|
|
1481
|
+
this.metricsCollector.increment("refreshes");
|
|
1104
1482
|
try {
|
|
1105
1483
|
await this.fetchWithGuards(key, fetcher, options);
|
|
1106
1484
|
} catch (error) {
|
|
1107
|
-
this.
|
|
1485
|
+
this.metricsCollector.increment("refreshErrors");
|
|
1108
1486
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
1109
1487
|
} finally {
|
|
1110
1488
|
this.backgroundRefreshes.delete(key);
|
|
@@ -1126,10 +1504,11 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1126
1504
|
await this.deleteKeysFromLayers(this.layers, keys);
|
|
1127
1505
|
for (const key of keys) {
|
|
1128
1506
|
await this.tagIndex.remove(key);
|
|
1129
|
-
this.
|
|
1507
|
+
this.ttlResolver.deleteProfile(key);
|
|
1508
|
+
this.circuitBreakerManager.delete(key);
|
|
1130
1509
|
}
|
|
1131
|
-
this.
|
|
1132
|
-
this.
|
|
1510
|
+
this.metricsCollector.increment("deletes", keys.length);
|
|
1511
|
+
this.metricsCollector.increment("invalidations");
|
|
1133
1512
|
this.logger.debug?.("delete", { keys });
|
|
1134
1513
|
this.emit("delete", { keys });
|
|
1135
1514
|
}
|
|
@@ -1150,7 +1529,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1150
1529
|
if (message.scope === "clear") {
|
|
1151
1530
|
await Promise.all(localLayers.map((layer) => layer.clear()));
|
|
1152
1531
|
await this.tagIndex.clear();
|
|
1153
|
-
this.
|
|
1532
|
+
this.ttlResolver.clearProfiles();
|
|
1154
1533
|
return;
|
|
1155
1534
|
}
|
|
1156
1535
|
const keys = message.keys ?? [];
|
|
@@ -1158,7 +1537,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1158
1537
|
if (message.operation !== "write") {
|
|
1159
1538
|
for (const key of keys) {
|
|
1160
1539
|
await this.tagIndex.remove(key);
|
|
1161
|
-
this.
|
|
1540
|
+
this.ttlResolver.deleteProfile(key);
|
|
1162
1541
|
}
|
|
1163
1542
|
}
|
|
1164
1543
|
}
|
|
@@ -1188,13 +1567,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1188
1567
|
}
|
|
1189
1568
|
return;
|
|
1190
1569
|
}
|
|
1191
|
-
await Promise.all(
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1570
|
+
await Promise.all(
|
|
1571
|
+
keys.map(async (key) => {
|
|
1572
|
+
try {
|
|
1573
|
+
await layer.delete(key);
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
await this.handleLayerFailure(layer, "delete", error);
|
|
1576
|
+
}
|
|
1577
|
+
})
|
|
1578
|
+
);
|
|
1198
1579
|
})
|
|
1199
1580
|
);
|
|
1200
1581
|
}
|
|
@@ -1295,7 +1676,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1295
1676
|
const ttl = remainingStoredTtlSeconds(refreshed);
|
|
1296
1677
|
for (let index = 0; index <= hit.layerIndex; index += 1) {
|
|
1297
1678
|
const layer = this.layers[index];
|
|
1298
|
-
if (this.shouldSkipLayer(layer)) {
|
|
1679
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
1299
1680
|
continue;
|
|
1300
1681
|
}
|
|
1301
1682
|
try {
|
|
@@ -1309,33 +1690,6 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1309
1690
|
this.scheduleBackgroundRefresh(key, fetcher, options);
|
|
1310
1691
|
}
|
|
1311
1692
|
}
|
|
1312
|
-
applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
|
|
1313
|
-
if (!ttl || !adaptiveTtl) {
|
|
1314
|
-
return ttl;
|
|
1315
|
-
}
|
|
1316
|
-
const profile = this.accessProfiles.get(key);
|
|
1317
|
-
if (!profile) {
|
|
1318
|
-
return ttl;
|
|
1319
|
-
}
|
|
1320
|
-
const config = adaptiveTtl === true ? {} : adaptiveTtl;
|
|
1321
|
-
const hotAfter = config.hotAfter ?? 3;
|
|
1322
|
-
if (profile.hits < hotAfter) {
|
|
1323
|
-
return ttl;
|
|
1324
|
-
}
|
|
1325
|
-
const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
|
|
1326
|
-
const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
|
|
1327
|
-
const multiplier = Math.floor(profile.hits / hotAfter);
|
|
1328
|
-
return Math.min(maxTtl, ttl + step * multiplier);
|
|
1329
|
-
}
|
|
1330
|
-
recordAccess(key) {
|
|
1331
|
-
const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
|
|
1332
|
-
profile.hits += 1;
|
|
1333
|
-
profile.lastAccessAt = Date.now();
|
|
1334
|
-
this.accessProfiles.set(key, profile);
|
|
1335
|
-
}
|
|
1336
|
-
incrementMetricMap(target, key) {
|
|
1337
|
-
target[key] = (target[key] ?? 0) + 1;
|
|
1338
|
-
}
|
|
1339
1693
|
shouldSkipLayer(layer) {
|
|
1340
1694
|
const degradedUntil = this.layerDegradedUntil.get(layer.name);
|
|
1341
1695
|
return degradedUntil !== void 0 && degradedUntil > Date.now();
|
|
@@ -1346,7 +1700,7 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1346
1700
|
}
|
|
1347
1701
|
const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
|
|
1348
1702
|
this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
|
|
1349
|
-
this.
|
|
1703
|
+
this.metricsCollector.increment("degradedOperations");
|
|
1350
1704
|
this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
|
|
1351
1705
|
this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
|
|
1352
1706
|
return null;
|
|
@@ -1354,37 +1708,15 @@ var CacheStack = class extends import_node_events.EventEmitter {
|
|
|
1354
1708
|
isGracefulDegradationEnabled() {
|
|
1355
1709
|
return Boolean(this.options.gracefulDegradation);
|
|
1356
1710
|
}
|
|
1357
|
-
assertCircuitClosed(key, options) {
|
|
1358
|
-
const state = this.circuitBreakers.get(key);
|
|
1359
|
-
if (!state?.openUntil) {
|
|
1360
|
-
return;
|
|
1361
|
-
}
|
|
1362
|
-
if (state.openUntil <= Date.now()) {
|
|
1363
|
-
state.openUntil = null;
|
|
1364
|
-
state.failures = 0;
|
|
1365
|
-
this.circuitBreakers.set(key, state);
|
|
1366
|
-
return;
|
|
1367
|
-
}
|
|
1368
|
-
this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
|
|
1369
|
-
throw new Error(`Circuit breaker is open for key "${key}".`);
|
|
1370
|
-
}
|
|
1371
1711
|
recordCircuitFailure(key, options, error) {
|
|
1372
1712
|
if (!options) {
|
|
1373
1713
|
return;
|
|
1374
1714
|
}
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
state.failures += 1;
|
|
1379
|
-
if (state.failures >= failureThreshold) {
|
|
1380
|
-
state.openUntil = Date.now() + cooldownMs;
|
|
1381
|
-
this.metrics.circuitBreakerTrips += 1;
|
|
1715
|
+
this.circuitBreakerManager.recordFailure(key, options);
|
|
1716
|
+
if (this.circuitBreakerManager.isOpen(key)) {
|
|
1717
|
+
this.metricsCollector.increment("circuitBreakerTrips");
|
|
1382
1718
|
}
|
|
1383
|
-
this.
|
|
1384
|
-
this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
|
|
1385
|
-
}
|
|
1386
|
-
resetCircuitBreaker(key) {
|
|
1387
|
-
this.circuitBreakers.delete(key);
|
|
1719
|
+
this.emitError("fetch", { key, error: this.formatError(error) });
|
|
1388
1720
|
}
|
|
1389
1721
|
isNegativeStoredValue(stored) {
|
|
1390
1722
|
return isStoredValueEnvelope(stored) && stored.kind === "empty";
|