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.js
CHANGED
|
@@ -1,6 +1,81 @@
|
|
|
1
1
|
// src/CacheStack.ts
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
3
|
|
|
4
|
+
// src/internal/StoredValue.ts
|
|
5
|
+
function isStoredValueEnvelope(value) {
|
|
6
|
+
return typeof value === "object" && value !== null && "__layercache" in value && value.__layercache === 1 && "kind" in value;
|
|
7
|
+
}
|
|
8
|
+
function createStoredValueEnvelope(options) {
|
|
9
|
+
const now = options.now ?? Date.now();
|
|
10
|
+
const freshTtlSeconds = normalizePositiveSeconds(options.freshTtlSeconds);
|
|
11
|
+
const staleWhileRevalidateSeconds = normalizePositiveSeconds(options.staleWhileRevalidateSeconds);
|
|
12
|
+
const staleIfErrorSeconds = normalizePositiveSeconds(options.staleIfErrorSeconds);
|
|
13
|
+
const freshUntil = freshTtlSeconds ? now + freshTtlSeconds * 1e3 : null;
|
|
14
|
+
const staleUntil = freshUntil && staleWhileRevalidateSeconds ? freshUntil + staleWhileRevalidateSeconds * 1e3 : null;
|
|
15
|
+
const errorUntil = freshUntil && staleIfErrorSeconds ? freshUntil + staleIfErrorSeconds * 1e3 : null;
|
|
16
|
+
return {
|
|
17
|
+
__layercache: 1,
|
|
18
|
+
kind: options.kind,
|
|
19
|
+
value: options.value,
|
|
20
|
+
freshUntil,
|
|
21
|
+
staleUntil,
|
|
22
|
+
errorUntil
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function resolveStoredValue(stored, now = Date.now()) {
|
|
26
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
27
|
+
return { state: "fresh", value: stored, stored };
|
|
28
|
+
}
|
|
29
|
+
if (stored.freshUntil === null || stored.freshUntil > now) {
|
|
30
|
+
return { state: "fresh", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
31
|
+
}
|
|
32
|
+
if (stored.staleUntil !== null && stored.staleUntil > now) {
|
|
33
|
+
return { state: "stale-while-revalidate", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
34
|
+
}
|
|
35
|
+
if (stored.errorUntil !== null && stored.errorUntil > now) {
|
|
36
|
+
return { state: "stale-if-error", value: unwrapStoredValue(stored), stored, envelope: stored };
|
|
37
|
+
}
|
|
38
|
+
return { state: "expired", value: null, stored, envelope: stored };
|
|
39
|
+
}
|
|
40
|
+
function unwrapStoredValue(stored) {
|
|
41
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
42
|
+
return stored;
|
|
43
|
+
}
|
|
44
|
+
if (stored.kind === "empty") {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return stored.value ?? null;
|
|
48
|
+
}
|
|
49
|
+
function remainingStoredTtlSeconds(stored, now = Date.now()) {
|
|
50
|
+
if (!isStoredValueEnvelope(stored)) {
|
|
51
|
+
return void 0;
|
|
52
|
+
}
|
|
53
|
+
const expiry = maxExpiry(stored);
|
|
54
|
+
if (expiry === null) {
|
|
55
|
+
return void 0;
|
|
56
|
+
}
|
|
57
|
+
const remainingMs = expiry - now;
|
|
58
|
+
if (remainingMs <= 0) {
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
return Math.max(1, Math.ceil(remainingMs / 1e3));
|
|
62
|
+
}
|
|
63
|
+
function maxExpiry(stored) {
|
|
64
|
+
const values = [stored.freshUntil, stored.staleUntil, stored.errorUntil].filter(
|
|
65
|
+
(value) => value !== null
|
|
66
|
+
);
|
|
67
|
+
if (values.length === 0) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return Math.max(...values);
|
|
71
|
+
}
|
|
72
|
+
function normalizePositiveSeconds(value) {
|
|
73
|
+
if (!value || value <= 0) {
|
|
74
|
+
return void 0;
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
|
|
4
79
|
// src/invalidation/PatternMatcher.ts
|
|
5
80
|
var PatternMatcher = class {
|
|
6
81
|
static matches(pattern, value) {
|
|
@@ -93,6 +168,10 @@ var StampedeGuard = class {
|
|
|
93
168
|
};
|
|
94
169
|
|
|
95
170
|
// src/CacheStack.ts
|
|
171
|
+
var DEFAULT_NEGATIVE_TTL_SECONDS = 60;
|
|
172
|
+
var DEFAULT_SINGLE_FLIGHT_LEASE_MS = 3e4;
|
|
173
|
+
var DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS = 5e3;
|
|
174
|
+
var DEFAULT_SINGLE_FLIGHT_POLL_MS = 50;
|
|
96
175
|
var EMPTY_METRICS = () => ({
|
|
97
176
|
hits: 0,
|
|
98
177
|
misses: 0,
|
|
@@ -100,7 +179,12 @@ var EMPTY_METRICS = () => ({
|
|
|
100
179
|
sets: 0,
|
|
101
180
|
deletes: 0,
|
|
102
181
|
backfills: 0,
|
|
103
|
-
invalidations: 0
|
|
182
|
+
invalidations: 0,
|
|
183
|
+
staleHits: 0,
|
|
184
|
+
refreshes: 0,
|
|
185
|
+
refreshErrors: 0,
|
|
186
|
+
writeFailures: 0,
|
|
187
|
+
singleFlightWaits: 0
|
|
104
188
|
});
|
|
105
189
|
var DebugLogger = class {
|
|
106
190
|
enabled;
|
|
@@ -112,7 +196,7 @@ var DebugLogger = class {
|
|
|
112
196
|
return;
|
|
113
197
|
}
|
|
114
198
|
const suffix = context ? ` ${JSON.stringify(context)}` : "";
|
|
115
|
-
console.debug(`[
|
|
199
|
+
console.debug(`[layercache] ${message}${suffix}`);
|
|
116
200
|
}
|
|
117
201
|
};
|
|
118
202
|
var CacheStack = class {
|
|
@@ -122,7 +206,7 @@ var CacheStack = class {
|
|
|
122
206
|
if (layers.length === 0) {
|
|
123
207
|
throw new Error("CacheStack requires at least one cache layer.");
|
|
124
208
|
}
|
|
125
|
-
const debugEnv = process.env.DEBUG?.split(",").includes("
|
|
209
|
+
const debugEnv = process.env.DEBUG?.split(",").includes("layercache:debug") ?? false;
|
|
126
210
|
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
127
211
|
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
128
212
|
this.startup = this.initialize();
|
|
@@ -136,49 +220,46 @@ var CacheStack = class {
|
|
|
136
220
|
unsubscribeInvalidation;
|
|
137
221
|
logger;
|
|
138
222
|
tagIndex;
|
|
223
|
+
backgroundRefreshes = /* @__PURE__ */ new Map();
|
|
139
224
|
async get(key, fetcher, options) {
|
|
140
225
|
await this.startup;
|
|
141
|
-
const hit = await this.
|
|
226
|
+
const hit = await this.readFromLayers(key, options, "allow-stale");
|
|
142
227
|
if (hit.found) {
|
|
143
|
-
|
|
144
|
-
|
|
228
|
+
if (hit.state === "fresh") {
|
|
229
|
+
this.metrics.hits += 1;
|
|
230
|
+
return hit.value;
|
|
231
|
+
}
|
|
232
|
+
if (hit.state === "stale-while-revalidate") {
|
|
233
|
+
this.metrics.hits += 1;
|
|
234
|
+
this.metrics.staleHits += 1;
|
|
235
|
+
if (fetcher) {
|
|
236
|
+
this.scheduleBackgroundRefresh(key, fetcher, options);
|
|
237
|
+
}
|
|
238
|
+
return hit.value;
|
|
239
|
+
}
|
|
240
|
+
if (!fetcher) {
|
|
241
|
+
this.metrics.hits += 1;
|
|
242
|
+
this.metrics.staleHits += 1;
|
|
243
|
+
return hit.value;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
return await this.fetchWithGuards(key, fetcher, options);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
this.metrics.staleHits += 1;
|
|
249
|
+
this.metrics.refreshErrors += 1;
|
|
250
|
+
this.logger.debug("stale-if-error", { key, error: this.formatError(error) });
|
|
251
|
+
return hit.value;
|
|
252
|
+
}
|
|
145
253
|
}
|
|
146
254
|
this.metrics.misses += 1;
|
|
147
255
|
if (!fetcher) {
|
|
148
256
|
return null;
|
|
149
257
|
}
|
|
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);
|
|
258
|
+
return this.fetchWithGuards(key, fetcher, options);
|
|
168
259
|
}
|
|
169
260
|
async set(key, value, options) {
|
|
170
261
|
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
|
-
}
|
|
262
|
+
await this.storeEntry(key, "value", value, options);
|
|
182
263
|
}
|
|
183
264
|
async delete(key) {
|
|
184
265
|
await this.startup;
|
|
@@ -194,7 +275,48 @@ var CacheStack = class {
|
|
|
194
275
|
await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
|
|
195
276
|
}
|
|
196
277
|
async mget(entries) {
|
|
197
|
-
|
|
278
|
+
if (entries.length === 0) {
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
const canFastPath = entries.every((entry) => entry.fetch === void 0 && entry.options === void 0);
|
|
282
|
+
if (!canFastPath) {
|
|
283
|
+
return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
|
|
284
|
+
}
|
|
285
|
+
await this.startup;
|
|
286
|
+
const pending = new Set(entries.map((_, index) => index));
|
|
287
|
+
const results = Array(entries.length).fill(null);
|
|
288
|
+
for (const layer of this.layers) {
|
|
289
|
+
const indexes = [...pending];
|
|
290
|
+
if (indexes.length === 0) {
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
const keys = indexes.map((index) => entries[index].key);
|
|
294
|
+
const values = layer.getMany ? await layer.getMany(keys) : await Promise.all(keys.map((key) => this.readLayerEntry(layer, key)));
|
|
295
|
+
for (let offset = 0; offset < values.length; offset += 1) {
|
|
296
|
+
const index = indexes[offset];
|
|
297
|
+
const stored = values[offset];
|
|
298
|
+
if (stored === null) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const resolved = resolveStoredValue(stored);
|
|
302
|
+
if (resolved.state === "expired") {
|
|
303
|
+
await layer.delete(entries[index].key);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
await this.tagIndex.touch(entries[index].key);
|
|
307
|
+
await this.backfill(entries[index].key, stored, this.layers.indexOf(layer) - 1, entries[index].options);
|
|
308
|
+
results[index] = resolved.value;
|
|
309
|
+
pending.delete(index);
|
|
310
|
+
this.metrics.hits += 1;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (pending.size > 0) {
|
|
314
|
+
for (const index of pending) {
|
|
315
|
+
await this.tagIndex.remove(entries[index].key);
|
|
316
|
+
this.metrics.misses += 1;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return results;
|
|
198
320
|
}
|
|
199
321
|
async mset(entries) {
|
|
200
322
|
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.options)));
|
|
@@ -220,6 +342,7 @@ var CacheStack = class {
|
|
|
220
342
|
async disconnect() {
|
|
221
343
|
await this.startup;
|
|
222
344
|
await this.unsubscribeInvalidation?.();
|
|
345
|
+
await Promise.allSettled(this.backgroundRefreshes.values());
|
|
223
346
|
}
|
|
224
347
|
async initialize() {
|
|
225
348
|
if (!this.options.invalidationBus) {
|
|
@@ -229,46 +352,225 @@ var CacheStack = class {
|
|
|
229
352
|
await this.handleInvalidationMessage(message);
|
|
230
353
|
});
|
|
231
354
|
}
|
|
232
|
-
async
|
|
355
|
+
async fetchWithGuards(key, fetcher, options) {
|
|
356
|
+
const fetchTask = async () => {
|
|
357
|
+
const secondHit = await this.readFromLayers(key, options, "fresh-only");
|
|
358
|
+
if (secondHit.found) {
|
|
359
|
+
this.metrics.hits += 1;
|
|
360
|
+
return secondHit.value;
|
|
361
|
+
}
|
|
362
|
+
return this.fetchAndPopulate(key, fetcher, options);
|
|
363
|
+
};
|
|
364
|
+
const singleFlightTask = async () => {
|
|
365
|
+
if (!this.options.singleFlightCoordinator) {
|
|
366
|
+
return fetchTask();
|
|
367
|
+
}
|
|
368
|
+
return this.options.singleFlightCoordinator.execute(
|
|
369
|
+
key,
|
|
370
|
+
this.resolveSingleFlightOptions(),
|
|
371
|
+
fetchTask,
|
|
372
|
+
() => this.waitForFreshValue(key, fetcher, options)
|
|
373
|
+
);
|
|
374
|
+
};
|
|
375
|
+
if (this.options.stampedePrevention === false) {
|
|
376
|
+
return singleFlightTask();
|
|
377
|
+
}
|
|
378
|
+
return this.stampedeGuard.execute(key, singleFlightTask);
|
|
379
|
+
}
|
|
380
|
+
async waitForFreshValue(key, fetcher, options) {
|
|
381
|
+
const timeoutMs = this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS;
|
|
382
|
+
const pollIntervalMs = this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS;
|
|
383
|
+
const deadline = Date.now() + timeoutMs;
|
|
384
|
+
this.metrics.singleFlightWaits += 1;
|
|
385
|
+
while (Date.now() < deadline) {
|
|
386
|
+
const hit = await this.readFromLayers(key, options, "fresh-only");
|
|
387
|
+
if (hit.found) {
|
|
388
|
+
this.metrics.hits += 1;
|
|
389
|
+
return hit.value;
|
|
390
|
+
}
|
|
391
|
+
await this.sleep(pollIntervalMs);
|
|
392
|
+
}
|
|
393
|
+
return this.fetchAndPopulate(key, fetcher, options);
|
|
394
|
+
}
|
|
395
|
+
async fetchAndPopulate(key, fetcher, options) {
|
|
396
|
+
this.metrics.fetches += 1;
|
|
397
|
+
const fetched = await fetcher();
|
|
398
|
+
if (fetched === null || fetched === void 0) {
|
|
399
|
+
if (!this.shouldNegativeCache(options)) {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
await this.storeEntry(key, "empty", null, options);
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
await this.storeEntry(key, "value", fetched, options);
|
|
406
|
+
return fetched;
|
|
407
|
+
}
|
|
408
|
+
async storeEntry(key, kind, value, options) {
|
|
409
|
+
await this.writeAcrossLayers(key, kind, value, options);
|
|
410
|
+
if (options?.tags) {
|
|
411
|
+
await this.tagIndex.track(key, options.tags);
|
|
412
|
+
} else {
|
|
413
|
+
await this.tagIndex.touch(key);
|
|
414
|
+
}
|
|
415
|
+
this.metrics.sets += 1;
|
|
416
|
+
this.logger.debug("set", { key, kind, tags: options?.tags });
|
|
417
|
+
if (this.options.publishSetInvalidation !== false) {
|
|
418
|
+
await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "write" });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
async readFromLayers(key, options, mode) {
|
|
422
|
+
let sawRetainableValue = false;
|
|
233
423
|
for (let index = 0; index < this.layers.length; index += 1) {
|
|
234
424
|
const layer = this.layers[index];
|
|
235
|
-
const
|
|
236
|
-
if (
|
|
425
|
+
const stored = await this.readLayerEntry(layer, key);
|
|
426
|
+
if (stored === null) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const resolved = resolveStoredValue(stored);
|
|
430
|
+
if (resolved.state === "expired") {
|
|
431
|
+
await layer.delete(key);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
sawRetainableValue = true;
|
|
435
|
+
if (mode === "fresh-only" && resolved.state !== "fresh") {
|
|
237
436
|
continue;
|
|
238
437
|
}
|
|
239
438
|
await this.tagIndex.touch(key);
|
|
240
|
-
await this.backfill(key,
|
|
241
|
-
this.logger.debug("hit", { key, layer: layer.name });
|
|
242
|
-
return { found: true, value };
|
|
439
|
+
await this.backfill(key, stored, index - 1, options);
|
|
440
|
+
this.logger.debug("hit", { key, layer: layer.name, state: resolved.state });
|
|
441
|
+
return { found: true, value: resolved.value, stored, state: resolved.state };
|
|
243
442
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
443
|
+
if (!sawRetainableValue) {
|
|
444
|
+
await this.tagIndex.remove(key);
|
|
445
|
+
}
|
|
446
|
+
this.logger.debug("miss", { key, mode });
|
|
447
|
+
return { found: false, value: null, stored: null, state: "miss" };
|
|
448
|
+
}
|
|
449
|
+
async readLayerEntry(layer, key) {
|
|
450
|
+
if (layer.getEntry) {
|
|
451
|
+
return layer.getEntry(key);
|
|
452
|
+
}
|
|
453
|
+
return layer.get(key);
|
|
247
454
|
}
|
|
248
|
-
async backfill(key,
|
|
455
|
+
async backfill(key, stored, upToIndex, options) {
|
|
249
456
|
if (upToIndex < 0) {
|
|
250
457
|
return;
|
|
251
458
|
}
|
|
252
459
|
for (let index = 0; index <= upToIndex; index += 1) {
|
|
253
460
|
const layer = this.layers[index];
|
|
254
|
-
|
|
461
|
+
const ttl = remainingStoredTtlSeconds(stored) ?? this.resolveLayerSeconds(layer.name, options?.ttl, void 0, layer.defaultTtl);
|
|
462
|
+
await layer.set(key, stored, ttl);
|
|
255
463
|
this.metrics.backfills += 1;
|
|
256
464
|
this.logger.debug("backfill", { key, layer: layer.name });
|
|
257
465
|
}
|
|
258
466
|
}
|
|
259
|
-
async
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
467
|
+
async writeAcrossLayers(key, kind, value, options) {
|
|
468
|
+
const now = Date.now();
|
|
469
|
+
const operations = this.layers.map((layer) => async () => {
|
|
470
|
+
const freshTtl = this.resolveFreshTtl(layer.name, kind, options, layer.defaultTtl);
|
|
471
|
+
const staleWhileRevalidate = this.resolveLayerSeconds(
|
|
472
|
+
layer.name,
|
|
473
|
+
options?.staleWhileRevalidate,
|
|
474
|
+
this.options.staleWhileRevalidate
|
|
475
|
+
);
|
|
476
|
+
const staleIfError = this.resolveLayerSeconds(
|
|
477
|
+
layer.name,
|
|
478
|
+
options?.staleIfError,
|
|
479
|
+
this.options.staleIfError
|
|
480
|
+
);
|
|
481
|
+
const payload = createStoredValueEnvelope({
|
|
482
|
+
kind,
|
|
483
|
+
value,
|
|
484
|
+
freshTtlSeconds: freshTtl,
|
|
485
|
+
staleWhileRevalidateSeconds: staleWhileRevalidate,
|
|
486
|
+
staleIfErrorSeconds: staleIfError,
|
|
487
|
+
now
|
|
488
|
+
});
|
|
489
|
+
const ttl = remainingStoredTtlSeconds(payload, now) ?? freshTtl;
|
|
490
|
+
await layer.set(key, payload, ttl);
|
|
491
|
+
});
|
|
492
|
+
await this.executeLayerOperations(operations, { key, action: kind === "empty" ? "negative-set" : "set" });
|
|
493
|
+
}
|
|
494
|
+
async executeLayerOperations(operations, context) {
|
|
495
|
+
if (this.options.writePolicy !== "best-effort") {
|
|
496
|
+
await Promise.all(operations.map((operation) => operation()));
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const results = await Promise.allSettled(operations.map((operation) => operation()));
|
|
500
|
+
const failures = results.filter((result) => result.status === "rejected");
|
|
501
|
+
if (failures.length === 0) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
this.metrics.writeFailures += failures.length;
|
|
505
|
+
this.logger.debug("write-failure", {
|
|
506
|
+
...context,
|
|
507
|
+
failures: failures.map((failure) => this.formatError(failure.reason))
|
|
508
|
+
});
|
|
509
|
+
if (failures.length === operations.length) {
|
|
510
|
+
throw new AggregateError(
|
|
511
|
+
failures.map((failure) => failure.reason),
|
|
512
|
+
`${context.action} failed for every cache layer`
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
resolveFreshTtl(layerName, kind, options, fallbackTtl) {
|
|
517
|
+
const baseTtl = kind === "empty" ? this.resolveLayerSeconds(
|
|
518
|
+
layerName,
|
|
519
|
+
options?.negativeTtl,
|
|
520
|
+
this.options.negativeTtl,
|
|
521
|
+
this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl) ?? DEFAULT_NEGATIVE_TTL_SECONDS
|
|
522
|
+
) : this.resolveLayerSeconds(layerName, options?.ttl, void 0, fallbackTtl);
|
|
523
|
+
const jitter = this.resolveLayerSeconds(layerName, options?.ttlJitter, this.options.ttlJitter);
|
|
524
|
+
return this.applyJitter(baseTtl, jitter);
|
|
525
|
+
}
|
|
526
|
+
resolveLayerSeconds(layerName, override, globalDefault, fallback) {
|
|
527
|
+
if (override !== void 0) {
|
|
528
|
+
return this.readLayerNumber(layerName, override) ?? fallback;
|
|
529
|
+
}
|
|
530
|
+
if (globalDefault !== void 0) {
|
|
531
|
+
return this.readLayerNumber(layerName, globalDefault) ?? fallback;
|
|
532
|
+
}
|
|
533
|
+
return fallback;
|
|
263
534
|
}
|
|
264
|
-
|
|
265
|
-
if (
|
|
266
|
-
return
|
|
535
|
+
readLayerNumber(layerName, value) {
|
|
536
|
+
if (typeof value === "number") {
|
|
537
|
+
return value;
|
|
267
538
|
}
|
|
268
|
-
|
|
269
|
-
|
|
539
|
+
return value[layerName];
|
|
540
|
+
}
|
|
541
|
+
applyJitter(ttl, jitter) {
|
|
542
|
+
if (!ttl || ttl <= 0 || !jitter || jitter <= 0) {
|
|
543
|
+
return ttl;
|
|
270
544
|
}
|
|
271
|
-
|
|
545
|
+
const delta = (Math.random() * 2 - 1) * jitter;
|
|
546
|
+
return Math.max(1, Math.round(ttl + delta));
|
|
547
|
+
}
|
|
548
|
+
shouldNegativeCache(options) {
|
|
549
|
+
return options?.negativeCache ?? this.options.negativeCaching ?? false;
|
|
550
|
+
}
|
|
551
|
+
scheduleBackgroundRefresh(key, fetcher, options) {
|
|
552
|
+
if (this.backgroundRefreshes.has(key)) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const refresh = (async () => {
|
|
556
|
+
this.metrics.refreshes += 1;
|
|
557
|
+
try {
|
|
558
|
+
await this.fetchWithGuards(key, fetcher, options);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
this.metrics.refreshErrors += 1;
|
|
561
|
+
this.logger.debug("refresh-error", { key, error: this.formatError(error) });
|
|
562
|
+
} finally {
|
|
563
|
+
this.backgroundRefreshes.delete(key);
|
|
564
|
+
}
|
|
565
|
+
})();
|
|
566
|
+
this.backgroundRefreshes.set(key, refresh);
|
|
567
|
+
}
|
|
568
|
+
resolveSingleFlightOptions() {
|
|
569
|
+
return {
|
|
570
|
+
leaseMs: this.options.singleFlightLeaseMs ?? DEFAULT_SINGLE_FLIGHT_LEASE_MS,
|
|
571
|
+
waitTimeoutMs: this.options.singleFlightTimeoutMs ?? DEFAULT_SINGLE_FLIGHT_TIMEOUT_MS,
|
|
572
|
+
pollIntervalMs: this.options.singleFlightPollMs ?? DEFAULT_SINGLE_FLIGHT_POLL_MS
|
|
573
|
+
};
|
|
272
574
|
}
|
|
273
575
|
async deleteKeys(keys) {
|
|
274
576
|
if (keys.length === 0) {
|
|
@@ -325,6 +627,15 @@ var CacheStack = class {
|
|
|
325
627
|
}
|
|
326
628
|
}
|
|
327
629
|
}
|
|
630
|
+
formatError(error) {
|
|
631
|
+
if (error instanceof Error) {
|
|
632
|
+
return error.message;
|
|
633
|
+
}
|
|
634
|
+
return String(error);
|
|
635
|
+
}
|
|
636
|
+
sleep(ms) {
|
|
637
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
638
|
+
}
|
|
328
639
|
};
|
|
329
640
|
|
|
330
641
|
// src/invalidation/RedisInvalidationBus.ts
|
|
@@ -335,7 +646,7 @@ var RedisInvalidationBus = class {
|
|
|
335
646
|
constructor(options) {
|
|
336
647
|
this.publisher = options.publisher;
|
|
337
648
|
this.subscriber = options.subscriber ?? options.publisher.duplicate();
|
|
338
|
-
this.channel = options.channel ?? "
|
|
649
|
+
this.channel = options.channel ?? "layercache:invalidation";
|
|
339
650
|
}
|
|
340
651
|
async subscribe(handler) {
|
|
341
652
|
const listener = async (_channel, payload) => {
|
|
@@ -361,7 +672,7 @@ var RedisTagIndex = class {
|
|
|
361
672
|
scanCount;
|
|
362
673
|
constructor(options) {
|
|
363
674
|
this.client = options.client;
|
|
364
|
-
this.prefix = options.prefix ?? "
|
|
675
|
+
this.prefix = options.prefix ?? "layercache:tag-index";
|
|
365
676
|
this.scanCount = options.scanCount ?? 100;
|
|
366
677
|
}
|
|
367
678
|
async touch(key) {
|
|
@@ -457,6 +768,10 @@ var MemoryLayer = class {
|
|
|
457
768
|
this.maxSize = options.maxSize ?? 1e3;
|
|
458
769
|
}
|
|
459
770
|
async get(key) {
|
|
771
|
+
const value = await this.getEntry(key);
|
|
772
|
+
return unwrapStoredValue(value);
|
|
773
|
+
}
|
|
774
|
+
async getEntry(key) {
|
|
460
775
|
const entry = this.entries.get(key);
|
|
461
776
|
if (!entry) {
|
|
462
777
|
return null;
|
|
@@ -469,6 +784,13 @@ var MemoryLayer = class {
|
|
|
469
784
|
this.entries.set(key, entry);
|
|
470
785
|
return entry.value;
|
|
471
786
|
}
|
|
787
|
+
async getMany(keys) {
|
|
788
|
+
const values = [];
|
|
789
|
+
for (const key of keys) {
|
|
790
|
+
values.push(await this.getEntry(key));
|
|
791
|
+
}
|
|
792
|
+
return values;
|
|
793
|
+
}
|
|
472
794
|
async set(key, value, ttl = this.defaultTtl) {
|
|
473
795
|
this.entries.delete(key);
|
|
474
796
|
this.entries.set(key, {
|
|
@@ -541,12 +863,36 @@ var RedisLayer = class {
|
|
|
541
863
|
this.scanCount = options.scanCount ?? 100;
|
|
542
864
|
}
|
|
543
865
|
async get(key) {
|
|
866
|
+
const payload = await this.getEntry(key);
|
|
867
|
+
return unwrapStoredValue(payload);
|
|
868
|
+
}
|
|
869
|
+
async getEntry(key) {
|
|
544
870
|
const payload = await this.client.getBuffer(this.withPrefix(key));
|
|
545
871
|
if (payload === null) {
|
|
546
872
|
return null;
|
|
547
873
|
}
|
|
548
874
|
return this.serializer.deserialize(payload);
|
|
549
875
|
}
|
|
876
|
+
async getMany(keys) {
|
|
877
|
+
if (keys.length === 0) {
|
|
878
|
+
return [];
|
|
879
|
+
}
|
|
880
|
+
const pipeline = this.client.pipeline();
|
|
881
|
+
for (const key of keys) {
|
|
882
|
+
pipeline.getBuffer(this.withPrefix(key));
|
|
883
|
+
}
|
|
884
|
+
const results = await pipeline.exec();
|
|
885
|
+
if (results === null) {
|
|
886
|
+
return keys.map(() => null);
|
|
887
|
+
}
|
|
888
|
+
return results.map((result) => {
|
|
889
|
+
const [, payload] = result;
|
|
890
|
+
if (payload === null) {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
return this.serializer.deserialize(payload);
|
|
894
|
+
});
|
|
895
|
+
}
|
|
550
896
|
async set(key, value, ttl = this.defaultTtl) {
|
|
551
897
|
const payload = this.serializer.serialize(value);
|
|
552
898
|
const normalizedKey = this.withPrefix(key);
|
|
@@ -608,6 +954,36 @@ var MsgpackSerializer = class {
|
|
|
608
954
|
return decode(normalized);
|
|
609
955
|
}
|
|
610
956
|
};
|
|
957
|
+
|
|
958
|
+
// src/singleflight/RedisSingleFlightCoordinator.ts
|
|
959
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
960
|
+
var RELEASE_SCRIPT = `
|
|
961
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
962
|
+
return redis.call("del", KEYS[1])
|
|
963
|
+
end
|
|
964
|
+
return 0
|
|
965
|
+
`;
|
|
966
|
+
var RedisSingleFlightCoordinator = class {
|
|
967
|
+
client;
|
|
968
|
+
prefix;
|
|
969
|
+
constructor(options) {
|
|
970
|
+
this.client = options.client;
|
|
971
|
+
this.prefix = options.prefix ?? "layercache:singleflight";
|
|
972
|
+
}
|
|
973
|
+
async execute(key, options, worker, waiter) {
|
|
974
|
+
const lockKey = `${this.prefix}:${encodeURIComponent(key)}`;
|
|
975
|
+
const token = randomUUID2();
|
|
976
|
+
const acquired = await this.client.set(lockKey, token, "PX", options.leaseMs, "NX");
|
|
977
|
+
if (acquired === "OK") {
|
|
978
|
+
try {
|
|
979
|
+
return await worker();
|
|
980
|
+
} finally {
|
|
981
|
+
await this.client.eval(RELEASE_SCRIPT, 1, lockKey, token);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return waiter();
|
|
985
|
+
}
|
|
986
|
+
};
|
|
611
987
|
export {
|
|
612
988
|
CacheStack,
|
|
613
989
|
JsonSerializer,
|
|
@@ -616,6 +992,7 @@ export {
|
|
|
616
992
|
PatternMatcher,
|
|
617
993
|
RedisInvalidationBus,
|
|
618
994
|
RedisLayer,
|
|
995
|
+
RedisSingleFlightCoordinator,
|
|
619
996
|
RedisTagIndex,
|
|
620
997
|
StampedeGuard,
|
|
621
998
|
TagIndex
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "layercache",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Unified multi-layer caching for Node.js with memory, Redis, stampede prevention, and invalidation helpers.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/flyingsquirrel0419/layercache"
|
|
9
|
+
},
|
|
6
10
|
"type": "module",
|
|
7
11
|
"main": "./dist/index.cjs",
|
|
8
12
|
"module": "./dist/index.js",
|