@naturalcycles/redis-lib 3.1.1 → 3.3.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.
@@ -47,11 +47,13 @@ export declare class RedisClient implements CommonClient {
47
47
  hmget(key: string, fields: string[]): Promise<NullableString[]>;
48
48
  hmgetBuffer(key: string, fields: string[]): Promise<NullableBuffer[]>;
49
49
  hincr(key: string, field: string, increment?: number): Promise<number>;
50
+ hincrBatch(key: string, incrementTuples: [string, number][]): Promise<[string, number][]>;
50
51
  setWithTTL(key: string, value: string | number | Buffer, expireAt: UnixTimestampNumber): Promise<void>;
51
- hsetWithTTL(key: string, value: AnyObject, expireAt: UnixTimestampNumber): Promise<void>;
52
+ hsetWithTTL(_key: string, _value: AnyObject, _expireAt: UnixTimestampNumber): Promise<void>;
52
53
  mset(obj: Record<string, string | number>): Promise<void>;
53
54
  msetBuffer(obj: Record<string, Buffer>): Promise<void>;
54
55
  incr(key: string, by?: number): Promise<number>;
56
+ incrBatch(incrementTuples: [string, number][]): Promise<[string, number][]>;
55
57
  ttl(key: string): Promise<number>;
56
58
  dropTable(table: string): Promise<void>;
57
59
  clearAll(): Promise<void>;
@@ -59,14 +61,14 @@ export declare class RedisClient implements CommonClient {
59
61
  Convenient type-safe wrapper.
60
62
  Returns BATCHES of keys in each iteration (as-is).
61
63
  */
62
- scanStream(opt: ScanStreamOptions): ReadableTyped<string[]>;
64
+ scanStream(opt?: ScanStreamOptions): ReadableTyped<string[]>;
63
65
  /**
64
66
  * Like scanStream, but flattens the stream of keys.
65
67
  */
66
68
  scanStreamFlat(opt: ScanStreamOptions): ReadableTyped<string>;
67
69
  scanCount(opt: ScanStreamOptions): Promise<number>;
68
- hscanStream(key: string, opt: ScanStreamOptions): ReadableTyped<string[]>;
69
- hscanCount(key: string, opt: ScanStreamOptions): Promise<number>;
70
+ hscanStream(key: string, opt?: ScanStreamOptions): ReadableTyped<string[]>;
71
+ hscanCount(key: string, opt?: ScanStreamOptions): Promise<number>;
70
72
  withPipeline(fn: (pipeline: ChainableCommander) => Promisable<void>): Promise<void>;
71
73
  private log;
72
74
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RedisClient = void 0;
4
+ const js_lib_1 = require("@naturalcycles/js-lib");
4
5
  /**
5
6
  Wraps the redis sdk with unified interface.
6
7
  Features:
@@ -98,17 +99,30 @@ class RedisClient {
98
99
  async hincr(key, field, increment = 1) {
99
100
  return await this.redis().hincrby(key, field, increment);
100
101
  }
102
+ async hincrBatch(key, incrementTuples) {
103
+ const results = {};
104
+ await this.withPipeline(async (pipeline) => {
105
+ for (const [field, increment] of incrementTuples) {
106
+ pipeline.hincrby(key, field, increment, (_err, newValue) => {
107
+ results[field] = newValue;
108
+ });
109
+ }
110
+ });
111
+ const validResults = (0, js_lib_1._stringMapEntries)(results).filter(([_, v]) => v !== undefined);
112
+ return validResults;
113
+ }
101
114
  async setWithTTL(key, value, expireAt) {
102
115
  await this.redis().set(key, value, 'EXAT', expireAt);
103
116
  }
104
- async hsetWithTTL(key, value, expireAt) {
105
- const valueKeys = Object.keys(value);
106
- const numberOfKeys = valueKeys.length;
107
- const keyList = valueKeys.join(' ');
108
- const commandString = `HEXPIREAT ${key} ${expireAt} FIELDS ${numberOfKeys} ${keyList}`;
109
- const [command, ...args] = commandString.split(' ');
110
- await this.redis().hset(key, value);
111
- await this.redis().call(command, args);
117
+ async hsetWithTTL(_key, _value, _expireAt) {
118
+ throw new Error('Not supported until Redis 7.4.0');
119
+ // const valueKeys = Object.keys(value)
120
+ // const numberOfKeys = valueKeys.length
121
+ // const keyList = valueKeys.join(' ')
122
+ // const commandString = `HEXPIREAT ${key} ${expireAt} FIELDS ${numberOfKeys} ${keyList}`
123
+ // const [command, ...args] = commandString.split(' ')
124
+ // await this.redis().hset(key, value)
125
+ // await this.redis().call(command!, args)
112
126
  }
113
127
  async mset(obj) {
114
128
  await this.redis().mset(obj);
@@ -119,6 +133,18 @@ class RedisClient {
119
133
  async incr(key, by = 1) {
120
134
  return await this.redis().incrby(key, by);
121
135
  }
136
+ async incrBatch(incrementTuples) {
137
+ const results = {};
138
+ await this.withPipeline(async (pipeline) => {
139
+ for (const [key, increment] of incrementTuples) {
140
+ pipeline.incrby(key, increment, (_err, newValue) => {
141
+ results[key] = newValue;
142
+ });
143
+ }
144
+ });
145
+ const validResults = (0, js_lib_1._stringMapEntries)(results).filter(([_, v]) => v !== undefined);
146
+ return validResults;
147
+ }
122
148
  async ttl(key) {
123
149
  return await this.redis().ttl(key);
124
150
  }
@@ -1,15 +1,21 @@
1
- import { CommonDBCreateOptions, CommonKeyValueDB, CommonKeyValueDBSaveBatchOptions, KeyValueDBTuple } from '@naturalcycles/db-lib';
2
- import { StringMap } from '@naturalcycles/js-lib';
1
+ import { CommonDBCreateOptions, CommonKeyValueDB, CommonKeyValueDBSaveBatchOptions, IncrementTuple, KeyValueDBTuple } from '@naturalcycles/db-lib';
3
2
  import { ReadableTyped } from '@naturalcycles/nodejs-lib';
4
- import { RedisClient } from './redisClient';
5
3
  import { RedisKeyValueDBCfg } from './redisKeyValueDB';
6
- export interface RedisHashKeyValueDBCfg extends RedisKeyValueDBCfg {
7
- hashKey: string;
8
- }
9
- export declare class RedisHashKeyValueDB implements CommonKeyValueDB, AsyncDisposable {
10
- client: RedisClient;
11
- keyOfHashField: string;
12
- constructor(cfg: RedisHashKeyValueDBCfg);
4
+ /**
5
+ * RedisHashKeyValueDB is a KeyValueDB implementation that uses hash fields to simulate tables.
6
+ * The value in the `table` arguments points to a hash field in Redis.
7
+ *
8
+ * The reason for having this approach and also the traditional RedisKeyValueDB is that
9
+ * the currently available Redis versions (in Memorystore, or on MacOs) do not support
10
+ * expiring hash properties.
11
+ * The expiring fields feature is important, and only available via RedisKeyValueDB.
12
+ *
13
+ * Once the available Redis version reaches 7.4.0+,
14
+ * this implementation can take over for RedisKeyValueDB.
15
+ */
16
+ export declare class RedishHashKeyValueDB implements CommonKeyValueDB, AsyncDisposable {
17
+ cfg: RedisKeyValueDBCfg;
18
+ constructor(cfg: RedisKeyValueDBCfg);
13
19
  support: {
14
20
  count?: boolean;
15
21
  increment?: boolean;
@@ -23,11 +29,6 @@ export declare class RedisHashKeyValueDB implements CommonKeyValueDB, AsyncDispo
23
29
  streamValues(table: string, limit?: number): ReadableTyped<Buffer>;
24
30
  streamEntries(table: string, limit?: number): ReadableTyped<KeyValueDBTuple>;
25
31
  count(table: string): Promise<number>;
26
- increment(table: string, id: string, by?: number): Promise<number>;
27
- incrementBatch(_table: string, _incrementMap: StringMap<number>): Promise<StringMap<number>>;
32
+ incrementBatch(table: string, increments: IncrementTuple[]): Promise<IncrementTuple[]>;
28
33
  createTable(table: string, opt?: CommonDBCreateOptions): Promise<void>;
29
- private idsToKeys;
30
- private idToKey;
31
- private keysToIds;
32
- private keyToId;
33
34
  }
@@ -1,121 +1,105 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.RedisHashKeyValueDB = void 0;
3
+ exports.RedishHashKeyValueDB = void 0;
4
4
  const db_lib_1 = require("@naturalcycles/db-lib");
5
- const js_lib_1 = require("@naturalcycles/js-lib");
6
- class RedisHashKeyValueDB {
5
+ /**
6
+ * RedisHashKeyValueDB is a KeyValueDB implementation that uses hash fields to simulate tables.
7
+ * The value in the `table` arguments points to a hash field in Redis.
8
+ *
9
+ * The reason for having this approach and also the traditional RedisKeyValueDB is that
10
+ * the currently available Redis versions (in Memorystore, or on MacOs) do not support
11
+ * expiring hash properties.
12
+ * The expiring fields feature is important, and only available via RedisKeyValueDB.
13
+ *
14
+ * Once the available Redis version reaches 7.4.0+,
15
+ * this implementation can take over for RedisKeyValueDB.
16
+ */
17
+ class RedishHashKeyValueDB {
7
18
  constructor(cfg) {
19
+ this.cfg = cfg;
8
20
  this.support = {
9
21
  ...db_lib_1.commonKeyValueDBFullSupport,
10
22
  };
11
- this.client = cfg.client;
12
- this.keyOfHashField = cfg.hashKey;
13
23
  }
14
24
  async ping() {
15
- await this.client.ping();
25
+ await this.cfg.client.ping();
16
26
  }
17
27
  async [Symbol.asyncDispose]() {
18
- await this.client.disconnect();
28
+ await this.cfg.client.disconnect();
19
29
  }
20
30
  async getByIds(table, ids) {
21
31
  if (!ids.length)
22
32
  return [];
23
33
  // we assume that the order of returned values is the same as order of input ids
24
- const bufs = await this.client.hmgetBuffer(this.keyOfHashField, this.idsToKeys(table, ids));
34
+ const bufs = await this.cfg.client.hmgetBuffer(table, ids);
25
35
  return bufs.map((buf, i) => [ids[i], buf]).filter(([_k, v]) => v !== null);
26
36
  }
27
37
  async deleteByIds(table, ids) {
28
38
  if (!ids.length)
29
39
  return;
30
- await this.client.hdel(this.keyOfHashField, this.idsToKeys(table, ids));
40
+ await this.cfg.client.hdel(table, ids);
31
41
  }
32
42
  async saveBatch(table, entries, opt) {
33
43
  if (!entries.length)
34
44
  return;
35
- const entriesWithKey = entries.map(([k, v]) => [this.idToKey(table, k), v]);
36
- const map = Object.fromEntries(entriesWithKey);
45
+ const record = Object.fromEntries(entries);
37
46
  if (opt?.expireAt) {
38
- await this.client.hsetWithTTL(this.keyOfHashField, map, opt.expireAt);
47
+ await this.cfg.client.hsetWithTTL(table, record, opt.expireAt);
39
48
  }
40
49
  else {
41
- await this.client.hset(this.keyOfHashField, map);
50
+ await this.cfg.client.hset(table, record);
42
51
  }
43
52
  }
44
53
  streamIds(table, limit) {
45
- let stream = this.client
46
- .hscanStream(this.keyOfHashField, {
47
- match: `${table}:*`,
48
- })
54
+ const stream = this.cfg.client
55
+ .hscanStream(table)
49
56
  .flatMap(keyValueList => {
50
57
  const keys = [];
51
- keyValueList.forEach((keyOrValue, index) => {
52
- if (index % 2 !== 0)
53
- return;
54
- keys.push(keyOrValue);
55
- });
56
- return this.keysToIds(table, keys);
57
- });
58
- if (limit) {
59
- stream = stream.take(limit);
60
- }
58
+ for (let i = 0; i < keyValueList.length; i += 2) {
59
+ keys.push(keyValueList[i]);
60
+ }
61
+ return keys;
62
+ })
63
+ .take(limit || Infinity);
61
64
  return stream;
62
65
  }
63
66
  streamValues(table, limit) {
64
- return this.client
65
- .hscanStream(this.keyOfHashField, {
66
- match: `${table}:*`,
67
- })
67
+ return this.cfg.client
68
+ .hscanStream(table)
68
69
  .flatMap(keyValueList => {
69
70
  const values = [];
70
- keyValueList.forEach((keyOrValue, index) => {
71
- if (index % 2 !== 1)
72
- return;
73
- values.push(keyOrValue);
74
- });
75
- return values.map(v => Buffer.from(v));
71
+ for (let i = 0; i < keyValueList.length; i += 2) {
72
+ const value = Buffer.from(keyValueList[i + 1]);
73
+ values.push(value);
74
+ }
75
+ return values;
76
76
  })
77
77
  .take(limit || Infinity);
78
78
  }
79
79
  streamEntries(table, limit) {
80
- return this.client
81
- .hscanStream(this.keyOfHashField, {
82
- match: `${table}:*`,
83
- })
80
+ return this.cfg.client
81
+ .hscanStream(table)
84
82
  .flatMap(keyValueList => {
85
- const entries = (0, js_lib_1._chunk)(keyValueList, 2);
86
- return entries.map(([k, v]) => {
87
- return [this.keyToId(table, String(k)), Buffer.from(String(v))];
88
- });
83
+ const entries = [];
84
+ for (let i = 0; i < keyValueList.length; i += 2) {
85
+ const key = keyValueList[i];
86
+ const value = Buffer.from(keyValueList[i + 1]);
87
+ entries.push([key, value]);
88
+ }
89
+ return entries;
89
90
  })
90
91
  .take(limit || Infinity);
91
92
  }
92
93
  async count(table) {
93
- return await this.client.hscanCount(this.keyOfHashField, {
94
- match: `${table}:*`,
95
- });
94
+ return await this.cfg.client.hscanCount(table);
96
95
  }
97
- async increment(table, id, by = 1) {
98
- return await this.client.hincr(this.keyOfHashField, this.idToKey(table, id), by);
99
- }
100
- async incrementBatch(_table, _incrementMap) {
101
- throw new Error('Not implemented');
96
+ async incrementBatch(table, increments) {
97
+ return await this.cfg.client.hincrBatch(table, increments);
102
98
  }
103
99
  async createTable(table, opt) {
104
100
  if (!opt?.dropIfExists)
105
101
  return;
106
- await this.client.dropTable(table);
107
- }
108
- idsToKeys(table, ids) {
109
- return ids.map(id => this.idToKey(table, id));
110
- }
111
- idToKey(table, id) {
112
- return `${table}:${id}`;
113
- }
114
- keysToIds(table, keys) {
115
- return keys.map(key => this.keyToId(table, key));
116
- }
117
- keyToId(table, key) {
118
- return key.slice(table.length + 1);
102
+ await this.cfg.client.del([table]);
119
103
  }
120
104
  }
121
- exports.RedisHashKeyValueDB = RedisHashKeyValueDB;
105
+ exports.RedishHashKeyValueDB = RedishHashKeyValueDB;
@@ -1,13 +1,12 @@
1
- import { CommonDBCreateOptions, CommonKeyValueDB, CommonKeyValueDBSaveBatchOptions, KeyValueDBTuple } from '@naturalcycles/db-lib';
2
- import { StringMap } from '@naturalcycles/js-lib';
1
+ import { CommonDBCreateOptions, CommonKeyValueDB, CommonKeyValueDBSaveBatchOptions, IncrementTuple, KeyValueDBTuple } from '@naturalcycles/db-lib';
3
2
  import { ReadableTyped } from '@naturalcycles/nodejs-lib';
4
3
  import { RedisClient } from './redisClient';
5
4
  export interface RedisKeyValueDBCfg {
6
5
  client: RedisClient;
7
6
  }
8
7
  export declare class RedisKeyValueDB implements CommonKeyValueDB, AsyncDisposable {
8
+ cfg: RedisKeyValueDBCfg;
9
9
  constructor(cfg: RedisKeyValueDBCfg);
10
- client: RedisClient;
11
10
  support: {
12
11
  count?: boolean;
13
12
  increment?: boolean;
@@ -21,8 +20,7 @@ export declare class RedisKeyValueDB implements CommonKeyValueDB, AsyncDisposabl
21
20
  streamValues(table: string, limit?: number): ReadableTyped<Buffer>;
22
21
  streamEntries(table: string, limit?: number): ReadableTyped<KeyValueDBTuple>;
23
22
  count(table: string): Promise<number>;
24
- increment(table: string, id: string, by?: number): Promise<number>;
25
- incrementBatch(_table: string, _incrementMap: StringMap<number>): Promise<StringMap<number>>;
23
+ incrementBatch(table: string, increments: IncrementTuple[]): Promise<IncrementTuple[]>;
26
24
  createTable(table: string, opt?: CommonDBCreateOptions): Promise<void>;
27
25
  private idsToKeys;
28
26
  private idToKey;
@@ -5,28 +5,28 @@ const db_lib_1 = require("@naturalcycles/db-lib");
5
5
  const js_lib_1 = require("@naturalcycles/js-lib");
6
6
  class RedisKeyValueDB {
7
7
  constructor(cfg) {
8
+ this.cfg = cfg;
8
9
  this.support = {
9
10
  ...db_lib_1.commonKeyValueDBFullSupport,
10
11
  };
11
- this.client = cfg.client;
12
12
  }
13
13
  async ping() {
14
- await this.client.ping();
14
+ await this.cfg.client.ping();
15
15
  }
16
16
  async [Symbol.asyncDispose]() {
17
- await this.client.disconnect();
17
+ await this.cfg.client.disconnect();
18
18
  }
19
19
  async getByIds(table, ids) {
20
20
  if (!ids.length)
21
21
  return [];
22
22
  // we assume that the order of returned values is the same as order of input ids
23
- const bufs = await this.client.mgetBuffer(this.idsToKeys(table, ids));
23
+ const bufs = await this.cfg.client.mgetBuffer(this.idsToKeys(table, ids));
24
24
  return bufs.map((buf, i) => [ids[i], buf]).filter(([_k, v]) => v !== null);
25
25
  }
26
26
  async deleteByIds(table, ids) {
27
27
  if (!ids.length)
28
28
  return;
29
- await this.client.del(this.idsToKeys(table, ids));
29
+ await this.cfg.client.del(this.idsToKeys(table, ids));
30
30
  }
31
31
  async saveBatch(table, entries, opt) {
32
32
  if (!entries.length)
@@ -34,7 +34,7 @@ class RedisKeyValueDB {
34
34
  if (opt?.expireAt) {
35
35
  // There's no supported mset with TTL: https://stackoverflow.com/questions/16423342/redis-multi-set-with-a-ttl
36
36
  // so we gonna use a pipeline instead
37
- await this.client.withPipeline(pipeline => {
37
+ await this.cfg.client.withPipeline(pipeline => {
38
38
  for (const [k, v] of entries) {
39
39
  pipeline.set(this.idToKey(table, k), v, 'EXAT', opt.expireAt);
40
40
  }
@@ -42,11 +42,11 @@ class RedisKeyValueDB {
42
42
  }
43
43
  else {
44
44
  const obj = Object.fromEntries(entries.map(([k, v]) => [this.idToKey(table, k), v]));
45
- await this.client.msetBuffer(obj);
45
+ await this.cfg.client.msetBuffer(obj);
46
46
  }
47
47
  }
48
48
  streamIds(table, limit) {
49
- let stream = this.client
49
+ let stream = this.cfg.client
50
50
  .scanStream({
51
51
  match: `${table}:*`,
52
52
  // count: limit, // count is actually a "batchSize", not a limit
@@ -58,25 +58,25 @@ class RedisKeyValueDB {
58
58
  return stream;
59
59
  }
60
60
  streamValues(table, limit) {
61
- return this.client
61
+ return this.cfg.client
62
62
  .scanStream({
63
63
  match: `${table}:*`,
64
64
  })
65
65
  .flatMap(async (keys) => {
66
- return (await this.client.mgetBuffer(keys)).filter(js_lib_1._isTruthy);
66
+ return (await this.cfg.client.mgetBuffer(keys)).filter(js_lib_1._isTruthy);
67
67
  }, {
68
68
  concurrency: 16,
69
69
  })
70
70
  .take(limit || Infinity);
71
71
  }
72
72
  streamEntries(table, limit) {
73
- return this.client
73
+ return this.cfg.client
74
74
  .scanStream({
75
75
  match: `${table}:*`,
76
76
  })
77
77
  .flatMap(async (keys) => {
78
78
  // casting as Buffer[], because values are expected to exist for given keys
79
- const bufs = (await this.client.mgetBuffer(keys));
79
+ const bufs = (await this.cfg.client.mgetBuffer(keys));
80
80
  return (0, js_lib_1._zip)(this.keysToIds(table, keys), bufs);
81
81
  }, {
82
82
  concurrency: 16,
@@ -85,20 +85,20 @@ class RedisKeyValueDB {
85
85
  }
86
86
  async count(table) {
87
87
  // todo: implement more efficiently, e.g via LUA?
88
- return await this.client.scanCount({
88
+ return await this.cfg.client.scanCount({
89
89
  match: `${table}:*`,
90
90
  });
91
91
  }
92
- async increment(table, id, by = 1) {
93
- return await this.client.incr(this.idToKey(table, id), by);
94
- }
95
- async incrementBatch(_table, _incrementMap) {
96
- throw new Error('Not implemented');
92
+ async incrementBatch(table, increments) {
93
+ const incrementTuplesWithInternalKeys = increments.map(([id, v]) => [this.idToKey(table, id), v]);
94
+ const resultsWithInternalKeys = await this.cfg.client.incrBatch(incrementTuplesWithInternalKeys);
95
+ const results = resultsWithInternalKeys.map(([k, v]) => [this.keyToId(table, k), v]);
96
+ return results;
97
97
  }
98
98
  async createTable(table, opt) {
99
99
  if (!opt?.dropIfExists)
100
100
  return;
101
- await this.client.dropTable(table);
101
+ await this.cfg.client.dropTable(table);
102
102
  }
103
103
  idsToKeys(table, ids) {
104
104
  return ids.map(id => this.idToKey(table, id));
package/package.json CHANGED
@@ -40,7 +40,7 @@
40
40
  "engines": {
41
41
  "node": ">=20.13"
42
42
  },
43
- "version": "3.1.1",
43
+ "version": "3.3.0",
44
44
  "description": "Redis implementation of CommonKeyValueDB interface",
45
45
  "author": "Natural Cycles Team",
46
46
  "license": "MIT"
@@ -1,9 +1,11 @@
1
1
  import {
2
+ _stringMapEntries,
2
3
  AnyObject,
3
4
  CommonLogger,
4
5
  NullableBuffer,
5
6
  NullableString,
6
7
  Promisable,
8
+ StringMap,
7
9
  UnixTimestampNumber,
8
10
  } from '@naturalcycles/js-lib'
9
11
  import { ReadableTyped } from '@naturalcycles/nodejs-lib'
@@ -157,6 +159,25 @@ export class RedisClient implements CommonClient {
157
159
  return await this.redis().hincrby(key, field, increment)
158
160
  }
159
161
 
162
+ async hincrBatch(key: string, incrementTuples: [string, number][]): Promise<[string, number][]> {
163
+ const results: StringMap<number | undefined> = {}
164
+
165
+ await this.withPipeline(async pipeline => {
166
+ for (const [field, increment] of incrementTuples) {
167
+ pipeline.hincrby(key, field, increment, (_err, newValue) => {
168
+ results[field] = newValue
169
+ })
170
+ }
171
+ })
172
+
173
+ const validResults = _stringMapEntries(results).filter(([_, v]) => v !== undefined) as [
174
+ string,
175
+ number,
176
+ ][]
177
+
178
+ return validResults
179
+ }
180
+
160
181
  async setWithTTL(
161
182
  key: string,
162
183
  value: string | number | Buffer,
@@ -165,14 +186,19 @@ export class RedisClient implements CommonClient {
165
186
  await this.redis().set(key, value, 'EXAT', expireAt)
166
187
  }
167
188
 
168
- async hsetWithTTL(key: string, value: AnyObject, expireAt: UnixTimestampNumber): Promise<void> {
169
- const valueKeys = Object.keys(value)
170
- const numberOfKeys = valueKeys.length
171
- const keyList = valueKeys.join(' ')
172
- const commandString = `HEXPIREAT ${key} ${expireAt} FIELDS ${numberOfKeys} ${keyList}`
173
- const [command, ...args] = commandString.split(' ')
174
- await this.redis().hset(key, value)
175
- await this.redis().call(command!, args)
189
+ async hsetWithTTL(
190
+ _key: string,
191
+ _value: AnyObject,
192
+ _expireAt: UnixTimestampNumber,
193
+ ): Promise<void> {
194
+ throw new Error('Not supported until Redis 7.4.0')
195
+ // const valueKeys = Object.keys(value)
196
+ // const numberOfKeys = valueKeys.length
197
+ // const keyList = valueKeys.join(' ')
198
+ // const commandString = `HEXPIREAT ${key} ${expireAt} FIELDS ${numberOfKeys} ${keyList}`
199
+ // const [command, ...args] = commandString.split(' ')
200
+ // await this.redis().hset(key, value)
201
+ // await this.redis().call(command!, args)
176
202
  }
177
203
 
178
204
  async mset(obj: Record<string, string | number>): Promise<void> {
@@ -187,6 +213,25 @@ export class RedisClient implements CommonClient {
187
213
  return await this.redis().incrby(key, by)
188
214
  }
189
215
 
216
+ async incrBatch(incrementTuples: [string, number][]): Promise<[string, number][]> {
217
+ const results: StringMap<number | undefined> = {}
218
+
219
+ await this.withPipeline(async pipeline => {
220
+ for (const [key, increment] of incrementTuples) {
221
+ pipeline.incrby(key, increment, (_err, newValue) => {
222
+ results[key] = newValue
223
+ })
224
+ }
225
+ })
226
+
227
+ const validResults = _stringMapEntries(results).filter(([_, v]) => v !== undefined) as [
228
+ string,
229
+ number,
230
+ ][]
231
+
232
+ return validResults
233
+ }
234
+
190
235
  async ttl(key: string): Promise<number> {
191
236
  return await this.redis().ttl(key)
192
237
  }
@@ -226,7 +271,7 @@ export class RedisClient implements CommonClient {
226
271
  Convenient type-safe wrapper.
227
272
  Returns BATCHES of keys in each iteration (as-is).
228
273
  */
229
- scanStream(opt: ScanStreamOptions): ReadableTyped<string[]> {
274
+ scanStream(opt?: ScanStreamOptions): ReadableTyped<string[]> {
230
275
  return this.redis().scanStream(opt)
231
276
  }
232
277
 
@@ -249,11 +294,11 @@ export class RedisClient implements CommonClient {
249
294
  return count
250
295
  }
251
296
 
252
- hscanStream(key: string, opt: ScanStreamOptions): ReadableTyped<string[]> {
297
+ hscanStream(key: string, opt?: ScanStreamOptions): ReadableTyped<string[]> {
253
298
  return this.redis().hscanStream(key, opt)
254
299
  }
255
300
 
256
- async hscanCount(key: string, opt: ScanStreamOptions): Promise<number> {
301
+ async hscanCount(key: string, opt?: ScanStreamOptions): Promise<number> {
257
302
  let count = 0
258
303
 
259
304
  const stream = this.redis().hscanStream(key, opt)
@@ -3,48 +3,49 @@ import {
3
3
  CommonKeyValueDB,
4
4
  commonKeyValueDBFullSupport,
5
5
  CommonKeyValueDBSaveBatchOptions,
6
+ IncrementTuple,
6
7
  KeyValueDBTuple,
7
8
  } from '@naturalcycles/db-lib'
8
- import { _chunk, StringMap } from '@naturalcycles/js-lib'
9
9
  import { ReadableTyped } from '@naturalcycles/nodejs-lib'
10
- import { RedisClient } from './redisClient'
11
10
  import { RedisKeyValueDBCfg } from './redisKeyValueDB'
12
11
 
13
- export interface RedisHashKeyValueDBCfg extends RedisKeyValueDBCfg {
14
- hashKey: string
15
- }
16
-
17
- export class RedisHashKeyValueDB implements CommonKeyValueDB, AsyncDisposable {
18
- client: RedisClient
19
- keyOfHashField: string
20
-
21
- constructor(cfg: RedisHashKeyValueDBCfg) {
22
- this.client = cfg.client
23
- this.keyOfHashField = cfg.hashKey
24
- }
12
+ /**
13
+ * RedisHashKeyValueDB is a KeyValueDB implementation that uses hash fields to simulate tables.
14
+ * The value in the `table` arguments points to a hash field in Redis.
15
+ *
16
+ * The reason for having this approach and also the traditional RedisKeyValueDB is that
17
+ * the currently available Redis versions (in Memorystore, or on MacOs) do not support
18
+ * expiring hash properties.
19
+ * The expiring fields feature is important, and only available via RedisKeyValueDB.
20
+ *
21
+ * Once the available Redis version reaches 7.4.0+,
22
+ * this implementation can take over for RedisKeyValueDB.
23
+ */
24
+ export class RedishHashKeyValueDB implements CommonKeyValueDB, AsyncDisposable {
25
+ constructor(public cfg: RedisKeyValueDBCfg) {}
25
26
 
26
27
  support = {
27
28
  ...commonKeyValueDBFullSupport,
28
29
  }
29
30
 
30
31
  async ping(): Promise<void> {
31
- await this.client.ping()
32
+ await this.cfg.client.ping()
32
33
  }
33
34
 
34
35
  async [Symbol.asyncDispose](): Promise<void> {
35
- await this.client.disconnect()
36
+ await this.cfg.client.disconnect()
36
37
  }
37
38
 
38
39
  async getByIds(table: string, ids: string[]): Promise<KeyValueDBTuple[]> {
39
40
  if (!ids.length) return []
40
41
  // we assume that the order of returned values is the same as order of input ids
41
- const bufs = await this.client.hmgetBuffer(this.keyOfHashField, this.idsToKeys(table, ids))
42
+ const bufs = await this.cfg.client.hmgetBuffer(table, ids)
42
43
  return bufs.map((buf, i) => [ids[i], buf] as KeyValueDBTuple).filter(([_k, v]) => v !== null)
43
44
  }
44
45
 
45
46
  async deleteByIds(table: string, ids: string[]): Promise<void> {
46
47
  if (!ids.length) return
47
- await this.client.hdel(this.keyOfHashField, this.idsToKeys(table, ids))
48
+ await this.cfg.client.hdel(table, ids)
48
49
  }
49
50
 
50
51
  async saveBatch(
@@ -54,103 +55,70 @@ export class RedisHashKeyValueDB implements CommonKeyValueDB, AsyncDisposable {
54
55
  ): Promise<void> {
55
56
  if (!entries.length) return
56
57
 
57
- const entriesWithKey = entries.map(([k, v]) => [this.idToKey(table, k), v])
58
- const map: StringMap<any> = Object.fromEntries(entriesWithKey)
58
+ const record = Object.fromEntries(entries)
59
59
 
60
60
  if (opt?.expireAt) {
61
- await this.client.hsetWithTTL(this.keyOfHashField, map, opt.expireAt)
61
+ await this.cfg.client.hsetWithTTL(table, record, opt.expireAt)
62
62
  } else {
63
- await this.client.hset(this.keyOfHashField, map)
63
+ await this.cfg.client.hset(table, record)
64
64
  }
65
65
  }
66
66
 
67
67
  streamIds(table: string, limit?: number): ReadableTyped<string> {
68
- let stream = this.client
69
- .hscanStream(this.keyOfHashField, {
70
- match: `${table}:*`,
71
- })
68
+ const stream = this.cfg.client
69
+ .hscanStream(table)
72
70
  .flatMap(keyValueList => {
73
71
  const keys: string[] = []
74
- keyValueList.forEach((keyOrValue, index) => {
75
- if (index % 2 !== 0) return
76
- keys.push(keyOrValue)
77
- })
78
- return this.keysToIds(table, keys)
72
+ for (let i = 0; i < keyValueList.length; i += 2) {
73
+ keys.push(keyValueList[i]!)
74
+ }
75
+ return keys
79
76
  })
80
-
81
- if (limit) {
82
- stream = stream.take(limit)
83
- }
77
+ .take(limit || Infinity)
84
78
 
85
79
  return stream
86
80
  }
87
81
 
88
82
  streamValues(table: string, limit?: number): ReadableTyped<Buffer> {
89
- return this.client
90
- .hscanStream(this.keyOfHashField, {
91
- match: `${table}:*`,
92
- })
83
+ return this.cfg.client
84
+ .hscanStream(table)
93
85
  .flatMap(keyValueList => {
94
- const values: string[] = []
95
- keyValueList.forEach((keyOrValue, index) => {
96
- if (index % 2 !== 1) return
97
- values.push(keyOrValue)
98
- })
99
- return values.map(v => Buffer.from(v))
86
+ const values: Buffer[] = []
87
+ for (let i = 0; i < keyValueList.length; i += 2) {
88
+ const value = Buffer.from(keyValueList[i + 1]!)
89
+ values.push(value)
90
+ }
91
+ return values
100
92
  })
101
93
  .take(limit || Infinity)
102
94
  }
103
95
 
104
96
  streamEntries(table: string, limit?: number): ReadableTyped<KeyValueDBTuple> {
105
- return this.client
106
- .hscanStream(this.keyOfHashField, {
107
- match: `${table}:*`,
108
- })
97
+ return this.cfg.client
98
+ .hscanStream(table)
109
99
  .flatMap(keyValueList => {
110
- const entries = _chunk(keyValueList, 2)
111
- return entries.map(([k, v]) => {
112
- return [this.keyToId(table, String(k)), Buffer.from(String(v))] satisfies KeyValueDBTuple
113
- })
100
+ const entries: [string, Buffer][] = []
101
+ for (let i = 0; i < keyValueList.length; i += 2) {
102
+ const key = keyValueList[i]!
103
+ const value = Buffer.from(keyValueList[i + 1]!)
104
+ entries.push([key, value])
105
+ }
106
+ return entries
114
107
  })
115
108
  .take(limit || Infinity)
116
109
  }
117
110
 
118
111
  async count(table: string): Promise<number> {
119
- return await this.client.hscanCount(this.keyOfHashField, {
120
- match: `${table}:*`,
121
- })
112
+ return await this.cfg.client.hscanCount(table)
122
113
  }
123
114
 
124
- async increment(table: string, id: string, by = 1): Promise<number> {
125
- return await this.client.hincr(this.keyOfHashField, this.idToKey(table, id), by)
126
- }
127
-
128
- async incrementBatch(
129
- _table: string,
130
- _incrementMap: StringMap<number>,
131
- ): Promise<StringMap<number>> {
132
- throw new Error('Not implemented')
115
+ async incrementBatch(table: string, increments: IncrementTuple[]): Promise<IncrementTuple[]> {
116
+ return await this.cfg.client.hincrBatch(table, increments)
133
117
  }
134
118
 
135
119
  async createTable(table: string, opt?: CommonDBCreateOptions): Promise<void> {
136
120
  if (!opt?.dropIfExists) return
137
121
 
138
- await this.client.dropTable(table)
139
- }
140
-
141
- private idsToKeys(table: string, ids: string[]): string[] {
142
- return ids.map(id => this.idToKey(table, id))
143
- }
144
-
145
- private idToKey(table: string, id: string): string {
146
- return `${table}:${id}`
147
- }
148
-
149
- private keysToIds(table: string, keys: string[]): string[] {
150
- return keys.map(key => this.keyToId(table, key))
151
- }
152
-
153
- private keyToId(table: string, key: string): string {
154
- return key.slice(table.length + 1)
122
+ await this.cfg.client.del([table])
155
123
  }
156
124
  }
@@ -3,9 +3,10 @@ import {
3
3
  CommonKeyValueDB,
4
4
  commonKeyValueDBFullSupport,
5
5
  CommonKeyValueDBSaveBatchOptions,
6
+ IncrementTuple,
6
7
  KeyValueDBTuple,
7
8
  } from '@naturalcycles/db-lib'
8
- import { _isTruthy, _zip, StringMap } from '@naturalcycles/js-lib'
9
+ import { _isTruthy, _zip } from '@naturalcycles/js-lib'
9
10
  import { ReadableTyped } from '@naturalcycles/nodejs-lib'
10
11
  import { RedisClient } from './redisClient'
11
12
 
@@ -14,34 +15,30 @@ export interface RedisKeyValueDBCfg {
14
15
  }
15
16
 
16
17
  export class RedisKeyValueDB implements CommonKeyValueDB, AsyncDisposable {
17
- constructor(cfg: RedisKeyValueDBCfg) {
18
- this.client = cfg.client
19
- }
20
-
21
- client: RedisClient
18
+ constructor(public cfg: RedisKeyValueDBCfg) {}
22
19
 
23
20
  support = {
24
21
  ...commonKeyValueDBFullSupport,
25
22
  }
26
23
 
27
24
  async ping(): Promise<void> {
28
- await this.client.ping()
25
+ await this.cfg.client.ping()
29
26
  }
30
27
 
31
28
  async [Symbol.asyncDispose](): Promise<void> {
32
- await this.client.disconnect()
29
+ await this.cfg.client.disconnect()
33
30
  }
34
31
 
35
32
  async getByIds(table: string, ids: string[]): Promise<KeyValueDBTuple[]> {
36
33
  if (!ids.length) return []
37
34
  // we assume that the order of returned values is the same as order of input ids
38
- const bufs = await this.client.mgetBuffer(this.idsToKeys(table, ids))
35
+ const bufs = await this.cfg.client.mgetBuffer(this.idsToKeys(table, ids))
39
36
  return bufs.map((buf, i) => [ids[i], buf] as KeyValueDBTuple).filter(([_k, v]) => v !== null)
40
37
  }
41
38
 
42
39
  async deleteByIds(table: string, ids: string[]): Promise<void> {
43
40
  if (!ids.length) return
44
- await this.client.del(this.idsToKeys(table, ids))
41
+ await this.cfg.client.del(this.idsToKeys(table, ids))
45
42
  }
46
43
 
47
44
  async saveBatch(
@@ -54,7 +51,7 @@ export class RedisKeyValueDB implements CommonKeyValueDB, AsyncDisposable {
54
51
  if (opt?.expireAt) {
55
52
  // There's no supported mset with TTL: https://stackoverflow.com/questions/16423342/redis-multi-set-with-a-ttl
56
53
  // so we gonna use a pipeline instead
57
- await this.client.withPipeline(pipeline => {
54
+ await this.cfg.client.withPipeline(pipeline => {
58
55
  for (const [k, v] of entries) {
59
56
  pipeline.set(this.idToKey(table, k), v, 'EXAT', opt.expireAt!)
60
57
  }
@@ -63,12 +60,12 @@ export class RedisKeyValueDB implements CommonKeyValueDB, AsyncDisposable {
63
60
  const obj: Record<string, Buffer> = Object.fromEntries(
64
61
  entries.map(([k, v]) => [this.idToKey(table, k), v]) as KeyValueDBTuple[],
65
62
  )
66
- await this.client.msetBuffer(obj)
63
+ await this.cfg.client.msetBuffer(obj)
67
64
  }
68
65
  }
69
66
 
70
67
  streamIds(table: string, limit?: number): ReadableTyped<string> {
71
- let stream = this.client
68
+ let stream = this.cfg.client
72
69
  .scanStream({
73
70
  match: `${table}:*`,
74
71
  // count: limit, // count is actually a "batchSize", not a limit
@@ -83,13 +80,13 @@ export class RedisKeyValueDB implements CommonKeyValueDB, AsyncDisposable {
83
80
  }
84
81
 
85
82
  streamValues(table: string, limit?: number): ReadableTyped<Buffer> {
86
- return this.client
83
+ return this.cfg.client
87
84
  .scanStream({
88
85
  match: `${table}:*`,
89
86
  })
90
87
  .flatMap(
91
88
  async keys => {
92
- return (await this.client.mgetBuffer(keys)).filter(_isTruthy)
89
+ return (await this.cfg.client.mgetBuffer(keys)).filter(_isTruthy)
93
90
  },
94
91
  {
95
92
  concurrency: 16,
@@ -99,14 +96,14 @@ export class RedisKeyValueDB implements CommonKeyValueDB, AsyncDisposable {
99
96
  }
100
97
 
101
98
  streamEntries(table: string, limit?: number): ReadableTyped<KeyValueDBTuple> {
102
- return this.client
99
+ return this.cfg.client
103
100
  .scanStream({
104
101
  match: `${table}:*`,
105
102
  })
106
103
  .flatMap(
107
104
  async keys => {
108
105
  // casting as Buffer[], because values are expected to exist for given keys
109
- const bufs = (await this.client.mgetBuffer(keys)) as Buffer[]
106
+ const bufs = (await this.cfg.client.mgetBuffer(keys)) as Buffer[]
110
107
  return _zip(this.keysToIds(table, keys), bufs)
111
108
  },
112
109
  {
@@ -118,26 +115,26 @@ export class RedisKeyValueDB implements CommonKeyValueDB, AsyncDisposable {
118
115
 
119
116
  async count(table: string): Promise<number> {
120
117
  // todo: implement more efficiently, e.g via LUA?
121
- return await this.client.scanCount({
118
+ return await this.cfg.client.scanCount({
122
119
  match: `${table}:*`,
123
120
  })
124
121
  }
125
122
 
126
- async increment(table: string, id: string, by = 1): Promise<number> {
127
- return await this.client.incr(this.idToKey(table, id), by)
128
- }
129
-
130
- async incrementBatch(
131
- _table: string,
132
- _incrementMap: StringMap<number>,
133
- ): Promise<StringMap<number>> {
134
- throw new Error('Not implemented')
123
+ async incrementBatch(table: string, increments: IncrementTuple[]): Promise<IncrementTuple[]> {
124
+ const incrementTuplesWithInternalKeys = increments.map(
125
+ ([id, v]) => [this.idToKey(table, id), v] as [string, number],
126
+ )
127
+ const resultsWithInternalKeys = await this.cfg.client.incrBatch(incrementTuplesWithInternalKeys)
128
+ const results = resultsWithInternalKeys.map(
129
+ ([k, v]) => [this.keyToId(table, k), v] as IncrementTuple,
130
+ )
131
+ return results
135
132
  }
136
133
 
137
134
  async createTable(table: string, opt?: CommonDBCreateOptions): Promise<void> {
138
135
  if (!opt?.dropIfExists) return
139
136
 
140
- await this.client.dropTable(table)
137
+ await this.cfg.client.dropTable(table)
141
138
  }
142
139
 
143
140
  private idsToKeys(table: string, ids: string[]): string[] {