layercache 1.0.0 → 1.0.1
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 +85 -6
- package/dist/index.cjs +437 -59
- package/dist/index.d.cts +64 -4
- package/dist/index.d.ts +64 -4
- package/dist/index.js +436 -59
- package/package.json +5 -1
- package/packages/nestjs/dist/index.cjs +368 -57
- package/packages/nestjs/dist/index.d.cts +23 -0
- package/packages/nestjs/dist/index.d.ts +23 -0
- package/packages/nestjs/dist/index.js +368 -57
package/dist/index.cjs
CHANGED
|
@@ -27,6 +27,7 @@ __export(index_exports, {
|
|
|
27
27
|
PatternMatcher: () => PatternMatcher,
|
|
28
28
|
RedisInvalidationBus: () => RedisInvalidationBus,
|
|
29
29
|
RedisLayer: () => RedisLayer,
|
|
30
|
+
RedisSingleFlightCoordinator: () => RedisSingleFlightCoordinator,
|
|
30
31
|
RedisTagIndex: () => RedisTagIndex,
|
|
31
32
|
StampedeGuard: () => StampedeGuard,
|
|
32
33
|
TagIndex: () => TagIndex
|
|
@@ -36,6 +37,81 @@ module.exports = __toCommonJS(index_exports);
|
|
|
36
37
|
// src/CacheStack.ts
|
|
37
38
|
var import_node_crypto = require("crypto");
|
|
38
39
|
|
|
40
|
+
// src/internal/StoredValue.ts
|
|
41
|
+
function isStoredValueEnvelope(value) {
|
|
42
|
+
return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
|
|
43
|
+
}
|
|
44
|
+
function createStoredValueEnvelope(options) {
|
|
45
|
+
const now = options.now ?? Date.now();
|
|
46
|
+
const freshTtlSeconds = normalizePositiveSeconds(options.freshTtlSeconds);
|
|
47
|
+
const staleWhileRevalidateSeconds = normalizePositiveSeconds(options.staleWhileRevalidateSeconds);
|
|
48
|
+
const staleIfErrorSeconds = normalizePositiveSeconds(options.staleIfErrorSeconds);
|
|
49
|
+
const freshUntil = freshTtlSeconds ? now + freshTtlSeconds * 1e3 : null;
|
|
50
|
+
const staleUntil = freshUntil && staleWhileRevalidateSeconds ? freshUntil + staleWhileRevalidateSeconds * 1e3 : null;
|
|
51
|
+
const errorUntil = freshUntil && staleIfErrorSeconds ? freshUntil + staleIfErrorSeconds * 1e3 : null;
|
|
52
|
+
return {
|
|
53
|
+
__layercache: 1,
|
|
54
|
+
kind: options.kind,
|
|
55
|
+
value: options.value,
|
|
56
|
+
freshUntil,
|
|
57
|
+
staleUntil,
|
|
58
|
+
errorUntil
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function resolveStoredValue(stored, now = Date.now()) {
|
|
62
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
63
|
+
return { state: "fresh", value: stored, stored };
|
|
64
|
+
}
|
|
65
|
+
if (stored.freshUntil === null || stored.freshUntil > now) {
|
|
66
|
+
return { state: "fresh", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
67
|
+
}
|
|
68
|
+
if (stored.staleUntil !== null && stored.staleUntil > now) {
|
|
69
|
+
return { state: "stale-while-revalidate", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
70
|
+
}
|
|
71
|
+
if (stored.errorUntil !== null && stored.errorUntil > now) {
|
|
72
|
+
return { state: "stale-if-error", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
73
|
+
}
|
|
74
|
+
return { state: "expired", value: null, stored, envelope: stored };
|
|
75
|
+
}
|
|
76
|
+
function unwrapStoredValue(stored) {
|
|
77
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
78
|
+
return stored;
|
|
79
|
+
}
|
|
80
|
+
if (stored.kind === "empty") {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return stored.value ?? null;
|
|
84
|
+
}
|
|
85
|
+
function remainingStoredTtlSeconds(stored, now = Date.now()) {
|
|
86
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
87
|
+
return void 0;
|
|
88
|
+
}
|
|
89
|
+
const expiry = maxExpiry(stored);
|
|
90
|
+
if (expiry === null) {
|
|
91
|
+
return void 0;
|
|
92
|
+
}
|
|
93
|
+
const remainingMs = expiry - now;
|
|
94
|
+
if (remainingMs <= 0) {
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
return Math.max(1, Math.ceil(remainingMs / 1e3));
|
|
98
|
+
}
|
|
99
|
+
function maxExpiry(stored) {
|
|
100
|
+
const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
|
|
101
|
+
(value) => value !== null
|
|
102
|
+
);
|
|
103
|
+
if (values.length === 0) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return Math.max(...values);
|
|
107
|
+
}
|
|
108
|
+
function normalizePositiveSeconds(value) {
|
|
109
|
+
if (!value || value <= 0) {
|
|
110
|
+
return void 0;
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
39
115
|
// src/invalidation/PatternMatcher.ts
|
|
40
116
|
var PatternMatcher = class {
|
|
41
117
|
static matches(pattern, value) {
|
|
@@ -128,6 +204,10 @@ var StampedeGuard = class {
|
|
|
128
204
|
};
|
|
129
205
|
|
|
130
206
|
// src/CacheStack.ts
|
|
207
|
+
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
208
|
+
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
209
|
+
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
210
|
+
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
131
211
|
var EMPTY_METRICS = () => ({
|
|
132
212
|
hits: 0,
|
|
133
213
|
misses: 0,
|
|
@@ -135,7 +215,12 @@ var EMPTY_METRICS = () => ({
|
|
|
135
215
|
sets: 0,
|
|
136
216
|
deletes: 0,
|
|
137
217
|
backfills: 0,
|
|
138
|
-
invalidations: 0
|
|
218
|
+
invalidations: 0,
|
|
219
|
+
staleHits: 0,
|
|
220
|
+
refreshes: 0,
|
|
221
|
+
refreshErrors: 0,
|
|
222
|
+
writeFailures: 0,
|
|
223
|
+
singleFlightWaits: 0
|
|
139
224
|
});
|
|
140
225
|
var DebugLogger = class {
|
|
141
226
|
enabled;
|
|
@@ -147,7 +232,7 @@ var DebugLogger = class {
|
|
|
147
232
|
return;
|
|
148
233
|
}
|
|
149
234
|
const suffix = context ? ` ${JSON.stringify(context)}` : "";
|
|
150
|
-
console.debug(`[
|
|
235
|
+
console.debug(`[layercache] ${message}${suffix}`);
|
|
151
236
|
}
|
|
152
237
|
};
|
|
153
238
|
var CacheStack = class {
|
|
@@ -157,7 +242,7 @@ var CacheStack = class {
|
|
|
157
242
|
if (layers.length === 0) {
|
|
158
243
|
throw new Error("CacheStack requires at least one cache layer.");
|
|
159
244
|
}
|
|
160
|
-
const debugEnv = process.env.DEBUG?.split(",").includes("
|
|
245
|
+
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
161
246
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
162
247
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
163
248
|
this.startup = this.initialize();
|
|
@@ -171,49 +256,46 @@ var CacheStack = class {
|
|
|
171
256
|
unsubscribeInvalidation;
|
|
172
257
|
logger;
|
|
173
258
|
tagIndex;
|
|
259
|
+
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
174
260
|
async get(key, fetcher, options) {
|
|
175
261
|
await this.startup;
|
|
176
|
-
const hit = await this.
|
|
262
|
+
const hit = await this.readFromLayers(key, options, "allow-stale");
|
|
177
263
|
if (hit.found) {
|
|
178
|
-
|
|
179
|
-
|
|
264
|
+
if (hit.state === "fresh") {
|
|
265
|
+
this.metrics.hits += 1;
|
|
266
|
+
return hit.value;
|
|
267
|
+
}
|
|
268
|
+
if (hit.state === "stale-while-revalidate") {
|
|
269
|
+
this.metrics.hits += 1;
|
|
270
|
+
this.metrics.staleHits += 1;
|
|
271
|
+
if (fetcher) {
|
|
272
|
+
this.scheduleBackgroundRefresh(key, fetcher, options);
|
|
273
|
+
}
|
|
274
|
+
return hit.value;
|
|
275
|
+
}
|
|
276
|
+
if (!fetcher) {
|
|
277
|
+
this.metrics.hits += 1;
|
|
278
|
+
this.metrics.staleHits += 1;
|
|
279
|
+
return hit.value;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
return await this.fetchWithGuards(key, fetcher, options);
|
|
283
|
+
} catch (error) {
|
|
284
|
+
this.metrics.staleHits += 1;
|
|
285
|
+
this.metrics.refreshErrors += 1;
|
|
286
|
+
this.logger.debug("stale-if-error", { key, error: this.formatError(error) });
|
|
287
|
+
return hit.value;
|
|
288
|
+
}
|
|
180
289
|
}
|
|
181
290
|
this.metrics.misses += 1;
|
|
182
291
|
if (!fetcher) {
|
|
183
292
|
return null;
|
|
184
293
|
}
|
|
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);
|
|
294
|
+
return this.fetchWithGuards(key, fetcher, options);
|
|
203
295
|
}
|
|
204
296
|
async set(key, value, options) {
|
|
205
297
|
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
|
-
}
|
|
298
|
+
await this.storeEntry(key, "value", value, options);
|
|
217
299
|
}
|
|
218
300
|
async delete(key) {
|
|
219
301
|
await this.startup;
|
|
@@ -229,7 +311,48 @@ var CacheStack = class {
|
|
|
229
311
|
await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
|
|
230
312
|
}
|
|
231
313
|
async mget(entries) {
|
|
232
|
-
|
|
314
|
+
if (entries.length === 0) {
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
const canFastPath = entries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
|
|
318
|
+
if (!canFastPath) {
|
|
319
|
+
return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
|
|
320
|
+
}
|
|
321
|
+
await this.startup;
|
|
322
|
+
const pending = new Set(entries.map((_, index) => index));
|
|
323
|
+
const results = Array(entries.length).fill(null);
|
|
324
|
+
for (const layer of this.layers) {
|
|
325
|
+
const indexes = [...pending];
|
|
326
|
+
if (indexes.length === 0) {
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
const keys = indexes.map((index) => entries[index].key);
|
|
330
|
+
const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
|
|
331
|
+
for (let offset = 0; offset < values.length; offset += 1) {
|
|
332
|
+
const index = indexes[offset];
|
|
333
|
+
const stored = values[offset];
|
|
334
|
+
if (stored === null) {
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const resolved = resolveStoredValue(stored);
|
|
338
|
+
if (resolved.state === "expired") {
|
|
339
|
+
await layer.delete(entries[index].key);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
await this.tagIndex.touch(entries[index].key);
|
|
343
|
+
await this.backfill(entries[index].key, stored, this.layers.indexOf(layer) - 1, entries[index].options);
|
|
344
|
+
results[index] = resolved.value;
|
|
345
|
+
pending.delete(index);
|
|
346
|
+
this.metrics.hits += 1;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (pending.size > 0) {
|
|
350
|
+
for (const index of pending) {
|
|
351
|
+
await this.tagIndex.remove(entries[index].key);
|
|
352
|
+
this.metrics.misses += 1;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return results;
|
|
233
356
|
}
|
|
234
357
|
async mset(entries) {
|
|
235
358
|
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.options)));
|
|
@@ -255,6 +378,7 @@ var CacheStack = class {
|
|
|
255
378
|
async disconnect() {
|
|
256
379
|
await this.startup;
|
|
257
380
|
await this.unsubscribeInvalidation?.();
|
|
381
|
+
await Promise.allSettled(this.backgroundRefreshes.values());
|
|
258
382
|
}
|
|
259
383
|
async initialize() {
|
|
260
384
|
if (!this.options.invalidationBus) {
|
|
@@ -264,46 +388,225 @@ var CacheStack = class {
|
|
|
264
388
|
await this.handleInvalidationMessage(message);
|
|
265
389
|
});
|
|
266
390
|
}
|
|
267
|
-
async
|
|
391
|
+
async fetchWithGuards(key, fetcher, options) {
|
|
392
|
+
const fetchTask = async () => {
|
|
393
|
+
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
394
|
+
if (secondHit.found) {
|
|
395
|
+
this.metrics.hits += 1;
|
|
396
|
+
return secondHit.value;
|
|
397
|
+
}
|
|
398
|
+
return this.fetchAndPopulate(key, fetcher, options);
|
|
399
|
+
};
|
|
400
|
+
const singleFlightTask = async () => {
|
|
401
|
+
if (!this.options.singleFlightCoordinator) {
|
|
402
|
+
return fetchTask();
|
|
403
|
+
}
|
|
404
|
+
return this.options.singleFlightCoordinator.execute(
|
|
405
|
+
key,
|
|
406
|
+
this.resolveSingleFlightOptions(),
|
|
407
|
+
fetchTask,
|
|
408
|
+
() => this.waitForFreshValue(key, fetcher, options)
|
|
409
|
+
);
|
|
410
|
+
};
|
|
411
|
+
if (this.options.stampedePrevention === false) {
|
|
412
|
+
return singleFlightTask();
|
|
413
|
+
}
|
|
414
|
+
return this.stampedeGuard.execute(key, singleFlightTask);
|
|
415
|
+
}
|
|
416
|
+
async waitForFreshValue(key, fetcher, options) {
|
|
417
|
+
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
418
|
+
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
419
|
+
const deadline = Date.now() + timeoutMs;
|
|
420
|
+
this.metrics.singleFlightWaits += 1;
|
|
421
|
+
while (Date.now() < deadline) {
|
|
422
|
+
const hit = await this.readFromLayers(key, options, "fresh-only");
|
|
423
|
+
if (hit.found) {
|
|
424
|
+
this.metrics.hits += 1;
|
|
425
|
+
return hit.value;
|
|
426
|
+
}
|
|
427
|
+
await this.sleep(pollIntervalMs);
|
|
428
|
+
}
|
|
429
|
+
return this.fetchAndPopulate(key, fetcher, options);
|
|
430
|
+
}
|
|
431
|
+
async fetchAndPopulate(key, fetcher, options) {
|
|
432
|
+
this.metrics.fetches += 1;
|
|
433
|
+
const fetched = await fetcher();
|
|
434
|
+
if (fetched === null || fetched === void 0) {
|
|
435
|
+
if (!this.shouldNegativeCache(options)) {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
await this.storeEntry(key, "empty", null, options);
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
await this.storeEntry(key, "value", fetched, options);
|
|
442
|
+
return fetched;
|
|
443
|
+
}
|
|
444
|
+
async storeEntry(key, kind, value, options) {
|
|
445
|
+
await this.writeAcrossLayers(key, kind, value, options);
|
|
446
|
+
if (options?.tags) {
|
|
447
|
+
await this.tagIndex.track(key, options.tags);
|
|
448
|
+
} else {
|
|
449
|
+
await this.tagIndex.touch(key);
|
|
450
|
+
}
|
|
451
|
+
this.metrics.sets += 1;
|
|
452
|
+
this.logger.debug("set", { key, kind, tags: options?.tags });
|
|
453
|
+
if (this.options.publishSetInvalidation !== false) {
|
|
454
|
+
await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async readFromLayers(key, options, mode) {
|
|
458
|
+
let sawRetainableValue = false;
|
|
268
459
|
for (let index = 0; index < this.layers.length; index += 1) {
|
|
269
460
|
const layer = this.layers[index];
|
|
270
|
-
const
|
|
271
|
-
if (
|
|
461
|
+
const stored = await this.readLayerEntry(layer, key);
|
|
462
|
+
if (stored === null) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const resolved = resolveStoredValue(stored);
|
|
466
|
+
if (resolved.state === "expired") {
|
|
467
|
+
await layer.delete(key);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
sawRetainableValue = true;
|
|
471
|
+
if (mode === "fresh-only" && resolved.state !== "fresh") {
|
|
272
472
|
continue;
|
|
273
473
|
}
|
|
274
474
|
await this.tagIndex.touch(key);
|
|
275
|
-
await this.backfill(key,
|
|
276
|
-
this.logger.debug("hit", { key, layer: layer.name });
|
|
277
|
-
return { found: true, value };
|
|
475
|
+
await this.backfill(key, stored, index - 1, options);
|
|
476
|
+
this.logger.debug("hit", { key, layer: layer.name, state: resolved.state });
|
|
477
|
+
return { found: true, value: resolved.value, stored, state: resolved.state };
|
|
478
|
+
}
|
|
479
|
+
if (!sawRetainableValue) {
|
|
480
|
+
await this.tagIndex.remove(key);
|
|
481
|
+
}
|
|
482
|
+
this.logger.debug("miss", { key, mode });
|
|
483
|
+
return { found: false, value: null, stored: null, state: "miss" };
|
|
484
|
+
}
|
|
485
|
+
async readLayerEntry(layer, key) {
|
|
486
|
+
if (layer.getEntry) {
|
|
487
|
+
return layer.getEntry(key);
|
|
278
488
|
}
|
|
279
|
-
|
|
280
|
-
this.logger.debug("miss", { key });
|
|
281
|
-
return { found: false, value: null };
|
|
489
|
+
return layer.get(key);
|
|
282
490
|
}
|
|
283
|
-
async backfill(key,
|
|
491
|
+
async backfill(key, stored, upToIndex, options) {
|
|
284
492
|
if (upToIndex < 0) {
|
|
285
493
|
return;
|
|
286
494
|
}
|
|
287
495
|
for (let index = 0; index <= upToIndex; index += 1) {
|
|
288
496
|
const layer = this.layers[index];
|
|
289
|
-
|
|
497
|
+
const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
|
|
498
|
+
await layer.set(key, stored, ttl);
|
|
290
499
|
this.metrics.backfills += 1;
|
|
291
500
|
this.logger.debug("backfill", { key, layer: layer.name });
|
|
292
501
|
}
|
|
293
502
|
}
|
|
294
|
-
async
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
503
|
+
async writeAcrossLayers(key, kind, value, options) {
|
|
504
|
+
const now = Date.now();
|
|
505
|
+
const operations = this.layers.map((layer) => async () => {
|
|
506
|
+
const freshTtl = this.resolveFreshTtl(layer.name, kind, options, layer.defaultTtl);
|
|
507
|
+
const staleWhileRevalidate = this.resolveLayerSeconds(
|
|
508
|
+
layer.name,
|
|
509
|
+
options?.staleWhileRevalidate,
|
|
510
|
+
this.options.staleWhileRevalidate
|
|
511
|
+
);
|
|
512
|
+
const staleIfError = this.resolveLayerSeconds(
|
|
513
|
+
layer.name,
|
|
514
|
+
options?.staleIfError,
|
|
515
|
+
this.options.staleIfError
|
|
516
|
+
);
|
|
517
|
+
const payload = createStoredValueEnvelope({
|
|
518
|
+
kind,
|
|
519
|
+
value,
|
|
520
|
+
freshTtlSeconds: freshTtl,
|
|
521
|
+
staleWhileRevalidateSeconds: staleWhileRevalidate,
|
|
522
|
+
staleIfErrorSeconds: staleIfError,
|
|
523
|
+
now
|
|
524
|
+
});
|
|
525
|
+
const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
|
|
526
|
+
await layer.set(key, payload, ttl);
|
|
527
|
+
});
|
|
528
|
+
await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
|
|
529
|
+
}
|
|
530
|
+
async executeLayerOperations(operations, context) {
|
|
531
|
+
if (this.options.writePolicy !== "best-effort") {
|
|
532
|
+
await Promise.all(operations.map((operation) => operation()));
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const results = await Promise.allSettled(operations.map((operation) => operation()));
|
|
536
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
537
|
+
if (failures.length === 0) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
this.metrics.writeFailures += failures.length;
|
|
541
|
+
this.logger.debug("write-failure", {
|
|
542
|
+
...context,
|
|
543
|
+
failures: failures.map((failure) => this.formatError(failure.reason))
|
|
544
|
+
});
|
|
545
|
+
if (failures.length === operations.length) {
|
|
546
|
+
throw new AggregateError(
|
|
547
|
+
failures.map((failure) => failure.reason),
|
|
548
|
+
`${context.action} failed for every cache layer`
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
resolveFreshTtl(layerName, kind, options, fallbackTtl) {
|
|
553
|
+
const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
|
|
554
|
+
layerName,
|
|
555
|
+
options?.negativeTtl,
|
|
556
|
+
this.options.negativeTtl,
|
|
557
|
+
this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
558
|
+
) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
|
|
559
|
+
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
|
|
560
|
+
return this.applyJitter(baseTtl, jitter);
|
|
561
|
+
}
|
|
562
|
+
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
563
|
+
if (override !== void 0) {
|
|
564
|
+
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
565
|
+
}
|
|
566
|
+
if (globalDefault !== void 0) {
|
|
567
|
+
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
568
|
+
}
|
|
569
|
+
return fallback;
|
|
298
570
|
}
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
return
|
|
571
|
+
readLayerNumber(layerName, value) {
|
|
572
|
+
if (typeof value === "number") {
|
|
573
|
+
return value;
|
|
302
574
|
}
|
|
303
|
-
|
|
304
|
-
|
|
575
|
+
return value[layerName];
|
|
576
|
+
}
|
|
577
|
+
applyJitter(ttl, jitter) {
|
|
578
|
+
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
579
|
+
return ttl;
|
|
305
580
|
}
|
|
306
|
-
|
|
581
|
+
const delta = (Math.random() * 2 - 1) * jitter;
|
|
582
|
+
return Math.max(1, Math.round(ttl + delta));
|
|
583
|
+
}
|
|
584
|
+
shouldNegativeCache(options) {
|
|
585
|
+
return options?.negativeCache ?? this.options.negativeCaching ?? false;
|
|
586
|
+
}
|
|
587
|
+
scheduleBackgroundRefresh(key, fetcher, options) {
|
|
588
|
+
if (this.backgroundRefreshes.has(key)) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const refresh = (async () => {
|
|
592
|
+
this.metrics.refreshes += 1;
|
|
593
|
+
try {
|
|
594
|
+
await this.fetchWithGuards(key, fetcher, options);
|
|
595
|
+
} catch (error) {
|
|
596
|
+
this.metrics.refreshErrors += 1;
|
|
597
|
+
this.logger.debug("refresh-error", { key, error: this.formatError(error) });
|
|
598
|
+
} finally {
|
|
599
|
+
this.backgroundRefreshes.delete(key);
|
|
600
|
+
}
|
|
601
|
+
})();
|
|
602
|
+
this.backgroundRefreshes.set(key, refresh);
|
|
603
|
+
}
|
|
604
|
+
resolveSingleFlightOptions() {
|
|
605
|
+
return {
|
|
606
|
+
leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
|
|
607
|
+
waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
|
|
608
|
+
pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
|
|
609
|
+
};
|
|
307
610
|
}
|
|
308
611
|
async deleteKeys(keys) {
|
|
309
612
|
if (keys.length === 0) {
|
|
@@ -360,6 +663,15 @@ var CacheStack = class {
|
|
|
360
663
|
}
|
|
361
664
|
}
|
|
362
665
|
}
|
|
666
|
+
formatError(error) {
|
|
667
|
+
if (error instanceof Error) {
|
|
668
|
+
return error.message;
|
|
669
|
+
}
|
|
670
|
+
return String(error);
|
|
671
|
+
}
|
|
672
|
+
sleep(ms) {
|
|
673
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
674
|
+
}
|
|
363
675
|
};
|
|
364
676
|
|
|
365
677
|
// src/invalidation/RedisInvalidationBus.ts
|
|
@@ -370,7 +682,7 @@ var RedisInvalidationBus = class {
|
|
|
370
682
|
constructor(options) {
|
|
371
683
|
this.publisher = options.publisher;
|
|
372
684
|
this.subscriber = options.subscriber ?? options.publisher.duplicate();
|
|
373
|
-
this.channel = options.channel ?? "
|
|
685
|
+
this.channel = options.channel ?? "layercache:invalidation";
|
|
374
686
|
}
|
|
375
687
|
async subscribe(handler) {
|
|
376
688
|
const listener = async (_channel, payload) => {
|
|
@@ -396,7 +708,7 @@ var RedisTagIndex = class {
|
|
|
396
708
|
scanCount;
|
|
397
709
|
constructor(options) {
|
|
398
710
|
this.client = options.client;
|
|
399
|
-
this.prefix = options.prefix ?? "
|
|
711
|
+
this.prefix = options.prefix ?? "layercache:tag-index";
|
|
400
712
|
this.scanCount = options.scanCount ?? 100;
|
|
401
713
|
}
|
|
402
714
|
async touch(key) {
|
|
@@ -492,6 +804,10 @@ var MemoryLayer = class {
|
|
|
492
804
|
this.maxSize = options.maxSize ?? 1e3;
|
|
493
805
|
}
|
|
494
806
|
async get(key) {
|
|
807
|
+
const value = await this.getEntry(key);
|
|
808
|
+
return unwrapStoredValue(value);
|
|
809
|
+
}
|
|
810
|
+
async getEntry(key) {
|
|
495
811
|
const entry = this.entries.get(key);
|
|
496
812
|
if (!entry) {
|
|
497
813
|
return null;
|
|
@@ -504,6 +820,13 @@ var MemoryLayer = class {
|
|
|
504
820
|
this.entries.set(key, entry);
|
|
505
821
|
return entry.value;
|
|
506
822
|
}
|
|
823
|
+
async getMany(keys) {
|
|
824
|
+
const values = [];
|
|
825
|
+
for (const key of keys) {
|
|
826
|
+
values.push(await this.getEntry(key));
|
|
827
|
+
}
|
|
828
|
+
return values;
|
|
829
|
+
}
|
|
507
830
|
async set(key, value, ttl = this.defaultTtl) {
|
|
508
831
|
this.entries.delete(key);
|
|
509
832
|
this.entries.set(key, {
|
|
@@ -576,12 +899,36 @@ var RedisLayer = class {
|
|
|
576
899
|
this.scanCount = options.scanCount ?? 100;
|
|
577
900
|
}
|
|
578
901
|
async get(key) {
|
|
902
|
+
const payload = await this.getEntry(key);
|
|
903
|
+
return unwrapStoredValue(payload);
|
|
904
|
+
}
|
|
905
|
+
async getEntry(key) {
|
|
579
906
|
const payload = await this.client.getBuffer(this.withPrefix(key));
|
|
580
907
|
if (payload === null) {
|
|
581
908
|
return null;
|
|
582
909
|
}
|
|
583
910
|
return this.serializer.deserialize(payload);
|
|
584
911
|
}
|
|
912
|
+
async getMany(keys) {
|
|
913
|
+
if (keys.length === 0) {
|
|
914
|
+
return [];
|
|
915
|
+
}
|
|
916
|
+
const pipeline = this.client.pipeline();
|
|
917
|
+
for (const key of keys) {
|
|
918
|
+
pipeline.getBuffer(this.withPrefix(key));
|
|
919
|
+
}
|
|
920
|
+
const results = await pipeline.exec();
|
|
921
|
+
if (results === null) {
|
|
922
|
+
return keys.map(() => null);
|
|
923
|
+
}
|
|
924
|
+
return results.map((result) => {
|
|
925
|
+
const [, payload] = result;
|
|
926
|
+
if (payload === null) {
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
return this.serializer.deserialize(payload);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
585
932
|
async set(key, value, ttl = this.defaultTtl) {
|
|
586
933
|
const payload = this.serializer.serialize(value);
|
|
587
934
|
const normalizedKey = this.withPrefix(key);
|
|
@@ -643,6 +990,36 @@ var MsgpackSerializer = class {
|
|
|
643
990
|
return (0, import_msgpack.decode)(normalized);
|
|
644
991
|
}
|
|
645
992
|
};
|
|
993
|
+
|
|
994
|
+
// src/singleflight/RedisSingleFlightCoordinator.ts
|
|
995
|
+
var import_node_crypto2 = require("crypto");
|
|
996
|
+
var RELEASE_SCRIPT = `
|
|
997
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
998
|
+
return redis.call("del", KEYS[1])
|
|
999
|
+
end
|
|
1000
|
+
return 0
|
|
1001
|
+
`;
|
|
1002
|
+
var RedisSingleFlightCoordinator = class {
|
|
1003
|
+
client;
|
|
1004
|
+
prefix;
|
|
1005
|
+
constructor(options) {
|
|
1006
|
+
this.client = options.client;
|
|
1007
|
+
this.prefix = options.prefix ?? "layercache:singleflight";
|
|
1008
|
+
}
|
|
1009
|
+
async execute(key, options, worker, waiter) {
|
|
1010
|
+
const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
|
|
1011
|
+
const token = (0, import_node_crypto2.randomUUID)();
|
|
1012
|
+
const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
|
|
1013
|
+
if (acquired === "OK") {
|
|
1014
|
+
try {
|
|
1015
|
+
return await worker();
|
|
1016
|
+
} finally {
|
|
1017
|
+
await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return waiter();
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
646
1023
|
// Annotate the CommonJS export names for ESM import in node:
|
|
647
1024
|
0 && (module.exports = {
|
|
648
1025
|
CacheStack,
|
|
@@ -652,6 +1029,7 @@ var MsgpackSerializer = class {
|
|
|
652
1029
|
PatternMatcher,
|
|
653
1030
|
RedisInvalidationBus,
|
|
654
1031
|
RedisLayer,
|
|
1032
|
+
RedisSingleFlightCoordinator,
|
|
655
1033
|
RedisTagIndex,
|
|
656
1034
|
StampedeGuard,
|
|
657
1035
|
TagIndex
|