layercache 1.0.1 → 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/README.md +286 -7
- package/benchmarks/latency.ts +1 -1
- package/benchmarks/stampede.ts +1 -4
- package/dist/chunk-QUB5VZFZ.js +132 -0
- package/dist/cli.cjs +296 -0
- package/dist/cli.d.cts +4 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +135 -0
- package/dist/index.cjs +1576 -184
- package/dist/index.d.cts +465 -7
- package/dist/index.d.ts +465 -7
- package/dist/index.js +1526 -266
- 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 +10 -2
- package/packages/nestjs/dist/index.cjs +1058 -155
- package/packages/nestjs/dist/index.d.cts +345 -2
- package/packages/nestjs/dist/index.d.ts +345 -2
- package/packages/nestjs/dist/index.js +1057 -155
|
@@ -12,11 +12,264 @@ var __decorateClass = (decorators, target, key, kind) => {
|
|
|
12
12
|
// src/constants.ts
|
|
13
13
|
var CACHE_STACK = /* @__PURE__ */ Symbol("CACHE_STACK");
|
|
14
14
|
|
|
15
|
+
// ../../src/decorators/createCachedMethodDecorator.ts
|
|
16
|
+
function createCachedMethodDecorator(options) {
|
|
17
|
+
const wrappedByInstance = /* @__PURE__ */ new WeakMap();
|
|
18
|
+
return ((_, propertyKey, descriptor) => {
|
|
19
|
+
const original = descriptor.value;
|
|
20
|
+
if (typeof original !== "function") {
|
|
21
|
+
throw new Error("createCachedMethodDecorator can only be applied to methods.");
|
|
22
|
+
}
|
|
23
|
+
descriptor.value = async function(...args) {
|
|
24
|
+
const instance = this;
|
|
25
|
+
let wrapped = wrappedByInstance.get(instance);
|
|
26
|
+
if (!wrapped) {
|
|
27
|
+
const cache = options.cache(instance);
|
|
28
|
+
wrapped = cache.wrap(
|
|
29
|
+
options.prefix ?? String(propertyKey),
|
|
30
|
+
(...methodArgs) => Promise.resolve(original.apply(instance, methodArgs)),
|
|
31
|
+
options
|
|
32
|
+
);
|
|
33
|
+
wrappedByInstance.set(instance, wrapped);
|
|
34
|
+
}
|
|
35
|
+
return wrapped(...args);
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/decorators.ts
|
|
41
|
+
function Cacheable(options) {
|
|
42
|
+
return createCachedMethodDecorator(options);
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
// src/module.ts
|
|
16
46
|
import { Global, Inject, Module } from "@nestjs/common";
|
|
17
47
|
|
|
18
48
|
// ../../src/CacheStack.ts
|
|
19
49
|
import { randomUUID } from "crypto";
|
|
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
|
+
};
|
|
20
273
|
|
|
21
274
|
// ../../src/internal/StoredValue.ts
|
|
22
275
|
function isStoredValueEnvelope(value) {
|
|
@@ -36,7 +289,10 @@ function createStoredValueEnvelope(options) {
|
|
|
36
289
|
value: options.value,
|
|
37
290
|
freshUntil,
|
|
38
291
|
staleUntil,
|
|
39
|
-
errorUntil
|
|
292
|
+
errorUntil,
|
|
293
|
+
freshTtlSeconds: freshTtlSeconds ?? null,
|
|
294
|
+
staleWhileRevalidateSeconds: staleWhileRevalidateSeconds ?? null,
|
|
295
|
+
staleIfErrorSeconds: staleIfErrorSeconds ?? null
|
|
40
296
|
};
|
|
41
297
|
}
|
|
42
298
|
function resolveStoredValue(stored, now = Date.now()) {
|
|
@@ -77,6 +333,29 @@ function remainingStoredTtlSeconds(stored, now = Date.now()) {
|
|
|
77
333
|
}
|
|
78
334
|
return Math.max(1, Math.ceil(remainingMs / 1e3));
|
|
79
335
|
}
|
|
336
|
+
function remainingFreshTtlSeconds(stored, now = Date.now()) {
|
|
337
|
+
if (!isStoredValueEnvelope(stored) || stored.freshUntil === null) {
|
|
338
|
+
return void 0;
|
|
339
|
+
}
|
|
340
|
+
const remainingMs = stored.freshUntil - now;
|
|
341
|
+
if (remainingMs <= 0) {
|
|
342
|
+
return 0;
|
|
343
|
+
}
|
|
344
|
+
return Math.max(1, Math.ceil(remainingMs / 1e3));
|
|
345
|
+
}
|
|
346
|
+
function refreshStoredEnvelope(stored, now = Date.now()) {
|
|
347
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
348
|
+
return stored;
|
|
349
|
+
}
|
|
350
|
+
return createStoredValueEnvelope({
|
|
351
|
+
kind: stored.kind,
|
|
352
|
+
value: stored.value,
|
|
353
|
+
freshTtlSeconds: stored.freshTtlSeconds ?? void 0,
|
|
354
|
+
staleWhileRevalidateSeconds: stored.staleWhileRevalidateSeconds ?? void 0,
|
|
355
|
+
staleIfErrorSeconds: stored.staleIfErrorSeconds ?? void 0,
|
|
356
|
+
now
|
|
357
|
+
});
|
|
358
|
+
}
|
|
80
359
|
function maxExpiry(stored) {
|
|
81
360
|
const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
|
|
82
361
|
(value) => value !== null
|
|
@@ -93,12 +372,129 @@ function normalizePositiveSeconds(value) {
|
|
|
93
372
|
return value;
|
|
94
373
|
}
|
|
95
374
|
|
|
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;
|
|
382
|
+
}
|
|
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();
|
|
389
|
+
}
|
|
390
|
+
deleteProfile(key) {
|
|
391
|
+
this.accessProfiles.delete(key);
|
|
392
|
+
}
|
|
393
|
+
clearProfiles() {
|
|
394
|
+
this.accessProfiles.clear();
|
|
395
|
+
}
|
|
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);
|
|
406
|
+
}
|
|
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;
|
|
415
|
+
}
|
|
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);
|
|
433
|
+
}
|
|
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));
|
|
440
|
+
}
|
|
441
|
+
readLayerNumber(layerName, value) {
|
|
442
|
+
if (typeof value === "number") {
|
|
443
|
+
return value;
|
|
444
|
+
}
|
|
445
|
+
return value[layerName];
|
|
446
|
+
}
|
|
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
|
+
}
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
96
463
|
// ../../src/invalidation/PatternMatcher.ts
|
|
97
|
-
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
|
+
*/
|
|
98
470
|
static matches(pattern, value) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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];
|
|
102
498
|
}
|
|
103
499
|
};
|
|
104
500
|
|
|
@@ -339,64 +735,75 @@ var Mutex = class {
|
|
|
339
735
|
var StampedeGuard = class {
|
|
340
736
|
mutexes = /* @__PURE__ */ new Map();
|
|
341
737
|
async execute(key, task) {
|
|
342
|
-
const
|
|
738
|
+
const entry = this.getMutexEntry(key);
|
|
343
739
|
try {
|
|
344
|
-
return await mutex.runExclusive(task);
|
|
740
|
+
return await entry.mutex.runExclusive(task);
|
|
345
741
|
} finally {
|
|
346
|
-
|
|
742
|
+
entry.references -= 1;
|
|
743
|
+
if (entry.references === 0 && !entry.mutex.isLocked()) {
|
|
347
744
|
this.mutexes.delete(key);
|
|
348
745
|
}
|
|
349
746
|
}
|
|
350
747
|
}
|
|
351
|
-
|
|
352
|
-
let
|
|
353
|
-
if (!
|
|
354
|
-
|
|
355
|
-
this.mutexes.set(key,
|
|
748
|
+
getMutexEntry(key) {
|
|
749
|
+
let entry = this.mutexes.get(key);
|
|
750
|
+
if (!entry) {
|
|
751
|
+
entry = { mutex: new Mutex(), references: 0 };
|
|
752
|
+
this.mutexes.set(key, entry);
|
|
356
753
|
}
|
|
357
|
-
|
|
754
|
+
entry.references += 1;
|
|
755
|
+
return entry;
|
|
358
756
|
}
|
|
359
757
|
};
|
|
360
758
|
|
|
361
759
|
// ../../src/CacheStack.ts
|
|
362
|
-
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
363
760
|
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
364
761
|
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
365
762
|
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
366
|
-
var
|
|
367
|
-
|
|
368
|
-
misses: 0,
|
|
369
|
-
fetches: 0,
|
|
370
|
-
sets: 0,
|
|
371
|
-
deletes: 0,
|
|
372
|
-
backfills: 0,
|
|
373
|
-
invalidations: 0,
|
|
374
|
-
staleHits: 0,
|
|
375
|
-
refreshes: 0,
|
|
376
|
-
refreshErrors: 0,
|
|
377
|
-
writeFailures: 0,
|
|
378
|
-
singleFlightWaits: 0
|
|
379
|
-
});
|
|
763
|
+
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
764
|
+
var DEFAULT_MAX_PROFILE_ENTRIES = 1e5;
|
|
380
765
|
var DebugLogger = class {
|
|
381
766
|
enabled;
|
|
382
767
|
constructor(enabled) {
|
|
383
768
|
this.enabled = enabled;
|
|
384
769
|
}
|
|
385
770
|
debug(message, context) {
|
|
771
|
+
this.write("debug", message, context);
|
|
772
|
+
}
|
|
773
|
+
info(message, context) {
|
|
774
|
+
this.write("info", message, context);
|
|
775
|
+
}
|
|
776
|
+
warn(message, context) {
|
|
777
|
+
this.write("warn", message, context);
|
|
778
|
+
}
|
|
779
|
+
error(message, context) {
|
|
780
|
+
this.write("error", message, context);
|
|
781
|
+
}
|
|
782
|
+
write(level, message, context) {
|
|
386
783
|
if (!this.enabled) {
|
|
387
784
|
return;
|
|
388
785
|
}
|
|
389
786
|
const suffix = context ? ` ${JSON.stringify(context)}` : "";
|
|
390
|
-
console
|
|
787
|
+
console[level](`[layercache] ${message}${suffix}`);
|
|
391
788
|
}
|
|
392
789
|
};
|
|
393
|
-
var CacheStack = class {
|
|
790
|
+
var CacheStack = class extends EventEmitter {
|
|
394
791
|
constructor(layers, options = {}) {
|
|
792
|
+
super();
|
|
395
793
|
this.layers = layers;
|
|
396
794
|
this.options = options;
|
|
397
795
|
if (layers.length === 0) {
|
|
398
796
|
throw new Error("CacheStack requires at least one cache layer.");
|
|
399
797
|
}
|
|
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
|
+
}
|
|
400
807
|
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
401
808
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
402
809
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
@@ -405,112 +812,313 @@ var CacheStack = class {
|
|
|
405
812
|
layers;
|
|
406
813
|
options;
|
|
407
814
|
stampedeGuard = new StampedeGuard();
|
|
408
|
-
|
|
815
|
+
metricsCollector = new MetricsCollector();
|
|
409
816
|
instanceId = randomUUID();
|
|
410
817
|
startup;
|
|
411
818
|
unsubscribeInvalidation;
|
|
412
819
|
logger;
|
|
413
820
|
tagIndex;
|
|
414
821
|
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
822
|
+
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
823
|
+
ttlResolver;
|
|
824
|
+
circuitBreakerManager;
|
|
825
|
+
isDisconnecting = false;
|
|
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
|
+
*/
|
|
415
833
|
async get(key, fetcher, options) {
|
|
834
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
835
|
+
this.validateWriteOptions(options);
|
|
416
836
|
await this.startup;
|
|
417
|
-
const hit = await this.readFromLayers(
|
|
837
|
+
const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
|
|
418
838
|
if (hit.found) {
|
|
839
|
+
this.ttlResolver.recordAccess(normalizedKey);
|
|
840
|
+
if (this.isNegativeStoredValue(hit.stored)) {
|
|
841
|
+
this.metricsCollector.increment("negativeCacheHits");
|
|
842
|
+
}
|
|
419
843
|
if (hit.state === "fresh") {
|
|
420
|
-
this.
|
|
844
|
+
this.metricsCollector.increment("hits");
|
|
845
|
+
await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
|
|
421
846
|
return hit.value;
|
|
422
847
|
}
|
|
423
848
|
if (hit.state === "stale-while-revalidate") {
|
|
424
|
-
this.
|
|
425
|
-
this.
|
|
849
|
+
this.metricsCollector.increment("hits");
|
|
850
|
+
this.metricsCollector.increment("staleHits");
|
|
851
|
+
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
426
852
|
if (fetcher) {
|
|
427
|
-
this.scheduleBackgroundRefresh(
|
|
853
|
+
this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
|
|
428
854
|
}
|
|
429
855
|
return hit.value;
|
|
430
856
|
}
|
|
431
857
|
if (!fetcher) {
|
|
432
|
-
this.
|
|
433
|
-
this.
|
|
858
|
+
this.metricsCollector.increment("hits");
|
|
859
|
+
this.metricsCollector.increment("staleHits");
|
|
860
|
+
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
434
861
|
return hit.value;
|
|
435
862
|
}
|
|
436
863
|
try {
|
|
437
|
-
return await this.fetchWithGuards(
|
|
864
|
+
return await this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
438
865
|
} catch (error) {
|
|
439
|
-
this.
|
|
440
|
-
this.
|
|
441
|
-
this.logger.debug("stale-if-error", { key, error: this.formatError(error) });
|
|
866
|
+
this.metricsCollector.increment("staleHits");
|
|
867
|
+
this.metricsCollector.increment("refreshErrors");
|
|
868
|
+
this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
|
|
442
869
|
return hit.value;
|
|
443
870
|
}
|
|
444
871
|
}
|
|
445
|
-
this.
|
|
872
|
+
this.metricsCollector.increment("misses");
|
|
446
873
|
if (!fetcher) {
|
|
447
874
|
return null;
|
|
448
875
|
}
|
|
449
|
-
return this.fetchWithGuards(
|
|
876
|
+
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
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;
|
|
450
937
|
}
|
|
938
|
+
/**
|
|
939
|
+
* Stores a value in all cache layers. Overwrites any existing value.
|
|
940
|
+
*/
|
|
451
941
|
async set(key, value, options) {
|
|
942
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
943
|
+
this.validateWriteOptions(options);
|
|
452
944
|
await this.startup;
|
|
453
|
-
await this.storeEntry(
|
|
945
|
+
await this.storeEntry(normalizedKey, "value", value, options);
|
|
454
946
|
}
|
|
947
|
+
/**
|
|
948
|
+
* Deletes the key from all layers and publishes an invalidation message.
|
|
949
|
+
*/
|
|
455
950
|
async delete(key) {
|
|
951
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
456
952
|
await this.startup;
|
|
457
|
-
await this.deleteKeys([
|
|
458
|
-
await this.publishInvalidation({
|
|
953
|
+
await this.deleteKeys([normalizedKey]);
|
|
954
|
+
await this.publishInvalidation({
|
|
955
|
+
scope: "key",
|
|
956
|
+
keys: [normalizedKey],
|
|
957
|
+
sourceId: this.instanceId,
|
|
958
|
+
operation: "delete"
|
|
959
|
+
});
|
|
459
960
|
}
|
|
460
961
|
async clear() {
|
|
461
962
|
await this.startup;
|
|
462
963
|
await Promise.all(this.layers.map((layer) => layer.clear()));
|
|
463
964
|
await this.tagIndex.clear();
|
|
464
|
-
this.
|
|
465
|
-
this.
|
|
965
|
+
this.ttlResolver.clearProfiles();
|
|
966
|
+
this.circuitBreakerManager.clear();
|
|
967
|
+
this.metricsCollector.increment("invalidations");
|
|
968
|
+
this.logger.debug?.("clear");
|
|
466
969
|
await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
|
|
467
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
|
+
}
|
|
468
988
|
async mget(entries) {
|
|
469
989
|
if (entries.length === 0) {
|
|
470
990
|
return [];
|
|
471
991
|
}
|
|
472
|
-
const
|
|
992
|
+
const normalizedEntries = entries.map((entry) => ({
|
|
993
|
+
...entry,
|
|
994
|
+
key: this.validateCacheKey(entry.key)
|
|
995
|
+
}));
|
|
996
|
+
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
997
|
+
const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
|
|
473
998
|
if (!canFastPath) {
|
|
474
|
-
|
|
999
|
+
const pendingReads = /* @__PURE__ */ new Map();
|
|
1000
|
+
return Promise.all(
|
|
1001
|
+
normalizedEntries.map((entry) => {
|
|
1002
|
+
const optionsSignature = this.serializeOptions(entry.options);
|
|
1003
|
+
const existing = pendingReads.get(entry.key);
|
|
1004
|
+
if (!existing) {
|
|
1005
|
+
const promise = this.get(entry.key, entry.fetch, entry.options);
|
|
1006
|
+
pendingReads.set(entry.key, {
|
|
1007
|
+
promise,
|
|
1008
|
+
fetch: entry.fetch,
|
|
1009
|
+
optionsSignature
|
|
1010
|
+
});
|
|
1011
|
+
return promise;
|
|
1012
|
+
}
|
|
1013
|
+
if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
|
|
1014
|
+
throw new Error(`mget received conflicting entries for key "${entry.key}".`);
|
|
1015
|
+
}
|
|
1016
|
+
return existing.promise;
|
|
1017
|
+
})
|
|
1018
|
+
);
|
|
475
1019
|
}
|
|
476
1020
|
await this.startup;
|
|
477
|
-
const pending = new Set(
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
1021
|
+
const pending = /* @__PURE__ */ new Set();
|
|
1022
|
+
const indexesByKey = /* @__PURE__ */ new Map();
|
|
1023
|
+
const resultsByKey = /* @__PURE__ */ new Map();
|
|
1024
|
+
for (let index = 0; index < normalizedEntries.length; index += 1) {
|
|
1025
|
+
const entry = normalizedEntries[index];
|
|
1026
|
+
if (!entry) continue;
|
|
1027
|
+
const key = entry.key;
|
|
1028
|
+
const indexes = indexesByKey.get(key) ?? [];
|
|
1029
|
+
indexes.push(index);
|
|
1030
|
+
indexesByKey.set(key, indexes);
|
|
1031
|
+
pending.add(key);
|
|
1032
|
+
}
|
|
1033
|
+
for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
|
|
1034
|
+
const layer = this.layers[layerIndex];
|
|
1035
|
+
if (!layer) continue;
|
|
1036
|
+
const keys = [...pending];
|
|
1037
|
+
if (keys.length === 0) {
|
|
482
1038
|
break;
|
|
483
1039
|
}
|
|
484
|
-
const keys = indexes.map((index) => entries[index].key);
|
|
485
1040
|
const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
|
|
486
1041
|
for (let offset = 0; offset < values.length; offset += 1) {
|
|
487
|
-
const
|
|
1042
|
+
const key = keys[offset];
|
|
488
1043
|
const stored = values[offset];
|
|
489
|
-
if (stored === null) {
|
|
1044
|
+
if (!key || stored === null) {
|
|
490
1045
|
continue;
|
|
491
1046
|
}
|
|
492
1047
|
const resolved = resolveStoredValue(stored);
|
|
493
1048
|
if (resolved.state === "expired") {
|
|
494
|
-
await layer.delete(
|
|
1049
|
+
await layer.delete(key);
|
|
495
1050
|
continue;
|
|
496
1051
|
}
|
|
497
|
-
await this.tagIndex.touch(
|
|
498
|
-
await this.backfill(
|
|
499
|
-
|
|
500
|
-
pending.delete(
|
|
501
|
-
this.
|
|
1052
|
+
await this.tagIndex.touch(key);
|
|
1053
|
+
await this.backfill(key, stored, layerIndex - 1);
|
|
1054
|
+
resultsByKey.set(key, resolved.value);
|
|
1055
|
+
pending.delete(key);
|
|
1056
|
+
this.metricsCollector.increment("hits", indexesByKey.get(key)?.length ?? 1);
|
|
502
1057
|
}
|
|
503
1058
|
}
|
|
504
1059
|
if (pending.size > 0) {
|
|
505
|
-
for (const
|
|
506
|
-
await this.tagIndex.remove(
|
|
507
|
-
this.
|
|
1060
|
+
for (const key of pending) {
|
|
1061
|
+
await this.tagIndex.remove(key);
|
|
1062
|
+
this.metricsCollector.increment("misses", indexesByKey.get(key)?.length ?? 1);
|
|
508
1063
|
}
|
|
509
1064
|
}
|
|
510
|
-
return
|
|
1065
|
+
return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
|
|
511
1066
|
}
|
|
512
1067
|
async mset(entries) {
|
|
513
|
-
|
|
1068
|
+
const normalizedEntries = entries.map((entry) => ({
|
|
1069
|
+
...entry,
|
|
1070
|
+
key: this.validateCacheKey(entry.key)
|
|
1071
|
+
}));
|
|
1072
|
+
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
1073
|
+
await Promise.all(normalizedEntries.map((entry) => this.set(entry.key, entry.value, entry.options)));
|
|
1074
|
+
}
|
|
1075
|
+
async warm(entries, options = {}) {
|
|
1076
|
+
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
1077
|
+
const total = entries.length;
|
|
1078
|
+
let completed = 0;
|
|
1079
|
+
const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
|
|
1080
|
+
const workers = Array.from({ length: Math.min(concurrency, queue.length || 1) }, async () => {
|
|
1081
|
+
while (queue.length > 0) {
|
|
1082
|
+
const entry = queue.shift();
|
|
1083
|
+
if (!entry) {
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
let success = false;
|
|
1087
|
+
try {
|
|
1088
|
+
await this.get(entry.key, entry.fetcher, entry.options);
|
|
1089
|
+
this.emit("warm", { key: entry.key });
|
|
1090
|
+
success = true;
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
this.emitError("warm", { key: entry.key, error: this.formatError(error) });
|
|
1093
|
+
if (!options.continueOnError) {
|
|
1094
|
+
throw error;
|
|
1095
|
+
}
|
|
1096
|
+
} finally {
|
|
1097
|
+
completed += 1;
|
|
1098
|
+
const progress = { completed, total, key: entry.key, success };
|
|
1099
|
+
options.onProgress?.(progress);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
await Promise.all(workers);
|
|
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
|
+
*/
|
|
1109
|
+
wrap(prefix, fetcher, options = {}) {
|
|
1110
|
+
return (...args) => {
|
|
1111
|
+
const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
|
|
1112
|
+
const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix;
|
|
1113
|
+
return this.get(key, () => fetcher(...args), options);
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Creates a `CacheNamespace` that automatically prefixes all keys with
|
|
1118
|
+
* `prefix:`. Useful for multi-tenant or module-level isolation.
|
|
1119
|
+
*/
|
|
1120
|
+
namespace(prefix) {
|
|
1121
|
+
return new CacheNamespace(this, prefix);
|
|
514
1122
|
}
|
|
515
1123
|
async invalidateByTag(tag) {
|
|
516
1124
|
await this.startup;
|
|
@@ -525,15 +1133,94 @@ var CacheStack = class {
|
|
|
525
1133
|
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
526
1134
|
}
|
|
527
1135
|
getMetrics() {
|
|
528
|
-
return
|
|
1136
|
+
return this.metricsCollector.snapshot;
|
|
1137
|
+
}
|
|
1138
|
+
getStats() {
|
|
1139
|
+
return {
|
|
1140
|
+
metrics: this.getMetrics(),
|
|
1141
|
+
layers: this.layers.map((layer) => ({
|
|
1142
|
+
name: layer.name,
|
|
1143
|
+
isLocal: Boolean(layer.isLocal),
|
|
1144
|
+
degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
|
|
1145
|
+
})),
|
|
1146
|
+
backgroundRefreshes: this.backgroundRefreshes.size
|
|
1147
|
+
};
|
|
529
1148
|
}
|
|
530
1149
|
resetMetrics() {
|
|
531
|
-
|
|
1150
|
+
this.metricsCollector.reset();
|
|
532
1151
|
}
|
|
533
|
-
|
|
1152
|
+
/**
|
|
1153
|
+
* Returns computed hit-rate statistics (overall and per-layer).
|
|
1154
|
+
*/
|
|
1155
|
+
getHitRate() {
|
|
1156
|
+
return this.metricsCollector.hitRate();
|
|
1157
|
+
}
|
|
1158
|
+
async exportState() {
|
|
1159
|
+
await this.startup;
|
|
1160
|
+
const exported = /* @__PURE__ */ new Map();
|
|
1161
|
+
for (const layer of this.layers) {
|
|
1162
|
+
if (!layer.keys) {
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
const keys = await layer.keys();
|
|
1166
|
+
for (const key of keys) {
|
|
1167
|
+
if (exported.has(key)) {
|
|
1168
|
+
continue;
|
|
1169
|
+
}
|
|
1170
|
+
const stored = await this.readLayerEntry(layer, key);
|
|
1171
|
+
if (stored === null) {
|
|
1172
|
+
continue;
|
|
1173
|
+
}
|
|
1174
|
+
exported.set(key, {
|
|
1175
|
+
key,
|
|
1176
|
+
value: stored,
|
|
1177
|
+
ttl: remainingStoredTtlSeconds(stored)
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return [...exported.values()];
|
|
1182
|
+
}
|
|
1183
|
+
async importState(entries) {
|
|
534
1184
|
await this.startup;
|
|
535
|
-
await
|
|
536
|
-
|
|
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
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
async persistToFile(filePath) {
|
|
1193
|
+
const snapshot = await this.exportState();
|
|
1194
|
+
await fs.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
1195
|
+
}
|
|
1196
|
+
async restoreFromFile(filePath) {
|
|
1197
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
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)})`);
|
|
1208
|
+
}
|
|
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);
|
|
1213
|
+
}
|
|
1214
|
+
async disconnect() {
|
|
1215
|
+
if (!this.disconnectPromise) {
|
|
1216
|
+
this.isDisconnecting = true;
|
|
1217
|
+
this.disconnectPromise = (async () => {
|
|
1218
|
+
await this.startup;
|
|
1219
|
+
await this.unsubscribeInvalidation?.();
|
|
1220
|
+
await Promise.allSettled([...this.backgroundRefreshes.values()]);
|
|
1221
|
+
})();
|
|
1222
|
+
}
|
|
1223
|
+
await this.disconnectPromise;
|
|
537
1224
|
}
|
|
538
1225
|
async initialize() {
|
|
539
1226
|
if (!this.options.invalidationBus) {
|
|
@@ -547,7 +1234,7 @@ var CacheStack = class {
|
|
|
547
1234
|
const fetchTask = async () => {
|
|
548
1235
|
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
549
1236
|
if (secondHit.found) {
|
|
550
|
-
this.
|
|
1237
|
+
this.metricsCollector.increment("hits");
|
|
551
1238
|
return secondHit.value;
|
|
552
1239
|
}
|
|
553
1240
|
return this.fetchAndPopulate(key, fetcher, options);
|
|
@@ -572,11 +1259,12 @@ var CacheStack = class {
|
|
|
572
1259
|
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
573
1260
|
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
574
1261
|
const deadline = Date.now() + timeoutMs;
|
|
575
|
-
this.
|
|
1262
|
+
this.metricsCollector.increment("singleFlightWaits");
|
|
1263
|
+
this.emit("stampede-dedupe", { key });
|
|
576
1264
|
while (Date.now() < deadline) {
|
|
577
1265
|
const hit = await this.readFromLayers(key, options, "fresh-only");
|
|
578
1266
|
if (hit.found) {
|
|
579
|
-
this.
|
|
1267
|
+
this.metricsCollector.increment("hits");
|
|
580
1268
|
return hit.value;
|
|
581
1269
|
}
|
|
582
1270
|
await this.sleep(pollIntervalMs);
|
|
@@ -584,8 +1272,18 @@ var CacheStack = class {
|
|
|
584
1272
|
return this.fetchAndPopulate(key, fetcher, options);
|
|
585
1273
|
}
|
|
586
1274
|
async fetchAndPopulate(key, fetcher, options) {
|
|
587
|
-
this.
|
|
588
|
-
|
|
1275
|
+
this.circuitBreakerManager.assertClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
|
|
1276
|
+
this.metricsCollector.increment("fetches");
|
|
1277
|
+
const fetchStart = Date.now();
|
|
1278
|
+
let fetched;
|
|
1279
|
+
try {
|
|
1280
|
+
fetched = await fetcher();
|
|
1281
|
+
this.circuitBreakerManager.recordSuccess(key);
|
|
1282
|
+
this.logger.debug?.("fetch", { key, durationMs: Date.now() - fetchStart });
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
|
|
1285
|
+
throw error;
|
|
1286
|
+
}
|
|
589
1287
|
if (fetched === null || fetched === void 0) {
|
|
590
1288
|
if (!this.shouldNegativeCache(options)) {
|
|
591
1289
|
return null;
|
|
@@ -603,9 +1301,10 @@ var CacheStack = class {
|
|
|
603
1301
|
} else {
|
|
604
1302
|
await this.tagIndex.touch(key);
|
|
605
1303
|
}
|
|
606
|
-
this.
|
|
607
|
-
this.logger.debug("set", { key, kind, tags: options?.tags });
|
|
608
|
-
|
|
1304
|
+
this.metricsCollector.increment("sets");
|
|
1305
|
+
this.logger.debug?.("set", { key, kind, tags: options?.tags });
|
|
1306
|
+
this.emit("set", { key, kind, tags: options?.tags });
|
|
1307
|
+
if (this.shouldBroadcastL1Invalidation()) {
|
|
609
1308
|
await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
|
|
610
1309
|
}
|
|
611
1310
|
}
|
|
@@ -613,8 +1312,10 @@ var CacheStack = class {
|
|
|
613
1312
|
let sawRetainableValue = false;
|
|
614
1313
|
for (let index = 0; index < this.layers.length; index += 1) {
|
|
615
1314
|
const layer = this.layers[index];
|
|
1315
|
+
if (!layer) continue;
|
|
616
1316
|
const stored = await this.readLayerEntry(layer, key);
|
|
617
1317
|
if (stored === null) {
|
|
1318
|
+
this.metricsCollector.incrementLayer("missesByLayer", layer.name);
|
|
618
1319
|
continue;
|
|
619
1320
|
}
|
|
620
1321
|
const resolved = resolveStoredValue(stored);
|
|
@@ -628,20 +1329,41 @@ var CacheStack = class {
|
|
|
628
1329
|
}
|
|
629
1330
|
await this.tagIndex.touch(key);
|
|
630
1331
|
await this.backfill(key, stored, index - 1, options);
|
|
631
|
-
this.
|
|
632
|
-
|
|
1332
|
+
this.metricsCollector.incrementLayer("hitsByLayer", layer.name);
|
|
1333
|
+
this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
|
|
1334
|
+
this.emit("hit", { key, layer: layer.name, state: resolved.state });
|
|
1335
|
+
return {
|
|
1336
|
+
found: true,
|
|
1337
|
+
value: resolved.value,
|
|
1338
|
+
stored,
|
|
1339
|
+
state: resolved.state,
|
|
1340
|
+
layerIndex: index,
|
|
1341
|
+
layerName: layer.name
|
|
1342
|
+
};
|
|
633
1343
|
}
|
|
634
1344
|
if (!sawRetainableValue) {
|
|
635
1345
|
await this.tagIndex.remove(key);
|
|
636
1346
|
}
|
|
637
|
-
this.logger.debug("miss", { key, mode });
|
|
1347
|
+
this.logger.debug?.("miss", { key, mode });
|
|
1348
|
+
this.emit("miss", { key, mode });
|
|
638
1349
|
return { found: false, value: null, stored: null, state: "miss" };
|
|
639
1350
|
}
|
|
640
1351
|
async readLayerEntry(layer, key) {
|
|
1352
|
+
if (this.shouldSkipLayer(layer)) {
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
641
1355
|
if (layer.getEntry) {
|
|
642
|
-
|
|
1356
|
+
try {
|
|
1357
|
+
return await layer.getEntry(key);
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
return this.handleLayerFailure(layer, "read", error);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
try {
|
|
1363
|
+
return await layer.get(key);
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
return this.handleLayerFailure(layer, "read", error);
|
|
643
1366
|
}
|
|
644
|
-
return layer.get(key);
|
|
645
1367
|
}
|
|
646
1368
|
async backfill(key, stored, upToIndex, options) {
|
|
647
1369
|
if (upToIndex < 0) {
|
|
@@ -649,26 +1371,34 @@ var CacheStack = class {
|
|
|
649
1371
|
}
|
|
650
1372
|
for (let index = 0; index <= upToIndex; index += 1) {
|
|
651
1373
|
const layer = this.layers[index];
|
|
1374
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
652
1377
|
const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
1378
|
+
try {
|
|
1379
|
+
await layer.set(key, stored, ttl);
|
|
1380
|
+
} catch (error) {
|
|
1381
|
+
await this.handleLayerFailure(layer, "backfill", error);
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
this.metricsCollector.increment("backfills");
|
|
1385
|
+
this.logger.debug?.("backfill", { key, layer: layer.name });
|
|
1386
|
+
this.emit("backfill", { key, layer: layer.name });
|
|
656
1387
|
}
|
|
657
1388
|
}
|
|
658
1389
|
async writeAcrossLayers(key, kind, value, options) {
|
|
659
1390
|
const now = Date.now();
|
|
660
1391
|
const operations = this.layers.map((layer) => async () => {
|
|
661
|
-
|
|
1392
|
+
if (this.shouldSkipLayer(layer)) {
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl);
|
|
662
1396
|
const staleWhileRevalidate = this.resolveLayerSeconds(
|
|
663
1397
|
layer.name,
|
|
664
1398
|
options?.staleWhileRevalidate,
|
|
665
1399
|
this.options.staleWhileRevalidate
|
|
666
1400
|
);
|
|
667
|
-
const staleIfError = this.resolveLayerSeconds(
|
|
668
|
-
layer.name,
|
|
669
|
-
options?.staleIfError,
|
|
670
|
-
this.options.staleIfError
|
|
671
|
-
);
|
|
1401
|
+
const staleIfError = this.resolveLayerSeconds(layer.name, options?.staleIfError, this.options.staleIfError);
|
|
672
1402
|
const payload = createStoredValueEnvelope({
|
|
673
1403
|
kind,
|
|
674
1404
|
value,
|
|
@@ -678,7 +1408,11 @@ var CacheStack = class {
|
|
|
678
1408
|
now
|
|
679
1409
|
});
|
|
680
1410
|
const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
|
|
681
|
-
|
|
1411
|
+
try {
|
|
1412
|
+
await layer.set(key, payload, ttl);
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
await this.handleLayerFailure(layer, "write", error);
|
|
1415
|
+
}
|
|
682
1416
|
});
|
|
683
1417
|
await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
|
|
684
1418
|
}
|
|
@@ -692,8 +1426,8 @@ var CacheStack = class {
|
|
|
692
1426
|
if (failures.length === 0) {
|
|
693
1427
|
return;
|
|
694
1428
|
}
|
|
695
|
-
this.
|
|
696
|
-
this.logger.debug("write-failure", {
|
|
1429
|
+
this.metricsCollector.increment("writeFailures", failures.length);
|
|
1430
|
+
this.logger.debug?.("write-failure", {
|
|
697
1431
|
...context,
|
|
698
1432
|
failures: failures.map((failure) => this.formatError(failure.reason))
|
|
699
1433
|
});
|
|
@@ -704,52 +1438,26 @@ var CacheStack = class {
|
|
|
704
1438
|
);
|
|
705
1439
|
}
|
|
706
1440
|
}
|
|
707
|
-
resolveFreshTtl(layerName, kind, options, fallbackTtl) {
|
|
708
|
-
|
|
709
|
-
layerName,
|
|
710
|
-
options?.negativeTtl,
|
|
711
|
-
this.options.negativeTtl,
|
|
712
|
-
this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
713
|
-
) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
|
|
714
|
-
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
|
|
715
|
-
return this.applyJitter(baseTtl, jitter);
|
|
1441
|
+
resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
|
|
1442
|
+
return this.ttlResolver.resolveFreshTtl(key, layerName, kind, options, fallbackTtl, this.options.negativeTtl);
|
|
716
1443
|
}
|
|
717
1444
|
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
718
|
-
|
|
719
|
-
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
720
|
-
}
|
|
721
|
-
if (globalDefault !== void 0) {
|
|
722
|
-
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
723
|
-
}
|
|
724
|
-
return fallback;
|
|
725
|
-
}
|
|
726
|
-
readLayerNumber(layerName, value) {
|
|
727
|
-
if (typeof value === "number") {
|
|
728
|
-
return value;
|
|
729
|
-
}
|
|
730
|
-
return value[layerName];
|
|
731
|
-
}
|
|
732
|
-
applyJitter(ttl, jitter) {
|
|
733
|
-
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
734
|
-
return ttl;
|
|
735
|
-
}
|
|
736
|
-
const delta = (Math.random() * 2 - 1) * jitter;
|
|
737
|
-
return Math.max(1, Math.round(ttl + delta));
|
|
1445
|
+
return this.ttlResolver.resolveLayerSeconds(layerName, override, globalDefault, fallback);
|
|
738
1446
|
}
|
|
739
1447
|
shouldNegativeCache(options) {
|
|
740
1448
|
return options?.negativeCache ?? this.options.negativeCaching ?? false;
|
|
741
1449
|
}
|
|
742
1450
|
scheduleBackgroundRefresh(key, fetcher, options) {
|
|
743
|
-
if (this.backgroundRefreshes.has(key)) {
|
|
1451
|
+
if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
|
|
744
1452
|
return;
|
|
745
1453
|
}
|
|
746
1454
|
const refresh = (async () => {
|
|
747
|
-
this.
|
|
1455
|
+
this.metricsCollector.increment("refreshes");
|
|
748
1456
|
try {
|
|
749
1457
|
await this.fetchWithGuards(key, fetcher, options);
|
|
750
1458
|
} catch (error) {
|
|
751
|
-
this.
|
|
752
|
-
this.logger.debug("refresh-error", { key, error: this.formatError(error) });
|
|
1459
|
+
this.metricsCollector.increment("refreshErrors");
|
|
1460
|
+
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
753
1461
|
} finally {
|
|
754
1462
|
this.backgroundRefreshes.delete(key);
|
|
755
1463
|
}
|
|
@@ -767,21 +1475,16 @@ var CacheStack = class {
|
|
|
767
1475
|
if (keys.length === 0) {
|
|
768
1476
|
return;
|
|
769
1477
|
}
|
|
770
|
-
await
|
|
771
|
-
this.layers.map(async (layer) => {
|
|
772
|
-
if (layer.deleteMany) {
|
|
773
|
-
await layer.deleteMany(keys);
|
|
774
|
-
return;
|
|
775
|
-
}
|
|
776
|
-
await Promise.all(keys.map((key) => layer.delete(key)));
|
|
777
|
-
})
|
|
778
|
-
);
|
|
1478
|
+
await this.deleteKeysFromLayers(this.layers, keys);
|
|
779
1479
|
for (const key of keys) {
|
|
780
1480
|
await this.tagIndex.remove(key);
|
|
1481
|
+
this.ttlResolver.deleteProfile(key);
|
|
1482
|
+
this.circuitBreakerManager.delete(key);
|
|
781
1483
|
}
|
|
782
|
-
this.
|
|
783
|
-
this.
|
|
784
|
-
this.logger.debug("delete", { keys });
|
|
1484
|
+
this.metricsCollector.increment("deletes", keys.length);
|
|
1485
|
+
this.metricsCollector.increment("invalidations");
|
|
1486
|
+
this.logger.debug?.("delete", { keys });
|
|
1487
|
+
this.emit("delete", { keys });
|
|
785
1488
|
}
|
|
786
1489
|
async publishInvalidation(message) {
|
|
787
1490
|
if (!this.options.invalidationBus) {
|
|
@@ -800,21 +1503,15 @@ var CacheStack = class {
|
|
|
800
1503
|
if (message.scope === "clear") {
|
|
801
1504
|
await Promise.all(localLayers.map((layer) => layer.clear()));
|
|
802
1505
|
await this.tagIndex.clear();
|
|
1506
|
+
this.ttlResolver.clearProfiles();
|
|
803
1507
|
return;
|
|
804
1508
|
}
|
|
805
1509
|
const keys = message.keys ?? [];
|
|
806
|
-
await
|
|
807
|
-
localLayers.map(async (layer) => {
|
|
808
|
-
if (layer.deleteMany) {
|
|
809
|
-
await layer.deleteMany(keys);
|
|
810
|
-
return;
|
|
811
|
-
}
|
|
812
|
-
await Promise.all(keys.map((key) => layer.delete(key)));
|
|
813
|
-
})
|
|
814
|
-
);
|
|
1510
|
+
await this.deleteKeysFromLayers(localLayers, keys);
|
|
815
1511
|
if (message.operation !== "write") {
|
|
816
1512
|
for (const key of keys) {
|
|
817
1513
|
await this.tagIndex.remove(key);
|
|
1514
|
+
this.ttlResolver.deleteProfile(key);
|
|
818
1515
|
}
|
|
819
1516
|
}
|
|
820
1517
|
}
|
|
@@ -827,6 +1524,210 @@ var CacheStack = class {
|
|
|
827
1524
|
sleep(ms) {
|
|
828
1525
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
829
1526
|
}
|
|
1527
|
+
shouldBroadcastL1Invalidation() {
|
|
1528
|
+
return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
|
|
1529
|
+
}
|
|
1530
|
+
async deleteKeysFromLayers(layers, keys) {
|
|
1531
|
+
await Promise.all(
|
|
1532
|
+
layers.map(async (layer) => {
|
|
1533
|
+
if (this.shouldSkipLayer(layer)) {
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
if (layer.deleteMany) {
|
|
1537
|
+
try {
|
|
1538
|
+
await layer.deleteMany(keys);
|
|
1539
|
+
} catch (error) {
|
|
1540
|
+
await this.handleLayerFailure(layer, "delete", error);
|
|
1541
|
+
}
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
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
|
+
);
|
|
1553
|
+
})
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
validateConfiguration() {
|
|
1557
|
+
if (this.options.broadcastL1Invalidation !== void 0 && this.options.publishSetInvalidation !== void 0 && this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation) {
|
|
1558
|
+
throw new Error("broadcastL1Invalidation and publishSetInvalidation cannot conflict.");
|
|
1559
|
+
}
|
|
1560
|
+
if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
|
|
1561
|
+
throw new Error("singleFlightCoordinator requires stampedePrevention to remain enabled.");
|
|
1562
|
+
}
|
|
1563
|
+
this.validateLayerNumberOption("negativeTtl", this.options.negativeTtl);
|
|
1564
|
+
this.validateLayerNumberOption("staleWhileRevalidate", this.options.staleWhileRevalidate);
|
|
1565
|
+
this.validateLayerNumberOption("staleIfError", this.options.staleIfError);
|
|
1566
|
+
this.validateLayerNumberOption("ttlJitter", this.options.ttlJitter);
|
|
1567
|
+
this.validateLayerNumberOption("refreshAhead", this.options.refreshAhead);
|
|
1568
|
+
this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
|
|
1569
|
+
this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
|
|
1570
|
+
this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
|
|
1571
|
+
this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
|
|
1572
|
+
this.validateCircuitBreakerOptions(this.options.circuitBreaker);
|
|
1573
|
+
}
|
|
1574
|
+
validateWriteOptions(options) {
|
|
1575
|
+
if (!options) {
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
this.validateLayerNumberOption("options.ttl", options.ttl);
|
|
1579
|
+
this.validateLayerNumberOption("options.negativeTtl", options.negativeTtl);
|
|
1580
|
+
this.validateLayerNumberOption("options.staleWhileRevalidate", options.staleWhileRevalidate);
|
|
1581
|
+
this.validateLayerNumberOption("options.staleIfError", options.staleIfError);
|
|
1582
|
+
this.validateLayerNumberOption("options.ttlJitter", options.ttlJitter);
|
|
1583
|
+
this.validateLayerNumberOption("options.refreshAhead", options.refreshAhead);
|
|
1584
|
+
this.validateAdaptiveTtlOptions(options.adaptiveTtl);
|
|
1585
|
+
this.validateCircuitBreakerOptions(options.circuitBreaker);
|
|
1586
|
+
}
|
|
1587
|
+
validateLayerNumberOption(name, value) {
|
|
1588
|
+
if (value === void 0) {
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
if (typeof value === "number") {
|
|
1592
|
+
this.validateNonNegativeNumber(name, value);
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
for (const [layerName, layerValue] of Object.entries(value)) {
|
|
1596
|
+
if (layerValue === void 0) {
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
this.validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
validatePositiveNumber(name, value) {
|
|
1603
|
+
if (value === void 0) {
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1607
|
+
throw new Error(`${name} must be a positive finite number.`);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
validateNonNegativeNumber(name, value) {
|
|
1611
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1612
|
+
throw new Error(`${name} must be a non-negative finite number.`);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
validateCacheKey(key) {
|
|
1616
|
+
if (key.length === 0) {
|
|
1617
|
+
throw new Error("Cache key must not be empty.");
|
|
1618
|
+
}
|
|
1619
|
+
if (key.length > MAX_CACHE_KEY_LENGTH) {
|
|
1620
|
+
throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
|
|
1621
|
+
}
|
|
1622
|
+
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
1623
|
+
throw new Error("Cache key contains unsupported control characters.");
|
|
1624
|
+
}
|
|
1625
|
+
return key;
|
|
1626
|
+
}
|
|
1627
|
+
serializeOptions(options) {
|
|
1628
|
+
return JSON.stringify(this.normalizeForSerialization(options) ?? null);
|
|
1629
|
+
}
|
|
1630
|
+
validateAdaptiveTtlOptions(options) {
|
|
1631
|
+
if (!options || options === true) {
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
this.validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
|
|
1635
|
+
this.validateLayerNumberOption("adaptiveTtl.step", options.step);
|
|
1636
|
+
this.validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
|
|
1637
|
+
}
|
|
1638
|
+
validateCircuitBreakerOptions(options) {
|
|
1639
|
+
if (!options) {
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
this.validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
|
|
1643
|
+
this.validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
|
|
1644
|
+
}
|
|
1645
|
+
async applyFreshReadPolicies(key, hit, options, fetcher) {
|
|
1646
|
+
const refreshAhead = this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0;
|
|
1647
|
+
const remainingFreshTtl = remainingFreshTtlSeconds(hit.stored) ?? 0;
|
|
1648
|
+
if ((options?.slidingTtl ?? false) && isStoredValueEnvelope(hit.stored)) {
|
|
1649
|
+
const refreshed = refreshStoredEnvelope(hit.stored);
|
|
1650
|
+
const ttl = remainingStoredTtlSeconds(refreshed);
|
|
1651
|
+
for (let index = 0; index <= hit.layerIndex; index += 1) {
|
|
1652
|
+
const layer = this.layers[index];
|
|
1653
|
+
if (!layer || this.shouldSkipLayer(layer)) {
|
|
1654
|
+
continue;
|
|
1655
|
+
}
|
|
1656
|
+
try {
|
|
1657
|
+
await layer.set(key, refreshed, ttl);
|
|
1658
|
+
} catch (error) {
|
|
1659
|
+
await this.handleLayerFailure(layer, "sliding-ttl", error);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
|
|
1664
|
+
this.scheduleBackgroundRefresh(key, fetcher, options);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
shouldSkipLayer(layer) {
|
|
1668
|
+
const degradedUntil = this.layerDegradedUntil.get(layer.name);
|
|
1669
|
+
return degradedUntil !== void 0 && degradedUntil > Date.now();
|
|
1670
|
+
}
|
|
1671
|
+
async handleLayerFailure(layer, operation, error) {
|
|
1672
|
+
if (!this.isGracefulDegradationEnabled()) {
|
|
1673
|
+
throw error;
|
|
1674
|
+
}
|
|
1675
|
+
const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
|
|
1676
|
+
this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
|
|
1677
|
+
this.metricsCollector.increment("degradedOperations");
|
|
1678
|
+
this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
|
|
1679
|
+
this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
|
|
1680
|
+
return null;
|
|
1681
|
+
}
|
|
1682
|
+
isGracefulDegradationEnabled() {
|
|
1683
|
+
return Boolean(this.options.gracefulDegradation);
|
|
1684
|
+
}
|
|
1685
|
+
recordCircuitFailure(key, options, error) {
|
|
1686
|
+
if (!options) {
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
this.circuitBreakerManager.recordFailure(key, options);
|
|
1690
|
+
if (this.circuitBreakerManager.isOpen(key)) {
|
|
1691
|
+
this.metricsCollector.increment("circuitBreakerTrips");
|
|
1692
|
+
}
|
|
1693
|
+
this.emitError("fetch", { key, error: this.formatError(error) });
|
|
1694
|
+
}
|
|
1695
|
+
isNegativeStoredValue(stored) {
|
|
1696
|
+
return isStoredValueEnvelope(stored) && stored.kind === "empty";
|
|
1697
|
+
}
|
|
1698
|
+
emitError(operation, context) {
|
|
1699
|
+
this.logger.error?.(operation, context);
|
|
1700
|
+
if (this.listenerCount("error") > 0) {
|
|
1701
|
+
this.emit("error", { operation, ...context });
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
serializeKeyPart(value) {
|
|
1705
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
1706
|
+
return String(value);
|
|
1707
|
+
}
|
|
1708
|
+
return JSON.stringify(this.normalizeForSerialization(value));
|
|
1709
|
+
}
|
|
1710
|
+
isCacheSnapshotEntries(value) {
|
|
1711
|
+
return Array.isArray(value) && value.every((entry) => {
|
|
1712
|
+
if (!entry || typeof entry !== "object") {
|
|
1713
|
+
return false;
|
|
1714
|
+
}
|
|
1715
|
+
const candidate = entry;
|
|
1716
|
+
return typeof candidate.key === "string";
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
normalizeForSerialization(value) {
|
|
1720
|
+
if (Array.isArray(value)) {
|
|
1721
|
+
return value.map((entry) => this.normalizeForSerialization(entry));
|
|
1722
|
+
}
|
|
1723
|
+
if (value && typeof value === "object") {
|
|
1724
|
+
return Object.keys(value).sort().reduce((normalized, key) => {
|
|
1725
|
+
normalized[key] = this.normalizeForSerialization(value[key]);
|
|
1726
|
+
return normalized;
|
|
1727
|
+
}, {});
|
|
1728
|
+
}
|
|
1729
|
+
return value;
|
|
1730
|
+
}
|
|
830
1731
|
};
|
|
831
1732
|
|
|
832
1733
|
// src/module.ts
|
|
@@ -852,5 +1753,6 @@ CacheStackModule = __decorateClass([
|
|
|
852
1753
|
export {
|
|
853
1754
|
CACHE_STACK,
|
|
854
1755
|
CacheStackModule,
|
|
1756
|
+
Cacheable,
|
|
855
1757
|
InjectCacheStack
|
|
856
1758
|
};
|