@tekir/redis 0.1.0 → 0.1.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/dist/manager.d.ts +2 -0
- package/dist/manager.js +2 -0
- package/dist/redis.d.ts +44 -4
- package/dist/redis.js +109 -11
- package/package.json +2 -2
- package/src/manager.ts +2 -0
- package/src/redis.ts +116 -10
package/dist/manager.d.ts
CHANGED
|
@@ -112,6 +112,8 @@ export declare class RedisManager {
|
|
|
112
112
|
setJSON(key: string, value: any, expireSeconds?: number): Promise<void>;
|
|
113
113
|
/** @see {@link Redis.remember} */
|
|
114
114
|
remember<T>(key: string, seconds: number, callback: () => Promise<T>): Promise<T>;
|
|
115
|
+
/** @see {@link Redis.clearPrefix} */
|
|
116
|
+
clearPrefix(): Promise<number>;
|
|
115
117
|
/** @see {@link Redis.flushdb} */
|
|
116
118
|
flushdb(): Promise<void>;
|
|
117
119
|
/** @see {@link Redis.connected} */
|
package/dist/manager.js
CHANGED
|
@@ -144,6 +144,8 @@ export class RedisManager {
|
|
|
144
144
|
setJSON(key, value, expireSeconds) { return this.connection().setJSON(key, value, expireSeconds); }
|
|
145
145
|
/** @see {@link Redis.remember} */
|
|
146
146
|
remember(key, seconds, callback) { return this.connection().remember(key, seconds, callback); }
|
|
147
|
+
/** @see {@link Redis.clearPrefix} */
|
|
148
|
+
clearPrefix() { return this.connection().clearPrefix(); }
|
|
147
149
|
/** @see {@link Redis.flushdb} */
|
|
148
150
|
flushdb() { return this.connection().flushdb(); }
|
|
149
151
|
/** @see {@link Redis.connected} */
|
package/dist/redis.d.ts
CHANGED
|
@@ -21,6 +21,13 @@ export declare class Redis {
|
|
|
21
21
|
*/
|
|
22
22
|
constructor(config?: RedisConnectionConfig);
|
|
23
23
|
private key;
|
|
24
|
+
/**
|
|
25
|
+
* Strip any credentials embedded in a Redis URL so it is safe to log.
|
|
26
|
+
*
|
|
27
|
+
* @param url - The connection URL, possibly containing `user:pass@host`.
|
|
28
|
+
* @returns The URL with the userinfo component masked.
|
|
29
|
+
*/
|
|
30
|
+
private static maskUrl;
|
|
24
31
|
/**
|
|
25
32
|
* Open the connection to the Redis server.
|
|
26
33
|
*
|
|
@@ -185,6 +192,11 @@ export declare class Redis {
|
|
|
185
192
|
/**
|
|
186
193
|
* Subscribe to a channel and receive messages via a callback.
|
|
187
194
|
*
|
|
195
|
+
* The message passed to the callback is the raw string received from Redis and
|
|
196
|
+
* is NOT deserialized by this wrapper. Treat it as untrusted input: validate
|
|
197
|
+
* it and never `eval` it. If you `JSON.parse` it, wrap the parse in a
|
|
198
|
+
* try/catch to guard against poisoned payloads.
|
|
199
|
+
*
|
|
188
200
|
* @param channel - The channel name to subscribe to.
|
|
189
201
|
* @param callback - Invoked for each message received on the channel.
|
|
190
202
|
*
|
|
@@ -203,10 +215,16 @@ export declare class Redis {
|
|
|
203
215
|
*/
|
|
204
216
|
unsubscribe(channel?: string): Promise<void>;
|
|
205
217
|
/**
|
|
206
|
-
* Send a raw Redis command.
|
|
218
|
+
* Send a raw Redis command. ADVANCED / INTERNAL escape hatch.
|
|
219
|
+
*
|
|
220
|
+
* This bypasses the key prefix and every higher-level safeguard. The `command`
|
|
221
|
+
* and `args` are passed straight to Redis, so they MUST be trusted, statically
|
|
222
|
+
* known values. Never build a command name or its arguments from user input:
|
|
223
|
+
* doing so allows execution of dangerous commands (`FLUSHALL`, `CONFIG`,
|
|
224
|
+
* `EVAL`, `KEYS *`, ...) and lets callers escape the prefix namespace.
|
|
207
225
|
*
|
|
208
|
-
* @param command -
|
|
209
|
-
* @param args - Arguments for the command.
|
|
226
|
+
* @param command - A trusted, statically-known Redis command (e.g. `'PING'`, `'INFO'`).
|
|
227
|
+
* @param args - Arguments for the command. Must not contain untrusted input.
|
|
210
228
|
* @returns The raw response from Redis.
|
|
211
229
|
*
|
|
212
230
|
* @example
|
|
@@ -249,6 +267,11 @@ export declare class Redis {
|
|
|
249
267
|
* @param callback - Async function invoked to compute the value on a cache miss.
|
|
250
268
|
* @returns The cached or freshly computed value.
|
|
251
269
|
*
|
|
270
|
+
* On a cache miss this acquires a short-lived `SET NX` lock so that, under
|
|
271
|
+
* concurrent misses, only one caller runs `callback()` while the others wait
|
|
272
|
+
* for the freshly-populated value. This protects the backend from a stampede
|
|
273
|
+
* (thundering herd) when a hot key expires.
|
|
274
|
+
*
|
|
252
275
|
* @example
|
|
253
276
|
* ```ts
|
|
254
277
|
* const users = await redis.remember('all-users', 60, async () => {
|
|
@@ -258,7 +281,24 @@ export declare class Redis {
|
|
|
258
281
|
*/
|
|
259
282
|
remember<T>(key: string, seconds: number, callback: () => Promise<T>): Promise<T>;
|
|
260
283
|
/**
|
|
261
|
-
* Delete
|
|
284
|
+
* Delete every key under this connection's prefix using a non-blocking SCAN.
|
|
285
|
+
*
|
|
286
|
+
* Unlike {@link Redis.flushdb}, this is scoped: it only removes keys that
|
|
287
|
+
* belong to this logical store (`<prefix>:*`), leaving other stores that
|
|
288
|
+
* share the same Redis database (sessions, queues, ...) untouched. If no
|
|
289
|
+
* prefix is configured this deletes nothing and returns `0`, to avoid
|
|
290
|
+
* accidentally wiping the whole database.
|
|
291
|
+
*
|
|
292
|
+
* @returns The number of keys deleted.
|
|
293
|
+
*/
|
|
294
|
+
clearPrefix(): Promise<number>;
|
|
295
|
+
/**
|
|
296
|
+
* Delete ALL keys in the currently selected database. DANGEROUS.
|
|
297
|
+
*
|
|
298
|
+
* This ignores the key prefix and removes every key in the database, including
|
|
299
|
+
* data owned by other logical stores (sessions, queues, other caches) that
|
|
300
|
+
* share the same Redis database. Prefer {@link Redis.clearPrefix} to delete
|
|
301
|
+
* only this store's keys. Never expose this to user-triggered code paths.
|
|
262
302
|
*
|
|
263
303
|
* @returns A promise that resolves once the database has been flushed.
|
|
264
304
|
*/
|
package/dist/redis.js
CHANGED
|
@@ -23,6 +23,14 @@ export class Redis {
|
|
|
23
23
|
this.prefix = config.prefix || '';
|
|
24
24
|
const { RedisClient } = Bun;
|
|
25
25
|
const url = config.url || process.env.REDIS_URL || 'redis://localhost:6379';
|
|
26
|
+
// Encourage TLS in production: a plaintext redis:// connection exposes
|
|
27
|
+
// credentials and data to network observers. Warn once instead of throwing
|
|
28
|
+
// to stay backward compatible with local/dev setups.
|
|
29
|
+
const isTls = config.tls != null || url.startsWith('rediss://');
|
|
30
|
+
if (!isTls && process.env.NODE_ENV === 'production') {
|
|
31
|
+
console.warn(`[@tekir/redis] Connecting to ${Redis.maskUrl(url)} without TLS in production. ` +
|
|
32
|
+
`Use a rediss:// URL or set tls to encrypt credentials and data in transit.`);
|
|
33
|
+
}
|
|
26
34
|
this.client = new RedisClient(url, {
|
|
27
35
|
connectionTimeout: config.connectionTimeout,
|
|
28
36
|
idleTimeout: config.idleTimeout,
|
|
@@ -35,6 +43,15 @@ export class Redis {
|
|
|
35
43
|
key(k) {
|
|
36
44
|
return this.prefix ? `${this.prefix}:${k}` : k;
|
|
37
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Strip any credentials embedded in a Redis URL so it is safe to log.
|
|
48
|
+
*
|
|
49
|
+
* @param url - The connection URL, possibly containing `user:pass@host`.
|
|
50
|
+
* @returns The URL with the userinfo component masked.
|
|
51
|
+
*/
|
|
52
|
+
static maskUrl(url) {
|
|
53
|
+
return url.replace(/(\w+:\/\/)([^@/]+)@/, '$1***@');
|
|
54
|
+
}
|
|
38
55
|
// ─── Connection ────────────────────────────────────────
|
|
39
56
|
/**
|
|
40
57
|
* Open the connection to the Redis server.
|
|
@@ -204,6 +221,11 @@ export class Redis {
|
|
|
204
221
|
/**
|
|
205
222
|
* Subscribe to a channel and receive messages via a callback.
|
|
206
223
|
*
|
|
224
|
+
* The message passed to the callback is the raw string received from Redis and
|
|
225
|
+
* is NOT deserialized by this wrapper. Treat it as untrusted input: validate
|
|
226
|
+
* it and never `eval` it. If you `JSON.parse` it, wrap the parse in a
|
|
227
|
+
* try/catch to guard against poisoned payloads.
|
|
228
|
+
*
|
|
207
229
|
* @param channel - The channel name to subscribe to.
|
|
208
230
|
* @param callback - Invoked for each message received on the channel.
|
|
209
231
|
*
|
|
@@ -225,10 +247,16 @@ export class Redis {
|
|
|
225
247
|
async unsubscribe(channel) { await this.client.unsubscribe(channel); }
|
|
226
248
|
// ─── Raw command ──────────────────────────────────────
|
|
227
249
|
/**
|
|
228
|
-
* Send a raw Redis command.
|
|
250
|
+
* Send a raw Redis command. ADVANCED / INTERNAL escape hatch.
|
|
251
|
+
*
|
|
252
|
+
* This bypasses the key prefix and every higher-level safeguard. The `command`
|
|
253
|
+
* and `args` are passed straight to Redis, so they MUST be trusted, statically
|
|
254
|
+
* known values. Never build a command name or its arguments from user input:
|
|
255
|
+
* doing so allows execution of dangerous commands (`FLUSHALL`, `CONFIG`,
|
|
256
|
+
* `EVAL`, `KEYS *`, ...) and lets callers escape the prefix namespace.
|
|
229
257
|
*
|
|
230
|
-
* @param command -
|
|
231
|
-
* @param args - Arguments for the command.
|
|
258
|
+
* @param command - A trusted, statically-known Redis command (e.g. `'PING'`, `'INFO'`).
|
|
259
|
+
* @param args - Arguments for the command. Must not contain untrusted input.
|
|
232
260
|
* @returns The raw response from Redis.
|
|
233
261
|
*
|
|
234
262
|
* @example
|
|
@@ -256,7 +284,11 @@ export class Redis {
|
|
|
256
284
|
try {
|
|
257
285
|
return JSON.parse(val);
|
|
258
286
|
}
|
|
259
|
-
catch {
|
|
287
|
+
catch (e) {
|
|
288
|
+
// Surface corrupt/poisoned payloads instead of silently masking them as a
|
|
289
|
+
// cache miss. Returning null still preserves the previous behaviour for
|
|
290
|
+
// callers, but the warning makes the problem diagnosable.
|
|
291
|
+
console.warn(`[@tekir/redis] Failed to parse JSON for key "${key}": ${e.message}`);
|
|
260
292
|
return null;
|
|
261
293
|
}
|
|
262
294
|
}
|
|
@@ -273,9 +305,15 @@ export class Redis {
|
|
|
273
305
|
* ```
|
|
274
306
|
*/
|
|
275
307
|
async setJSON(key, value, expireSeconds) {
|
|
276
|
-
|
|
277
|
-
if (expireSeconds)
|
|
278
|
-
|
|
308
|
+
const payload = JSON.stringify(value);
|
|
309
|
+
if (expireSeconds && expireSeconds > 0) {
|
|
310
|
+
// Atomic SET ... EX so a crash between writing the value and setting the
|
|
311
|
+
// TTL can never leave a permanent (TTL-less) key behind.
|
|
312
|
+
await this.client.send('SET', [this.key(key), payload, 'EX', String(Math.floor(expireSeconds))]);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
await this.client.set(this.key(key), payload);
|
|
316
|
+
}
|
|
279
317
|
}
|
|
280
318
|
/**
|
|
281
319
|
* Cache-aside helper: return the cached value if it exists, otherwise execute
|
|
@@ -286,6 +324,11 @@ export class Redis {
|
|
|
286
324
|
* @param callback - Async function invoked to compute the value on a cache miss.
|
|
287
325
|
* @returns The cached or freshly computed value.
|
|
288
326
|
*
|
|
327
|
+
* On a cache miss this acquires a short-lived `SET NX` lock so that, under
|
|
328
|
+
* concurrent misses, only one caller runs `callback()` while the others wait
|
|
329
|
+
* for the freshly-populated value. This protects the backend from a stampede
|
|
330
|
+
* (thundering herd) when a hot key expires.
|
|
331
|
+
*
|
|
289
332
|
* @example
|
|
290
333
|
* ```ts
|
|
291
334
|
* const users = await redis.remember('all-users', 60, async () => {
|
|
@@ -297,12 +340,67 @@ export class Redis {
|
|
|
297
340
|
const cached = await this.getJSON(key);
|
|
298
341
|
if (cached !== null)
|
|
299
342
|
return cached;
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
343
|
+
const lockKey = this.key(`${key}:__lock`);
|
|
344
|
+
// Try to become the single flight that computes the value. SET NX EX gives
|
|
345
|
+
// a self-expiring lock so a crashed holder cannot deadlock other callers.
|
|
346
|
+
const acquired = await this.client.send('SET', [lockKey, '1', 'NX', 'EX', '10']);
|
|
347
|
+
if (acquired == null) {
|
|
348
|
+
// Another caller is computing it. Briefly poll for the populated value
|
|
349
|
+
// before falling back to computing it ourselves (lock may have expired).
|
|
350
|
+
for (let i = 0; i < 50; i++) {
|
|
351
|
+
await new Promise(r => setTimeout(r, 100));
|
|
352
|
+
const waited = await this.getJSON(key);
|
|
353
|
+
if (waited !== null)
|
|
354
|
+
return waited;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
// Re-check after acquiring the lock: a racing holder may have populated it.
|
|
359
|
+
const fresh = await this.getJSON(key);
|
|
360
|
+
if (fresh !== null)
|
|
361
|
+
return fresh;
|
|
362
|
+
const value = await callback();
|
|
363
|
+
await this.setJSON(key, value, seconds);
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
finally {
|
|
367
|
+
await this.del(`${key}:__lock`).catch(() => { });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Delete every key under this connection's prefix using a non-blocking SCAN.
|
|
372
|
+
*
|
|
373
|
+
* Unlike {@link Redis.flushdb}, this is scoped: it only removes keys that
|
|
374
|
+
* belong to this logical store (`<prefix>:*`), leaving other stores that
|
|
375
|
+
* share the same Redis database (sessions, queues, ...) untouched. If no
|
|
376
|
+
* prefix is configured this deletes nothing and returns `0`, to avoid
|
|
377
|
+
* accidentally wiping the whole database.
|
|
378
|
+
*
|
|
379
|
+
* @returns The number of keys deleted.
|
|
380
|
+
*/
|
|
381
|
+
async clearPrefix() {
|
|
382
|
+
if (!this.prefix)
|
|
383
|
+
return 0;
|
|
384
|
+
const pattern = `${this.prefix}:*`;
|
|
385
|
+
let cursor = '0';
|
|
386
|
+
let deleted = 0;
|
|
387
|
+
do {
|
|
388
|
+
const [next, batch] = await this.client.send('SCAN', [cursor, 'MATCH', pattern, 'COUNT', '100']);
|
|
389
|
+
cursor = next;
|
|
390
|
+
if (batch.length) {
|
|
391
|
+
await this.client.send('DEL', batch);
|
|
392
|
+
deleted += batch.length;
|
|
393
|
+
}
|
|
394
|
+
} while (cursor !== '0');
|
|
395
|
+
return deleted;
|
|
303
396
|
}
|
|
304
397
|
/**
|
|
305
|
-
* Delete
|
|
398
|
+
* Delete ALL keys in the currently selected database. DANGEROUS.
|
|
399
|
+
*
|
|
400
|
+
* This ignores the key prefix and removes every key in the database, including
|
|
401
|
+
* data owned by other logical stores (sessions, queues, other caches) that
|
|
402
|
+
* share the same Redis database. Prefer {@link Redis.clearPrefix} to delete
|
|
403
|
+
* only this store's keys. Never expose this to user-triggered code paths.
|
|
306
404
|
*
|
|
307
405
|
* @returns A promise that resolves once the database has been flushed.
|
|
308
406
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekir/redis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Redis connection and client management",
|
|
5
5
|
"author": "dev@tekir.io",
|
|
6
6
|
"license": "MIT",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@tekir/core": "^0.1.
|
|
42
|
+
"@tekir/core": "^0.1.34"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "rm -rf dist && tsc --noEmit false",
|
package/src/manager.ts
CHANGED
|
@@ -152,6 +152,8 @@ export class RedisManager {
|
|
|
152
152
|
setJSON(key: string, value: any, expireSeconds?: number) { return this.connection().setJSON(key, value, expireSeconds) }
|
|
153
153
|
/** @see {@link Redis.remember} */
|
|
154
154
|
remember<T>(key: string, seconds: number, callback: () => Promise<T>) { return this.connection().remember<T>(key, seconds, callback) }
|
|
155
|
+
/** @see {@link Redis.clearPrefix} */
|
|
156
|
+
clearPrefix() { return this.connection().clearPrefix() }
|
|
155
157
|
/** @see {@link Redis.flushdb} */
|
|
156
158
|
flushdb() { return this.connection().flushdb() }
|
|
157
159
|
/** @see {@link Redis.connected} */
|
package/src/redis.ts
CHANGED
|
@@ -28,6 +28,17 @@ export class Redis {
|
|
|
28
28
|
const { RedisClient } = Bun as any
|
|
29
29
|
const url = config.url || process.env.REDIS_URL || 'redis://localhost:6379'
|
|
30
30
|
|
|
31
|
+
// Encourage TLS in production: a plaintext redis:// connection exposes
|
|
32
|
+
// credentials and data to network observers. Warn once instead of throwing
|
|
33
|
+
// to stay backward compatible with local/dev setups.
|
|
34
|
+
const isTls = config.tls != null || url.startsWith('rediss://')
|
|
35
|
+
if (!isTls && process.env.NODE_ENV === 'production') {
|
|
36
|
+
console.warn(
|
|
37
|
+
`[@tekir/redis] Connecting to ${Redis.maskUrl(url)} without TLS in production. ` +
|
|
38
|
+
`Use a rediss:// URL or set tls to encrypt credentials and data in transit.`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
this.client = new RedisClient(url, {
|
|
32
43
|
connectionTimeout: config.connectionTimeout,
|
|
33
44
|
idleTimeout: config.idleTimeout,
|
|
@@ -42,6 +53,16 @@ export class Redis {
|
|
|
42
53
|
return this.prefix ? `${this.prefix}:${k}` : k
|
|
43
54
|
}
|
|
44
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Strip any credentials embedded in a Redis URL so it is safe to log.
|
|
58
|
+
*
|
|
59
|
+
* @param url - The connection URL, possibly containing `user:pass@host`.
|
|
60
|
+
* @returns The URL with the userinfo component masked.
|
|
61
|
+
*/
|
|
62
|
+
private static maskUrl(url: string): string {
|
|
63
|
+
return url.replace(/(\w+:\/\/)([^@/]+)@/, '$1***@')
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
// ─── Connection ────────────────────────────────────────
|
|
46
67
|
|
|
47
68
|
/**
|
|
@@ -236,6 +257,11 @@ export class Redis {
|
|
|
236
257
|
/**
|
|
237
258
|
* Subscribe to a channel and receive messages via a callback.
|
|
238
259
|
*
|
|
260
|
+
* The message passed to the callback is the raw string received from Redis and
|
|
261
|
+
* is NOT deserialized by this wrapper. Treat it as untrusted input: validate
|
|
262
|
+
* it and never `eval` it. If you `JSON.parse` it, wrap the parse in a
|
|
263
|
+
* try/catch to guard against poisoned payloads.
|
|
264
|
+
*
|
|
239
265
|
* @param channel - The channel name to subscribe to.
|
|
240
266
|
* @param callback - Invoked for each message received on the channel.
|
|
241
267
|
*
|
|
@@ -260,10 +286,16 @@ export class Redis {
|
|
|
260
286
|
// ─── Raw command ──────────────────────────────────────
|
|
261
287
|
|
|
262
288
|
/**
|
|
263
|
-
* Send a raw Redis command.
|
|
289
|
+
* Send a raw Redis command. ADVANCED / INTERNAL escape hatch.
|
|
264
290
|
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
291
|
+
* This bypasses the key prefix and every higher-level safeguard. The `command`
|
|
292
|
+
* and `args` are passed straight to Redis, so they MUST be trusted, statically
|
|
293
|
+
* known values. Never build a command name or its arguments from user input:
|
|
294
|
+
* doing so allows execution of dangerous commands (`FLUSHALL`, `CONFIG`,
|
|
295
|
+
* `EVAL`, `KEYS *`, ...) and lets callers escape the prefix namespace.
|
|
296
|
+
*
|
|
297
|
+
* @param command - A trusted, statically-known Redis command (e.g. `'PING'`, `'INFO'`).
|
|
298
|
+
* @param args - Arguments for the command. Must not contain untrusted input.
|
|
267
299
|
* @returns The raw response from Redis.
|
|
268
300
|
*
|
|
269
301
|
* @example
|
|
@@ -289,7 +321,15 @@ export class Redis {
|
|
|
289
321
|
async getJSON<T = any>(key: string): Promise<T | null> {
|
|
290
322
|
const val = await this.get(key)
|
|
291
323
|
if (val === null) return null
|
|
292
|
-
try {
|
|
324
|
+
try {
|
|
325
|
+
return JSON.parse(val)
|
|
326
|
+
} catch (e) {
|
|
327
|
+
// Surface corrupt/poisoned payloads instead of silently masking them as a
|
|
328
|
+
// cache miss. Returning null still preserves the previous behaviour for
|
|
329
|
+
// callers, but the warning makes the problem diagnosable.
|
|
330
|
+
console.warn(`[@tekir/redis] Failed to parse JSON for key "${key}": ${(e as Error).message}`)
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
293
333
|
}
|
|
294
334
|
|
|
295
335
|
/**
|
|
@@ -305,8 +345,14 @@ export class Redis {
|
|
|
305
345
|
* ```
|
|
306
346
|
*/
|
|
307
347
|
async setJSON(key: string, value: any, expireSeconds?: number): Promise<void> {
|
|
308
|
-
|
|
309
|
-
if (expireSeconds
|
|
348
|
+
const payload = JSON.stringify(value)
|
|
349
|
+
if (expireSeconds && expireSeconds > 0) {
|
|
350
|
+
// Atomic SET ... EX so a crash between writing the value and setting the
|
|
351
|
+
// TTL can never leave a permanent (TTL-less) key behind.
|
|
352
|
+
await this.client.send('SET', [this.key(key), payload, 'EX', String(Math.floor(expireSeconds))])
|
|
353
|
+
} else {
|
|
354
|
+
await this.client.set(this.key(key), payload)
|
|
355
|
+
}
|
|
310
356
|
}
|
|
311
357
|
|
|
312
358
|
/**
|
|
@@ -318,6 +364,11 @@ export class Redis {
|
|
|
318
364
|
* @param callback - Async function invoked to compute the value on a cache miss.
|
|
319
365
|
* @returns The cached or freshly computed value.
|
|
320
366
|
*
|
|
367
|
+
* On a cache miss this acquires a short-lived `SET NX` lock so that, under
|
|
368
|
+
* concurrent misses, only one caller runs `callback()` while the others wait
|
|
369
|
+
* for the freshly-populated value. This protects the backend from a stampede
|
|
370
|
+
* (thundering herd) when a hot key expires.
|
|
371
|
+
*
|
|
321
372
|
* @example
|
|
322
373
|
* ```ts
|
|
323
374
|
* const users = await redis.remember('all-users', 60, async () => {
|
|
@@ -328,13 +379,68 @@ export class Redis {
|
|
|
328
379
|
async remember<T>(key: string, seconds: number, callback: () => Promise<T>): Promise<T> {
|
|
329
380
|
const cached = await this.getJSON<T>(key)
|
|
330
381
|
if (cached !== null) return cached
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
382
|
+
|
|
383
|
+
const lockKey = this.key(`${key}:__lock`)
|
|
384
|
+
// Try to become the single flight that computes the value. SET NX EX gives
|
|
385
|
+
// a self-expiring lock so a crashed holder cannot deadlock other callers.
|
|
386
|
+
const acquired = await this.client.send('SET', [lockKey, '1', 'NX', 'EX', '10'])
|
|
387
|
+
|
|
388
|
+
if (acquired == null) {
|
|
389
|
+
// Another caller is computing it. Briefly poll for the populated value
|
|
390
|
+
// before falling back to computing it ourselves (lock may have expired).
|
|
391
|
+
for (let i = 0; i < 50; i++) {
|
|
392
|
+
await new Promise(r => setTimeout(r, 100))
|
|
393
|
+
const waited = await this.getJSON<T>(key)
|
|
394
|
+
if (waited !== null) return waited
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
// Re-check after acquiring the lock: a racing holder may have populated it.
|
|
400
|
+
const fresh = await this.getJSON<T>(key)
|
|
401
|
+
if (fresh !== null) return fresh
|
|
402
|
+
const value = await callback()
|
|
403
|
+
await this.setJSON(key, value, seconds)
|
|
404
|
+
return value
|
|
405
|
+
} finally {
|
|
406
|
+
await this.del(`${key}:__lock`).catch(() => {})
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Delete every key under this connection's prefix using a non-blocking SCAN.
|
|
412
|
+
*
|
|
413
|
+
* Unlike {@link Redis.flushdb}, this is scoped: it only removes keys that
|
|
414
|
+
* belong to this logical store (`<prefix>:*`), leaving other stores that
|
|
415
|
+
* share the same Redis database (sessions, queues, ...) untouched. If no
|
|
416
|
+
* prefix is configured this deletes nothing and returns `0`, to avoid
|
|
417
|
+
* accidentally wiping the whole database.
|
|
418
|
+
*
|
|
419
|
+
* @returns The number of keys deleted.
|
|
420
|
+
*/
|
|
421
|
+
async clearPrefix(): Promise<number> {
|
|
422
|
+
if (!this.prefix) return 0
|
|
423
|
+
const pattern = `${this.prefix}:*`
|
|
424
|
+
let cursor = '0'
|
|
425
|
+
let deleted = 0
|
|
426
|
+
do {
|
|
427
|
+
const [next, batch]: [string, string[]] = await this.client.send('SCAN', [cursor, 'MATCH', pattern, 'COUNT', '100'])
|
|
428
|
+
cursor = next
|
|
429
|
+
if (batch.length) {
|
|
430
|
+
await this.client.send('DEL', batch)
|
|
431
|
+
deleted += batch.length
|
|
432
|
+
}
|
|
433
|
+
} while (cursor !== '0')
|
|
434
|
+
return deleted
|
|
334
435
|
}
|
|
335
436
|
|
|
336
437
|
/**
|
|
337
|
-
* Delete
|
|
438
|
+
* Delete ALL keys in the currently selected database. DANGEROUS.
|
|
439
|
+
*
|
|
440
|
+
* This ignores the key prefix and removes every key in the database, including
|
|
441
|
+
* data owned by other logical stores (sessions, queues, other caches) that
|
|
442
|
+
* share the same Redis database. Prefer {@link Redis.clearPrefix} to delete
|
|
443
|
+
* only this store's keys. Never expose this to user-triggered code paths.
|
|
338
444
|
*
|
|
339
445
|
* @returns A promise that resolves once the database has been flushed.
|
|
340
446
|
*/
|