@tekir/redis 0.1.1 → 0.1.3

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 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, '1', 'NX', 'EX', '10']);
357
+ const acquired = await this.client.send('SET', [lockKey, lockToken, 'NX', 'EX', '10']);
347
358
  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++) {
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
- await this.del(`${key}:__lock`).catch(() => { });
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.1",
3
+ "version": "0.1.3",
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.34"
42
+ "@tekir/core": "^0.1.0"
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, '1', 'NX', 'EX', '10'])
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. 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++) {
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
- await this.del(`${key}:__lock`).catch(() => {})
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