@tekir/redis 0.1.1 → 0.1.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/dist/manager.d.ts +4 -0
- package/dist/manager.js +4 -0
- package/dist/redis.d.ts +4 -0
- package/dist/redis.js +42 -6
- package/package.json +2 -2
- package/src/manager.ts +4 -0
- package/src/redis.ts +43 -6
package/dist/manager.d.ts
CHANGED
|
@@ -106,8 +106,12 @@ export declare class RedisManager {
|
|
|
106
106
|
unsubscribe(channel?: string): Promise<void>;
|
|
107
107
|
/** @see {@link Redis.send} */
|
|
108
108
|
send(command: string, args?: string[]): Promise<any>;
|
|
109
|
+
/** @see {@link Redis.keyName} */
|
|
110
|
+
keyName(key: string): string;
|
|
109
111
|
/** @see {@link Redis.getJSON} */
|
|
110
112
|
getJSON<T = any>(key: string): Promise<T | null>;
|
|
113
|
+
/** @see {@link Redis.setEx} */
|
|
114
|
+
setEx(key: string, value: string | number, seconds: number): Promise<void>;
|
|
111
115
|
/** @see {@link Redis.setJSON} */
|
|
112
116
|
setJSON(key: string, value: any, expireSeconds?: number): Promise<void>;
|
|
113
117
|
/** @see {@link Redis.remember} */
|
package/dist/manager.js
CHANGED
|
@@ -138,8 +138,12 @@ export class RedisManager {
|
|
|
138
138
|
unsubscribe(channel) { return this.connection().unsubscribe(channel); }
|
|
139
139
|
/** @see {@link Redis.send} */
|
|
140
140
|
send(command, args = []) { return this.connection().send(command, args); }
|
|
141
|
+
/** @see {@link Redis.keyName} */
|
|
142
|
+
keyName(key) { return this.connection().keyName(key); }
|
|
141
143
|
/** @see {@link Redis.getJSON} */
|
|
142
144
|
getJSON(key) { return this.connection().getJSON(key); }
|
|
145
|
+
/** @see {@link Redis.setEx} */
|
|
146
|
+
setEx(key, value, seconds) { return this.connection().setEx(key, value, seconds); }
|
|
143
147
|
/** @see {@link Redis.setJSON} */
|
|
144
148
|
setJSON(key, value, expireSeconds) { return this.connection().setJSON(key, value, expireSeconds); }
|
|
145
149
|
/** @see {@link Redis.remember} */
|
package/dist/redis.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export declare class Redis {
|
|
|
21
21
|
*/
|
|
22
22
|
constructor(config?: RedisConnectionConfig);
|
|
23
23
|
private key;
|
|
24
|
+
/** Resolve the actual Redis key after applying this connection's namespace. */
|
|
25
|
+
keyName(key: string): string;
|
|
24
26
|
/**
|
|
25
27
|
* Strip any credentials embedded in a Redis URL so it is safe to log.
|
|
26
28
|
*
|
|
@@ -73,6 +75,8 @@ export declare class Redis {
|
|
|
73
75
|
* ```
|
|
74
76
|
*/
|
|
75
77
|
set(key: string, value: string | number): Promise<void>;
|
|
78
|
+
/** Atomically set a value and its expiration time. */
|
|
79
|
+
setEx(key: string, value: string | number, seconds: number): Promise<void>;
|
|
76
80
|
/**
|
|
77
81
|
* Delete one or more keys.
|
|
78
82
|
*
|
package/dist/redis.js
CHANGED
|
@@ -20,7 +20,7 @@ export class Redis {
|
|
|
20
20
|
*/
|
|
21
21
|
constructor(config = {}) {
|
|
22
22
|
this.config = config;
|
|
23
|
-
this.prefix = config.prefix || '';
|
|
23
|
+
this.prefix = (config.prefix || '').replace(/:+$/, '');
|
|
24
24
|
const { RedisClient } = Bun;
|
|
25
25
|
const url = config.url || process.env.REDIS_URL || 'redis://localhost:6379';
|
|
26
26
|
// Encourage TLS in production: a plaintext redis:// connection exposes
|
|
@@ -43,6 +43,8 @@ export class Redis {
|
|
|
43
43
|
key(k) {
|
|
44
44
|
return this.prefix ? `${this.prefix}:${k}` : k;
|
|
45
45
|
}
|
|
46
|
+
/** Resolve the actual Redis key after applying this connection's namespace. */
|
|
47
|
+
keyName(key) { return this.key(key); }
|
|
46
48
|
/**
|
|
47
49
|
* Strip any credentials embedded in a Redis URL so it is safe to log.
|
|
48
50
|
*
|
|
@@ -99,6 +101,12 @@ export class Redis {
|
|
|
99
101
|
* ```
|
|
100
102
|
*/
|
|
101
103
|
async set(key, value) { await this.client.set(this.key(key), String(value)); }
|
|
104
|
+
/** Atomically set a value and its expiration time. */
|
|
105
|
+
async setEx(key, value, seconds) {
|
|
106
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
107
|
+
throw new Error('Redis setEx seconds must be a positive number');
|
|
108
|
+
await this.client.send('SET', [this.key(key), String(value), 'EX', String(Math.floor(seconds))]);
|
|
109
|
+
}
|
|
102
110
|
/**
|
|
103
111
|
* Delete one or more keys.
|
|
104
112
|
*
|
|
@@ -306,6 +314,8 @@ export class Redis {
|
|
|
306
314
|
*/
|
|
307
315
|
async setJSON(key, value, expireSeconds) {
|
|
308
316
|
const payload = JSON.stringify(value);
|
|
317
|
+
if (payload === undefined)
|
|
318
|
+
throw new Error(`Redis cannot serialize undefined for key "${key}"`);
|
|
309
319
|
if (expireSeconds && expireSeconds > 0) {
|
|
310
320
|
// Atomic SET ... EX so a crash between writing the value and setting the
|
|
311
321
|
// TTL can never leave a permanent (TTL-less) key behind.
|
|
@@ -341,19 +351,33 @@ export class Redis {
|
|
|
341
351
|
if (cached !== null)
|
|
342
352
|
return cached;
|
|
343
353
|
const lockKey = this.key(`${key}:__lock`);
|
|
354
|
+
const lockToken = crypto.randomUUID();
|
|
344
355
|
// Try to become the single flight that computes the value. SET NX EX gives
|
|
345
356
|
// a self-expiring lock so a crashed holder cannot deadlock other callers.
|
|
346
|
-
const acquired = await this.client.send('SET', [lockKey,
|
|
357
|
+
const acquired = await this.client.send('SET', [lockKey, lockToken, 'NX', 'EX', '10']);
|
|
347
358
|
if (acquired == null) {
|
|
348
|
-
// Another caller is computing it.
|
|
349
|
-
//
|
|
350
|
-
|
|
359
|
+
// Another caller is computing it. Wait for the populated value, but do
|
|
360
|
+
// not run the callback without owning the lock: doing so would turn slow
|
|
361
|
+
// cache fills back into a stampede. A bounded timeout keeps callers from
|
|
362
|
+
// hanging forever if Redis itself is unhealthy.
|
|
363
|
+
for (let i = 0; i < 300; i++) {
|
|
351
364
|
await new Promise(r => setTimeout(r, 100));
|
|
352
365
|
const waited = await this.getJSON(key);
|
|
353
366
|
if (waited !== null)
|
|
354
367
|
return waited;
|
|
355
368
|
}
|
|
369
|
+
throw new Error(`Redis remember timed out waiting for lock on "${key}"`);
|
|
356
370
|
}
|
|
371
|
+
const renewScript = `
|
|
372
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
373
|
+
return redis.call('EXPIRE', KEYS[1], 10)
|
|
374
|
+
end
|
|
375
|
+
return 0
|
|
376
|
+
`;
|
|
377
|
+
const renewTimer = setInterval(() => {
|
|
378
|
+
void this.client.send('EVAL', [renewScript, '1', lockKey, lockToken]).catch(() => { });
|
|
379
|
+
}, 3000);
|
|
380
|
+
renewTimer.unref?.();
|
|
357
381
|
try {
|
|
358
382
|
// Re-check after acquiring the lock: a racing holder may have populated it.
|
|
359
383
|
const fresh = await this.getJSON(key);
|
|
@@ -364,7 +388,19 @@ export class Redis {
|
|
|
364
388
|
return value;
|
|
365
389
|
}
|
|
366
390
|
finally {
|
|
367
|
-
|
|
391
|
+
clearInterval(renewTimer);
|
|
392
|
+
// Only the owner may release the lock. A plain DEL lets a slow callback
|
|
393
|
+
// delete a successor's lock after its own 10s lease expired, reopening
|
|
394
|
+
// the stampede window. Waiters that never acquired it release nothing.
|
|
395
|
+
if (acquired != null) {
|
|
396
|
+
const releaseScript = `
|
|
397
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
398
|
+
return redis.call('DEL', KEYS[1])
|
|
399
|
+
end
|
|
400
|
+
return 0
|
|
401
|
+
`;
|
|
402
|
+
await this.client.send('EVAL', [releaseScript, '1', lockKey, lockToken]).catch(() => { });
|
|
403
|
+
}
|
|
368
404
|
}
|
|
369
405
|
}
|
|
370
406
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekir/redis",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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.35"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "rm -rf dist && tsc --noEmit false",
|
package/src/manager.ts
CHANGED
|
@@ -146,8 +146,12 @@ export class RedisManager {
|
|
|
146
146
|
unsubscribe(channel?: string) { return this.connection().unsubscribe(channel) }
|
|
147
147
|
/** @see {@link Redis.send} */
|
|
148
148
|
send(command: string, args: string[] = []) { return this.connection().send(command, args) }
|
|
149
|
+
/** @see {@link Redis.keyName} */
|
|
150
|
+
keyName(key: string) { return this.connection().keyName(key) }
|
|
149
151
|
/** @see {@link Redis.getJSON} */
|
|
150
152
|
getJSON<T = any>(key: string) { return this.connection().getJSON<T>(key) }
|
|
153
|
+
/** @see {@link Redis.setEx} */
|
|
154
|
+
setEx(key: string, value: string | number, seconds: number) { return this.connection().setEx(key, value, seconds) }
|
|
151
155
|
/** @see {@link Redis.setJSON} */
|
|
152
156
|
setJSON(key: string, value: any, expireSeconds?: number) { return this.connection().setJSON(key, value, expireSeconds) }
|
|
153
157
|
/** @see {@link Redis.remember} */
|
package/src/redis.ts
CHANGED
|
@@ -23,7 +23,7 @@ export class Redis {
|
|
|
23
23
|
*/
|
|
24
24
|
constructor(config: RedisConnectionConfig = {}) {
|
|
25
25
|
this.config = config
|
|
26
|
-
this.prefix = config.prefix || ''
|
|
26
|
+
this.prefix = (config.prefix || '').replace(/:+$/, '')
|
|
27
27
|
|
|
28
28
|
const { RedisClient } = Bun as any
|
|
29
29
|
const url = config.url || process.env.REDIS_URL || 'redis://localhost:6379'
|
|
@@ -53,6 +53,9 @@ export class Redis {
|
|
|
53
53
|
return this.prefix ? `${this.prefix}:${k}` : k
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Resolve the actual Redis key after applying this connection's namespace. */
|
|
57
|
+
keyName(key: string): string { return this.key(key) }
|
|
58
|
+
|
|
56
59
|
/**
|
|
57
60
|
* Strip any credentials embedded in a Redis URL so it is safe to log.
|
|
58
61
|
*
|
|
@@ -117,6 +120,12 @@ export class Redis {
|
|
|
117
120
|
*/
|
|
118
121
|
async set(key: string, value: string | number): Promise<void> { await this.client.set(this.key(key), String(value)) }
|
|
119
122
|
|
|
123
|
+
/** Atomically set a value and its expiration time. */
|
|
124
|
+
async setEx(key: string, value: string | number, seconds: number): Promise<void> {
|
|
125
|
+
if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('Redis setEx seconds must be a positive number')
|
|
126
|
+
await this.client.send('SET', [this.key(key), String(value), 'EX', String(Math.floor(seconds))])
|
|
127
|
+
}
|
|
128
|
+
|
|
120
129
|
/**
|
|
121
130
|
* Delete one or more keys.
|
|
122
131
|
*
|
|
@@ -346,6 +355,7 @@ export class Redis {
|
|
|
346
355
|
*/
|
|
347
356
|
async setJSON(key: string, value: any, expireSeconds?: number): Promise<void> {
|
|
348
357
|
const payload = JSON.stringify(value)
|
|
358
|
+
if (payload === undefined) throw new Error(`Redis cannot serialize undefined for key "${key}"`)
|
|
349
359
|
if (expireSeconds && expireSeconds > 0) {
|
|
350
360
|
// Atomic SET ... EX so a crash between writing the value and setting the
|
|
351
361
|
// TTL can never leave a permanent (TTL-less) key behind.
|
|
@@ -381,20 +391,35 @@ export class Redis {
|
|
|
381
391
|
if (cached !== null) return cached
|
|
382
392
|
|
|
383
393
|
const lockKey = this.key(`${key}:__lock`)
|
|
394
|
+
const lockToken = crypto.randomUUID()
|
|
384
395
|
// Try to become the single flight that computes the value. SET NX EX gives
|
|
385
396
|
// a self-expiring lock so a crashed holder cannot deadlock other callers.
|
|
386
|
-
const acquired = await this.client.send('SET', [lockKey,
|
|
397
|
+
const acquired = await this.client.send('SET', [lockKey, lockToken, 'NX', 'EX', '10'])
|
|
387
398
|
|
|
388
399
|
if (acquired == null) {
|
|
389
|
-
// Another caller is computing it.
|
|
390
|
-
//
|
|
391
|
-
|
|
400
|
+
// Another caller is computing it. Wait for the populated value, but do
|
|
401
|
+
// not run the callback without owning the lock: doing so would turn slow
|
|
402
|
+
// cache fills back into a stampede. A bounded timeout keeps callers from
|
|
403
|
+
// hanging forever if Redis itself is unhealthy.
|
|
404
|
+
for (let i = 0; i < 300; i++) {
|
|
392
405
|
await new Promise(r => setTimeout(r, 100))
|
|
393
406
|
const waited = await this.getJSON<T>(key)
|
|
394
407
|
if (waited !== null) return waited
|
|
395
408
|
}
|
|
409
|
+
throw new Error(`Redis remember timed out waiting for lock on "${key}"`)
|
|
396
410
|
}
|
|
397
411
|
|
|
412
|
+
const renewScript = `
|
|
413
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
414
|
+
return redis.call('EXPIRE', KEYS[1], 10)
|
|
415
|
+
end
|
|
416
|
+
return 0
|
|
417
|
+
`
|
|
418
|
+
const renewTimer = setInterval(() => {
|
|
419
|
+
void this.client.send('EVAL', [renewScript, '1', lockKey, lockToken]).catch(() => {})
|
|
420
|
+
}, 3000)
|
|
421
|
+
;(renewTimer as any).unref?.()
|
|
422
|
+
|
|
398
423
|
try {
|
|
399
424
|
// Re-check after acquiring the lock: a racing holder may have populated it.
|
|
400
425
|
const fresh = await this.getJSON<T>(key)
|
|
@@ -403,7 +428,19 @@ export class Redis {
|
|
|
403
428
|
await this.setJSON(key, value, seconds)
|
|
404
429
|
return value
|
|
405
430
|
} finally {
|
|
406
|
-
|
|
431
|
+
clearInterval(renewTimer)
|
|
432
|
+
// Only the owner may release the lock. A plain DEL lets a slow callback
|
|
433
|
+
// delete a successor's lock after its own 10s lease expired, reopening
|
|
434
|
+
// the stampede window. Waiters that never acquired it release nothing.
|
|
435
|
+
if (acquired != null) {
|
|
436
|
+
const releaseScript = `
|
|
437
|
+
if redis.call('GET', KEYS[1]) == ARGV[1] then
|
|
438
|
+
return redis.call('DEL', KEYS[1])
|
|
439
|
+
end
|
|
440
|
+
return 0
|
|
441
|
+
`
|
|
442
|
+
await this.client.send('EVAL', [releaseScript, '1', lockKey, lockToken]).catch(() => {})
|
|
443
|
+
}
|
|
407
444
|
}
|
|
408
445
|
}
|
|
409
446
|
|