layercache 1.0.0 → 1.0.2
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 +371 -13
- package/dist/chunk-IILH5XTS.js +103 -0
- package/dist/cli.cjs +228 -0
- package/dist/cli.d.cts +4 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +96 -0
- package/dist/index.cjs +1214 -98
- package/dist/index.d.cts +245 -7
- package/dist/index.d.ts +245 -7
- package/dist/index.js +1200 -185
- package/package.json +9 -2
- package/packages/nestjs/dist/index.cjs +971 -89
- package/packages/nestjs/dist/index.d.cts +227 -2
- package/packages/nestjs/dist/index.d.ts +227 -2
- package/packages/nestjs/dist/index.js +970 -89
package/dist/index.cjs
CHANGED
|
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
CacheNamespace: () => CacheNamespace,
|
|
23
24
|
CacheStack: () => CacheStack,
|
|
24
25
|
JsonSerializer: () => JsonSerializer,
|
|
25
26
|
MemoryLayer: () => MemoryLayer,
|
|
@@ -27,14 +28,178 @@ __export(index_exports, {
|
|
|
27
28
|
PatternMatcher: () => PatternMatcher,
|
|
28
29
|
RedisInvalidationBus: () => RedisInvalidationBus,
|
|
29
30
|
RedisLayer: () => RedisLayer,
|
|
31
|
+
RedisSingleFlightCoordinator: () => RedisSingleFlightCoordinator,
|
|
30
32
|
RedisTagIndex: () => RedisTagIndex,
|
|
31
33
|
StampedeGuard: () => StampedeGuard,
|
|
32
|
-
TagIndex: () => TagIndex
|
|
34
|
+
TagIndex: () => TagIndex,
|
|
35
|
+
cacheGraphqlResolver: () => cacheGraphqlResolver,
|
|
36
|
+
createCacheStatsHandler: () => createCacheStatsHandler,
|
|
37
|
+
createCachedMethodDecorator: () => createCachedMethodDecorator,
|
|
38
|
+
createFastifyLayercachePlugin: () => createFastifyLayercachePlugin,
|
|
39
|
+
createTrpcCacheMiddleware: () => createTrpcCacheMiddleware
|
|
33
40
|
});
|
|
34
41
|
module.exports = __toCommonJS(index_exports);
|
|
35
42
|
|
|
36
43
|
// src/CacheStack.ts
|
|
37
44
|
var import_node_crypto = require("crypto");
|
|
45
|
+
var import_node_fs = require("fs");
|
|
46
|
+
var import_node_events = require("events");
|
|
47
|
+
|
|
48
|
+
// src/internal/StoredValue.ts
|
|
49
|
+
function isStoredValueEnvelope(value) {
|
|
50
|
+
return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
|
|
51
|
+
}
|
|
52
|
+
function createStoredValueEnvelope(options) {
|
|
53
|
+
const now = options.now ?? Date.now();
|
|
54
|
+
const freshTtlSeconds = normalizePositiveSeconds(options.freshTtlSeconds);
|
|
55
|
+
const staleWhileRevalidateSeconds = normalizePositiveSeconds(options.staleWhileRevalidateSeconds);
|
|
56
|
+
const staleIfErrorSeconds = normalizePositiveSeconds(options.staleIfErrorSeconds);
|
|
57
|
+
const freshUntil = freshTtlSeconds ? now + freshTtlSeconds * 1e3 : null;
|
|
58
|
+
const staleUntil = freshUntil && staleWhileRevalidateSeconds ? freshUntil + staleWhileRevalidateSeconds * 1e3 : null;
|
|
59
|
+
const errorUntil = freshUntil && staleIfErrorSeconds ? freshUntil + staleIfErrorSeconds * 1e3 : null;
|
|
60
|
+
return {
|
|
61
|
+
__layercache: 1,
|
|
62
|
+
kind: options.kind,
|
|
63
|
+
value: options.value,
|
|
64
|
+
freshUntil,
|
|
65
|
+
staleUntil,
|
|
66
|
+
errorUntil,
|
|
67
|
+
freshTtlSeconds: freshTtlSeconds ?? null,
|
|
68
|
+
staleWhileRevalidateSeconds: staleWhileRevalidateSeconds ?? null,
|
|
69
|
+
staleIfErrorSeconds: staleIfErrorSeconds ?? null
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function resolveStoredValue(stored, now = Date.now()) {
|
|
73
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
74
|
+
return { state: "fresh", value: stored, stored };
|
|
75
|
+
}
|
|
76
|
+
if (stored.freshUntil === null || stored.freshUntil > now) {
|
|
77
|
+
return { state: "fresh", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
78
|
+
}
|
|
79
|
+
if (stored.staleUntil !== null && stored.staleUntil > now) {
|
|
80
|
+
return { state: "stale-while-revalidate", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
81
|
+
}
|
|
82
|
+
if (stored.errorUntil !== null && stored.errorUntil > now) {
|
|
83
|
+
return { state: "stale-if-error", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
84
|
+
}
|
|
85
|
+
return { state: "expired", value: null, stored, envelope: stored };
|
|
86
|
+
}
|
|
87
|
+
function unwrapStoredValue(stored) {
|
|
88
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
89
|
+
return stored;
|
|
90
|
+
}
|
|
91
|
+
if (stored.kind === "empty") {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return stored.value ?? null;
|
|
95
|
+
}
|
|
96
|
+
function remainingStoredTtlSeconds(stored, now = Date.now()) {
|
|
97
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
98
|
+
return void 0;
|
|
99
|
+
}
|
|
100
|
+
const expiry = maxExpiry(stored);
|
|
101
|
+
if (expiry === null) {
|
|
102
|
+
return void 0;
|
|
103
|
+
}
|
|
104
|
+
const remainingMs = expiry - now;
|
|
105
|
+
if (remainingMs <= 0) {
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
return Math.max(1, Math.ceil(remainingMs / 1e3));
|
|
109
|
+
}
|
|
110
|
+
function remainingFreshTtlSeconds(stored, now = Date.now()) {
|
|
111
|
+
if (!isStoredValueEnvelope(stored) || stored.freshUntil === null) {
|
|
112
|
+
return void 0;
|
|
113
|
+
}
|
|
114
|
+
const remainingMs = stored.freshUntil - now;
|
|
115
|
+
if (remainingMs <= 0) {
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
return Math.max(1, Math.ceil(remainingMs / 1e3));
|
|
119
|
+
}
|
|
120
|
+
function refreshStoredEnvelope(stored, now = Date.now()) {
|
|
121
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
122
|
+
return stored;
|
|
123
|
+
}
|
|
124
|
+
return createStoredValueEnvelope({
|
|
125
|
+
kind: stored.kind,
|
|
126
|
+
value: stored.value,
|
|
127
|
+
freshTtlSeconds: stored.freshTtlSeconds ?? void 0,
|
|
128
|
+
staleWhileRevalidateSeconds: stored.staleWhileRevalidateSeconds ?? void 0,
|
|
129
|
+
staleIfErrorSeconds: stored.staleIfErrorSeconds ?? void 0,
|
|
130
|
+
now
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function maxExpiry(stored) {
|
|
134
|
+
const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
|
|
135
|
+
(value) => value !== null
|
|
136
|
+
);
|
|
137
|
+
if (values.length === 0) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return Math.max(...values);
|
|
141
|
+
}
|
|
142
|
+
function normalizePositiveSeconds(value) {
|
|
143
|
+
if (!value || value <= 0) {
|
|
144
|
+
return void 0;
|
|
145
|
+
}
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/CacheNamespace.ts
|
|
150
|
+
var CacheNamespace = class {
|
|
151
|
+
constructor(cache, prefix) {
|
|
152
|
+
this.cache = cache;
|
|
153
|
+
this.prefix = prefix;
|
|
154
|
+
}
|
|
155
|
+
cache;
|
|
156
|
+
prefix;
|
|
157
|
+
async get(key, fetcher, options) {
|
|
158
|
+
return this.cache.get(this.qualify(key), fetcher, options);
|
|
159
|
+
}
|
|
160
|
+
async set(key, value, options) {
|
|
161
|
+
await this.cache.set(this.qualify(key), value, options);
|
|
162
|
+
}
|
|
163
|
+
async delete(key) {
|
|
164
|
+
await this.cache.delete(this.qualify(key));
|
|
165
|
+
}
|
|
166
|
+
async clear() {
|
|
167
|
+
await this.cache.invalidateByPattern(`${this.prefix}:*`);
|
|
168
|
+
}
|
|
169
|
+
async mget(entries) {
|
|
170
|
+
return this.cache.mget(entries.map((entry) => ({
|
|
171
|
+
...entry,
|
|
172
|
+
key: this.qualify(entry.key)
|
|
173
|
+
})));
|
|
174
|
+
}
|
|
175
|
+
async mset(entries) {
|
|
176
|
+
await this.cache.mset(entries.map((entry) => ({
|
|
177
|
+
...entry,
|
|
178
|
+
key: this.qualify(entry.key)
|
|
179
|
+
})));
|
|
180
|
+
}
|
|
181
|
+
async invalidateByTag(tag) {
|
|
182
|
+
await this.cache.invalidateByTag(tag);
|
|
183
|
+
}
|
|
184
|
+
async invalidateByPattern(pattern) {
|
|
185
|
+
await this.cache.invalidateByPattern(this.qualify(pattern));
|
|
186
|
+
}
|
|
187
|
+
wrap(keyPrefix, fetcher, options) {
|
|
188
|
+
return this.cache.wrap(`${this.prefix}:${keyPrefix}`, fetcher, options);
|
|
189
|
+
}
|
|
190
|
+
warm(entries, options) {
|
|
191
|
+
return this.cache.warm(entries.map((entry) => ({
|
|
192
|
+
...entry,
|
|
193
|
+
key: this.qualify(entry.key)
|
|
194
|
+
})), options);
|
|
195
|
+
}
|
|
196
|
+
getMetrics() {
|
|
197
|
+
return this.cache.getMetrics();
|
|
198
|
+
}
|
|
199
|
+
qualify(key) {
|
|
200
|
+
return `${this.prefix}:${key}`;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
38
203
|
|
|
39
204
|
// src/invalidation/PatternMatcher.ts
|
|
40
205
|
var PatternMatcher = class {
|
|
@@ -108,26 +273,33 @@ var import_async_mutex = require("async-mutex");
|
|
|
108
273
|
var StampedeGuard = class {
|
|
109
274
|
mutexes = /* @__PURE__ */ new Map();
|
|
110
275
|
async execute(key, task) {
|
|
111
|
-
const
|
|
276
|
+
const entry = this.getMutexEntry(key);
|
|
112
277
|
try {
|
|
113
|
-
return await mutex.runExclusive(task);
|
|
278
|
+
return await entry.mutex.runExclusive(task);
|
|
114
279
|
} finally {
|
|
115
|
-
|
|
280
|
+
entry.references -= 1;
|
|
281
|
+
if (entry.references === 0 && !entry.mutex.isLocked()) {
|
|
116
282
|
this.mutexes.delete(key);
|
|
117
283
|
}
|
|
118
284
|
}
|
|
119
285
|
}
|
|
120
|
-
|
|
121
|
-
let
|
|
122
|
-
if (!
|
|
123
|
-
|
|
124
|
-
this.mutexes.set(key,
|
|
286
|
+
getMutexEntry(key) {
|
|
287
|
+
let entry = this.mutexes.get(key);
|
|
288
|
+
if (!entry) {
|
|
289
|
+
entry = { mutex: new import_async_mutex.Mutex(), references: 0 };
|
|
290
|
+
this.mutexes.set(key, entry);
|
|
125
291
|
}
|
|
126
|
-
|
|
292
|
+
entry.references += 1;
|
|
293
|
+
return entry;
|
|
127
294
|
}
|
|
128
295
|
};
|
|
129
296
|
|
|
130
297
|
// src/CacheStack.ts
|
|
298
|
+
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
299
|
+
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
300
|
+
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
301
|
+
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
302
|
+
var MAX_CACHE_KEY_LENGTH = 1024;
|
|
131
303
|
var EMPTY_METRICS = () => ({
|
|
132
304
|
hits: 0,
|
|
133
305
|
misses: 0,
|
|
@@ -135,7 +307,17 @@ var EMPTY_METRICS = () => ({
|
|
|
135
307
|
sets: 0,
|
|
136
308
|
deletes: 0,
|
|
137
309
|
backfills: 0,
|
|
138
|
-
invalidations: 0
|
|
310
|
+
invalidations: 0,
|
|
311
|
+
staleHits: 0,
|
|
312
|
+
refreshes: 0,
|
|
313
|
+
refreshErrors: 0,
|
|
314
|
+
writeFailures: 0,
|
|
315
|
+
singleFlightWaits: 0,
|
|
316
|
+
negativeCacheHits: 0,
|
|
317
|
+
circuitBreakerTrips: 0,
|
|
318
|
+
degradedOperations: 0,
|
|
319
|
+
hitsByLayer: {},
|
|
320
|
+
missesByLayer: {}
|
|
139
321
|
});
|
|
140
322
|
var DebugLogger = class {
|
|
141
323
|
enabled;
|
|
@@ -143,21 +325,35 @@ var DebugLogger = class {
|
|
|
143
325
|
this.enabled = enabled;
|
|
144
326
|
}
|
|
145
327
|
debug(message, context) {
|
|
328
|
+
this.write("debug", message, context);
|
|
329
|
+
}
|
|
330
|
+
info(message, context) {
|
|
331
|
+
this.write("info", message, context);
|
|
332
|
+
}
|
|
333
|
+
warn(message, context) {
|
|
334
|
+
this.write("warn", message, context);
|
|
335
|
+
}
|
|
336
|
+
error(message, context) {
|
|
337
|
+
this.write("error", message, context);
|
|
338
|
+
}
|
|
339
|
+
write(level, message, context) {
|
|
146
340
|
if (!this.enabled) {
|
|
147
341
|
return;
|
|
148
342
|
}
|
|
149
343
|
const suffix = context ? ` ${JSON.stringify(context)}` : "";
|
|
150
|
-
console
|
|
344
|
+
console[level](`[layercache] ${message}${suffix}`);
|
|
151
345
|
}
|
|
152
346
|
};
|
|
153
|
-
var CacheStack = class {
|
|
347
|
+
var CacheStack = class extends import_node_events.EventEmitter {
|
|
154
348
|
constructor(layers, options = {}) {
|
|
349
|
+
super();
|
|
155
350
|
this.layers = layers;
|
|
156
351
|
this.options = options;
|
|
157
352
|
if (layers.length === 0) {
|
|
158
353
|
throw new Error("CacheStack requires at least one cache layer.");
|
|
159
354
|
}
|
|
160
|
-
|
|
355
|
+
this.validateConfiguration();
|
|
356
|
+
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
161
357
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
162
358
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
163
359
|
this.startup = this.initialize();
|
|
@@ -171,68 +367,193 @@ var CacheStack = class {
|
|
|
171
367
|
unsubscribeInvalidation;
|
|
172
368
|
logger;
|
|
173
369
|
tagIndex;
|
|
370
|
+
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
371
|
+
accessProfiles = /* @__PURE__ */ new Map();
|
|
372
|
+
layerDegradedUntil = /* @__PURE__ */ new Map();
|
|
373
|
+
circuitBreakers = /* @__PURE__ */ new Map();
|
|
374
|
+
isDisconnecting = false;
|
|
375
|
+
disconnectPromise;
|
|
174
376
|
async get(key, fetcher, options) {
|
|
377
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
378
|
+
this.validateWriteOptions(options);
|
|
175
379
|
await this.startup;
|
|
176
|
-
const hit = await this.
|
|
380
|
+
const hit = await this.readFromLayers(normalizedKey, options, "allow-stale");
|
|
177
381
|
if (hit.found) {
|
|
178
|
-
this.
|
|
179
|
-
|
|
382
|
+
this.recordAccess(normalizedKey);
|
|
383
|
+
if (this.isNegativeStoredValue(hit.stored)) {
|
|
384
|
+
this.metrics.negativeCacheHits += 1;
|
|
385
|
+
}
|
|
386
|
+
if (hit.state === "fresh") {
|
|
387
|
+
this.metrics.hits += 1;
|
|
388
|
+
await this.applyFreshReadPolicies(normalizedKey, hit, options, fetcher);
|
|
389
|
+
return hit.value;
|
|
390
|
+
}
|
|
391
|
+
if (hit.state === "stale-while-revalidate") {
|
|
392
|
+
this.metrics.hits += 1;
|
|
393
|
+
this.metrics.staleHits += 1;
|
|
394
|
+
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
395
|
+
if (fetcher) {
|
|
396
|
+
this.scheduleBackgroundRefresh(normalizedKey, fetcher, options);
|
|
397
|
+
}
|
|
398
|
+
return hit.value;
|
|
399
|
+
}
|
|
400
|
+
if (!fetcher) {
|
|
401
|
+
this.metrics.hits += 1;
|
|
402
|
+
this.metrics.staleHits += 1;
|
|
403
|
+
this.emit("stale-serve", { key: normalizedKey, state: hit.state, layer: hit.layerName });
|
|
404
|
+
return hit.value;
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
return await this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
this.metrics.staleHits += 1;
|
|
410
|
+
this.metrics.refreshErrors += 1;
|
|
411
|
+
this.logger.debug?.("stale-if-error", { key: normalizedKey, error: this.formatError(error) });
|
|
412
|
+
return hit.value;
|
|
413
|
+
}
|
|
180
414
|
}
|
|
181
415
|
this.metrics.misses += 1;
|
|
182
416
|
if (!fetcher) {
|
|
183
417
|
return null;
|
|
184
418
|
}
|
|
185
|
-
|
|
186
|
-
const secondHit = await this.getFromLayers(key, options);
|
|
187
|
-
if (secondHit.found) {
|
|
188
|
-
this.metrics.hits += 1;
|
|
189
|
-
return secondHit.value;
|
|
190
|
-
}
|
|
191
|
-
this.metrics.fetches += 1;
|
|
192
|
-
const fetched = await fetcher();
|
|
193
|
-
if (fetched === null || fetched === void 0) {
|
|
194
|
-
return null;
|
|
195
|
-
}
|
|
196
|
-
await this.set(key, fetched, options);
|
|
197
|
-
return fetched;
|
|
198
|
-
};
|
|
199
|
-
if (this.options.stampedePrevention === false) {
|
|
200
|
-
return runFetch();
|
|
201
|
-
}
|
|
202
|
-
return this.stampedeGuard.execute(key, runFetch);
|
|
419
|
+
return this.fetchWithGuards(normalizedKey, fetcher, options);
|
|
203
420
|
}
|
|
204
421
|
async set(key, value, options) {
|
|
422
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
423
|
+
this.validateWriteOptions(options);
|
|
205
424
|
await this.startup;
|
|
206
|
-
await this.
|
|
207
|
-
if (options?.tags) {
|
|
208
|
-
await this.tagIndex.track(key, options.tags);
|
|
209
|
-
} else {
|
|
210
|
-
await this.tagIndex.touch(key);
|
|
211
|
-
}
|
|
212
|
-
this.metrics.sets += 1;
|
|
213
|
-
this.logger.debug("set", { key, tags: options?.tags });
|
|
214
|
-
if (this.options.publishSetInvalidation !== false) {
|
|
215
|
-
await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
|
|
216
|
-
}
|
|
425
|
+
await this.storeEntry(normalizedKey, "value", value, options);
|
|
217
426
|
}
|
|
218
427
|
async delete(key) {
|
|
428
|
+
const normalizedKey = this.validateCacheKey(key);
|
|
219
429
|
await this.startup;
|
|
220
|
-
await this.deleteKeys([
|
|
221
|
-
await this.publishInvalidation({ scope: "key", keys: [
|
|
430
|
+
await this.deleteKeys([normalizedKey]);
|
|
431
|
+
await this.publishInvalidation({ scope: "key", keys: [normalizedKey], sourceId: this.instanceId, operation: "delete" });
|
|
222
432
|
}
|
|
223
433
|
async clear() {
|
|
224
434
|
await this.startup;
|
|
225
435
|
await Promise.all(this.layers.map((layer) => layer.clear()));
|
|
226
436
|
await this.tagIndex.clear();
|
|
437
|
+
this.accessProfiles.clear();
|
|
227
438
|
this.metrics.invalidations += 1;
|
|
228
|
-
this.logger.debug("clear");
|
|
439
|
+
this.logger.debug?.("clear");
|
|
229
440
|
await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
|
|
230
441
|
}
|
|
231
442
|
async mget(entries) {
|
|
232
|
-
|
|
443
|
+
if (entries.length === 0) {
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
446
|
+
const normalizedEntries = entries.map((entry) => ({
|
|
447
|
+
...entry,
|
|
448
|
+
key: this.validateCacheKey(entry.key)
|
|
449
|
+
}));
|
|
450
|
+
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
451
|
+
const canFastPath = normalizedEntries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
|
|
452
|
+
if (!canFastPath) {
|
|
453
|
+
const pendingReads = /* @__PURE__ */ new Map();
|
|
454
|
+
return Promise.all(
|
|
455
|
+
normalizedEntries.map((entry) => {
|
|
456
|
+
const optionsSignature = this.serializeOptions(entry.options);
|
|
457
|
+
const existing = pendingReads.get(entry.key);
|
|
458
|
+
if (!existing) {
|
|
459
|
+
const promise = this.get(entry.key, entry.fetch, entry.options);
|
|
460
|
+
pendingReads.set(entry.key, {
|
|
461
|
+
promise,
|
|
462
|
+
fetch: entry.fetch,
|
|
463
|
+
optionsSignature
|
|
464
|
+
});
|
|
465
|
+
return promise;
|
|
466
|
+
}
|
|
467
|
+
if (existing.fetch !== entry.fetch || existing.optionsSignature !== optionsSignature) {
|
|
468
|
+
throw new Error(`mget received conflicting entries for key "${entry.key}".`);
|
|
469
|
+
}
|
|
470
|
+
return existing.promise;
|
|
471
|
+
})
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
await this.startup;
|
|
475
|
+
const pending = /* @__PURE__ */ new Set();
|
|
476
|
+
const indexesByKey = /* @__PURE__ */ new Map();
|
|
477
|
+
const resultsByKey = /* @__PURE__ */ new Map();
|
|
478
|
+
for (let index = 0; index < normalizedEntries.length; index += 1) {
|
|
479
|
+
const key = normalizedEntries[index].key;
|
|
480
|
+
const indexes = indexesByKey.get(key) ?? [];
|
|
481
|
+
indexes.push(index);
|
|
482
|
+
indexesByKey.set(key, indexes);
|
|
483
|
+
pending.add(key);
|
|
484
|
+
}
|
|
485
|
+
for (let layerIndex = 0; layerIndex < this.layers.length; layerIndex += 1) {
|
|
486
|
+
const layer = this.layers[layerIndex];
|
|
487
|
+
const keys = [...pending];
|
|
488
|
+
if (keys.length === 0) {
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
|
|
492
|
+
for (let offset = 0; offset < values.length; offset += 1) {
|
|
493
|
+
const key = keys[offset];
|
|
494
|
+
const stored = values[offset];
|
|
495
|
+
if (stored === null) {
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const resolved = resolveStoredValue(stored);
|
|
499
|
+
if (resolved.state === "expired") {
|
|
500
|
+
await layer.delete(key);
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
await this.tagIndex.touch(key);
|
|
504
|
+
await this.backfill(key, stored, layerIndex - 1);
|
|
505
|
+
resultsByKey.set(key, resolved.value);
|
|
506
|
+
pending.delete(key);
|
|
507
|
+
this.metrics.hits += indexesByKey.get(key)?.length ?? 1;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (pending.size > 0) {
|
|
511
|
+
for (const key of pending) {
|
|
512
|
+
await this.tagIndex.remove(key);
|
|
513
|
+
this.metrics.misses += indexesByKey.get(key)?.length ?? 1;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return normalizedEntries.map((entry) => resultsByKey.get(entry.key) ?? null);
|
|
233
517
|
}
|
|
234
518
|
async mset(entries) {
|
|
235
|
-
|
|
519
|
+
const normalizedEntries = entries.map((entry) => ({
|
|
520
|
+
...entry,
|
|
521
|
+
key: this.validateCacheKey(entry.key)
|
|
522
|
+
}));
|
|
523
|
+
normalizedEntries.forEach((entry) => this.validateWriteOptions(entry.options));
|
|
524
|
+
await Promise.all(normalizedEntries.map((entry) => this.set(entry.key, entry.value, entry.options)));
|
|
525
|
+
}
|
|
526
|
+
async warm(entries, options = {}) {
|
|
527
|
+
const concurrency = Math.max(1, options.concurrency ?? 4);
|
|
528
|
+
const queue = [...entries].sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
|
|
529
|
+
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
530
|
+
while (queue.length > 0) {
|
|
531
|
+
const entry = queue.shift();
|
|
532
|
+
if (!entry) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
await this.get(entry.key, entry.fetcher, entry.options);
|
|
537
|
+
this.emit("warm", { key: entry.key });
|
|
538
|
+
} catch (error) {
|
|
539
|
+
this.emitError("warm", { key: entry.key, error: this.formatError(error) });
|
|
540
|
+
if (!options.continueOnError) {
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
await Promise.all(workers);
|
|
547
|
+
}
|
|
548
|
+
wrap(prefix, fetcher, options = {}) {
|
|
549
|
+
return (...args) => {
|
|
550
|
+
const suffix = options.keyResolver ? options.keyResolver(...args) : args.map((argument) => this.serializeKeyPart(argument)).join(":");
|
|
551
|
+
const key = suffix.length > 0 ? `${prefix}:${suffix}` : prefix;
|
|
552
|
+
return this.get(key, () => fetcher(...args), options);
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
namespace(prefix) {
|
|
556
|
+
return new CacheNamespace(this, prefix);
|
|
236
557
|
}
|
|
237
558
|
async invalidateByTag(tag) {
|
|
238
559
|
await this.startup;
|
|
@@ -249,12 +570,74 @@ var CacheStack = class {
|
|
|
249
570
|
getMetrics() {
|
|
250
571
|
return { ...this.metrics };
|
|
251
572
|
}
|
|
573
|
+
getStats() {
|
|
574
|
+
return {
|
|
575
|
+
metrics: this.getMetrics(),
|
|
576
|
+
layers: this.layers.map((layer) => ({
|
|
577
|
+
name: layer.name,
|
|
578
|
+
isLocal: Boolean(layer.isLocal),
|
|
579
|
+
degradedUntil: this.layerDegradedUntil.get(layer.name) ?? null
|
|
580
|
+
})),
|
|
581
|
+
backgroundRefreshes: this.backgroundRefreshes.size
|
|
582
|
+
};
|
|
583
|
+
}
|
|
252
584
|
resetMetrics() {
|
|
253
585
|
Object.assign(this.metrics, EMPTY_METRICS());
|
|
254
586
|
}
|
|
255
|
-
async
|
|
587
|
+
async exportState() {
|
|
588
|
+
await this.startup;
|
|
589
|
+
const exported = /* @__PURE__ */ new Map();
|
|
590
|
+
for (const layer of this.layers) {
|
|
591
|
+
if (!layer.keys) {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const keys = await layer.keys();
|
|
595
|
+
for (const key of keys) {
|
|
596
|
+
if (exported.has(key)) {
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const stored = await this.readLayerEntry(layer, key);
|
|
600
|
+
if (stored === null) {
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
exported.set(key, {
|
|
604
|
+
key,
|
|
605
|
+
value: stored,
|
|
606
|
+
ttl: remainingStoredTtlSeconds(stored)
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return [...exported.values()];
|
|
611
|
+
}
|
|
612
|
+
async importState(entries) {
|
|
256
613
|
await this.startup;
|
|
257
|
-
await
|
|
614
|
+
await Promise.all(entries.map(async (entry) => {
|
|
615
|
+
await Promise.all(this.layers.map((layer) => layer.set(entry.key, entry.value, entry.ttl)));
|
|
616
|
+
await this.tagIndex.touch(entry.key);
|
|
617
|
+
}));
|
|
618
|
+
}
|
|
619
|
+
async persistToFile(filePath) {
|
|
620
|
+
const snapshot = await this.exportState();
|
|
621
|
+
await import_node_fs.promises.writeFile(filePath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
622
|
+
}
|
|
623
|
+
async restoreFromFile(filePath) {
|
|
624
|
+
const raw = await import_node_fs.promises.readFile(filePath, "utf8");
|
|
625
|
+
const snapshot = JSON.parse(raw);
|
|
626
|
+
if (!this.isCacheSnapshotEntries(snapshot)) {
|
|
627
|
+
throw new Error("Invalid snapshot file: expected CacheSnapshotEntry[]");
|
|
628
|
+
}
|
|
629
|
+
await this.importState(snapshot);
|
|
630
|
+
}
|
|
631
|
+
async disconnect() {
|
|
632
|
+
if (!this.disconnectPromise) {
|
|
633
|
+
this.isDisconnecting = true;
|
|
634
|
+
this.disconnectPromise = (async () => {
|
|
635
|
+
await this.startup;
|
|
636
|
+
await this.unsubscribeInvalidation?.();
|
|
637
|
+
await Promise.allSettled([...this.backgroundRefreshes.values()]);
|
|
638
|
+
})();
|
|
639
|
+
}
|
|
640
|
+
await this.disconnectPromise;
|
|
258
641
|
}
|
|
259
642
|
async initialize() {
|
|
260
643
|
if (!this.options.invalidationBus) {
|
|
@@ -264,66 +647,286 @@ var CacheStack = class {
|
|
|
264
647
|
await this.handleInvalidationMessage(message);
|
|
265
648
|
});
|
|
266
649
|
}
|
|
267
|
-
async
|
|
650
|
+
async fetchWithGuards(key, fetcher, options) {
|
|
651
|
+
const fetchTask = async () => {
|
|
652
|
+
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
653
|
+
if (secondHit.found) {
|
|
654
|
+
this.metrics.hits += 1;
|
|
655
|
+
return secondHit.value;
|
|
656
|
+
}
|
|
657
|
+
return this.fetchAndPopulate(key, fetcher, options);
|
|
658
|
+
};
|
|
659
|
+
const singleFlightTask = async () => {
|
|
660
|
+
if (!this.options.singleFlightCoordinator) {
|
|
661
|
+
return fetchTask();
|
|
662
|
+
}
|
|
663
|
+
return this.options.singleFlightCoordinator.execute(
|
|
664
|
+
key,
|
|
665
|
+
this.resolveSingleFlightOptions(),
|
|
666
|
+
fetchTask,
|
|
667
|
+
() => this.waitForFreshValue(key, fetcher, options)
|
|
668
|
+
);
|
|
669
|
+
};
|
|
670
|
+
if (this.options.stampedePrevention === false) {
|
|
671
|
+
return singleFlightTask();
|
|
672
|
+
}
|
|
673
|
+
return this.stampedeGuard.execute(key, singleFlightTask);
|
|
674
|
+
}
|
|
675
|
+
async waitForFreshValue(key, fetcher, options) {
|
|
676
|
+
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
677
|
+
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
678
|
+
const deadline = Date.now() + timeoutMs;
|
|
679
|
+
this.metrics.singleFlightWaits += 1;
|
|
680
|
+
this.emit("stampede-dedupe", { key });
|
|
681
|
+
while (Date.now() < deadline) {
|
|
682
|
+
const hit = await this.readFromLayers(key, options, "fresh-only");
|
|
683
|
+
if (hit.found) {
|
|
684
|
+
this.metrics.hits += 1;
|
|
685
|
+
return hit.value;
|
|
686
|
+
}
|
|
687
|
+
await this.sleep(pollIntervalMs);
|
|
688
|
+
}
|
|
689
|
+
return this.fetchAndPopulate(key, fetcher, options);
|
|
690
|
+
}
|
|
691
|
+
async fetchAndPopulate(key, fetcher, options) {
|
|
692
|
+
this.assertCircuitClosed(key, options?.circuitBreaker ?? this.options.circuitBreaker);
|
|
693
|
+
this.metrics.fetches += 1;
|
|
694
|
+
let fetched;
|
|
695
|
+
try {
|
|
696
|
+
fetched = await fetcher();
|
|
697
|
+
this.resetCircuitBreaker(key);
|
|
698
|
+
} catch (error) {
|
|
699
|
+
this.recordCircuitFailure(key, options?.circuitBreaker ?? this.options.circuitBreaker, error);
|
|
700
|
+
throw error;
|
|
701
|
+
}
|
|
702
|
+
if (fetched === null || fetched === void 0) {
|
|
703
|
+
if (!this.shouldNegativeCache(options)) {
|
|
704
|
+
return null;
|
|
705
|
+
}
|
|
706
|
+
await this.storeEntry(key, "empty", null, options);
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
await this.storeEntry(key, "value", fetched, options);
|
|
710
|
+
return fetched;
|
|
711
|
+
}
|
|
712
|
+
async storeEntry(key, kind, value, options) {
|
|
713
|
+
await this.writeAcrossLayers(key, kind, value, options);
|
|
714
|
+
if (options?.tags) {
|
|
715
|
+
await this.tagIndex.track(key, options.tags);
|
|
716
|
+
} else {
|
|
717
|
+
await this.tagIndex.touch(key);
|
|
718
|
+
}
|
|
719
|
+
this.metrics.sets += 1;
|
|
720
|
+
this.logger.debug?.("set", { key, kind, tags: options?.tags });
|
|
721
|
+
this.emit("set", { key, kind, tags: options?.tags });
|
|
722
|
+
if (this.shouldBroadcastL1Invalidation()) {
|
|
723
|
+
await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
async readFromLayers(key, options, mode) {
|
|
727
|
+
let sawRetainableValue = false;
|
|
268
728
|
for (let index = 0; index < this.layers.length; index += 1) {
|
|
269
729
|
const layer = this.layers[index];
|
|
270
|
-
const
|
|
271
|
-
if (
|
|
730
|
+
const stored = await this.readLayerEntry(layer, key);
|
|
731
|
+
if (stored === null) {
|
|
732
|
+
this.incrementMetricMap(this.metrics.missesByLayer, layer.name);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
const resolved = resolveStoredValue(stored);
|
|
736
|
+
if (resolved.state === "expired") {
|
|
737
|
+
await layer.delete(key);
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
sawRetainableValue = true;
|
|
741
|
+
if (mode === "fresh-only" && resolved.state !== "fresh") {
|
|
272
742
|
continue;
|
|
273
743
|
}
|
|
274
744
|
await this.tagIndex.touch(key);
|
|
275
|
-
await this.backfill(key,
|
|
276
|
-
this.
|
|
277
|
-
|
|
745
|
+
await this.backfill(key, stored, index - 1, options);
|
|
746
|
+
this.incrementMetricMap(this.metrics.hitsByLayer, layer.name);
|
|
747
|
+
this.logger.debug?.("hit", { key, layer: layer.name, state: resolved.state });
|
|
748
|
+
this.emit("hit", { key, layer: layer.name, state: resolved.state });
|
|
749
|
+
return { found: true, value: resolved.value, stored, state: resolved.state, layerIndex: index, layerName: layer.name };
|
|
750
|
+
}
|
|
751
|
+
if (!sawRetainableValue) {
|
|
752
|
+
await this.tagIndex.remove(key);
|
|
278
753
|
}
|
|
279
|
-
|
|
280
|
-
this.
|
|
281
|
-
return { found: false, value: null };
|
|
754
|
+
this.logger.debug?.("miss", { key, mode });
|
|
755
|
+
this.emit("miss", { key, mode });
|
|
756
|
+
return { found: false, value: null, stored: null, state: "miss" };
|
|
282
757
|
}
|
|
283
|
-
async
|
|
758
|
+
async readLayerEntry(layer, key) {
|
|
759
|
+
if (this.shouldSkipLayer(layer)) {
|
|
760
|
+
return null;
|
|
761
|
+
}
|
|
762
|
+
if (layer.getEntry) {
|
|
763
|
+
try {
|
|
764
|
+
return await layer.getEntry(key);
|
|
765
|
+
} catch (error) {
|
|
766
|
+
return this.handleLayerFailure(layer, "read", error);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
return await layer.get(key);
|
|
771
|
+
} catch (error) {
|
|
772
|
+
return this.handleLayerFailure(layer, "read", error);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
async backfill(key, stored, upToIndex, options) {
|
|
284
776
|
if (upToIndex < 0) {
|
|
285
777
|
return;
|
|
286
778
|
}
|
|
287
779
|
for (let index = 0; index <= upToIndex; index += 1) {
|
|
288
780
|
const layer = this.layers[index];
|
|
289
|
-
|
|
781
|
+
if (this.shouldSkipLayer(layer)) {
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
|
|
785
|
+
try {
|
|
786
|
+
await layer.set(key, stored, ttl);
|
|
787
|
+
} catch (error) {
|
|
788
|
+
await this.handleLayerFailure(layer, "backfill", error);
|
|
789
|
+
continue;
|
|
790
|
+
}
|
|
290
791
|
this.metrics.backfills += 1;
|
|
291
|
-
this.logger.debug("backfill", { key, layer: layer.name });
|
|
792
|
+
this.logger.debug?.("backfill", { key, layer: layer.name });
|
|
793
|
+
this.emit("backfill", { key, layer: layer.name });
|
|
292
794
|
}
|
|
293
795
|
}
|
|
294
|
-
async
|
|
295
|
-
|
|
296
|
-
|
|
796
|
+
async writeAcrossLayers(key, kind, value, options) {
|
|
797
|
+
const now = Date.now();
|
|
798
|
+
const operations = this.layers.map((layer) => async () => {
|
|
799
|
+
if (this.shouldSkipLayer(layer)) {
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
const freshTtl = this.resolveFreshTtl(key, layer.name, kind, options, layer.defaultTtl);
|
|
803
|
+
const staleWhileRevalidate = this.resolveLayerSeconds(
|
|
804
|
+
layer.name,
|
|
805
|
+
options?.staleWhileRevalidate,
|
|
806
|
+
this.options.staleWhileRevalidate
|
|
807
|
+
);
|
|
808
|
+
const staleIfError = this.resolveLayerSeconds(
|
|
809
|
+
layer.name,
|
|
810
|
+
options?.staleIfError,
|
|
811
|
+
this.options.staleIfError
|
|
812
|
+
);
|
|
813
|
+
const payload = createStoredValueEnvelope({
|
|
814
|
+
kind,
|
|
815
|
+
value,
|
|
816
|
+
freshTtlSeconds: freshTtl,
|
|
817
|
+
staleWhileRevalidateSeconds: staleWhileRevalidate,
|
|
818
|
+
staleIfErrorSeconds: staleIfError,
|
|
819
|
+
now
|
|
820
|
+
});
|
|
821
|
+
const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
|
|
822
|
+
try {
|
|
823
|
+
await layer.set(key, payload, ttl);
|
|
824
|
+
} catch (error) {
|
|
825
|
+
await this.handleLayerFailure(layer, "write", error);
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
|
|
829
|
+
}
|
|
830
|
+
async executeLayerOperations(operations, context) {
|
|
831
|
+
if (this.options.writePolicy !== "best-effort") {
|
|
832
|
+
await Promise.all(operations.map((operation) => operation()));
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
const results = await Promise.allSettled(operations.map((operation) => operation()));
|
|
836
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
837
|
+
if (failures.length === 0) {
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
this.metrics.writeFailures += failures.length;
|
|
841
|
+
this.logger.debug?.("write-failure", {
|
|
842
|
+
...context,
|
|
843
|
+
failures: failures.map((failure) => this.formatError(failure.reason))
|
|
844
|
+
});
|
|
845
|
+
if (failures.length === operations.length) {
|
|
846
|
+
throw new AggregateError(
|
|
847
|
+
failures.map((failure) => failure.reason),
|
|
848
|
+
`${context.action} failed for every cache layer`
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
resolveFreshTtl(key, layerName, kind, options, fallbackTtl) {
|
|
853
|
+
const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
|
|
854
|
+
layerName,
|
|
855
|
+
options?.negativeTtl,
|
|
856
|
+
this.options.negativeTtl,
|
|
857
|
+
this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
858
|
+
) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
|
|
859
|
+
const adaptiveTtl = this.applyAdaptiveTtl(
|
|
860
|
+
key,
|
|
861
|
+
layerName,
|
|
862
|
+
baseTtl,
|
|
863
|
+
options?.adaptiveTtl ?? this.options.adaptiveTtl
|
|
297
864
|
);
|
|
865
|
+
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
|
|
866
|
+
return this.applyJitter(adaptiveTtl, jitter);
|
|
298
867
|
}
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
return
|
|
868
|
+
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
869
|
+
if (override !== void 0) {
|
|
870
|
+
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
871
|
+
}
|
|
872
|
+
if (globalDefault !== void 0) {
|
|
873
|
+
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
302
874
|
}
|
|
303
|
-
|
|
304
|
-
|
|
875
|
+
return fallback;
|
|
876
|
+
}
|
|
877
|
+
readLayerNumber(layerName, value) {
|
|
878
|
+
if (typeof value === "number") {
|
|
879
|
+
return value;
|
|
880
|
+
}
|
|
881
|
+
return value[layerName];
|
|
882
|
+
}
|
|
883
|
+
applyJitter(ttl, jitter) {
|
|
884
|
+
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
885
|
+
return ttl;
|
|
305
886
|
}
|
|
306
|
-
|
|
887
|
+
const delta = (Math.random() * 2 - 1) * jitter;
|
|
888
|
+
return Math.max(1, Math.round(ttl + delta));
|
|
889
|
+
}
|
|
890
|
+
shouldNegativeCache(options) {
|
|
891
|
+
return options?.negativeCache ?? this.options.negativeCaching ?? false;
|
|
892
|
+
}
|
|
893
|
+
scheduleBackgroundRefresh(key, fetcher, options) {
|
|
894
|
+
if (this.isDisconnecting || this.backgroundRefreshes.has(key)) {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const refresh = (async () => {
|
|
898
|
+
this.metrics.refreshes += 1;
|
|
899
|
+
try {
|
|
900
|
+
await this.fetchWithGuards(key, fetcher, options);
|
|
901
|
+
} catch (error) {
|
|
902
|
+
this.metrics.refreshErrors += 1;
|
|
903
|
+
this.logger.debug?.("refresh-error", { key, error: this.formatError(error) });
|
|
904
|
+
} finally {
|
|
905
|
+
this.backgroundRefreshes.delete(key);
|
|
906
|
+
}
|
|
907
|
+
})();
|
|
908
|
+
this.backgroundRefreshes.set(key, refresh);
|
|
909
|
+
}
|
|
910
|
+
resolveSingleFlightOptions() {
|
|
911
|
+
return {
|
|
912
|
+
leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
|
|
913
|
+
waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
|
|
914
|
+
pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
|
|
915
|
+
};
|
|
307
916
|
}
|
|
308
917
|
async deleteKeys(keys) {
|
|
309
918
|
if (keys.length === 0) {
|
|
310
919
|
return;
|
|
311
920
|
}
|
|
312
|
-
await
|
|
313
|
-
this.layers.map(async (layer) => {
|
|
314
|
-
if (layer.deleteMany) {
|
|
315
|
-
await layer.deleteMany(keys);
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
await Promise.all(keys.map((key) => layer.delete(key)));
|
|
319
|
-
})
|
|
320
|
-
);
|
|
921
|
+
await this.deleteKeysFromLayers(this.layers, keys);
|
|
321
922
|
for (const key of keys) {
|
|
322
923
|
await this.tagIndex.remove(key);
|
|
924
|
+
this.accessProfiles.delete(key);
|
|
323
925
|
}
|
|
324
926
|
this.metrics.deletes += keys.length;
|
|
325
927
|
this.metrics.invalidations += 1;
|
|
326
|
-
this.logger.debug("delete", { keys });
|
|
928
|
+
this.logger.debug?.("delete", { keys });
|
|
929
|
+
this.emit("delete", { keys });
|
|
327
930
|
}
|
|
328
931
|
async publishInvalidation(message) {
|
|
329
932
|
if (!this.options.invalidationBus) {
|
|
@@ -342,23 +945,277 @@ var CacheStack = class {
|
|
|
342
945
|
if (message.scope === "clear") {
|
|
343
946
|
await Promise.all(localLayers.map((layer) => layer.clear()));
|
|
344
947
|
await this.tagIndex.clear();
|
|
948
|
+
this.accessProfiles.clear();
|
|
345
949
|
return;
|
|
346
950
|
}
|
|
347
951
|
const keys = message.keys ?? [];
|
|
952
|
+
await this.deleteKeysFromLayers(localLayers, keys);
|
|
953
|
+
if (message.operation !== "write") {
|
|
954
|
+
for (const key of keys) {
|
|
955
|
+
await this.tagIndex.remove(key);
|
|
956
|
+
this.accessProfiles.delete(key);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
formatError(error) {
|
|
961
|
+
if (error instanceof Error) {
|
|
962
|
+
return error.message;
|
|
963
|
+
}
|
|
964
|
+
return String(error);
|
|
965
|
+
}
|
|
966
|
+
sleep(ms) {
|
|
967
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
968
|
+
}
|
|
969
|
+
shouldBroadcastL1Invalidation() {
|
|
970
|
+
return this.options.broadcastL1Invalidation ?? this.options.publishSetInvalidation ?? true;
|
|
971
|
+
}
|
|
972
|
+
async deleteKeysFromLayers(layers, keys) {
|
|
348
973
|
await Promise.all(
|
|
349
|
-
|
|
974
|
+
layers.map(async (layer) => {
|
|
975
|
+
if (this.shouldSkipLayer(layer)) {
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
350
978
|
if (layer.deleteMany) {
|
|
351
|
-
|
|
979
|
+
try {
|
|
980
|
+
await layer.deleteMany(keys);
|
|
981
|
+
} catch (error) {
|
|
982
|
+
await this.handleLayerFailure(layer, "delete", error);
|
|
983
|
+
}
|
|
352
984
|
return;
|
|
353
985
|
}
|
|
354
|
-
await Promise.all(keys.map((key) =>
|
|
986
|
+
await Promise.all(keys.map(async (key) => {
|
|
987
|
+
try {
|
|
988
|
+
await layer.delete(key);
|
|
989
|
+
} catch (error) {
|
|
990
|
+
await this.handleLayerFailure(layer, "delete", error);
|
|
991
|
+
}
|
|
992
|
+
}));
|
|
355
993
|
})
|
|
356
994
|
);
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
995
|
+
}
|
|
996
|
+
validateConfiguration() {
|
|
997
|
+
if (this.options.broadcastL1Invalidation !== void 0 && this.options.publishSetInvalidation !== void 0 && this.options.broadcastL1Invalidation !== this.options.publishSetInvalidation) {
|
|
998
|
+
throw new Error("broadcastL1Invalidation and publishSetInvalidation cannot conflict.");
|
|
999
|
+
}
|
|
1000
|
+
if (this.options.stampedePrevention === false && this.options.singleFlightCoordinator) {
|
|
1001
|
+
throw new Error("singleFlightCoordinator requires stampedePrevention to remain enabled.");
|
|
1002
|
+
}
|
|
1003
|
+
this.validateLayerNumberOption("negativeTtl", this.options.negativeTtl);
|
|
1004
|
+
this.validateLayerNumberOption("staleWhileRevalidate", this.options.staleWhileRevalidate);
|
|
1005
|
+
this.validateLayerNumberOption("staleIfError", this.options.staleIfError);
|
|
1006
|
+
this.validateLayerNumberOption("ttlJitter", this.options.ttlJitter);
|
|
1007
|
+
this.validateLayerNumberOption("refreshAhead", this.options.refreshAhead);
|
|
1008
|
+
this.validatePositiveNumber("singleFlightLeaseMs", this.options.singleFlightLeaseMs);
|
|
1009
|
+
this.validatePositiveNumber("singleFlightTimeoutMs", this.options.singleFlightTimeoutMs);
|
|
1010
|
+
this.validatePositiveNumber("singleFlightPollMs", this.options.singleFlightPollMs);
|
|
1011
|
+
this.validateAdaptiveTtlOptions(this.options.adaptiveTtl);
|
|
1012
|
+
this.validateCircuitBreakerOptions(this.options.circuitBreaker);
|
|
1013
|
+
}
|
|
1014
|
+
validateWriteOptions(options) {
|
|
1015
|
+
if (!options) {
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
this.validateLayerNumberOption("options.ttl", options.ttl);
|
|
1019
|
+
this.validateLayerNumberOption("options.negativeTtl", options.negativeTtl);
|
|
1020
|
+
this.validateLayerNumberOption("options.staleWhileRevalidate", options.staleWhileRevalidate);
|
|
1021
|
+
this.validateLayerNumberOption("options.staleIfError", options.staleIfError);
|
|
1022
|
+
this.validateLayerNumberOption("options.ttlJitter", options.ttlJitter);
|
|
1023
|
+
this.validateLayerNumberOption("options.refreshAhead", options.refreshAhead);
|
|
1024
|
+
this.validateAdaptiveTtlOptions(options.adaptiveTtl);
|
|
1025
|
+
this.validateCircuitBreakerOptions(options.circuitBreaker);
|
|
1026
|
+
}
|
|
1027
|
+
validateLayerNumberOption(name, value) {
|
|
1028
|
+
if (value === void 0) {
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
if (typeof value === "number") {
|
|
1032
|
+
this.validateNonNegativeNumber(name, value);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
for (const [layerName, layerValue] of Object.entries(value)) {
|
|
1036
|
+
if (layerValue === void 0) {
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
this.validateNonNegativeNumber(`${name}.${layerName}`, layerValue);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
validatePositiveNumber(name, value) {
|
|
1043
|
+
if (value === void 0) {
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1047
|
+
throw new Error(`${name} must be a positive finite number.`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
validateNonNegativeNumber(name, value) {
|
|
1051
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1052
|
+
throw new Error(`${name} must be a non-negative finite number.`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
validateCacheKey(key) {
|
|
1056
|
+
if (key.length === 0) {
|
|
1057
|
+
throw new Error("Cache key must not be empty.");
|
|
1058
|
+
}
|
|
1059
|
+
if (key.length > MAX_CACHE_KEY_LENGTH) {
|
|
1060
|
+
throw new Error(`Cache key length must be at most ${MAX_CACHE_KEY_LENGTH} characters.`);
|
|
1061
|
+
}
|
|
1062
|
+
if (/[\u0000-\u001F\u007F]/.test(key)) {
|
|
1063
|
+
throw new Error("Cache key contains unsupported control characters.");
|
|
1064
|
+
}
|
|
1065
|
+
return key;
|
|
1066
|
+
}
|
|
1067
|
+
serializeOptions(options) {
|
|
1068
|
+
return JSON.stringify(this.normalizeForSerialization(options) ?? null);
|
|
1069
|
+
}
|
|
1070
|
+
validateAdaptiveTtlOptions(options) {
|
|
1071
|
+
if (!options || options === true) {
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
this.validatePositiveNumber("adaptiveTtl.hotAfter", options.hotAfter);
|
|
1075
|
+
this.validateLayerNumberOption("adaptiveTtl.step", options.step);
|
|
1076
|
+
this.validateLayerNumberOption("adaptiveTtl.maxTtl", options.maxTtl);
|
|
1077
|
+
}
|
|
1078
|
+
validateCircuitBreakerOptions(options) {
|
|
1079
|
+
if (!options) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
this.validatePositiveNumber("circuitBreaker.failureThreshold", options.failureThreshold);
|
|
1083
|
+
this.validatePositiveNumber("circuitBreaker.cooldownMs", options.cooldownMs);
|
|
1084
|
+
}
|
|
1085
|
+
async applyFreshReadPolicies(key, hit, options, fetcher) {
|
|
1086
|
+
const refreshAhead = this.resolveLayerSeconds(hit.layerName, options?.refreshAhead, this.options.refreshAhead, 0) ?? 0;
|
|
1087
|
+
const remainingFreshTtl = remainingFreshTtlSeconds(hit.stored) ?? 0;
|
|
1088
|
+
if ((options?.slidingTtl ?? false) && isStoredValueEnvelope(hit.stored)) {
|
|
1089
|
+
const refreshed = refreshStoredEnvelope(hit.stored);
|
|
1090
|
+
const ttl = remainingStoredTtlSeconds(refreshed);
|
|
1091
|
+
for (let index = 0; index <= hit.layerIndex; index += 1) {
|
|
1092
|
+
const layer = this.layers[index];
|
|
1093
|
+
if (this.shouldSkipLayer(layer)) {
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
try {
|
|
1097
|
+
await layer.set(key, refreshed, ttl);
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
await this.handleLayerFailure(layer, "sliding-ttl", error);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
if (fetcher && refreshAhead > 0 && remainingFreshTtl > 0 && remainingFreshTtl <= refreshAhead) {
|
|
1104
|
+
this.scheduleBackgroundRefresh(key, fetcher, options);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
applyAdaptiveTtl(key, layerName, ttl, adaptiveTtl) {
|
|
1108
|
+
if (!ttl || !adaptiveTtl) {
|
|
1109
|
+
return ttl;
|
|
1110
|
+
}
|
|
1111
|
+
const profile = this.accessProfiles.get(key);
|
|
1112
|
+
if (!profile) {
|
|
1113
|
+
return ttl;
|
|
1114
|
+
}
|
|
1115
|
+
const config = adaptiveTtl === true ? {} : adaptiveTtl;
|
|
1116
|
+
const hotAfter = config.hotAfter ?? 3;
|
|
1117
|
+
if (profile.hits < hotAfter) {
|
|
1118
|
+
return ttl;
|
|
1119
|
+
}
|
|
1120
|
+
const step = this.resolveLayerSeconds(layerName, config.step, void 0, Math.max(1, Math.round(ttl / 2))) ?? 0;
|
|
1121
|
+
const maxTtl = this.resolveLayerSeconds(layerName, config.maxTtl, void 0, ttl + step * 4) ?? ttl;
|
|
1122
|
+
const multiplier = Math.floor(profile.hits / hotAfter);
|
|
1123
|
+
return Math.min(maxTtl, ttl + step * multiplier);
|
|
1124
|
+
}
|
|
1125
|
+
recordAccess(key) {
|
|
1126
|
+
const profile = this.accessProfiles.get(key) ?? { hits: 0, lastAccessAt: Date.now() };
|
|
1127
|
+
profile.hits += 1;
|
|
1128
|
+
profile.lastAccessAt = Date.now();
|
|
1129
|
+
this.accessProfiles.set(key, profile);
|
|
1130
|
+
}
|
|
1131
|
+
incrementMetricMap(target, key) {
|
|
1132
|
+
target[key] = (target[key] ?? 0) + 1;
|
|
1133
|
+
}
|
|
1134
|
+
shouldSkipLayer(layer) {
|
|
1135
|
+
const degradedUntil = this.layerDegradedUntil.get(layer.name);
|
|
1136
|
+
return degradedUntil !== void 0 && degradedUntil > Date.now();
|
|
1137
|
+
}
|
|
1138
|
+
async handleLayerFailure(layer, operation, error) {
|
|
1139
|
+
if (!this.isGracefulDegradationEnabled()) {
|
|
1140
|
+
throw error;
|
|
1141
|
+
}
|
|
1142
|
+
const retryAfterMs = typeof this.options.gracefulDegradation === "object" ? this.options.gracefulDegradation.retryAfterMs ?? 1e4 : 1e4;
|
|
1143
|
+
this.layerDegradedUntil.set(layer.name, Date.now() + retryAfterMs);
|
|
1144
|
+
this.metrics.degradedOperations += 1;
|
|
1145
|
+
this.logger.warn?.("layer-degraded", { layer: layer.name, operation, error: this.formatError(error) });
|
|
1146
|
+
this.emitError(operation, { layer: layer.name, degraded: true, error: this.formatError(error) });
|
|
1147
|
+
return null;
|
|
1148
|
+
}
|
|
1149
|
+
isGracefulDegradationEnabled() {
|
|
1150
|
+
return Boolean(this.options.gracefulDegradation);
|
|
1151
|
+
}
|
|
1152
|
+
assertCircuitClosed(key, options) {
|
|
1153
|
+
const state = this.circuitBreakers.get(key);
|
|
1154
|
+
if (!state?.openUntil) {
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
if (state.openUntil <= Date.now()) {
|
|
1158
|
+
state.openUntil = null;
|
|
1159
|
+
state.failures = 0;
|
|
1160
|
+
this.circuitBreakers.set(key, state);
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
this.emitError("circuit-breaker-open", { key, openUntil: state.openUntil });
|
|
1164
|
+
throw new Error(`Circuit breaker is open for key "${key}".`);
|
|
1165
|
+
}
|
|
1166
|
+
recordCircuitFailure(key, options, error) {
|
|
1167
|
+
if (!options) {
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
const failureThreshold = options.failureThreshold ?? 3;
|
|
1171
|
+
const cooldownMs = options.cooldownMs ?? 3e4;
|
|
1172
|
+
const state = this.circuitBreakers.get(key) ?? { failures: 0, openUntil: null };
|
|
1173
|
+
state.failures += 1;
|
|
1174
|
+
if (state.failures >= failureThreshold) {
|
|
1175
|
+
state.openUntil = Date.now() + cooldownMs;
|
|
1176
|
+
this.metrics.circuitBreakerTrips += 1;
|
|
1177
|
+
}
|
|
1178
|
+
this.circuitBreakers.set(key, state);
|
|
1179
|
+
this.emitError("fetch", { key, error: this.formatError(error), failures: state.failures });
|
|
1180
|
+
}
|
|
1181
|
+
resetCircuitBreaker(key) {
|
|
1182
|
+
this.circuitBreakers.delete(key);
|
|
1183
|
+
}
|
|
1184
|
+
isNegativeStoredValue(stored) {
|
|
1185
|
+
return isStoredValueEnvelope(stored) && stored.kind === "empty";
|
|
1186
|
+
}
|
|
1187
|
+
emitError(operation, context) {
|
|
1188
|
+
this.logger.error?.(operation, context);
|
|
1189
|
+
if (this.listenerCount("error") > 0) {
|
|
1190
|
+
this.emit("error", { operation, ...context });
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
serializeKeyPart(value) {
|
|
1194
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
1195
|
+
return String(value);
|
|
1196
|
+
}
|
|
1197
|
+
return JSON.stringify(this.normalizeForSerialization(value));
|
|
1198
|
+
}
|
|
1199
|
+
isCacheSnapshotEntries(value) {
|
|
1200
|
+
return Array.isArray(value) && value.every((entry) => {
|
|
1201
|
+
if (!entry || typeof entry !== "object") {
|
|
1202
|
+
return false;
|
|
360
1203
|
}
|
|
1204
|
+
const candidate = entry;
|
|
1205
|
+
return typeof candidate.key === "string";
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
normalizeForSerialization(value) {
|
|
1209
|
+
if (Array.isArray(value)) {
|
|
1210
|
+
return value.map((entry) => this.normalizeForSerialization(entry));
|
|
361
1211
|
}
|
|
1212
|
+
if (value && typeof value === "object") {
|
|
1213
|
+
return Object.keys(value).sort().reduce((normalized, key) => {
|
|
1214
|
+
normalized[key] = this.normalizeForSerialization(value[key]);
|
|
1215
|
+
return normalized;
|
|
1216
|
+
}, {});
|
|
1217
|
+
}
|
|
1218
|
+
return value;
|
|
362
1219
|
}
|
|
363
1220
|
};
|
|
364
1221
|
|
|
@@ -367,19 +1224,27 @@ var RedisInvalidationBus = class {
|
|
|
367
1224
|
channel;
|
|
368
1225
|
publisher;
|
|
369
1226
|
subscriber;
|
|
1227
|
+
activeListener;
|
|
370
1228
|
constructor(options) {
|
|
371
1229
|
this.publisher = options.publisher;
|
|
372
1230
|
this.subscriber = options.subscriber ?? options.publisher.duplicate();
|
|
373
|
-
this.channel = options.channel ?? "
|
|
1231
|
+
this.channel = options.channel ?? "layercache:invalidation";
|
|
374
1232
|
}
|
|
375
1233
|
async subscribe(handler) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
1234
|
+
if (this.activeListener) {
|
|
1235
|
+
throw new Error("RedisInvalidationBus already has an active subscription.");
|
|
1236
|
+
}
|
|
1237
|
+
const listener = (_channel, payload) => {
|
|
1238
|
+
void this.handleMessage(payload, handler);
|
|
379
1239
|
};
|
|
1240
|
+
this.activeListener = listener;
|
|
380
1241
|
this.subscriber.on("message", listener);
|
|
381
1242
|
await this.subscriber.subscribe(this.channel);
|
|
382
1243
|
return async () => {
|
|
1244
|
+
if (this.activeListener !== listener) {
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
this.activeListener = void 0;
|
|
383
1248
|
this.subscriber.off("message", listener);
|
|
384
1249
|
await this.subscriber.unsubscribe(this.channel);
|
|
385
1250
|
};
|
|
@@ -387,6 +1252,37 @@ var RedisInvalidationBus = class {
|
|
|
387
1252
|
async publish(message) {
|
|
388
1253
|
await this.publisher.publish(this.channel, JSON.stringify(message));
|
|
389
1254
|
}
|
|
1255
|
+
async handleMessage(payload, handler) {
|
|
1256
|
+
let message;
|
|
1257
|
+
try {
|
|
1258
|
+
const parsed = JSON.parse(payload);
|
|
1259
|
+
if (!this.isInvalidationMessage(parsed)) {
|
|
1260
|
+
throw new Error("Invalid invalidation payload shape.");
|
|
1261
|
+
}
|
|
1262
|
+
message = parsed;
|
|
1263
|
+
} catch (error) {
|
|
1264
|
+
this.reportError("invalid invalidation payload", error);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
try {
|
|
1268
|
+
await handler(message);
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
this.reportError("invalidation handler failed", error);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
isInvalidationMessage(value) {
|
|
1274
|
+
if (!value || typeof value !== "object") {
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
const candidate = value;
|
|
1278
|
+
const validScope = candidate.scope === "key" || candidate.scope === "keys" || candidate.scope === "clear";
|
|
1279
|
+
const validOperation = candidate.operation === void 0 || candidate.operation === "write" || candidate.operation === "delete" || candidate.operation === "invalidate" || candidate.operation === "clear";
|
|
1280
|
+
const validKeys = candidate.keys === void 0 || Array.isArray(candidate.keys) && candidate.keys.every((key) => typeof key === "string");
|
|
1281
|
+
return validScope && typeof candidate.sourceId === "string" && candidate.sourceId.length > 0 && validOperation && validKeys;
|
|
1282
|
+
}
|
|
1283
|
+
reportError(message, error) {
|
|
1284
|
+
console.error(`[layercache] ${message}`, error);
|
|
1285
|
+
}
|
|
390
1286
|
};
|
|
391
1287
|
|
|
392
1288
|
// src/invalidation/RedisTagIndex.ts
|
|
@@ -396,7 +1292,7 @@ var RedisTagIndex = class {
|
|
|
396
1292
|
scanCount;
|
|
397
1293
|
constructor(options) {
|
|
398
1294
|
this.client = options.client;
|
|
399
|
-
this.prefix = options.prefix ?? "
|
|
1295
|
+
this.prefix = options.prefix ?? "layercache:tag-index";
|
|
400
1296
|
this.scanCount = options.scanCount ?? 100;
|
|
401
1297
|
}
|
|
402
1298
|
async touch(key) {
|
|
@@ -479,6 +1375,84 @@ var RedisTagIndex = class {
|
|
|
479
1375
|
}
|
|
480
1376
|
};
|
|
481
1377
|
|
|
1378
|
+
// src/http/createCacheStatsHandler.ts
|
|
1379
|
+
function createCacheStatsHandler(cache) {
|
|
1380
|
+
return async (_request, response) => {
|
|
1381
|
+
response.statusCode = 200;
|
|
1382
|
+
response.setHeader?.("content-type", "application/json; charset=utf-8");
|
|
1383
|
+
response.end(JSON.stringify(cache.getStats(), null, 2));
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// src/decorators/createCachedMethodDecorator.ts
|
|
1388
|
+
function createCachedMethodDecorator(options) {
|
|
1389
|
+
const wrappedByInstance = /* @__PURE__ */ new WeakMap();
|
|
1390
|
+
return ((_, propertyKey, descriptor) => {
|
|
1391
|
+
const original = descriptor.value;
|
|
1392
|
+
if (typeof original !== "function") {
|
|
1393
|
+
throw new Error("createCachedMethodDecorator can only be applied to methods.");
|
|
1394
|
+
}
|
|
1395
|
+
descriptor.value = async function(...args) {
|
|
1396
|
+
const instance = this;
|
|
1397
|
+
let wrapped = wrappedByInstance.get(instance);
|
|
1398
|
+
if (!wrapped) {
|
|
1399
|
+
const cache = options.cache(instance);
|
|
1400
|
+
wrapped = cache.wrap(
|
|
1401
|
+
options.prefix ?? String(propertyKey),
|
|
1402
|
+
(...methodArgs) => Promise.resolve(original.apply(instance, methodArgs)),
|
|
1403
|
+
options
|
|
1404
|
+
);
|
|
1405
|
+
wrappedByInstance.set(instance, wrapped);
|
|
1406
|
+
}
|
|
1407
|
+
return wrapped(...args);
|
|
1408
|
+
};
|
|
1409
|
+
});
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// src/integrations/fastify.ts
|
|
1413
|
+
function createFastifyLayercachePlugin(cache, options = {}) {
|
|
1414
|
+
return async (fastify) => {
|
|
1415
|
+
fastify.decorate("cache", cache);
|
|
1416
|
+
if (options.exposeStatsRoute !== false && fastify.get) {
|
|
1417
|
+
fastify.get(options.statsPath ?? "/cache/stats", async () => cache.getStats());
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// src/integrations/graphql.ts
|
|
1423
|
+
function cacheGraphqlResolver(cache, prefix, resolver, options = {}) {
|
|
1424
|
+
const wrapped = cache.wrap(prefix, resolver, {
|
|
1425
|
+
...options,
|
|
1426
|
+
keyResolver: options.keyResolver
|
|
1427
|
+
});
|
|
1428
|
+
return (...args) => wrapped(...args);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// src/integrations/trpc.ts
|
|
1432
|
+
function createTrpcCacheMiddleware(cache, prefix, options = {}) {
|
|
1433
|
+
return async (context) => {
|
|
1434
|
+
const key = options.keyResolver ? `${prefix}:${options.keyResolver(context.rawInput, context.path, context.type)}` : `${prefix}:${context.path ?? "procedure"}:${JSON.stringify(context.rawInput ?? null)}`;
|
|
1435
|
+
let didFetch = false;
|
|
1436
|
+
let fetchedResult = null;
|
|
1437
|
+
const cached = await cache.get(
|
|
1438
|
+
key,
|
|
1439
|
+
async () => {
|
|
1440
|
+
didFetch = true;
|
|
1441
|
+
fetchedResult = await context.next();
|
|
1442
|
+
return fetchedResult;
|
|
1443
|
+
},
|
|
1444
|
+
options
|
|
1445
|
+
);
|
|
1446
|
+
if (cached !== null) {
|
|
1447
|
+
return cached;
|
|
1448
|
+
}
|
|
1449
|
+
if (didFetch) {
|
|
1450
|
+
return fetchedResult;
|
|
1451
|
+
}
|
|
1452
|
+
return context.next();
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
|
|
482
1456
|
// src/layers/MemoryLayer.ts
|
|
483
1457
|
var MemoryLayer = class {
|
|
484
1458
|
name;
|
|
@@ -492,6 +1466,10 @@ var MemoryLayer = class {
|
|
|
492
1466
|
this.maxSize = options.maxSize ?? 1e3;
|
|
493
1467
|
}
|
|
494
1468
|
async get(key) {
|
|
1469
|
+
const value = await this.getEntry(key);
|
|
1470
|
+
return unwrapStoredValue(value);
|
|
1471
|
+
}
|
|
1472
|
+
async getEntry(key) {
|
|
495
1473
|
const entry = this.entries.get(key);
|
|
496
1474
|
if (!entry) {
|
|
497
1475
|
return null;
|
|
@@ -504,6 +1482,13 @@ var MemoryLayer = class {
|
|
|
504
1482
|
this.entries.set(key, entry);
|
|
505
1483
|
return entry.value;
|
|
506
1484
|
}
|
|
1485
|
+
async getMany(keys) {
|
|
1486
|
+
const values = [];
|
|
1487
|
+
for (const key of keys) {
|
|
1488
|
+
values.push(await this.getEntry(key));
|
|
1489
|
+
}
|
|
1490
|
+
return values;
|
|
1491
|
+
}
|
|
507
1492
|
async set(key, value, ttl = this.defaultTtl) {
|
|
508
1493
|
this.entries.delete(key);
|
|
509
1494
|
this.entries.set(key, {
|
|
@@ -533,6 +1518,32 @@ var MemoryLayer = class {
|
|
|
533
1518
|
this.pruneExpired();
|
|
534
1519
|
return [...this.entries.keys()];
|
|
535
1520
|
}
|
|
1521
|
+
exportState() {
|
|
1522
|
+
this.pruneExpired();
|
|
1523
|
+
return [...this.entries.entries()].map(([key, entry]) => ({
|
|
1524
|
+
key,
|
|
1525
|
+
value: entry.value,
|
|
1526
|
+
expiresAt: entry.expiresAt
|
|
1527
|
+
}));
|
|
1528
|
+
}
|
|
1529
|
+
importState(entries) {
|
|
1530
|
+
for (const entry of entries) {
|
|
1531
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1534
|
+
this.entries.set(entry.key, {
|
|
1535
|
+
value: entry.value,
|
|
1536
|
+
expiresAt: entry.expiresAt
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
while (this.entries.size > this.maxSize) {
|
|
1540
|
+
const oldestKey = this.entries.keys().next().value;
|
|
1541
|
+
if (!oldestKey) {
|
|
1542
|
+
break;
|
|
1543
|
+
}
|
|
1544
|
+
this.entries.delete(oldestKey);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
536
1547
|
pruneExpired() {
|
|
537
1548
|
for (const [key, entry] of this.entries.entries()) {
|
|
538
1549
|
if (this.isExpired(entry)) {
|
|
@@ -545,6 +1556,9 @@ var MemoryLayer = class {
|
|
|
545
1556
|
}
|
|
546
1557
|
};
|
|
547
1558
|
|
|
1559
|
+
// src/layers/RedisLayer.ts
|
|
1560
|
+
var import_node_zlib = require("zlib");
|
|
1561
|
+
|
|
548
1562
|
// src/serialization/JsonSerializer.ts
|
|
549
1563
|
var JsonSerializer = class {
|
|
550
1564
|
serialize(value) {
|
|
@@ -566,6 +1580,8 @@ var RedisLayer = class {
|
|
|
566
1580
|
prefix;
|
|
567
1581
|
allowUnprefixedClear;
|
|
568
1582
|
scanCount;
|
|
1583
|
+
compression;
|
|
1584
|
+
compressionThreshold;
|
|
569
1585
|
constructor(options) {
|
|
570
1586
|
this.client = options.client;
|
|
571
1587
|
this.defaultTtl = options.ttl;
|
|
@@ -574,16 +1590,44 @@ var RedisLayer = class {
|
|
|
574
1590
|
this.prefix = options.prefix ?? "";
|
|
575
1591
|
this.allowUnprefixedClear = options.allowUnprefixedClear ?? false;
|
|
576
1592
|
this.scanCount = options.scanCount ?? 100;
|
|
1593
|
+
this.compression = options.compression;
|
|
1594
|
+
this.compressionThreshold = options.compressionThreshold ?? 1024;
|
|
577
1595
|
}
|
|
578
1596
|
async get(key) {
|
|
1597
|
+
const payload = await this.getEntry(key);
|
|
1598
|
+
return unwrapStoredValue(payload);
|
|
1599
|
+
}
|
|
1600
|
+
async getEntry(key) {
|
|
579
1601
|
const payload = await this.client.getBuffer(this.withPrefix(key));
|
|
580
1602
|
if (payload === null) {
|
|
581
1603
|
return null;
|
|
582
1604
|
}
|
|
583
|
-
return this.
|
|
1605
|
+
return this.deserializeOrDelete(key, payload);
|
|
1606
|
+
}
|
|
1607
|
+
async getMany(keys) {
|
|
1608
|
+
if (keys.length === 0) {
|
|
1609
|
+
return [];
|
|
1610
|
+
}
|
|
1611
|
+
const pipeline = this.client.pipeline();
|
|
1612
|
+
for (const key of keys) {
|
|
1613
|
+
pipeline.getBuffer(this.withPrefix(key));
|
|
1614
|
+
}
|
|
1615
|
+
const results = await pipeline.exec();
|
|
1616
|
+
if (results === null) {
|
|
1617
|
+
return keys.map(() => null);
|
|
1618
|
+
}
|
|
1619
|
+
return Promise.all(
|
|
1620
|
+
results.map(async (result, index) => {
|
|
1621
|
+
const [error, payload] = result;
|
|
1622
|
+
if (error || payload === null || !this.isSerializablePayload(payload)) {
|
|
1623
|
+
return null;
|
|
1624
|
+
}
|
|
1625
|
+
return this.deserializeOrDelete(keys[index], payload);
|
|
1626
|
+
})
|
|
1627
|
+
);
|
|
584
1628
|
}
|
|
585
1629
|
async set(key, value, ttl = this.defaultTtl) {
|
|
586
|
-
const payload = this.serializer.serialize(value);
|
|
1630
|
+
const payload = this.encodePayload(this.serializer.serialize(value));
|
|
587
1631
|
const normalizedKey = this.withPrefix(key);
|
|
588
1632
|
if (ttl && ttl > 0) {
|
|
589
1633
|
await this.client.set(normalizedKey, payload, "EX", ttl);
|
|
@@ -630,6 +1674,41 @@ var RedisLayer = class {
|
|
|
630
1674
|
withPrefix(key) {
|
|
631
1675
|
return `${this.prefix}${key}`;
|
|
632
1676
|
}
|
|
1677
|
+
async deserializeOrDelete(key, payload) {
|
|
1678
|
+
try {
|
|
1679
|
+
return this.serializer.deserialize(this.decodePayload(payload));
|
|
1680
|
+
} catch {
|
|
1681
|
+
await this.client.del(this.withPrefix(key)).catch(() => void 0);
|
|
1682
|
+
return null;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
isSerializablePayload(payload) {
|
|
1686
|
+
return typeof payload === "string" || Buffer.isBuffer(payload);
|
|
1687
|
+
}
|
|
1688
|
+
encodePayload(payload) {
|
|
1689
|
+
if (!this.compression) {
|
|
1690
|
+
return payload;
|
|
1691
|
+
}
|
|
1692
|
+
const source = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
1693
|
+
if (source.byteLength < this.compressionThreshold) {
|
|
1694
|
+
return payload;
|
|
1695
|
+
}
|
|
1696
|
+
const header = Buffer.from(`LCZ1:${this.compression}:`);
|
|
1697
|
+
const compressed = this.compression === "gzip" ? (0, import_node_zlib.gzipSync)(source) : (0, import_node_zlib.brotliCompressSync)(source);
|
|
1698
|
+
return Buffer.concat([header, compressed]);
|
|
1699
|
+
}
|
|
1700
|
+
decodePayload(payload) {
|
|
1701
|
+
if (!Buffer.isBuffer(payload)) {
|
|
1702
|
+
return payload;
|
|
1703
|
+
}
|
|
1704
|
+
if (payload.subarray(0, 10).toString() === "LCZ1:gzip:") {
|
|
1705
|
+
return (0, import_node_zlib.gunzipSync)(payload.subarray(10));
|
|
1706
|
+
}
|
|
1707
|
+
if (payload.subarray(0, 12).toString() === "LCZ1:brotli:") {
|
|
1708
|
+
return (0, import_node_zlib.brotliDecompressSync)(payload.subarray(12));
|
|
1709
|
+
}
|
|
1710
|
+
return payload;
|
|
1711
|
+
}
|
|
633
1712
|
};
|
|
634
1713
|
|
|
635
1714
|
// src/serialization/MsgpackSerializer.ts
|
|
@@ -643,8 +1722,39 @@ var MsgpackSerializer = class {
|
|
|
643
1722
|
return (0, import_msgpack.decode)(normalized);
|
|
644
1723
|
}
|
|
645
1724
|
};
|
|
1725
|
+
|
|
1726
|
+
// src/singleflight/RedisSingleFlightCoordinator.ts
|
|
1727
|
+
var import_node_crypto2 = require("crypto");
|
|
1728
|
+
var RELEASE_SCRIPT = `
|
|
1729
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
1730
|
+
return redis.call("del", KEYS[1])
|
|
1731
|
+
end
|
|
1732
|
+
return 0
|
|
1733
|
+
`;
|
|
1734
|
+
var RedisSingleFlightCoordinator = class {
|
|
1735
|
+
client;
|
|
1736
|
+
prefix;
|
|
1737
|
+
constructor(options) {
|
|
1738
|
+
this.client = options.client;
|
|
1739
|
+
this.prefix = options.prefix ?? "layercache:singleflight";
|
|
1740
|
+
}
|
|
1741
|
+
async execute(key, options, worker, waiter) {
|
|
1742
|
+
const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
|
|
1743
|
+
const token = (0, import_node_crypto2.randomUUID)();
|
|
1744
|
+
const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
|
|
1745
|
+
if (acquired === "OK") {
|
|
1746
|
+
try {
|
|
1747
|
+
return await worker();
|
|
1748
|
+
} finally {
|
|
1749
|
+
await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return waiter();
|
|
1753
|
+
}
|
|
1754
|
+
};
|
|
646
1755
|
// Annotate the CommonJS export names for ESM import in node:
|
|
647
1756
|
0 && (module.exports = {
|
|
1757
|
+
CacheNamespace,
|
|
648
1758
|
CacheStack,
|
|
649
1759
|
JsonSerializer,
|
|
650
1760
|
MemoryLayer,
|
|
@@ -652,7 +1762,13 @@ var MsgpackSerializer = class {
|
|
|
652
1762
|
PatternMatcher,
|
|
653
1763
|
RedisInvalidationBus,
|
|
654
1764
|
RedisLayer,
|
|
1765
|
+
RedisSingleFlightCoordinator,
|
|
655
1766
|
RedisTagIndex,
|
|
656
1767
|
StampedeGuard,
|
|
657
|
-
TagIndex
|
|
1768
|
+
TagIndex,
|
|
1769
|
+
cacheGraphqlResolver,
|
|
1770
|
+
createCacheStatsHandler,
|
|
1771
|
+
createCachedMethodDecorator,
|
|
1772
|
+
createFastifyLayercachePlugin,
|
|
1773
|
+
createTrpcCacheMiddleware
|
|
658
1774
|
});
|