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