@naturalcycles/redis-lib 4.5.0 → 4.7.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.
@@ -13,7 +13,7 @@ export interface CommonClient extends AsyncDisposable {
13
13
  export interface RedisClientCfg {
14
14
  redisOptions?: RedisOptions;
15
15
  /**
16
- * Defaults to console.
16
+ * Defaults to consoleLogger.
17
17
  */
18
18
  logger?: CommonLogger;
19
19
  }
@@ -53,8 +53,28 @@ export declare class RedisClient implements CommonClient {
53
53
  hsetWithTTL(_key: string, _value: AnyObject, _expireAt: UnixTimestamp): Promise<void>;
54
54
  mset(obj: Record<string, string | number>): Promise<void>;
55
55
  msetBuffer(obj: Record<string, Buffer>): Promise<void>;
56
+ /**
57
+ * For counters that are supposed to expire, use {@link incrWithTTL} instead.
58
+ */
56
59
  incr(key: string, by?: number): Promise<number>;
60
+ /**
61
+ * Increments the key and guarantees it has an expiry, in a single transaction.
62
+ *
63
+ * Returns the new value.
64
+ *
65
+ * Requires Redis 7.0+
66
+ */
67
+ incrWithTTL(key: string, expireAt: UnixTimestamp, by?: number): Promise<number>;
68
+ /**
69
+ * For counters that are supposed to expire, use {@link incrBatchWithTTL} instead.
70
+ */
57
71
  incrBatch(incrementTuples: [string, number][]): Promise<[string, number][]>;
72
+ /**
73
+ * Batch version of {@link incrWithTTL}, all increments and expiries in a single transaction.
74
+ *
75
+ * Requires Redis 7.0+
76
+ */
77
+ incrBatchWithTTL(incrementTuples: [string, number][], expireAt: UnixTimestamp): Promise<[string, number][]>;
58
78
  ttl(key: string): Promise<number>;
59
79
  dropTable(table: string): Promise<void>;
60
80
  clearAll(): Promise<void>;
@@ -1,3 +1,5 @@
1
+ import { _assert } from '@naturalcycles/js-lib/error';
2
+ import { consoleLogger } from '@naturalcycles/js-lib/log';
1
3
  import { _stringMapEntries } from '@naturalcycles/js-lib/types';
2
4
  import { Pipeline } from '@naturalcycles/nodejs-lib/stream';
3
5
  /**
@@ -11,7 +13,7 @@ import { Pipeline } from '@naturalcycles/nodejs-lib/stream';
11
13
  export class RedisClient {
12
14
  constructor(cfg = {}) {
13
15
  this.cfg = {
14
- logger: console,
16
+ logger: consoleLogger,
15
17
  ...cfg,
16
18
  redisOptions: {
17
19
  showFriendlyErrorStack: true,
@@ -50,7 +52,7 @@ export class RedisClient {
50
52
  async disconnect() {
51
53
  const redis = await this.redis();
52
54
  this.log('redis: quit...');
53
- this.log(`redis: quit`, await redis.quit());
55
+ this.log({ msg: `redis: quit`, result: await redis.quit() });
54
56
  this.connected = false;
55
57
  }
56
58
  async [Symbol.asyncDispose]() {
@@ -149,10 +151,37 @@ export class RedisClient {
149
151
  const redis = await this.redis();
150
152
  await redis.mset(obj);
151
153
  }
154
+ /**
155
+ * For counters that are supposed to expire, use {@link incrWithTTL} instead.
156
+ */
152
157
  async incr(key, by = 1) {
153
158
  const redis = await this.redis();
154
159
  return await redis.incrby(key, by);
155
160
  }
161
+ /**
162
+ * Increments the key and guarantees it has an expiry, in a single transaction.
163
+ *
164
+ * Returns the new value.
165
+ *
166
+ * Requires Redis 7.0+
167
+ */
168
+ async incrWithTTL(key, expireAt, by = 1) {
169
+ const redis = await this.redis();
170
+ const results = await redis.multi().incrby(key, by).expireat(key, expireAt, 'NX').exec();
171
+ const result = results?.[0];
172
+ _assert(result, `redis: incrWithTTL transaction returned no result, key: ${key}`);
173
+ // Redis does not roll back, so a failed EXPIREAT leaves the key incremented and without an
174
+ // expiry. It self-heals on the next successful call, as EXPIREAT NX applies to a TTL-less key.
175
+ for (const [err] of results) {
176
+ if (err)
177
+ throw err;
178
+ }
179
+ const [, value] = result;
180
+ return value;
181
+ }
182
+ /**
183
+ * For counters that are supposed to expire, use {@link incrBatchWithTTL} instead.
184
+ */
156
185
  async incrBatch(incrementTuples) {
157
186
  const results = {};
158
187
  await this.withPipeline(async (pipeline) => {
@@ -165,6 +194,31 @@ export class RedisClient {
165
194
  const validResults = _stringMapEntries(results).filter(([_, v]) => v !== undefined);
166
195
  return validResults;
167
196
  }
197
+ /**
198
+ * Batch version of {@link incrWithTTL}, all increments and expiries in a single transaction.
199
+ *
200
+ * Requires Redis 7.0+
201
+ */
202
+ async incrBatchWithTTL(incrementTuples, expireAt) {
203
+ const redis = await this.redis();
204
+ const multi = redis.multi();
205
+ for (const [key, increment] of incrementTuples) {
206
+ multi.incrby(key, increment);
207
+ multi.expireat(key, expireAt, 'NX');
208
+ }
209
+ // 2 commands are queued per key, so the increments sit at the even indexes
210
+ const results = await multi.exec();
211
+ const expectedLength = incrementTuples.length * 2;
212
+ _assert(results?.length === expectedLength, `redis: incrBatchWithTTL expected ${expectedLength} results, got ${results?.length}`);
213
+ for (const [err] of results) {
214
+ if (err)
215
+ throw err;
216
+ }
217
+ return incrementTuples.map(([key], i) => {
218
+ const [, newValue] = results[i * 2];
219
+ return [key, newValue];
220
+ });
221
+ }
168
222
  async ttl(key) {
169
223
  const redis = await this.redis();
170
224
  return await redis.ttl(key);
@@ -240,7 +294,7 @@ export class RedisClient {
240
294
  await fn(pipeline);
241
295
  await pipeline.exec();
242
296
  }
243
- log(...args) {
244
- this.cfg.logger.log(...args);
297
+ log(input) {
298
+ this.cfg.logger.log(input);
245
299
  }
246
300
  }
package/package.json CHANGED
@@ -9,9 +9,9 @@
9
9
  "tslib": "^2"
10
10
  },
11
11
  "devDependencies": {
12
+ "@naturalcycles/dev-lib": "20.64.0",
12
13
  "@types/node": "^26",
13
- "typescript": "^7",
14
- "@naturalcycles/dev-lib": "0.0.0"
14
+ "typescript": "^7"
15
15
  },
16
16
  "exports": {
17
17
  ".": "./dist/index.js"
@@ -38,7 +38,7 @@
38
38
  "engines": {
39
39
  "node": ">=24.10.0"
40
40
  },
41
- "version": "4.5.0",
41
+ "version": "4.7.0",
42
42
  "description": "Redis implementation of CommonKeyValueDB interface",
43
43
  "author": "Natural Cycles Team",
44
44
  "license": "MIT",
@@ -1,4 +1,6 @@
1
- import type { CommonLogger } from '@naturalcycles/js-lib/log'
1
+ import { _assert } from '@naturalcycles/js-lib/error'
2
+ import { consoleLogger } from '@naturalcycles/js-lib/log'
3
+ import type { CommonLogger, LogInput } from '@naturalcycles/js-lib/log'
2
4
  import type {
3
5
  AnyObject,
4
6
  NullableBuffer,
@@ -24,7 +26,7 @@ export interface RedisClientCfg {
24
26
  redisOptions?: RedisOptions
25
27
 
26
28
  /**
27
- * Defaults to console.
29
+ * Defaults to consoleLogger.
28
30
  */
29
31
  logger?: CommonLogger
30
32
  }
@@ -40,7 +42,7 @@ export interface RedisClientCfg {
40
42
  export class RedisClient implements CommonClient {
41
43
  constructor(cfg: RedisClientCfg = {}) {
42
44
  this.cfg = {
43
- logger: console,
45
+ logger: consoleLogger,
44
46
  ...cfg,
45
47
  redisOptions: {
46
48
  showFriendlyErrorStack: true,
@@ -90,7 +92,7 @@ export class RedisClient implements CommonClient {
90
92
  async disconnect(): Promise<void> {
91
93
  const redis = await this.redis()
92
94
  this.log('redis: quit...')
93
- this.log(`redis: quit`, await redis.quit())
95
+ this.log({ msg: `redis: quit`, result: await redis.quit() })
94
96
  this.connected = false
95
97
  }
96
98
 
@@ -221,11 +223,40 @@ export class RedisClient implements CommonClient {
221
223
  await redis.mset(obj)
222
224
  }
223
225
 
226
+ /**
227
+ * For counters that are supposed to expire, use {@link incrWithTTL} instead.
228
+ */
224
229
  async incr(key: string, by = 1): Promise<number> {
225
230
  const redis = await this.redis()
226
231
  return await redis.incrby(key, by)
227
232
  }
228
233
 
234
+ /**
235
+ * Increments the key and guarantees it has an expiry, in a single transaction.
236
+ *
237
+ * Returns the new value.
238
+ *
239
+ * Requires Redis 7.0+
240
+ */
241
+ async incrWithTTL(key: string, expireAt: UnixTimestamp, by = 1): Promise<number> {
242
+ const redis = await this.redis()
243
+ const results = await redis.multi().incrby(key, by).expireat(key, expireAt, 'NX').exec()
244
+ const result = results?.[0]
245
+ _assert(result, `redis: incrWithTTL transaction returned no result, key: ${key}`)
246
+
247
+ // Redis does not roll back, so a failed EXPIREAT leaves the key incremented and without an
248
+ // expiry. It self-heals on the next successful call, as EXPIREAT NX applies to a TTL-less key.
249
+ for (const [err] of results) {
250
+ if (err) throw err
251
+ }
252
+
253
+ const [, value] = result
254
+ return value as number
255
+ }
256
+
257
+ /**
258
+ * For counters that are supposed to expire, use {@link incrBatchWithTTL} instead.
259
+ */
229
260
  async incrBatch(incrementTuples: [string, number][]): Promise<[string, number][]> {
230
261
  const results: StringMap<number | undefined> = {}
231
262
 
@@ -245,6 +276,41 @@ export class RedisClient implements CommonClient {
245
276
  return validResults
246
277
  }
247
278
 
279
+ /**
280
+ * Batch version of {@link incrWithTTL}, all increments and expiries in a single transaction.
281
+ *
282
+ * Requires Redis 7.0+
283
+ */
284
+ async incrBatchWithTTL(
285
+ incrementTuples: [string, number][],
286
+ expireAt: UnixTimestamp,
287
+ ): Promise<[string, number][]> {
288
+ const redis = await this.redis()
289
+ const multi = redis.multi()
290
+
291
+ for (const [key, increment] of incrementTuples) {
292
+ multi.incrby(key, increment)
293
+ multi.expireat(key, expireAt, 'NX')
294
+ }
295
+
296
+ // 2 commands are queued per key, so the increments sit at the even indexes
297
+ const results = await multi.exec()
298
+ const expectedLength = incrementTuples.length * 2
299
+ _assert(
300
+ results?.length === expectedLength,
301
+ `redis: incrBatchWithTTL expected ${expectedLength} results, got ${results?.length}`,
302
+ )
303
+
304
+ for (const [err] of results) {
305
+ if (err) throw err
306
+ }
307
+
308
+ return incrementTuples.map(([key], i) => {
309
+ const [, newValue] = results[i * 2]!
310
+ return [key, newValue as number]
311
+ })
312
+ }
313
+
248
314
  async ttl(key: string): Promise<number> {
249
315
  const redis = await this.redis()
250
316
  return await redis.ttl(key)
@@ -338,7 +404,7 @@ export class RedisClient implements CommonClient {
338
404
  await pipeline.exec()
339
405
  }
340
406
 
341
- private log(...args: any[]): void {
342
- this.cfg.logger.log(...args)
407
+ private log(input: LogInput): void {
408
+ this.cfg.logger.log(input)
343
409
  }
344
410
  }