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
|
@@ -47,8 +47,229 @@ import { Global, Inject, Module } from "@nestjs/common";
|
|
|
47
47
|
|
|
48
48
|
// ../../src/CacheStack.ts
|
|
49
49
|
import { randomUUID } from "crypto";
|
|
50
|
-
import { promises as fs } from "fs";
|
|
51
50
|
import { EventEmitter } from "events";
|
|
51
|
+
import { promises as fs } from "fs";
|
|
52
|
+
|
|
53
|
+
// ../../src/CacheNamespace.ts
|
|
54
|
+
var CacheNamespace = class {
|
|
55
|
+
constructor(cache, prefix) {
|
|
56
|
+
this.cache = cache;
|
|
57
|
+
this.prefix = prefix;
|
|
58
|
+
}
|
|
59
|
+
cache;
|
|
60
|
+
prefix;
|
|
61
|
+
async get(key, fetcher, options) {
|
|
62
|
+
return this.cache.get(this.qualify(key), fetcher, options);
|
|
63
|
+
}
|
|
64
|
+
async getOrSet(key, fetcher, options) {
|
|
65
|
+
return this.cache.getOrSet(this.qualify(key), fetcher, options);
|
|
66
|
+
}
|
|
67
|
+
async has(key) {
|
|
68
|
+
return this.cache.has(this.qualify(key));
|
|
69
|
+
}
|
|
70
|
+
async ttl(key) {
|
|
71
|
+
return this.cache.ttl(this.qualify(key));
|
|
72
|
+
}
|
|
73
|
+
async set(key, value, options) {
|
|
74
|
+
await this.cache.set(this.qualify(key), value, options);
|
|
75
|
+
}
|
|
76
|
+
async delete(key) {
|
|
77
|
+
await this.cache.delete(this.qualify(key));
|
|
78
|
+
}
|
|
79
|
+
async mdelete(keys) {
|
|
80
|
+
await this.cache.mdelete(keys.map((k) => this.qualify(k)));
|
|
81
|
+
}
|
|
82
|
+
async clear() {
|
|
83
|
+
await this.cache.invalidateByPattern(`${this.prefix}:*`);
|
|
84
|
+
}
|
|
85
|
+
async mget(entries) {
|
|
86
|
+
return this.cache.mget(
|
|
87
|
+
entries.map((entry) => ({
|
|
88
|
+
...entry,
|
|
89
|
+
key: this.qualify(entry.key)
|
|
90
|
+
}))
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
async mset(entries) {
|
|
94
|
+
await this.cache.mset(
|
|
95
|
+
entries.map((entry) => ({
|
|
96
|
+
...entry,
|
|
97
|
+
key: this.qualify(entry.key)
|
|
98
|
+
}))
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
async invalidateByTag(tag) {
|
|
102
|
+
await this.cache.invalidateByTag(tag);
|
|
103
|
+
}
|
|
104
|
+
async invalidateByPattern(pattern) {
|
|
105
|
+
await this.cache.invalidateByPattern(this.qualify(pattern));
|
|
106
|
+
}
|
|
107
|
+
wrap(keyPrefix, fetcher, options) {
|
|
108
|
+
return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
|
|
109
|
+
}
|
|
110
|
+
warm(entries, options) {
|
|
111
|
+
return this.cache.warm(
|
|
112
|
+
entries.map((entry) => ({
|
|
113
|
+
...entry,
|
|
114
|
+
key: this.qualify(entry.key)
|
|
115
|
+
})),
|
|
116
|
+
options
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
getMetrics() {
|
|
120
|
+
return this.cache.getMetrics();
|
|
121
|
+
}
|
|
122
|
+
getHitRate() {
|
|
123
|
+
return this.cache.getHitRate();
|
|
124
|
+
}
|
|
125
|
+
qualify(key) {
|
|
126
|
+
return `${this.prefix}:${key}`;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// ../../src/internal/CircuitBreakerManager.ts
|
|
131
|
+
var CircuitBreakerManager = class {
|
|
132
|
+
breakers = /* @__PURE__ */ new Map();
|
|
133
|
+
maxEntries;
|
|
134
|
+
constructor(options) {
|
|
135
|
+
this.maxEntries = options.maxEntries;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Throws if the circuit is open for the given key.
|
|
139
|
+
* Automatically resets if the cooldown has elapsed.
|
|
140
|
+
*/
|
|
141
|
+
assertClosed(key, options) {
|
|
142
|
+
const state = this.breakers.get(key);
|
|
143
|
+
if (!state?.openUntil) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
if (state.openUntil <= now) {
|
|
148
|
+
state.openUntil = null;
|
|
149
|
+
state.failures = 0;
|
|
150
|
+
this.breakers.set(key, state);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const remainingMs = state.openUntil - now;
|
|
154
|
+
const remainingSecs = Math.ceil(remainingMs / 1e3);
|
|
155
|
+
throw new Error(`Circuit breaker is open for key "${key}" (resets in ${remainingSecs}s).`);
|
|
156
|
+
}
|
|
157
|
+
recordFailure(key, options) {
|
|
158
|
+
if (!options) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const failureThreshold = options.failureThreshold ?? 3;
|
|
162
|
+
const cooldownMs = options.cooldownMs ?? 3e4;
|
|
163
|
+
const state = this.breakers.get(key) ?? { failures: 0, openUntil: null };
|
|
164
|
+
state.failures += 1;
|
|
165
|
+
if (state.failures >= failureThreshold) {
|
|
166
|
+
state.openUntil = Date.now() + cooldownMs;
|
|
167
|
+
}
|
|
168
|
+
this.breakers.set(key, state);
|
|
169
|
+
this.pruneIfNeeded();
|
|
170
|
+
}
|
|
171
|
+
recordSuccess(key) {
|
|
172
|
+
this.breakers.delete(key);
|
|
173
|
+
}
|
|
174
|
+
isOpen(key) {
|
|
175
|
+
const state = this.breakers.get(key);
|
|
176
|
+
if (!state?.openUntil) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
if (state.openUntil <= Date.now()) {
|
|
180
|
+
state.openUntil = null;
|
|
181
|
+
state.failures = 0;
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
delete(key) {
|
|
187
|
+
this.breakers.delete(key);
|
|
188
|
+
}
|
|
189
|
+
clear() {
|
|
190
|
+
this.breakers.clear();
|
|
191
|
+
}
|
|
192
|
+
tripCount() {
|
|
193
|
+
let count = 0;
|
|
194
|
+
for (const state of this.breakers.values()) {
|
|
195
|
+
if (state.openUntil !== null) {
|
|
196
|
+
count += 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return count;
|
|
200
|
+
}
|
|
201
|
+
pruneIfNeeded() {
|
|
202
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
for (const [key, state] of this.breakers.entries()) {
|
|
206
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
if (!state.openUntil || state.openUntil <= Date.now()) {
|
|
210
|
+
this.breakers.delete(key);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (const key of this.breakers.keys()) {
|
|
214
|
+
if (this.breakers.size <= this.maxEntries) {
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
this.breakers.delete(key);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// ../../src/internal/MetricsCollector.ts
|
|
223
|
+
var MetricsCollector = class {
|
|
224
|
+
data = this.empty();
|
|
225
|
+
get snapshot() {
|
|
226
|
+
return { ...this.data };
|
|
227
|
+
}
|
|
228
|
+
increment(field, amount = 1) {
|
|
229
|
+
;
|
|
230
|
+
this.data[field] += amount;
|
|
231
|
+
}
|
|
232
|
+
incrementLayer(map, layerName) {
|
|
233
|
+
this.data[map][layerName] = (this.data[map][layerName] ?? 0) + 1;
|
|
234
|
+
}
|
|
235
|
+
reset() {
|
|
236
|
+
this.data = this.empty();
|
|
237
|
+
}
|
|
238
|
+
hitRate() {
|
|
239
|
+
const total = this.data.hits + this.data.misses;
|
|
240
|
+
const overall = total === 0 ? 0 : this.data.hits / total;
|
|
241
|
+
const byLayer = {};
|
|
242
|
+
const allLayers = /* @__PURE__ */ new Set([...Object.keys(this.data.hitsByLayer), ...Object.keys(this.data.missesByLayer)]);
|
|
243
|
+
for (const layer of allLayers) {
|
|
244
|
+
const h = this.data.hitsByLayer[layer] ?? 0;
|
|
245
|
+
const m = this.data.missesByLayer[layer] ?? 0;
|
|
246
|
+
byLayer[layer] = h + m === 0 ? 0 : h / (h + m);
|
|
247
|
+
}
|
|
248
|
+
return { overall, byLayer };
|
|
249
|
+
}
|
|
250
|
+
empty() {
|
|
251
|
+
return {
|
|
252
|
+
hits: 0,
|
|
253
|
+
misses: 0,
|
|
254
|
+
fetches: 0,
|
|
255
|
+
sets: 0,
|
|
256
|
+
deletes: 0,
|
|
257
|
+
backfills: 0,
|
|
258
|
+
invalidations: 0,
|
|
259
|
+
staleHits: 0,
|
|
260
|
+
refreshes: 0,
|
|
261
|
+
refreshErrors: 0,
|
|
262
|
+
writeFailures: 0,
|
|
263
|
+
singleFlightWaits: 0,
|
|
264
|
+
negativeCacheHits: 0,
|
|
265
|
+
circuitBreakerTrips: 0,
|
|
266
|
+
degradedOperations: 0,
|
|
267
|
+
hitsByLayer: {},
|
|
268
|
+
missesByLayer: {},
|
|
269
|
+
resetAt: Date.now()
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
};
|
|
52
273
|
|
|
53
274
|
// ../../src/internal/StoredValue.ts
|
|
54
275
|
function isStoredValueEnvelope(value) {
|
|
@@ -151,67 +372,129 @@ function normalizePositiveSeconds(value) {
|
|
|
151
372
|
return value;
|
|
152
373
|
}
|
|
153
374
|
|
|
154
|
-
// ../../src/
|
|
155
|
-
var
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
prefix;
|
|
162
|
-
async get(key, fetcher, options) {
|
|
163
|
-
return this.cache.get(this.qualify(key), fetcher, options);
|
|
164
|
-
}
|
|
165
|
-
async set(key, value, options) {
|
|
166
|
-
await this.cache.set(this.qualify(key), value, options);
|
|
167
|
-
}
|
|
168
|
-
async delete(key) {
|
|
169
|
-
await this.cache.delete(this.qualify(key));
|
|
375
|
+
// ../../src/internal/TtlResolver.ts
|
|
376
|
+
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
377
|
+
var TtlResolver = class {
|
|
378
|
+
accessProfiles = /* @__PURE__ */ new Map();
|
|
379
|
+
maxProfileEntries;
|
|
380
|
+
constructor(options) {
|
|
381
|
+
this.maxProfileEntries = options.maxProfileEntries;
|
|
170
382
|
}
|
|
171
|
-
|
|
172
|
-
|
|
383
|
+
recordAccess(key) {
|
|
384
|
+
const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
|
|
385
|
+
profile.hits += 1;
|
|
386
|
+
profile.lastAccessAt = Date.now();
|
|
387
|
+
this.accessProfiles.set(key, profile);
|
|
388
|
+
this.pruneIfNeeded();
|
|
173
389
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
...entry,
|
|
177
|
-
key: this.qualify(entry.key)
|
|
178
|
-
})));
|
|
390
|
+
deleteProfile(key) {
|
|
391
|
+
this.accessProfiles.delete(key);
|
|
179
392
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
...entry,
|
|
183
|
-
key: this.qualify(entry.key)
|
|
184
|
-
})));
|
|
393
|
+
clearProfiles() {
|
|
394
|
+
this.accessProfiles.clear();
|
|
185
395
|
}
|
|
186
|
-
|
|
187
|
-
|
|
396
|
+
resolveFreshTtl(key, layerName, kind, options, fallbackTtl, globalNegativeTtl, globalTtl) {
|
|
397
|
+
const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
|
|
398
|
+
layerName,
|
|
399
|
+
options?.negativeTtl,
|
|
400
|
+
globalNegativeTtl,
|
|
401
|
+
this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
402
|
+
) : this.resolveLayerSeconds(layerName, options?.ttl, globalTtl, fallbackTtl);
|
|
403
|
+
const adaptiveTtl = this.applyAdaptiveTtl(key, layerName, baseTtl, options?.adaptiveTtl);
|
|
404
|
+
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, void 0);
|
|
405
|
+
return this.applyJitter(adaptiveTtl, jitter);
|
|
188
406
|
}
|
|
189
|
-
|
|
190
|
-
|
|
407
|
+
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
408
|
+
if (override !== void 0) {
|
|
409
|
+
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
410
|
+
}
|
|
411
|
+
if (globalDefault !== void 0) {
|
|
412
|
+
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
413
|
+
}
|
|
414
|
+
return fallback;
|
|
191
415
|
}
|
|
192
|
-
|
|
193
|
-
|
|
416
|
+
applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
|
|
417
|
+
if (!ttl || !adaptiveTtl) {
|
|
418
|
+
return ttl;
|
|
419
|
+
}
|
|
420
|
+
const profile = this.accessProfiles.get(key);
|
|
421
|
+
if (!profile) {
|
|
422
|
+
return ttl;
|
|
423
|
+
}
|
|
424
|
+
const config = adaptiveTtl === true ? {} : adaptiveTtl;
|
|
425
|
+
const hotAfter = config.hotAfter ?? 3;
|
|
426
|
+
if (profile.hits < hotAfter) {
|
|
427
|
+
return ttl;
|
|
428
|
+
}
|
|
429
|
+
const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
|
|
430
|
+
const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
|
|
431
|
+
const multiplier = Math.floor(profile.hits / hotAfter);
|
|
432
|
+
return Math.min(maxTtl, ttl + step * multiplier);
|
|
194
433
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
434
|
+
applyJitter(ttl, jitter) {
|
|
435
|
+
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
436
|
+
return ttl;
|
|
437
|
+
}
|
|
438
|
+
const delta = (Math.random() * 2 - 1) * jitter;
|
|
439
|
+
return Math.max(1, Math.round(ttl + delta));
|
|
200
440
|
}
|
|
201
|
-
|
|
202
|
-
|
|
441
|
+
readLayerNumber(layerName, value) {
|
|
442
|
+
if (typeof value === "number") {
|
|
443
|
+
return value;
|
|
444
|
+
}
|
|
445
|
+
return value[layerName];
|
|
203
446
|
}
|
|
204
|
-
|
|
205
|
-
|
|
447
|
+
pruneIfNeeded() {
|
|
448
|
+
if (this.accessProfiles.size <= this.maxProfileEntries) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const toRemove = Math.ceil(this.maxProfileEntries * 0.1);
|
|
452
|
+
let removed = 0;
|
|
453
|
+
for (const key of this.accessProfiles.keys()) {
|
|
454
|
+
if (removed >= toRemove) {
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
this.accessProfiles.delete(key);
|
|
458
|
+
removed += 1;
|
|
459
|
+
}
|
|
206
460
|
}
|
|
207
461
|
};
|
|
208
462
|
|
|
209
463
|
// ../../src/invalidation/PatternMatcher.ts
|
|
210
|
-
var PatternMatcher = class {
|
|
464
|
+
var PatternMatcher = class _PatternMatcher {
|
|
465
|
+
/**
|
|
466
|
+
* Tests whether a glob-style pattern matches a value.
|
|
467
|
+
* Supports `*` (any sequence of characters) and `?` (any single character).
|
|
468
|
+
* Uses a linear-time algorithm to avoid ReDoS vulnerabilities.
|
|
469
|
+
*/
|
|
211
470
|
static matches(pattern, value) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
471
|
+
return _PatternMatcher.matchLinear(pattern, value);
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Linear-time glob matching using dynamic programming.
|
|
475
|
+
* Avoids catastrophic backtracking that RegExp-based glob matching can cause.
|
|
476
|
+
*/
|
|
477
|
+
static matchLinear(pattern, value) {
|
|
478
|
+
const m = pattern.length;
|
|
479
|
+
const n = value.length;
|
|
480
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(false));
|
|
481
|
+
dp[0][0] = true;
|
|
482
|
+
for (let i = 1; i <= m; i++) {
|
|
483
|
+
if (pattern[i - 1] === "*") {
|
|
484
|
+
dp[i][0] = dp[i - 1]?.[0];
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
for (let i = 1; i <= m; i++) {
|
|
488
|
+
for (let j = 1; j <= n; j++) {
|
|
489
|
+
const pc = pattern[i - 1];
|
|
490
|
+
if (pc === "*") {
|
|
491
|
+
dp[i][j] = dp[i - 1]?.[j] || dp[i]?.[j - 1];
|
|
492
|
+
} else if (pc === "?" || pc === value[j - 1]) {
|
|
493
|
+
dp[i][j] = dp[i - 1]?.[j - 1];
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return dp[m]?.[n];
|
|
215
498
|
}
|
|
216
499
|
};
|
|
217
500
|
|
|
@@ -474,30 +757,11 @@ var StampedeGuard = class {
|
|
|
474
757
|
};
|
|
475
758
|
|
|
476
759
|
// ../../src/CacheStack.ts
|
|
477
|
-
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
478
760
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
479
761
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
480
762
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
481
763
|
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
482
|
-
var
|
|
483
|
-
hits: 0,
|
|
484
|
-
misses: 0,
|
|
485
|
-
fetches: 0,
|
|
486
|
-
sets: 0,
|
|
487
|
-
deletes: 0,
|
|
488
|
-
backfills: 0,
|
|
489
|
-
invalidations: 0,
|
|
490
|
-
staleHits: 0,
|
|
491
|
-
refreshes: 0,
|
|
492
|
-
refreshErrors: 0,
|
|
493
|
-
writeFailures: 0,
|
|
494
|
-
singleFlightWaits: 0,
|
|
495
|
-
negativeCacheHits: 0,
|
|
496
|
-
circuitBreakerTrips: 0,
|
|
497
|
-
degradedOperations: 0,
|
|
498
|
-
hitsByLayer: {},
|
|
499
|
-
missesByLayer: {}
|
|
500
|
-
});
|
|
764
|
+
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
501
765
|
var DebugLogger = class {
|
|
502
766
|
enabled;
|
|
503
767
|
constructor(enabled) {
|
|
@@ -532,6 +796,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
532
796
|
throw new Error("CacheStack requires at least one cache layer.");
|
|
533
797
|
}
|
|
534
798
|
this.validateConfiguration();
|
|
799
|
+
const maxProfileEntries = options.maxProfileEntries ?? DEFAULT_MAX_PROFILE_ENTRIES;
|
|
800
|
+
this.ttlResolver = new TtlResolver({ maxProfileEntries });
|
|
801
|
+
this.circuitBreakerManager = new CircuitBreakerManager({ maxEntries: maxProfileEntries });
|
|
802
|
+
if (options.publishSetInvalidation !== void 0) {
|
|
803
|
+
console.warn(
|
|
804
|
+
"[layercache] CacheStackOptions.publishSetInvalidation is deprecated. Use broadcastL1Invalidation instead."
|
|
805
|
+
);
|
|
806
|
+
}
|
|
535
807
|
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
536
808
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
537
809
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
@@ -540,36 +812,42 @@ var CacheStack = class extends EventEmitter {
|
|
|
540
812
|
layers;
|
|
541
813
|
options;
|
|
542
814
|
stampedeGuard = new StampedeGuard();
|
|
543
|
-
|
|
815
|
+
metricsCollector = new MetricsCollector();
|
|
544
816
|
instanceId = randomUUID();
|
|
545
817
|
startup;
|
|
546
818
|
unsubscribeInvalidation;
|
|
547
819
|
logger;
|
|
548
820
|
tagIndex;
|
|
549
821
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
550
|
-
accessProfiles = /* @__PURE__ */ new Map();
|
|
551
822
|
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
552
|
-
|
|
823
|
+
ttlResolver;
|
|
824
|
+
circuitBreakerManager;
|
|
553
825
|
isDisconnecting = false;
|
|
554
826
|
disconnectPromise;
|
|
827
|
+
/**
|
|
828
|
+
* Read-through cache get.
|
|
829
|
+
* Returns the cached value if present and fresh, or invokes `fetcher` on a miss
|
|
830
|
+
* and stores the result across all layers. Returns `null` if the key is not found
|
|
831
|
+
* and no `fetcher` is provided.
|
|
832
|
+
*/
|
|
555
833
|
async get(key, fetcher, options) {
|
|
556
834
|
const normalizedKey = this.validateCacheKey(key);
|
|
557
835
|
this.validateWriteOptions(options);
|
|
558
836
|
await this.startup;
|
|
559
837
|
const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
|
|
560
838
|
if (hit.found) {
|
|
561
|
-
this.recordAccess(normalizedKey);
|
|
839
|
+
this.ttlResolver.recordAccess(normalizedKey);
|
|
562
840
|
if (this.isNegativeStoredValue(hit.stored)) {
|
|
563
|
-
this.
|
|
841
|
+
this.metricsCollector.increment("negativeCacheHits");
|
|
564
842
|
}
|
|
565
843
|
if (hit.state === "fresh") {
|
|
566
|
-
this.
|
|
844
|
+
this.metricsCollector.increment("hits");
|
|
567
845
|
await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
|
|
568
846
|
return hit.value;
|
|
569
847
|
}
|
|
570
848
|
if (hit.state === "stale-while-revalidate") {
|
|
571
|
-
this.
|
|
572
|
-
this.
|
|
849
|
+
this.metricsCollector.increment("hits");
|
|
850
|
+
this.metricsCollector.increment("staleHits");
|
|
573
851
|
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
574
852
|
if (fetcher) {
|
|
575
853
|
this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
|
|
@@ -577,47 +855,136 @@ var CacheStack = class extends EventEmitter {
|
|
|
577
855
|
return hit.value;
|
|
578
856
|
}
|
|
579
857
|
if (!fetcher) {
|
|
580
|
-
this.
|
|
581
|
-
this.
|
|
858
|
+
this.metricsCollector.increment("hits");
|
|
859
|
+
this.metricsCollector.increment("staleHits");
|
|
582
860
|
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
583
861
|
return hit.value;
|
|
584
862
|
}
|
|
585
863
|
try {
|
|
586
864
|
return await this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
587
865
|
} catch (error) {
|
|
588
|
-
this.
|
|
589
|
-
this.
|
|
866
|
+
this.metricsCollector.increment("staleHits");
|
|
867
|
+
this.metricsCollector.increment("refreshErrors");
|
|
590
868
|
this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
|
|
591
869
|
return hit.value;
|
|
592
870
|
}
|
|
593
871
|
}
|
|
594
|
-
this.
|
|
872
|
+
this.metricsCollector.increment("misses");
|
|
595
873
|
if (!fetcher) {
|
|
596
874
|
return null;
|
|
597
875
|
}
|
|
598
876
|
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
599
877
|
}
|
|
878
|
+
/**
|
|
879
|
+
* Alias for `get(key, fetcher, options)` — explicit get-or-set pattern.
|
|
880
|
+
* Fetches and caches the value if not already present.
|
|
881
|
+
*/
|
|
882
|
+
async getOrSet(key, fetcher, options) {
|
|
883
|
+
return this.get(key, fetcher, options);
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Returns true if the given key exists and is not expired in any layer.
|
|
887
|
+
*/
|
|
888
|
+
async has(key) {
|
|
889
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
890
|
+
await this.startup;
|
|
891
|
+
for (const layer of this.layers) {
|
|
892
|
+
if (this.shouldSkipLayer(layer)) {
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (layer.has) {
|
|
896
|
+
try {
|
|
897
|
+
const exists = await layer.has(normalizedKey);
|
|
898
|
+
if (exists) {
|
|
899
|
+
return true;
|
|
900
|
+
}
|
|
901
|
+
} catch {
|
|
902
|
+
}
|
|
903
|
+
} else {
|
|
904
|
+
try {
|
|
905
|
+
const value = await layer.get(normalizedKey);
|
|
906
|
+
if (value !== null) {
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
} catch {
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return false;
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Returns the remaining TTL in seconds for the key in the fastest layer
|
|
917
|
+
* that has it, or null if the key is not found / has no TTL.
|
|
918
|
+
*/
|
|
919
|
+
async ttl(key) {
|
|
920
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
921
|
+
await this.startup;
|
|
922
|
+
for (const layer of this.layers) {
|
|
923
|
+
if (this.shouldSkipLayer(layer)) {
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (layer.ttl) {
|
|
927
|
+
try {
|
|
928
|
+
const remaining = await layer.ttl(normalizedKey);
|
|
929
|
+
if (remaining !== null) {
|
|
930
|
+
return remaining;
|
|
931
|
+
}
|
|
932
|
+
} catch {
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return null;
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Stores a value in all cache layers. Overwrites any existing value.
|
|
940
|
+
*/
|
|
600
941
|
async set(key, value, options) {
|
|
601
942
|
const normalizedKey = this.validateCacheKey(key);
|
|
602
943
|
this.validateWriteOptions(options);
|
|
603
944
|
await this.startup;
|
|
604
945
|
await this.storeEntry(normalizedKey, "value", value, options);
|
|
605
946
|
}
|
|
947
|
+
/**
|
|
948
|
+
* Deletes the key from all layers and publishes an invalidation message.
|
|
949
|
+
*/
|
|
606
950
|
async delete(key) {
|
|
607
951
|
const normalizedKey = this.validateCacheKey(key);
|
|
608
952
|
await this.startup;
|
|
609
953
|
await this.deleteKeys([normalizedKey]);
|
|
610
|
-
await this.publishInvalidation({
|
|
954
|
+
await this.publishInvalidation({
|
|
955
|
+
scope: "key",
|
|
956
|
+
keys: [normalizedKey],
|
|
957
|
+
sourceId: this.instanceId,
|
|
958
|
+
operation: "delete"
|
|
959
|
+
});
|
|
611
960
|
}
|
|
612
961
|
async clear() {
|
|
613
962
|
await this.startup;
|
|
614
963
|
await Promise.all(this.layers.map((layer) => layer.clear()));
|
|
615
964
|
await this.tagIndex.clear();
|
|
616
|
-
this.
|
|
617
|
-
this.
|
|
965
|
+
this.ttlResolver.clearProfiles();
|
|
966
|
+
this.circuitBreakerManager.clear();
|
|
967
|
+
this.metricsCollector.increment("invalidations");
|
|
618
968
|
this.logger.debug?.("clear");
|
|
619
969
|
await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
|
|
620
970
|
}
|
|
971
|
+
/**
|
|
972
|
+
* Deletes multiple keys at once. More efficient than calling `delete()` in a loop.
|
|
973
|
+
*/
|
|
974
|
+
async mdelete(keys) {
|
|
975
|
+
if (keys.length === 0) {
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
await this.startup;
|
|
979
|
+
const normalizedKeys = keys.map((k) => this.validateCacheKey(k));
|
|
980
|
+
await this.deleteKeys(normalizedKeys);
|
|
981
|
+
await this.publishInvalidation({
|
|
982
|
+
scope: "keys",
|
|
983
|
+
keys: normalizedKeys,
|
|
984
|
+
sourceId: this.instanceId,
|
|
985
|
+
operation: "delete"
|
|
986
|
+
});
|
|
987
|
+
}
|
|
621
988
|
async mget(entries) {
|
|
622
989
|
if (entries.length === 0) {
|
|
623
990
|
return [];
|
|
@@ -655,7 +1022,9 @@ var CacheStack = class extends EventEmitter {
|
|
|
655
1022
|
const indexesByKey = /* @__PURE__ */ new Map();
|
|
656
1023
|
const resultsByKey = /* @__PURE__ */ new Map();
|
|
657
1024
|
for (let index = 0; index < normalizedEntries.length; index += 1) {
|
|
658
|
-
const
|
|
1025
|
+
const entry = normalizedEntries[index];
|
|
1026
|
+
if (!entry) continue;
|
|
1027
|
+
const key = entry.key;
|
|
659
1028
|
const indexes = indexesByKey.get(key) ?? [];
|
|
660
1029
|
indexes.push(index);
|
|
661
1030
|
indexesByKey.set(key, indexes);
|
|
@@ -663,6 +1032,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
663
1032
|
}
|
|
664
1033
|
for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
|
|
665
1034
|
const layer = this.layers[layerIndex];
|
|
1035
|
+
if (!layer) continue;
|
|
666
1036
|
const keys = [...pending];
|
|
667
1037
|
if (keys.length === 0) {
|
|
668
1038
|
break;
|
|
@@ -671,7 +1041,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
671
1041
|
for (let offset = 0; offset < values.length; offset += 1) {
|
|
672
1042
|
const key = keys[offset];
|
|
673
1043
|
const stored = values[offset];
|
|
674
|
-
if (stored === null) {
|
|
1044
|
+
if (!key || stored === null) {
|
|
675
1045
|
continue;
|
|
676
1046
|
}
|
|
677
1047
|
const resolved = resolveStoredValue(stored);
|
|
@@ -683,13 +1053,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
683
1053
|
await this.backfill(key, stored, layerIndex - 1);
|
|
684
1054
|
resultsByKey.set(key, resolved.value);
|
|
685
1055
|
pending.delete(key);
|
|
686
|
-
this.
|
|
1056
|
+
this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
|
|
687
1057
|
}
|
|
688
1058
|
}
|
|
689
1059
|
if (pending.size > 0) {
|
|
690
1060
|
for (const key of pending) {
|
|
691
1061
|
await this.tagIndex.remove(key);
|
|
692
|
-
this.
|
|
1062
|
+
this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
|
|
693
1063
|
}
|
|
694
1064
|
}
|
|
695
1065
|
return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
|
|
@@ -704,26 +1074,38 @@ var CacheStack = class extends EventEmitter {
|
|
|
704
1074
|
}
|
|
705
1075
|
async warm(entries, options = {}) {
|
|
706
1076
|
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
1077
|
+
const total = entries.length;
|
|
1078
|
+
let completed = 0;
|
|
707
1079
|
const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
|
|
708
|
-
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
1080
|
+
const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
|
|
709
1081
|
while (queue.length > 0) {
|
|
710
1082
|
const entry = queue.shift();
|
|
711
1083
|
if (!entry) {
|
|
712
1084
|
return;
|
|
713
1085
|
}
|
|
1086
|
+
let success = false;
|
|
714
1087
|
try {
|
|
715
1088
|
await this.get(entry.key, entry.fetcher, entry.options);
|
|
716
1089
|
this.emit("warm", { key: entry.key });
|
|
1090
|
+
success = true;
|
|
717
1091
|
} catch (error) {
|
|
718
1092
|
this.emitError("warm", { key: entry.key, error: this.formatError(error) });
|
|
719
1093
|
if (!options.continueOnError) {
|
|
720
1094
|
throw error;
|
|
721
1095
|
}
|
|
1096
|
+
} finally {
|
|
1097
|
+
completed += 1;
|
|
1098
|
+
const progress = { completed, total, key: entry.key, success };
|
|
1099
|
+
options.onProgress?.(progress);
|
|
722
1100
|
}
|
|
723
1101
|
}
|
|
724
1102
|
});
|
|
725
1103
|
await Promise.all(workers);
|
|
726
1104
|
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Returns a cached version of `fetcher`. The cache key is derived from
|
|
1107
|
+
* `prefix` plus the serialized arguments unless a `keyResolver` is provided.
|
|
1108
|
+
*/
|
|
727
1109
|
wrap(prefix, fetcher, options = {}) {
|
|
728
1110
|
return (...args) => {
|
|
729
1111
|
const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
|
|
@@ -731,6 +1113,10 @@ var CacheStack = class extends EventEmitter {
|
|
|
731
1113
|
return this.get(key, () => fetcher(...args), options);
|
|
732
1114
|
};
|
|
733
1115
|
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Creates a `CacheNamespace` that automatically prefixes all keys with
|
|
1118
|
+
* `prefix:`. Useful for multi-tenant or module-level isolation.
|
|
1119
|
+
*/
|
|
734
1120
|
namespace(prefix) {
|
|
735
1121
|
return new CacheNamespace(this, prefix);
|
|
736
1122
|
}
|
|
@@ -747,7 +1133,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
747
1133
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
748
1134
|
}
|
|
749
1135
|
getMetrics() {
|
|
750
|
-
return
|
|
1136
|
+
return this.metricsCollector.snapshot;
|
|
751
1137
|
}
|
|
752
1138
|
getStats() {
|
|
753
1139
|
return {
|
|
@@ -761,7 +1147,13 @@ var CacheStack = class extends EventEmitter {
|
|
|
761
1147
|
};
|
|
762
1148
|
}
|
|
763
1149
|
resetMetrics() {
|
|
764
|
-
|
|
1150
|
+
this.metricsCollector.reset();
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Returns computed hit-rate statistics (overall and per-layer).
|
|
1154
|
+
*/
|
|
1155
|
+
getHitRate() {
|
|
1156
|
+
return this.metricsCollector.hitRate();
|
|
765
1157
|
}
|
|
766
1158
|
async exportState() {
|
|
767
1159
|
await this.startup;
|
|
@@ -790,10 +1182,12 @@ var CacheStack = class extends EventEmitter {
|
|
|
790
1182
|
}
|
|
791
1183
|
async importState(entries) {
|
|
792
1184
|
await this.startup;
|
|
793
|
-
await Promise.all(
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1185
|
+
await Promise.all(
|
|
1186
|
+
entries.map(async (entry) => {
|
|
1187
|
+
await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
|
|
1188
|
+
await this.tagIndex.touch(entry.key);
|
|
1189
|
+
})
|
|
1190
|
+
);
|
|
797
1191
|
}
|
|
798
1192
|
async persistToFile(filePath) {
|
|
799
1193
|
const snapshot = await this.exportState();
|
|
@@ -801,11 +1195,21 @@ var CacheStack = class extends EventEmitter {
|
|
|
801
1195
|
}
|
|
802
1196
|
async restoreFromFile(filePath) {
|
|
803
1197
|
const raw = await fs.readFile(filePath, "utf8");
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1198
|
+
let parsed;
|
|
1199
|
+
try {
|
|
1200
|
+
parsed = JSON.parse(raw, (_key, value) => {
|
|
1201
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
1202
|
+
return Object.assign(/* @__PURE__ */ Object.create(null), value);
|
|
1203
|
+
}
|
|
1204
|
+
return value;
|
|
1205
|
+
});
|
|
1206
|
+
} catch (cause) {
|
|
1207
|
+
throw new Error(`Invalid snapshot file: could not parse JSON (${this.formatError(cause)})`);
|
|
807
1208
|
}
|
|
808
|
-
|
|
1209
|
+
if (!this.isCacheSnapshotEntries(parsed)) {
|
|
1210
|
+
throw new Error("Invalid snapshot file: expected an array of { key: string, value, ttl? } entries");
|
|
1211
|
+
}
|
|
1212
|
+
await this.importState(parsed);
|
|
809
1213
|
}
|
|
810
1214
|
async disconnect() {
|
|
811
1215
|
if (!this.disconnectPromise) {
|
|
@@ -830,7 +1234,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
830
1234
|
const fetchTask = async () => {
|
|
831
1235
|
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
832
1236
|
if (secondHit.found) {
|
|
833
|
-
this.
|
|
1237
|
+
this.metricsCollector.increment("hits");
|
|
834
1238
|
return secondHit.value;
|
|
835
1239
|
}
|
|
836
1240
|
return this.fetchAndPopulate(key, fetcher, options);
|
|
@@ -855,12 +1259,12 @@ var CacheStack = class extends EventEmitter {
|
|
|
855
1259
|
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
856
1260
|
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
857
1261
|
const deadline = Date.now() + timeoutMs;
|
|
858
|
-
this.
|
|
1262
|
+
this.metricsCollector.increment("singleFlightWaits");
|
|
859
1263
|
this.emit("stampede-dedupe", { key });
|
|
860
1264
|
while (Date.now() < deadline) {
|
|
861
1265
|
const hit = await this.readFromLayers(key, options, "fresh-only");
|
|
862
1266
|
if (hit.found) {
|
|
863
|
-
this.
|
|
1267
|
+
this.metricsCollector.increment("hits");
|
|
864
1268
|
return hit.value;
|
|
865
1269
|
}
|
|
866
1270
|
await this.sleep(pollIntervalMs);
|
|
@@ -868,12 +1272,14 @@ var CacheStack = class extends EventEmitter {
|
|
|
868
1272
|
return this.fetchAndPopulate(key, fetcher, options);
|
|
869
1273
|
}
|
|
870
1274
|
async fetchAndPopulate(key, fetcher, options) {
|
|
871
|
-
this.
|
|
872
|
-
this.
|
|
1275
|
+
this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
|
|
1276
|
+
this.metricsCollector.increment("fetches");
|
|
1277
|
+
const fetchStart = Date.now();
|
|
873
1278
|
let fetched;
|
|
874
1279
|
try {
|
|
875
1280
|
fetched = await fetcher();
|
|
876
|
-
this.
|
|
1281
|
+
this.circuitBreakerManager.recordSuccess(key);
|
|
1282
|
+
this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
|
|
877
1283
|
} catch (error) {
|
|
878
1284
|
this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
|
|
879
1285
|
throw error;
|
|
@@ -895,7 +1301,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
895
1301
|
} else {
|
|
896
1302
|
await this.tagIndex.touch(key);
|
|
897
1303
|
}
|
|
898
|
-
this.
|
|
1304
|
+
this.metricsCollector.increment("sets");
|
|
899
1305
|
this.logger.debug?.("set", { key, kind, tags: options?.tags });
|
|
900
1306
|
this.emit("set", { key, kind, tags: options?.tags });
|
|
901
1307
|
if (this.shouldBroadcastL1Invalidation()) {
|
|
@@ -906,9 +1312,10 @@ var CacheStack = class extends EventEmitter {
|
|
|
906
1312
|
let sawRetainableValue = false;
|
|
907
1313
|
for (let index = 0; index < this.layers.length; index += 1) {
|
|
908
1314
|
const layer = this.layers[index];
|
|
1315
|
+
if (!layer) continue;
|
|
909
1316
|
const stored = await this.readLayerEntry(layer, key);
|
|
910
1317
|
if (stored === null) {
|
|
911
|
-
this.
|
|
1318
|
+
this.metricsCollector.incrementLayer("missesByLayer", layer.name);
|
|
912
1319
|
continue;
|
|
913
1320
|
}
|
|
914
1321
|
const resolved = resolveStoredValue(stored);
|
|
@@ -922,10 +1329,17 @@ var CacheStack = class extends EventEmitter {
|
|
|
922
1329
|
}
|
|
923
1330
|
await this.tagIndex.touch(key);
|
|
924
1331
|
await this.backfill(key, stored, index - 1, options);
|
|
925
|
-
this.
|
|
1332
|
+
this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
|
|
926
1333
|
this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
|
|
927
1334
|
this.emit("hit", { key, layer: layer.name, state: resolved.state });
|
|
928
|
-
return {
|
|
1335
|
+
return {
|
|
1336
|
+
found: true,
|
|
1337
|
+
value: resolved.value,
|
|
1338
|
+
stored,
|
|
1339
|
+
state: resolved.state,
|
|
1340
|
+
layerIndex: index,
|
|
1341
|
+
layerName: layer.name
|
|
1342
|
+
};
|
|
929
1343
|
}
|
|
930
1344
|
if (!sawRetainableValue) {
|
|
931
1345
|
await this.tagIndex.remove(key);
|
|
@@ -957,7 +1371,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
957
1371
|
}
|
|
958
1372
|
for (let index = 0; index <= upToIndex; index += 1) {
|
|
959
1373
|
const layer = this.layers[index];
|
|
960
|
-
if (this.shouldSkipLayer(layer)) {
|
|
1374
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
961
1375
|
continue;
|
|
962
1376
|
}
|
|
963
1377
|
const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
|
|
@@ -967,7 +1381,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
967
1381
|
await this.handleLayerFailure(layer, "backfill", error);
|
|
968
1382
|
continue;
|
|
969
1383
|
}
|
|
970
|
-
this.
|
|
1384
|
+
this.metricsCollector.increment("backfills");
|
|
971
1385
|
this.logger.debug?.("backfill", { key, layer: layer.name });
|
|
972
1386
|
this.emit("backfill", { key, layer: layer.name });
|
|
973
1387
|
}
|
|
@@ -984,11 +1398,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
984
1398
|
options?.staleWhileRevalidate,
|
|
985
1399
|
this.options.staleWhileRevalidate
|
|
986
1400
|
);
|
|
987
|
-
const staleIfError = this.resolveLayerSeconds(
|
|
988
|
-
layer.name,
|
|
989
|
-
options?.staleIfError,
|
|
990
|
-
this.options.staleIfError
|
|
991
|
-
);
|
|
1401
|
+
const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
|
|
992
1402
|
const payload = createStoredValueEnvelope({
|
|
993
1403
|
kind,
|
|
994
1404
|
value,
|
|
@@ -1016,7 +1426,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1016
1426
|
if (failures.length === 0) {
|
|
1017
1427
|
return;
|
|
1018
1428
|
}
|
|
1019
|
-
this.
|
|
1429
|
+
this.metricsCollector.increment("writeFailures", failures.length);
|
|
1020
1430
|
this.logger.debug?.("write-failure", {
|
|
1021
1431
|
...context,
|
|
1022
1432
|
failures: failures.map((failure) => this.formatError(failure.reason))
|
|
@@ -1029,42 +1439,10 @@ var CacheStack = class extends EventEmitter {
|
|
|
1029
1439
|
}
|
|
1030
1440
|
}
|
|
1031
1441
|
resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
|
|
1032
|
-
|
|
1033
|
-
layerName,
|
|
1034
|
-
options?.negativeTtl,
|
|
1035
|
-
this.options.negativeTtl,
|
|
1036
|
-
this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
1037
|
-
) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
|
|
1038
|
-
const adaptiveTtl = this.applyAdaptiveTtl(
|
|
1039
|
-
key,
|
|
1040
|
-
layerName,
|
|
1041
|
-
baseTtl,
|
|
1042
|
-
options?.adaptiveTtl ?? this.options.adaptiveTtl
|
|
1043
|
-
);
|
|
1044
|
-
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
|
|
1045
|
-
return this.applyJitter(adaptiveTtl, jitter);
|
|
1442
|
+
return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
|
|
1046
1443
|
}
|
|
1047
1444
|
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
1048
|
-
|
|
1049
|
-
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
1050
|
-
}
|
|
1051
|
-
if (globalDefault !== void 0) {
|
|
1052
|
-
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
1053
|
-
}
|
|
1054
|
-
return fallback;
|
|
1055
|
-
}
|
|
1056
|
-
readLayerNumber(layerName, value) {
|
|
1057
|
-
if (typeof value === "number") {
|
|
1058
|
-
return value;
|
|
1059
|
-
}
|
|
1060
|
-
return value[layerName];
|
|
1061
|
-
}
|
|
1062
|
-
applyJitter(ttl, jitter) {
|
|
1063
|
-
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
1064
|
-
return ttl;
|
|
1065
|
-
}
|
|
1066
|
-
const delta = (Math.random() * 2 - 1) * jitter;
|
|
1067
|
-
return Math.max(1, Math.round(ttl + delta));
|
|
1445
|
+
return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
|
|
1068
1446
|
}
|
|
1069
1447
|
shouldNegativeCache(options) {
|
|
1070
1448
|
return options?.negativeCache ?? this.options.negativeCaching ?? false;
|
|
@@ -1074,11 +1452,11 @@ var CacheStack = class extends EventEmitter {
|
|
|
1074
1452
|
return;
|
|
1075
1453
|
}
|
|
1076
1454
|
const refresh = (async () => {
|
|
1077
|
-
this.
|
|
1455
|
+
this.metricsCollector.increment("refreshes");
|
|
1078
1456
|
try {
|
|
1079
1457
|
await this.fetchWithGuards(key, fetcher, options);
|
|
1080
1458
|
} catch (error) {
|
|
1081
|
-
this.
|
|
1459
|
+
this.metricsCollector.increment("refreshErrors");
|
|
1082
1460
|
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
1083
1461
|
} finally {
|
|
1084
1462
|
this.backgroundRefreshes.delete(key);
|
|
@@ -1100,10 +1478,11 @@ var CacheStack = class extends EventEmitter {
|
|
|
1100
1478
|
await this.deleteKeysFromLayers(this.layers, keys);
|
|
1101
1479
|
for (const key of keys) {
|
|
1102
1480
|
await this.tagIndex.remove(key);
|
|
1103
|
-
this.
|
|
1481
|
+
this.ttlResolver.deleteProfile(key);
|
|
1482
|
+
this.circuitBreakerManager.delete(key);
|
|
1104
1483
|
}
|
|
1105
|
-
this.
|
|
1106
|
-
this.
|
|
1484
|
+
this.metricsCollector.increment("deletes", keys.length);
|
|
1485
|
+
this.metricsCollector.increment("invalidations");
|
|
1107
1486
|
this.logger.debug?.("delete", { keys });
|
|
1108
1487
|
this.emit("delete", { keys });
|
|
1109
1488
|
}
|
|
@@ -1124,7 +1503,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1124
1503
|
if (message.scope === "clear") {
|
|
1125
1504
|
await Promise.all(localLayers.map((layer) => layer.clear()));
|
|
1126
1505
|
await this.tagIndex.clear();
|
|
1127
|
-
this.
|
|
1506
|
+
this.ttlResolver.clearProfiles();
|
|
1128
1507
|
return;
|
|
1129
1508
|
}
|
|
1130
1509
|
const keys = message.keys ?? [];
|
|
@@ -1132,7 +1511,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1132
1511
|
if (message.operation !== "write") {
|
|
1133
1512
|
for (const key of keys) {
|
|
1134
1513
|
await this.tagIndex.remove(key);
|
|
1135
|
-
this.
|
|
1514
|
+
this.ttlResolver.deleteProfile(key);
|
|
1136
1515
|
}
|
|
1137
1516
|
}
|
|
1138
1517
|
}
|
|
@@ -1162,13 +1541,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
1162
1541
|
}
|
|
1163
1542
|
return;
|
|
1164
1543
|
}
|
|
1165
|
-
await Promise.all(
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1544
|
+
await Promise.all(
|
|
1545
|
+
keys.map(async (key) => {
|
|
1546
|
+
try {
|
|
1547
|
+
await layer.delete(key);
|
|
1548
|
+
} catch (error) {
|
|
1549
|
+
await this.handleLayerFailure(layer, "delete", error);
|
|
1550
|
+
}
|
|
1551
|
+
})
|
|
1552
|
+
);
|
|
1172
1553
|
})
|
|
1173
1554
|
);
|
|
1174
1555
|
}
|
|
@@ -1269,7 +1650,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1269
1650
|
const ttl = remainingStoredTtlSeconds(refreshed);
|
|
1270
1651
|
for (let index = 0; index <= hit.layerIndex; index += 1) {
|
|
1271
1652
|
const layer = this.layers[index];
|
|
1272
|
-
if (this.shouldSkipLayer(layer)) {
|
|
1653
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
1273
1654
|
continue;
|
|
1274
1655
|
}
|
|
1275
1656
|
try {
|
|
@@ -1283,33 +1664,6 @@ var CacheStack = class extends EventEmitter {
|
|
|
1283
1664
|
this.scheduleBackgroundRefresh(key, fetcher, options);
|
|
1284
1665
|
}
|
|
1285
1666
|
}
|
|
1286
|
-
applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
|
|
1287
|
-
if (!ttl || !adaptiveTtl) {
|
|
1288
|
-
return ttl;
|
|
1289
|
-
}
|
|
1290
|
-
const profile = this.accessProfiles.get(key);
|
|
1291
|
-
if (!profile) {
|
|
1292
|
-
return ttl;
|
|
1293
|
-
}
|
|
1294
|
-
const config = adaptiveTtl === true ? {} : adaptiveTtl;
|
|
1295
|
-
const hotAfter = config.hotAfter ?? 3;
|
|
1296
|
-
if (profile.hits < hotAfter) {
|
|
1297
|
-
return ttl;
|
|
1298
|
-
}
|
|
1299
|
-
const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
|
|
1300
|
-
const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
|
|
1301
|
-
const multiplier = Math.floor(profile.hits / hotAfter);
|
|
1302
|
-
return Math.min(maxTtl, ttl + step * multiplier);
|
|
1303
|
-
}
|
|
1304
|
-
recordAccess(key) {
|
|
1305
|
-
const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
|
|
1306
|
-
profile.hits += 1;
|
|
1307
|
-
profile.lastAccessAt = Date.now();
|
|
1308
|
-
this.accessProfiles.set(key, profile);
|
|
1309
|
-
}
|
|
1310
|
-
incrementMetricMap(target, key) {
|
|
1311
|
-
target[key] = (target[key] ?? 0) + 1;
|
|
1312
|
-
}
|
|
1313
1667
|
shouldSkipLayer(layer) {
|
|
1314
1668
|
const degradedUntil = this.layerDegradedUntil.get(layer.name);
|
|
1315
1669
|
return degradedUntil !== void 0 && degradedUntil > Date.now();
|
|
@@ -1320,7 +1674,7 @@ var CacheStack = class extends EventEmitter {
|
|
|
1320
1674
|
}
|
|
1321
1675
|
const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
|
|
1322
1676
|
this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
|
|
1323
|
-
this.
|
|
1677
|
+
this.metricsCollector.increment("degradedOperations");
|
|
1324
1678
|
this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
|
|
1325
1679
|
this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
|
|
1326
1680
|
return null;
|
|
@@ -1328,37 +1682,15 @@ var CacheStack = class extends EventEmitter {
|
|
|
1328
1682
|
isGracefulDegradationEnabled() {
|
|
1329
1683
|
return Boolean(this.options.gracefulDegradation);
|
|
1330
1684
|
}
|
|
1331
|
-
assertCircuitClosed(key, options) {
|
|
1332
|
-
const state = this.circuitBreakers.get(key);
|
|
1333
|
-
if (!state?.openUntil) {
|
|
1334
|
-
return;
|
|
1335
|
-
}
|
|
1336
|
-
if (state.openUntil <= Date.now()) {
|
|
1337
|
-
state.openUntil = null;
|
|
1338
|
-
state.failures = 0;
|
|
1339
|
-
this.circuitBreakers.set(key, state);
|
|
1340
|
-
return;
|
|
1341
|
-
}
|
|
1342
|
-
this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
|
|
1343
|
-
throw new Error(`Circuit breaker is open for key "${key}".`);
|
|
1344
|
-
}
|
|
1345
1685
|
recordCircuitFailure(key, options, error) {
|
|
1346
1686
|
if (!options) {
|
|
1347
1687
|
return;
|
|
1348
1688
|
}
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
state.failures += 1;
|
|
1353
|
-
if (state.failures >= failureThreshold) {
|
|
1354
|
-
state.openUntil = Date.now() + cooldownMs;
|
|
1355
|
-
this.metrics.circuitBreakerTrips += 1;
|
|
1689
|
+
this.circuitBreakerManager.recordFailure(key, options);
|
|
1690
|
+
if (this.circuitBreakerManager.isOpen(key)) {
|
|
1691
|
+
this.metricsCollector.increment("circuitBreakerTrips");
|
|
1356
1692
|
}
|
|
1357
|
-
this.
|
|
1358
|
-
this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
|
|
1359
|
-
}
|
|
1360
|
-
resetCircuitBreaker(key) {
|
|
1361
|
-
this.circuitBreakers.delete(key);
|
|
1693
|
+
this.emitError("fetch", { key, error: this.formatError(error) });
|
|
1362
1694
|
}
|
|
1363
1695
|
isNegativeStoredValue(stored) {
|
|
1364
1696
|
return isStoredValueEnvelope(stored) && stored.kind === "empty";
|