layercache 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +492 -0
- package/benchmarks/latency.ts +45 -0
- package/benchmarks/stampede.ts +29 -0
- package/dist/index.cjs +658 -0
- package/dist/index.d.cts +227 -0
- package/dist/index.d.ts +227 -0
- package/dist/index.js +622 -0
- package/examples/express-api/index.ts +27 -0
- package/examples/nestjs-module/app.module.ts +18 -0
- package/examples/nextjs-api-routes/route.ts +19 -0
- package/package.json +61 -0
- package/packages/nestjs/dist/index.cjs +571 -0
- package/packages/nestjs/dist/index.d.cts +55 -0
- package/packages/nestjs/dist/index.d.ts +55 -0
- package/packages/nestjs/dist/index.js +545 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
// src/CacheStack.ts
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
|
|
4
|
+
// src/invalidation/PatternMatcher.ts
|
|
5
|
+
var PatternMatcher = class {
|
|
6
|
+
static matches(pattern, value) {
|
|
7
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
8
|
+
const regex = new RegExp(`^${escaped.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
9
|
+
return regex.test(value);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/invalidation/TagIndex.ts
|
|
14
|
+
var TagIndex = class {
|
|
15
|
+
tagToKeys = /* @__PURE__ */ new Map();
|
|
16
|
+
keyToTags = /* @__PURE__ */ new Map();
|
|
17
|
+
knownKeys = /* @__PURE__ */ new Set();
|
|
18
|
+
async touch(key) {
|
|
19
|
+
this.knownKeys.add(key);
|
|
20
|
+
}
|
|
21
|
+
async track(key, tags) {
|
|
22
|
+
this.knownKeys.add(key);
|
|
23
|
+
if (tags.length === 0) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const existingTags = this.keyToTags.get(key);
|
|
27
|
+
if (existingTags) {
|
|
28
|
+
for (const tag of existingTags) {
|
|
29
|
+
this.tagToKeys.get(tag)?.delete(key);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const tagSet = new Set(tags);
|
|
33
|
+
this.keyToTags.set(key, tagSet);
|
|
34
|
+
for (const tag of tagSet) {
|
|
35
|
+
const keys = this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set();
|
|
36
|
+
keys.add(key);
|
|
37
|
+
this.tagToKeys.set(tag, keys);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async remove(key) {
|
|
41
|
+
this.knownKeys.delete(key);
|
|
42
|
+
const tags = this.keyToTags.get(key);
|
|
43
|
+
if (!tags) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
for (const tag of tags) {
|
|
47
|
+
const keys = this.tagToKeys.get(tag);
|
|
48
|
+
if (!keys) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
keys.delete(key);
|
|
52
|
+
if (keys.size === 0) {
|
|
53
|
+
this.tagToKeys.delete(tag);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
this.keyToTags.delete(key);
|
|
57
|
+
}
|
|
58
|
+
async keysForTag(tag) {
|
|
59
|
+
return [...this.tagToKeys.get(tag) ?? /* @__PURE__ */ new Set()];
|
|
60
|
+
}
|
|
61
|
+
async matchPattern(pattern) {
|
|
62
|
+
return [...this.knownKeys].filter((key) => PatternMatcher.matches(pattern, key));
|
|
63
|
+
}
|
|
64
|
+
async clear() {
|
|
65
|
+
this.tagToKeys.clear();
|
|
66
|
+
this.keyToTags.clear();
|
|
67
|
+
this.knownKeys.clear();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// src/stampede/StampedeGuard.ts
|
|
72
|
+
import { Mutex } from "async-mutex";
|
|
73
|
+
var StampedeGuard = class {
|
|
74
|
+
mutexes = /* @__PURE__ */ new Map();
|
|
75
|
+
async execute(key, task) {
|
|
76
|
+
const mutex = this.getMutex(key);
|
|
77
|
+
try {
|
|
78
|
+
return await mutex.runExclusive(task);
|
|
79
|
+
} finally {
|
|
80
|
+
if (!mutex.isLocked()) {
|
|
81
|
+
this.mutexes.delete(key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
getMutex(key) {
|
|
86
|
+
let mutex = this.mutexes.get(key);
|
|
87
|
+
if (!mutex) {
|
|
88
|
+
mutex = new Mutex();
|
|
89
|
+
this.mutexes.set(key, mutex);
|
|
90
|
+
}
|
|
91
|
+
return mutex;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// src/CacheStack.ts
|
|
96
|
+
var EMPTY_METRICS = () => ({
|
|
97
|
+
hits: 0,
|
|
98
|
+
misses: 0,
|
|
99
|
+
fetches: 0,
|
|
100
|
+
sets: 0,
|
|
101
|
+
deletes: 0,
|
|
102
|
+
backfills: 0,
|
|
103
|
+
invalidations: 0
|
|
104
|
+
});
|
|
105
|
+
var DebugLogger = class {
|
|
106
|
+
enabled;
|
|
107
|
+
constructor(enabled) {
|
|
108
|
+
this.enabled = enabled;
|
|
109
|
+
}
|
|
110
|
+
debug(message, context) {
|
|
111
|
+
if (!this.enabled) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const suffix = context ? ` ${JSON.stringify(context)}` : "";
|
|
115
|
+
console.debug(`[cachestack] ${message}${suffix}`);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
var CacheStack = class {
|
|
119
|
+
constructor(layers, options = {}) {
|
|
120
|
+
this.layers = layers;
|
|
121
|
+
this.options = options;
|
|
122
|
+
if (layers.length === 0) {
|
|
123
|
+
throw new Error("CacheStack requires at least one cache layer.");
|
|
124
|
+
}
|
|
125
|
+
const debugEnv = process.env.DEBUG?.split(",").includes("cachestack:debug") ?? false;
|
|
126
|
+
this.logger = typeof options.logger === "object" ? options.logger : new DebugLogger(Boolean(options.logger) || debugEnv);
|
|
127
|
+
this.tagIndex = options.tagIndex ?? new TagIndex();
|
|
128
|
+
this.startup = this.initialize();
|
|
129
|
+
}
|
|
130
|
+
layers;
|
|
131
|
+
options;
|
|
132
|
+
stampedeGuard = new StampedeGuard();
|
|
133
|
+
metrics = EMPTY_METRICS();
|
|
134
|
+
instanceId = randomUUID();
|
|
135
|
+
startup;
|
|
136
|
+
unsubscribeInvalidation;
|
|
137
|
+
logger;
|
|
138
|
+
tagIndex;
|
|
139
|
+
async get(key, fetcher, options) {
|
|
140
|
+
await this.startup;
|
|
141
|
+
const hit = await this.getFromLayers(key, options);
|
|
142
|
+
if (hit.found) {
|
|
143
|
+
this.metrics.hits += 1;
|
|
144
|
+
return hit.value;
|
|
145
|
+
}
|
|
146
|
+
this.metrics.misses += 1;
|
|
147
|
+
if (!fetcher) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
const runFetch = async () => {
|
|
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);
|
|
168
|
+
}
|
|
169
|
+
async set(key, value, options) {
|
|
170
|
+
await this.startup;
|
|
171
|
+
await this.setAcrossLayers(key, value, options);
|
|
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
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async delete(key) {
|
|
184
|
+
await this.startup;
|
|
185
|
+
await this.deleteKeys([key]);
|
|
186
|
+
await this.publishInvalidation({ scope: "key", keys: [key], sourceId: this.instanceId, operation: "delete" });
|
|
187
|
+
}
|
|
188
|
+
async clear() {
|
|
189
|
+
await this.startup;
|
|
190
|
+
await Promise.all(this.layers.map((layer) => layer.clear()));
|
|
191
|
+
await this.tagIndex.clear();
|
|
192
|
+
this.metrics.invalidations += 1;
|
|
193
|
+
this.logger.debug("clear");
|
|
194
|
+
await this.publishInvalidation({ scope: "clear", sourceId: this.instanceId, operation: "clear" });
|
|
195
|
+
}
|
|
196
|
+
async mget(entries) {
|
|
197
|
+
return Promise.all(entries.map((entry) => this.get(entry.key, entry.fetch, entry.options)));
|
|
198
|
+
}
|
|
199
|
+
async mset(entries) {
|
|
200
|
+
await Promise.all(entries.map((entry) => this.set(entry.key, entry.value, entry.options)));
|
|
201
|
+
}
|
|
202
|
+
async invalidateByTag(tag) {
|
|
203
|
+
await this.startup;
|
|
204
|
+
const keys = await this.tagIndex.keysForTag(tag);
|
|
205
|
+
await this.deleteKeys(keys);
|
|
206
|
+
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
207
|
+
}
|
|
208
|
+
async invalidateByPattern(pattern) {
|
|
209
|
+
await this.startup;
|
|
210
|
+
const keys = await this.tagIndex.matchPattern(pattern);
|
|
211
|
+
await this.deleteKeys(keys);
|
|
212
|
+
await this.publishInvalidation({ scope: "keys", keys, sourceId: this.instanceId, operation: "invalidate" });
|
|
213
|
+
}
|
|
214
|
+
getMetrics() {
|
|
215
|
+
return { ...this.metrics };
|
|
216
|
+
}
|
|
217
|
+
resetMetrics() {
|
|
218
|
+
Object.assign(this.metrics, EMPTY_METRICS());
|
|
219
|
+
}
|
|
220
|
+
async disconnect() {
|
|
221
|
+
await this.startup;
|
|
222
|
+
await this.unsubscribeInvalidation?.();
|
|
223
|
+
}
|
|
224
|
+
async initialize() {
|
|
225
|
+
if (!this.options.invalidationBus) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.unsubscribeInvalidation = await this.options.invalidationBus.subscribe(async (message) => {
|
|
229
|
+
await this.handleInvalidationMessage(message);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
async getFromLayers(key, options) {
|
|
233
|
+
for (let index = 0; index < this.layers.length; index += 1) {
|
|
234
|
+
const layer = this.layers[index];
|
|
235
|
+
const value = await layer.get(key);
|
|
236
|
+
if (value === null) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
await this.tagIndex.touch(key);
|
|
240
|
+
await this.backfill(key, value, index - 1, options);
|
|
241
|
+
this.logger.debug("hit", { key, layer: layer.name });
|
|
242
|
+
return { found: true, value };
|
|
243
|
+
}
|
|
244
|
+
await this.tagIndex.remove(key);
|
|
245
|
+
this.logger.debug("miss", { key });
|
|
246
|
+
return { found: false, value: null };
|
|
247
|
+
}
|
|
248
|
+
async backfill(key, value, upToIndex, options) {
|
|
249
|
+
if (upToIndex < 0) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
for (let index = 0; index <= upToIndex; index += 1) {
|
|
253
|
+
const layer = this.layers[index];
|
|
254
|
+
await layer.set(key, value, this.resolveTtl(layer.name, layer.defaultTtl, options?.ttl));
|
|
255
|
+
this.metrics.backfills += 1;
|
|
256
|
+
this.logger.debug("backfill", { key, layer: layer.name });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async setAcrossLayers(key, value, options) {
|
|
260
|
+
await Promise.all(
|
|
261
|
+
this.layers.map((layer) => layer.set(key, value, this.resolveTtl(layer.name, layer.defaultTtl, options?.ttl)))
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
resolveTtl(layerName, fallbackTtl, ttlOverride) {
|
|
265
|
+
if (ttlOverride === void 0) {
|
|
266
|
+
return fallbackTtl;
|
|
267
|
+
}
|
|
268
|
+
if (typeof ttlOverride === "number") {
|
|
269
|
+
return ttlOverride;
|
|
270
|
+
}
|
|
271
|
+
return ttlOverride[layerName] ?? fallbackTtl;
|
|
272
|
+
}
|
|
273
|
+
async deleteKeys(keys) {
|
|
274
|
+
if (keys.length === 0) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
await Promise.all(
|
|
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
|
+
);
|
|
286
|
+
for (const key of keys) {
|
|
287
|
+
await this.tagIndex.remove(key);
|
|
288
|
+
}
|
|
289
|
+
this.metrics.deletes += keys.length;
|
|
290
|
+
this.metrics.invalidations += 1;
|
|
291
|
+
this.logger.debug("delete", { keys });
|
|
292
|
+
}
|
|
293
|
+
async publishInvalidation(message) {
|
|
294
|
+
if (!this.options.invalidationBus) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
await this.options.invalidationBus.publish(message);
|
|
298
|
+
}
|
|
299
|
+
async handleInvalidationMessage(message) {
|
|
300
|
+
if (message.sourceId === this.instanceId) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const localLayers = this.layers.filter((layer) => layer.isLocal);
|
|
304
|
+
if (localLayers.length === 0) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (message.scope === "clear") {
|
|
308
|
+
await Promise.all(localLayers.map((layer) => layer.clear()));
|
|
309
|
+
await this.tagIndex.clear();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const keys = message.keys ?? [];
|
|
313
|
+
await Promise.all(
|
|
314
|
+
localLayers.map(async (layer) => {
|
|
315
|
+
if (layer.deleteMany) {
|
|
316
|
+
await layer.deleteMany(keys);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
await Promise.all(keys.map((key) => layer.delete(key)));
|
|
320
|
+
})
|
|
321
|
+
);
|
|
322
|
+
if (message.operation !== "write") {
|
|
323
|
+
for (const key of keys) {
|
|
324
|
+
await this.tagIndex.remove(key);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// src/invalidation/RedisInvalidationBus.ts
|
|
331
|
+
var RedisInvalidationBus = class {
|
|
332
|
+
channel;
|
|
333
|
+
publisher;
|
|
334
|
+
subscriber;
|
|
335
|
+
constructor(options) {
|
|
336
|
+
this.publisher = options.publisher;
|
|
337
|
+
this.subscriber = options.subscriber ?? options.publisher.duplicate();
|
|
338
|
+
this.channel = options.channel ?? "cachestack:invalidation";
|
|
339
|
+
}
|
|
340
|
+
async subscribe(handler) {
|
|
341
|
+
const listener = async (_channel, payload) => {
|
|
342
|
+
const message = JSON.parse(payload);
|
|
343
|
+
await handler(message);
|
|
344
|
+
};
|
|
345
|
+
this.subscriber.on("message", listener);
|
|
346
|
+
await this.subscriber.subscribe(this.channel);
|
|
347
|
+
return async () => {
|
|
348
|
+
this.subscriber.off("message", listener);
|
|
349
|
+
await this.subscriber.unsubscribe(this.channel);
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
async publish(message) {
|
|
353
|
+
await this.publisher.publish(this.channel, JSON.stringify(message));
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// src/invalidation/RedisTagIndex.ts
|
|
358
|
+
var RedisTagIndex = class {
|
|
359
|
+
client;
|
|
360
|
+
prefix;
|
|
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);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
await pipeline.exec();
|
|
386
|
+
}
|
|
387
|
+
async remove(key) {
|
|
388
|
+
const keyTagsKey = this.keyTagsKey(key);
|
|
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);
|
|
395
|
+
}
|
|
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
|
+
}
|
|
418
|
+
async clear() {
|
|
419
|
+
const indexKeys = await this.scanIndexKeys();
|
|
420
|
+
if (indexKeys.length === 0) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
await this.client.del(...indexKeys);
|
|
424
|
+
}
|
|
425
|
+
async scanIndexKeys() {
|
|
426
|
+
const matches = [];
|
|
427
|
+
let cursor = "0";
|
|
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`;
|
|
438
|
+
}
|
|
439
|
+
keyTagsKey(key) {
|
|
440
|
+
return `${this.prefix}:key:${encodeURIComponent(key)}`;
|
|
441
|
+
}
|
|
442
|
+
tagKeysKey(tag) {
|
|
443
|
+
return `${this.prefix}:tag:${encodeURIComponent(tag)}`;
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// src/layers/MemoryLayer.ts
|
|
448
|
+
var MemoryLayer = class {
|
|
449
|
+
name;
|
|
450
|
+
defaultTtl;
|
|
451
|
+
isLocal = true;
|
|
452
|
+
maxSize;
|
|
453
|
+
entries = /* @__PURE__ */ new Map();
|
|
454
|
+
constructor(options = {}) {
|
|
455
|
+
this.name = options.name ?? "memory";
|
|
456
|
+
this.defaultTtl = options.ttl;
|
|
457
|
+
this.maxSize = options.maxSize ?? 1e3;
|
|
458
|
+
}
|
|
459
|
+
async get(key) {
|
|
460
|
+
const entry = this.entries.get(key);
|
|
461
|
+
if (!entry) {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
if (this.isExpired(entry)) {
|
|
465
|
+
this.entries.delete(key);
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
this.entries.delete(key);
|
|
469
|
+
this.entries.set(key, entry);
|
|
470
|
+
return entry.value;
|
|
471
|
+
}
|
|
472
|
+
async set(key, value, ttl = this.defaultTtl) {
|
|
473
|
+
this.entries.delete(key);
|
|
474
|
+
this.entries.set(key, {
|
|
475
|
+
value,
|
|
476
|
+
expiresAt: ttl && ttl > 0 ? Date.now() + ttl * 1e3 : null
|
|
477
|
+
});
|
|
478
|
+
while (this.entries.size > this.maxSize) {
|
|
479
|
+
const oldestKey = this.entries.keys().next().value;
|
|
480
|
+
if (!oldestKey) {
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
this.entries.delete(oldestKey);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
async delete(key) {
|
|
487
|
+
this.entries.delete(key);
|
|
488
|
+
}
|
|
489
|
+
async deleteMany(keys) {
|
|
490
|
+
for (const key of keys) {
|
|
491
|
+
this.entries.delete(key);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async clear() {
|
|
495
|
+
this.entries.clear();
|
|
496
|
+
}
|
|
497
|
+
async keys() {
|
|
498
|
+
this.pruneExpired();
|
|
499
|
+
return [...this.entries.keys()];
|
|
500
|
+
}
|
|
501
|
+
pruneExpired() {
|
|
502
|
+
for (const [key, entry] of this.entries.entries()) {
|
|
503
|
+
if (this.isExpired(entry)) {
|
|
504
|
+
this.entries.delete(key);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
isExpired(entry) {
|
|
509
|
+
return entry.expiresAt !== null && entry.expiresAt <= Date.now();
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
// src/serialization/JsonSerializer.ts
|
|
514
|
+
var JsonSerializer = class {
|
|
515
|
+
serialize(value) {
|
|
516
|
+
return JSON.stringify(value);
|
|
517
|
+
}
|
|
518
|
+
deserialize(payload) {
|
|
519
|
+
const normalized = Buffer.isBuffer(payload) ? payload.toString("utf8") : payload;
|
|
520
|
+
return JSON.parse(normalized);
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// src/layers/RedisLayer.ts
|
|
525
|
+
var RedisLayer = class {
|
|
526
|
+
name;
|
|
527
|
+
defaultTtl;
|
|
528
|
+
isLocal = false;
|
|
529
|
+
client;
|
|
530
|
+
serializer;
|
|
531
|
+
prefix;
|
|
532
|
+
allowUnprefixedClear;
|
|
533
|
+
scanCount;
|
|
534
|
+
constructor(options) {
|
|
535
|
+
this.client = options.client;
|
|
536
|
+
this.defaultTtl = options.ttl;
|
|
537
|
+
this.name = options.name ?? "redis";
|
|
538
|
+
this.serializer = options.serializer ?? new JsonSerializer();
|
|
539
|
+
this.prefix = options.prefix ?? "";
|
|
540
|
+
this.allowUnprefixedClear = options.allowUnprefixedClear ?? false;
|
|
541
|
+
this.scanCount = options.scanCount ?? 100;
|
|
542
|
+
}
|
|
543
|
+
async get(key) {
|
|
544
|
+
const payload = await this.client.getBuffer(this.withPrefix(key));
|
|
545
|
+
if (payload === null) {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
return this.serializer.deserialize(payload);
|
|
549
|
+
}
|
|
550
|
+
async set(key, value, ttl = this.defaultTtl) {
|
|
551
|
+
const payload = this.serializer.serialize(value);
|
|
552
|
+
const normalizedKey = this.withPrefix(key);
|
|
553
|
+
if (ttl && ttl > 0) {
|
|
554
|
+
await this.client.set(normalizedKey, payload, "EX", ttl);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
await this.client.set(normalizedKey, payload);
|
|
558
|
+
}
|
|
559
|
+
async delete(key) {
|
|
560
|
+
await this.client.del(this.withPrefix(key));
|
|
561
|
+
}
|
|
562
|
+
async deleteMany(keys) {
|
|
563
|
+
if (keys.length === 0) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
await this.client.del(...keys.map((key) => this.withPrefix(key)));
|
|
567
|
+
}
|
|
568
|
+
async clear() {
|
|
569
|
+
if (!this.prefix && !this.allowUnprefixedClear) {
|
|
570
|
+
throw new Error("RedisLayer.clear() requires a prefix or allowUnprefixedClear=true to avoid deleting unrelated keys.");
|
|
571
|
+
}
|
|
572
|
+
const keys = await this.keys();
|
|
573
|
+
if (keys.length > 0) {
|
|
574
|
+
await this.deleteMany(keys);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
async keys() {
|
|
578
|
+
const pattern = `${this.prefix}*`;
|
|
579
|
+
const keys = await this.scanKeys(pattern);
|
|
580
|
+
if (!this.prefix) {
|
|
581
|
+
return keys;
|
|
582
|
+
}
|
|
583
|
+
return keys.map((key) => key.slice(this.prefix.length));
|
|
584
|
+
}
|
|
585
|
+
async scanKeys(pattern) {
|
|
586
|
+
const matches = [];
|
|
587
|
+
let cursor = "0";
|
|
588
|
+
do {
|
|
589
|
+
const [nextCursor, keys] = await this.client.scan(cursor, "MATCH", pattern, "COUNT", this.scanCount);
|
|
590
|
+
cursor = nextCursor;
|
|
591
|
+
matches.push(...keys);
|
|
592
|
+
} while (cursor !== "0");
|
|
593
|
+
return matches;
|
|
594
|
+
}
|
|
595
|
+
withPrefix(key) {
|
|
596
|
+
return `${this.prefix}${key}`;
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// src/serialization/MsgpackSerializer.ts
|
|
601
|
+
import { decode, encode } from "@msgpack/msgpack";
|
|
602
|
+
var MsgpackSerializer = class {
|
|
603
|
+
serialize(value) {
|
|
604
|
+
return Buffer.from(encode(value));
|
|
605
|
+
}
|
|
606
|
+
deserialize(payload) {
|
|
607
|
+
const normalized = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
608
|
+
return decode(normalized);
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
export {
|
|
612
|
+
CacheStack,
|
|
613
|
+
JsonSerializer,
|
|
614
|
+
MemoryLayer,
|
|
615
|
+
MsgpackSerializer,
|
|
616
|
+
PatternMatcher,
|
|
617
|
+
RedisInvalidationBus,
|
|
618
|
+
RedisLayer,
|
|
619
|
+
RedisTagIndex,
|
|
620
|
+
StampedeGuard,
|
|
621
|
+
TagIndex
|
|
622
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import express from 'express'
|
|
2
|
+
import Redis from 'ioredis'
|
|
3
|
+
import { CacheStack, MemoryLayer, RedisLayer } from '../../src'
|
|
4
|
+
|
|
5
|
+
const redis = new Redis(process.env.REDIS_URL)
|
|
6
|
+
const cache = new CacheStack([
|
|
7
|
+
new MemoryLayer({ ttl: 30, maxSize: 5_000 }),
|
|
8
|
+
new RedisLayer({ client: redis, ttl: 300 })
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
const app = express()
|
|
12
|
+
|
|
13
|
+
app.get('/users/:id', async (req, res) => {
|
|
14
|
+
const user = await cache.get(`user:${req.params.id}`, async () => {
|
|
15
|
+
return {
|
|
16
|
+
id: Number(req.params.id),
|
|
17
|
+
name: `User ${req.params.id}`,
|
|
18
|
+
source: 'db'
|
|
19
|
+
}
|
|
20
|
+
}, {
|
|
21
|
+
tags: ['user', `user:${req.params.id}`]
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
res.json(user)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
app.listen(3000)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common'
|
|
2
|
+
import Redis from 'ioredis'
|
|
3
|
+
import { MemoryLayer, RedisLayer } from '../../src'
|
|
4
|
+
import { CacheStackModule } from '../../packages/nestjs/src'
|
|
5
|
+
|
|
6
|
+
const redis = new Redis(process.env.REDIS_URL)
|
|
7
|
+
|
|
8
|
+
@Module({
|
|
9
|
+
imports: [
|
|
10
|
+
CacheStackModule.forRoot({
|
|
11
|
+
layers: [
|
|
12
|
+
new MemoryLayer({ ttl: 20 }),
|
|
13
|
+
new RedisLayer({ client: redis, ttl: 300 })
|
|
14
|
+
]
|
|
15
|
+
})
|
|
16
|
+
]
|
|
17
|
+
})
|
|
18
|
+
export class AppModule {}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import Redis from 'ioredis'
|
|
2
|
+
import { CacheStack, MemoryLayer, RedisLayer } from '../../src'
|
|
3
|
+
|
|
4
|
+
const redis = new Redis(process.env.REDIS_URL)
|
|
5
|
+
const cache = new CacheStack([
|
|
6
|
+
new MemoryLayer({ ttl: 15 }),
|
|
7
|
+
new RedisLayer({ client: redis, ttl: 120 })
|
|
8
|
+
])
|
|
9
|
+
|
|
10
|
+
export async function GET(_request: Request, context: { params: { id: string } }): Promise<Response> {
|
|
11
|
+
const data = await cache.get(`user:${context.params.id}`, async () => {
|
|
12
|
+
return {
|
|
13
|
+
id: Number(context.params.id),
|
|
14
|
+
cachedAt: new Date().toISOString()
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
return Response.json(data)
|
|
19
|
+
}
|