cachette 3.0.1 → 4.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 +2 -1
- package/package.json +2 -3
- package/src/index.ts +5 -0
- package/src/lib/CacheClient.ts +145 -0
- package/src/lib/CacheInstance.ts +222 -0
- package/src/lib/LocalCache.ts +178 -0
- package/src/lib/RedisCache.ts +431 -0
- package/src/lib/WriteThroughCache.ts +200 -0
- package/test/CacheClient_test.ts +318 -0
- package/test/CacheInstance_test.ts +488 -0
- package/test/LocalCache_test.ts +128 -0
- package/test/RedisCache_test.ts +303 -0
- package/test/WriteThroughCache_test.ts +306 -0
- package/test/mocharc-ci.js +12 -0
- package/test/mocharc.js +10 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import Redis from 'ioredis';
|
|
2
|
+
import * as Redlock from 'redlock';
|
|
3
|
+
|
|
4
|
+
import { CachableValue, CacheInstance } from './CacheInstance';
|
|
5
|
+
|
|
6
|
+
export const SIZE_THRESHOLD_WARNING_BYTES = 20_000_000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Wrapper class for using Redis as a cache.
|
|
10
|
+
*
|
|
11
|
+
* If no redis_strategy nor error event handler are defined, the client
|
|
12
|
+
* will throw uncaught exceptions on stream errors! These must be defined,
|
|
13
|
+
* or the process might crash unexpectedly.
|
|
14
|
+
*/
|
|
15
|
+
export class RedisCache extends CacheInstance {
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* We cannot store null and booleans in Redis, so we store
|
|
19
|
+
* random values representing these values instead.
|
|
20
|
+
*/
|
|
21
|
+
public static NULL_VALUE = 'f405eed4-507c-4aa5-a6d2-c1813d584b8f-NULL';
|
|
22
|
+
public static TRUE_VALUE = 'f405eed4-507c-4aa5-a6d2-c1813d584b8f-TRUE';
|
|
23
|
+
public static FALSE_VALUE = 'f405eed4-507c-4aa5-a6d2-c1813d584b8f-FALSE';
|
|
24
|
+
public static JSON_PREFIX = 'f405eed4-507c-4aa5-a6d2-c1813d584b8f-JSON';
|
|
25
|
+
public static ERROR_PREFIX = 'f405eed4-507c-4aa5-a6d2-c1813d584b8f-ERROR';
|
|
26
|
+
public static NUMBER_PREFIX = 'f405eed4-507c-4aa5-a6d2-c1813d584b8f-NUMBER';
|
|
27
|
+
|
|
28
|
+
public static REDIS_CONNECTION_TIMEOUT_MS = parseInt(process.env.REDIS_CONNECTION_TIMEOUT_MS as string, 10) || 5000;
|
|
29
|
+
public static REDLOCK_RETRY_COUNT = parseInt(process.env.REDLOCK_RETRY_COUNT as string, 10) || 20; // lib. default: 10
|
|
30
|
+
public static REDLOCK_RETRY_DELAY_MS = parseInt(process.env.REDLOCK_RETRY_DELAY_MS as string, 10) || 200; // lib. default: 200
|
|
31
|
+
public static REDLOCK_CLOCK_DRIFT_FACTOR = parseInt(process.env.REDLOCK_CLOCK_DRIFT_FACTOR as string, 10) || 0.01; // lib. default: 0.01
|
|
32
|
+
public static REDLOCK_JITTER_MS = parseInt(process.env.REDLOCK_JITTER_MS as string, 10) || 200; // lib. default: 200
|
|
33
|
+
|
|
34
|
+
private redisClient: Redis;
|
|
35
|
+
private ready = false;
|
|
36
|
+
private url: string;
|
|
37
|
+
// We manage several redlock instances because some options (like retryCount)
|
|
38
|
+
// are set at redlock init. By having these options in our constructor too
|
|
39
|
+
// (and only having one redlock with fixed behavior), we would be unable to
|
|
40
|
+
// support mixing calls requiring one behavior, then another.
|
|
41
|
+
// And so, we have as many redlocks as we need to honor these runtime needs.
|
|
42
|
+
private redlock: Redlock;
|
|
43
|
+
private redlockWithoutRetry: Redlock;
|
|
44
|
+
|
|
45
|
+
constructor(redisUrl: string, readOnly = false) {
|
|
46
|
+
super();
|
|
47
|
+
|
|
48
|
+
if (!redisUrl || (!redisUrl.startsWith('redis://') && !redisUrl.startsWith('rediss://'))) {
|
|
49
|
+
throw new Error(`Invalid redis url ${redisUrl}.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.url = redisUrl;
|
|
53
|
+
this.redisClient = new Redis(redisUrl, {
|
|
54
|
+
readOnly,
|
|
55
|
+
retryStrategy: () => RedisCache.REDIS_CONNECTION_TIMEOUT_MS,
|
|
56
|
+
// Force a reconnect during Redis maintenance|upgrades, where a failover
|
|
57
|
+
// primary / replica causes clients connected to the primary to become
|
|
58
|
+
// connected to what is now a replica, and error that writes fail.
|
|
59
|
+
// Following upstream recipe at https://github.com/redis/ioredis#reconnect-on-error
|
|
60
|
+
reconnectOnError: (err: Error) => !readOnly && err.message.includes('READONLY'),
|
|
61
|
+
// This prevents get/setValue calls from hanging if there is no active connection
|
|
62
|
+
enableOfflineQueue: false,
|
|
63
|
+
});
|
|
64
|
+
this.redlock = new Redlock([this.redisClient as unknown as Redlock.CompatibleRedisClient], { // Hack until Redlock 5.x is out of beta
|
|
65
|
+
driftFactor: RedisCache.REDLOCK_CLOCK_DRIFT_FACTOR,
|
|
66
|
+
retryCount: RedisCache.REDLOCK_RETRY_COUNT,
|
|
67
|
+
retryDelay: RedisCache.REDLOCK_RETRY_DELAY_MS,
|
|
68
|
+
retryJitter: RedisCache.REDLOCK_JITTER_MS,
|
|
69
|
+
});
|
|
70
|
+
this.redlockWithoutRetry = new Redlock([this.redisClient as unknown as Redlock.CompatibleRedisClient], { // Hack until Redlock 5.x is out of beta
|
|
71
|
+
driftFactor: RedisCache.REDLOCK_CLOCK_DRIFT_FACTOR,
|
|
72
|
+
retryCount: 0,
|
|
73
|
+
retryDelay: 0,
|
|
74
|
+
retryJitter: 0,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
this.redisClient.on('ready', this.startConnectionStrategy.bind(this));
|
|
78
|
+
this.redisClient.on('end', this.endConnectionStrategy.bind(this));
|
|
79
|
+
this.redisClient.on('error', this.errorStrategy.bind(this));
|
|
80
|
+
|
|
81
|
+
// TODO when migrating to Redlock v5: rename 'clientError' to 'error'
|
|
82
|
+
this.redlock.on('clientError', this.redlockErrorStrategy.bind(this));
|
|
83
|
+
this.redlockWithoutRetry.on('clientError', this.redlockErrorStrategy.bind(this));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @inheritdoc
|
|
88
|
+
*/
|
|
89
|
+
public async isReady(): Promise<void> {
|
|
90
|
+
if (this.ready) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
return new Promise<void>(resolve => this.redisClient.on('ready', resolve));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @inheritdoc
|
|
98
|
+
*/
|
|
99
|
+
public async itemCount(): Promise<number> {
|
|
100
|
+
return this.redisClient.dbsize();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The error event is emitted on stream error.
|
|
105
|
+
* We must catch it, otherwise it will crash the process
|
|
106
|
+
* with an UncaughtException.
|
|
107
|
+
*/
|
|
108
|
+
public errorStrategy(): void {
|
|
109
|
+
this.emit('warn', 'Error while connected to the Redis cache!');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public redlockErrorStrategy(err: any): void {
|
|
113
|
+
this.emit('warn', 'Redlock error:', err);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The end event is emitted by the redis client when an
|
|
118
|
+
* established connection has ended.
|
|
119
|
+
*/
|
|
120
|
+
public endConnectionStrategy(err): void {
|
|
121
|
+
this.emit('warn', 'Connection lost to Redis.', err);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The connect event is emitted by the redis client as
|
|
126
|
+
* soon as a new connection is established.
|
|
127
|
+
*/
|
|
128
|
+
public startConnectionStrategy(): void {
|
|
129
|
+
this.ready = true;
|
|
130
|
+
this.emit('info', `Connection established to Redis at ${this.url}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Some values are not supported by Redis and/or by the
|
|
135
|
+
* Redis client library.
|
|
136
|
+
* As per the documentation:
|
|
137
|
+
* > Please be aware that sending null, undefined and Boolean
|
|
138
|
+
* > values will result in the value coerced to a string!
|
|
139
|
+
*
|
|
140
|
+
* We serialize these values to be able to store
|
|
141
|
+
* and retrieve them as strings.
|
|
142
|
+
*
|
|
143
|
+
*/
|
|
144
|
+
public static serializeValue(value: CachableValue): string {
|
|
145
|
+
|
|
146
|
+
if (value === null) {
|
|
147
|
+
return RedisCache.NULL_VALUE;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (value === true) {
|
|
151
|
+
return RedisCache.TRUE_VALUE;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (value === false) {
|
|
155
|
+
return RedisCache.FALSE_VALUE;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (value instanceof Error) {
|
|
159
|
+
return RedisCache.ERROR_PREFIX + JSON.stringify({
|
|
160
|
+
...value, // serialize potential Error metadata set as object properties
|
|
161
|
+
message: value.message,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (typeof value === 'number') {
|
|
166
|
+
return `${RedisCache.NUMBER_PREFIX}${value}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (value instanceof Object) {
|
|
170
|
+
return RedisCache.JSON_PREFIX + JSON.stringify(value, (key, value) => {
|
|
171
|
+
if (value instanceof Set) {
|
|
172
|
+
return { __dataType: 'Set', value: Array.from(value) };
|
|
173
|
+
} else if (value instanceof Map) {
|
|
174
|
+
return { __dataType: 'Map', value: Array.from(value) };
|
|
175
|
+
} else {
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Deserializes a value coming from Redis.
|
|
186
|
+
*
|
|
187
|
+
* As per the documentation:
|
|
188
|
+
* > Minimal parsing is done on the replies. Commands that return a
|
|
189
|
+
* > integer return JavaScript Numbers, arrays return JavaScript Array.
|
|
190
|
+
* > HGETALL returns an Object keyed by the hash keys.
|
|
191
|
+
*
|
|
192
|
+
* also from the documentation:
|
|
193
|
+
* > If the key is missing, reply will be null.
|
|
194
|
+
*
|
|
195
|
+
*/
|
|
196
|
+
public static deserializeValue(value: string | null): CachableValue {
|
|
197
|
+
|
|
198
|
+
if (value === null) {
|
|
199
|
+
// null means that the key was not present, which we interpret as undefined.
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (value === RedisCache.NULL_VALUE) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (value === RedisCache.TRUE_VALUE) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (value === RedisCache.FALSE_VALUE) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (value.startsWith(RedisCache.ERROR_PREFIX)) {
|
|
216
|
+
const deserializedError = JSON.parse(value.substring(RedisCache.ERROR_PREFIX.length));
|
|
217
|
+
// return error, restoring potential Error metadata set as object properties
|
|
218
|
+
return Object.assign(new Error(deserializedError.message), deserializedError);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (value.startsWith(RedisCache.NUMBER_PREFIX)) {
|
|
222
|
+
const deserializedNumber = value.substring(RedisCache.NUMBER_PREFIX.length);
|
|
223
|
+
return Number.parseFloat(deserializedNumber);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (value.startsWith(RedisCache.JSON_PREFIX)) {
|
|
227
|
+
return JSON.parse(value.substring(RedisCache.JSON_PREFIX.length), (key, value) => {
|
|
228
|
+
if (typeof value === 'object' && value !== null) {
|
|
229
|
+
if (value.__dataType === 'Set') {
|
|
230
|
+
return new Set(value.value);
|
|
231
|
+
} else if (value.__dataType === 'Map') {
|
|
232
|
+
return new Map(value.value);
|
|
233
|
+
} else {
|
|
234
|
+
return value;
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return value;
|
|
243
|
+
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @inheritdoc
|
|
248
|
+
*/
|
|
249
|
+
public async setValue(
|
|
250
|
+
key: string,
|
|
251
|
+
value: CachableValue,
|
|
252
|
+
ttl = 0,
|
|
253
|
+
): Promise<boolean> {
|
|
254
|
+
try {
|
|
255
|
+
return await this.setValueInternal(key, value, ttl);
|
|
256
|
+
} catch (err) {
|
|
257
|
+
// Examples of things that may occur here:
|
|
258
|
+
// - A timeout, if the connection was broken during a value fetch.
|
|
259
|
+
// - A general error, e.g. if Redis is Out Of Memory.
|
|
260
|
+
const error = err as Error;
|
|
261
|
+
this.emit('warn', `Error while setting Redis key ${key} with ttl ${ttl}: ${error.name} - ${error.message}\n${error.stack}`);
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
public async setValueInternal(
|
|
267
|
+
key: string,
|
|
268
|
+
value: CachableValue,
|
|
269
|
+
ttl: number,
|
|
270
|
+
): Promise<boolean> {
|
|
271
|
+
this.emit('set', key, value);
|
|
272
|
+
|
|
273
|
+
if (value === undefined) {
|
|
274
|
+
this.emit('warn', `Cannot set ${key} to undefined!`);
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
value = RedisCache.serializeValue(value);
|
|
279
|
+
|
|
280
|
+
const serializedValueBytes = Buffer.byteLength(value, 'utf8');
|
|
281
|
+
if (serializedValueBytes >= SIZE_THRESHOLD_WARNING_BYTES) {
|
|
282
|
+
const mb = Math.round(serializedValueBytes / 1_000_000);
|
|
283
|
+
this.emit('warn', `Writing large value to Redis! Key "${key}" with ttl=${ttl} has a value of ${mb} MB!`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let result: 'OK' | undefined;
|
|
287
|
+
if (ttl !== 0) {
|
|
288
|
+
result = await this.redisClient.set(key, value, 'EX', ttl);
|
|
289
|
+
} else {
|
|
290
|
+
result = await this.redisClient.set(key, value);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return result === 'OK';
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @inheritdoc
|
|
298
|
+
*/
|
|
299
|
+
public async getValue(key: string): Promise<CachableValue> {
|
|
300
|
+
try {
|
|
301
|
+
return await this.getValueInternal(key);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
/**
|
|
304
|
+
* A timeout can occur if the connection was broken during
|
|
305
|
+
* a value fetching. We don't want to hang forever if this is the case.
|
|
306
|
+
*/
|
|
307
|
+
this.emit('warn', 'Error while fetching value from the Redis cache', error);
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async getValueInternal(key: string): Promise<CachableValue> {
|
|
313
|
+
const value = await this.redisClient.get(key);
|
|
314
|
+
this.emit('get', key, value);
|
|
315
|
+
return RedisCache.deserializeValue(value);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* @inheritdoc
|
|
320
|
+
*/
|
|
321
|
+
public async getTtl(key: string): Promise<number | undefined> {
|
|
322
|
+
try {
|
|
323
|
+
const ttl = await this.redisClient.pttl(key);
|
|
324
|
+
if (ttl === -1) {
|
|
325
|
+
return 0;
|
|
326
|
+
}
|
|
327
|
+
if (ttl <= 0) {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
return ttl;
|
|
331
|
+
} catch (error) {
|
|
332
|
+
this.emit('warn', 'Error while fetching ttl from the Redis cache', error);
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* @inheritdoc
|
|
339
|
+
*/
|
|
340
|
+
public async delValue(key: string): Promise<void> {
|
|
341
|
+
this.emit('del', key);
|
|
342
|
+
await this.redisClient.del(key);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* @inheritdoc
|
|
347
|
+
*/
|
|
348
|
+
public async waitForReplication(replicas: number, timeout: number): Promise<number> {
|
|
349
|
+
this.emit('wait');
|
|
350
|
+
return this.redisClient.wait(replicas, timeout);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* @inheritdoc
|
|
355
|
+
*/
|
|
356
|
+
public async clear(): Promise<void> {
|
|
357
|
+
await this.redisClient.flushall();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @inheritdoc
|
|
362
|
+
*/
|
|
363
|
+
public async clearMemory(): Promise<void> {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* @inheritdoc
|
|
369
|
+
* Locking through the redlock algorithm
|
|
370
|
+
* https://redis.io/topics/distlock
|
|
371
|
+
*/
|
|
372
|
+
public isLockingSupported(): boolean {
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* @inheritdoc
|
|
378
|
+
*/
|
|
379
|
+
public async lock(resource: string, ttlMs: number, retry = true): Promise<Redlock.Lock> {
|
|
380
|
+
const redlock = retry === false ? this.redlockWithoutRetry : this.redlock;
|
|
381
|
+
return redlock.lock(resource, ttlMs);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* @inheritdoc
|
|
386
|
+
*/
|
|
387
|
+
public async unlock(lock: Redlock.Lock): Promise<void> {
|
|
388
|
+
return this.redlock.unlock(lock);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* @inheritdoc
|
|
393
|
+
*
|
|
394
|
+
* Implementation note & usage ***warning***: looking at Redis docs and the www,
|
|
395
|
+
* there's no "index-backed" Redis function to do this in O(1).
|
|
396
|
+
*
|
|
397
|
+
* So, doing it with a Redis SCAN, https://redis.io/commands/scan . In many use cases it's okay,
|
|
398
|
+
* 1. Because Redis SCAN is fast (10M keys / 40ms on a laptop)
|
|
399
|
+
* 2. If your use case writes a reasonable number of locks, and sets reasonably-small TTLs,
|
|
400
|
+
* guaranteeing Redis contains a reasonable-to-scan volume of items (depending on your hardware).
|
|
401
|
+
*
|
|
402
|
+
* Recommendation: This implies **workloads relying on this function should
|
|
403
|
+
* own their own Redis db**, to not scan through tons of unrelated keys.
|
|
404
|
+
*
|
|
405
|
+
* Implementation note: you might try to use instead a redis Hashmap / Set / Sorted set to group
|
|
406
|
+
* "sublocks", to be able use H/S/Z Redis functions to query efficiently inside a group of locks.
|
|
407
|
+
* That won't work in use cases where you need one TTL per lock, because it'd limit to one TTL
|
|
408
|
+
* (associated to a Redis *value*!) per prefix. Thus, values with TTLs, thus, SCAN.
|
|
409
|
+
*/
|
|
410
|
+
public async hasLock(prefix: string): Promise<boolean> {
|
|
411
|
+
const redisPrefix = prefix.endsWith('*') ? prefix : `${prefix}*`;
|
|
412
|
+
let cursor = '';
|
|
413
|
+
while (cursor !== '0') { // indicates Redis completed the scan
|
|
414
|
+
// Redis detail: we set the `count` option to a number (1000) greater than
|
|
415
|
+
// the default (10), to minimize the amount of network round-trips caused
|
|
416
|
+
// by incomplete scans needing more scanning from the returned cursor.
|
|
417
|
+
const [nextCursor, matchingKeys] = await this.redisClient.scan(cursor || '0', 'MATCH', redisPrefix, 'COUNT', 1000);
|
|
418
|
+
if (matchingKeys.length > 0) {
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
cursor = nextCursor;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
public async quit(): Promise<void> {
|
|
428
|
+
await this.redisClient.quit();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { CachableValue, CacheInstance } from './CacheInstance';
|
|
2
|
+
import { RedisCache } from './RedisCache';
|
|
3
|
+
import { LocalCache } from './LocalCache';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Write-through cache, using Redis and a local LRU cache with aligned TTLs.
|
|
8
|
+
*
|
|
9
|
+
* **WARNING** if using this in a distributed app where cache consistency matters!
|
|
10
|
+
* Consider this case of an app with several servers/instances using WriteThroughCache:
|
|
11
|
+
* 1. Instance I1 sets key/value foo: bar (both local & redis)
|
|
12
|
+
* 2. Instance I2 gets value foo, populating its local cache with "bar"
|
|
13
|
+
* ----- At that moment, LocalCaches of I1 & I2 are aligned, foo: bar
|
|
14
|
+
* 3. Instance I1 deletes key foo (both local & redis)
|
|
15
|
+
* ----- At that moment, LocalCaches of I1 & I2 are *mis*aligned about foo!
|
|
16
|
+
* - I1 has nothing
|
|
17
|
+
* - I2 has "bar"
|
|
18
|
+
*
|
|
19
|
+
* -> This is fixable, e.g. using Redis pub/sub, to let clients subscribe to
|
|
20
|
+
* set/del events, and react with an eviction from their LocalCache.
|
|
21
|
+
* In the context where we cachette maintainers got bitten by this, it made
|
|
22
|
+
* sense to just abandon usage of WriteThroughCache, and switch the app with
|
|
23
|
+
* high consistency expectations from a WriteThroughCache to a RedisCache:
|
|
24
|
+
* +++: simpler, more consistent
|
|
25
|
+
* ---: a tolerable increase in Redis usage (CPU, network, latency)
|
|
26
|
+
*
|
|
27
|
+
* So, pub/sub remains unimplemented, but it would be a reasonable addition.
|
|
28
|
+
* API design consideration: set/dels maybe shouldn't *always* cause Redis
|
|
29
|
+
* publish (said differently, maybe not *all* set/dels should cause a
|
|
30
|
+
* LocalCache eviction, to limit pub/sub traffic explosion? Maybe, or maybe
|
|
31
|
+
* "pub/sub traffic explosion" is a non-concern? To be evaluated :)
|
|
32
|
+
*/
|
|
33
|
+
export class WriteThroughCache extends CacheInstance {
|
|
34
|
+
|
|
35
|
+
private redisCacheForWriting: CacheInstance;
|
|
36
|
+
private redisCacheForReading: CacheInstance;
|
|
37
|
+
private localCache: CacheInstance;
|
|
38
|
+
|
|
39
|
+
private metrics: {
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
localHits: number;
|
|
42
|
+
redisHits: number;
|
|
43
|
+
doubleMisses: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
constructor(redisUrl: string) {
|
|
47
|
+
super();
|
|
48
|
+
this.redisCacheForWriting = new RedisCache(redisUrl);
|
|
49
|
+
this.redisCacheForReading = new RedisCache(redisUrl, true);
|
|
50
|
+
this.localCache = new LocalCache();
|
|
51
|
+
|
|
52
|
+
this.metrics = {
|
|
53
|
+
enabled: false,
|
|
54
|
+
localHits: 0,
|
|
55
|
+
redisHits: 0,
|
|
56
|
+
doubleMisses: 0,
|
|
57
|
+
};
|
|
58
|
+
if (process.env.CACHETTE_METRICS_PERIOD_MINUTES) {
|
|
59
|
+
const metricsPeriod = parseInt(process.env.CACHETTE_METRICS_PERIOD_MINUTES, 10);
|
|
60
|
+
if (Number.isInteger(metricsPeriod) && metricsPeriod > 0) {
|
|
61
|
+
this.metrics.enabled = true;
|
|
62
|
+
this.redisCacheForWriting.emit('info', `WriteThroughCache metrics enabled, will report every ${metricsPeriod} min`);
|
|
63
|
+
setInterval(() => {
|
|
64
|
+
const total = this.metrics.localHits + this.metrics.redisHits + this.metrics.doubleMisses;
|
|
65
|
+
this.redisCacheForWriting.emit(
|
|
66
|
+
'info',
|
|
67
|
+
`WriteThroughCache metrics during last ${metricsPeriod} min - Total: ${total}, ` +
|
|
68
|
+
`Local hits: ${this.metrics.localHits} (${total && Math.floor(100 * this.metrics.localHits / total)}%), ` +
|
|
69
|
+
`Redis hits: ${this.metrics.redisHits} (${total && Math.floor(100 * this.metrics.redisHits / total)}%), ` +
|
|
70
|
+
`Double misses: ${this.metrics.doubleMisses} (${total && Math.floor(100 * this.metrics.doubleMisses / total)}%).`,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
this.metrics.localHits = 0;
|
|
74
|
+
this.metrics.redisHits = 0;
|
|
75
|
+
this.metrics.doubleMisses = 0;
|
|
76
|
+
}, metricsPeriod * 60 * 1000);
|
|
77
|
+
} else {
|
|
78
|
+
this.redisCacheForWriting.emit(
|
|
79
|
+
'warn',
|
|
80
|
+
'WriteThroughCache metrics activation impossible, CACHETTE_METRICS_PERIOD_MINUTES is invalid. ' +
|
|
81
|
+
`Must be a positive integer, but was ${process.env.CACHETTE_METRICS_PERIOD_MINUTES}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
public on(eventName: string | symbol, listener: (...args: any[]) => void): this {
|
|
87
|
+
this.redisCacheForWriting.on(eventName, listener);
|
|
88
|
+
this.redisCacheForReading.on(eventName, listener);
|
|
89
|
+
this.localCache.on(eventName, listener);
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @inheritdoc
|
|
95
|
+
*/
|
|
96
|
+
public async isReady(): Promise<any> {
|
|
97
|
+
return Promise.all([this.redisCacheForWriting.isReady(), this.redisCacheForReading.isReady()]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @inheritdoc
|
|
102
|
+
*/
|
|
103
|
+
public async itemCount(): Promise<number> {
|
|
104
|
+
return await this.redisCacheForReading.itemCount() + await this.localCache.itemCount();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @inheritdoc
|
|
109
|
+
*/
|
|
110
|
+
public async setValue(
|
|
111
|
+
key: string,
|
|
112
|
+
value: CachableValue,
|
|
113
|
+
ttl = 0,
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
const response = await this.localCache.setValue(key, value, ttl);
|
|
116
|
+
return await this.redisCacheForWriting.setValue(key, value, ttl) && response;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @inheritdoc
|
|
121
|
+
*/
|
|
122
|
+
public async getValue(key: string): Promise<CachableValue> {
|
|
123
|
+
const localValue = await this.localCache.getValue(key);
|
|
124
|
+
if (localValue !== undefined) {
|
|
125
|
+
if (this.metrics.enabled) {
|
|
126
|
+
this.metrics.localHits++;
|
|
127
|
+
}
|
|
128
|
+
return localValue;
|
|
129
|
+
}
|
|
130
|
+
const [redisValue, ttl] = await Promise.all([
|
|
131
|
+
this.redisCacheForReading.getValue(key),
|
|
132
|
+
this.redisCacheForWriting.getTtl(key),
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
if (redisValue !== undefined && ttl !== undefined) {
|
|
136
|
+
await this.localCache.setValue(key, redisValue, ttl / 1000);
|
|
137
|
+
if (this.metrics.enabled) {
|
|
138
|
+
this.metrics.redisHits++;
|
|
139
|
+
}
|
|
140
|
+
return redisValue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (this.metrics.enabled) {
|
|
144
|
+
this.metrics.doubleMisses++;
|
|
145
|
+
}
|
|
146
|
+
return redisValue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @inheritdoc
|
|
151
|
+
*/
|
|
152
|
+
public async getTtl(key: string): Promise<number | undefined> {
|
|
153
|
+
return this.redisCacheForWriting.getTtl(key);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @inheritdoc
|
|
158
|
+
*/
|
|
159
|
+
public async delValue(key: string): Promise<void> {
|
|
160
|
+
this.emit('del', key);
|
|
161
|
+
await this.localCache.delValue(key);
|
|
162
|
+
await this.redisCacheForWriting.delValue(key);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @inheritdoc
|
|
167
|
+
*/
|
|
168
|
+
public async waitForReplication(replicas: number, timeout: number): Promise<number> {
|
|
169
|
+
this.emit('wait')
|
|
170
|
+
await this.localCache.waitForReplication(replicas, timeout);
|
|
171
|
+
return this.redisCacheForWriting.waitForReplication(replicas, timeout);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @inheritdoc
|
|
176
|
+
*/
|
|
177
|
+
public async clear(): Promise<void> {
|
|
178
|
+
await this.localCache.clear();
|
|
179
|
+
await this.redisCacheForWriting.clear();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @inheritdoc
|
|
184
|
+
*/
|
|
185
|
+
public async clearMemory(): Promise<void> {
|
|
186
|
+
await this.localCache.clearMemory();
|
|
187
|
+
await this.redisCacheForWriting.clearMemory();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @inheritdoc
|
|
192
|
+
* Locking is *not* supported by the Write-Through cache. You want either:
|
|
193
|
+
* - The full-fledged RedisCache for prod workloads
|
|
194
|
+
* - A dumb LocalCache for dev/local workloads
|
|
195
|
+
*/
|
|
196
|
+
public isLockingSupported(): boolean {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
}
|