@jusbid/redis 1.0.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.
- package/index.js +893 -0
- package/package.json +10 -0
package/index.js
ADDED
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
const { createClient } = require('redis');
|
|
2
|
+
const { logger } = require('@jusbid/logger');
|
|
3
|
+
|
|
4
|
+
const redisOptions = {};
|
|
5
|
+
|
|
6
|
+
// Build Redis connection options dynamically
|
|
7
|
+
if (process.env.REDIS_URL) {
|
|
8
|
+
redisOptions.url = process.env.REDIS_URL;
|
|
9
|
+
} else {
|
|
10
|
+
const protocol = process.env.REDIS_TLS === 'true' ? 'rediss' : 'redis';
|
|
11
|
+
const host = process.env.REDIS_HOST || 'localhost';
|
|
12
|
+
const port = process.env.REDIS_PORT || 6379;
|
|
13
|
+
const username = process.env.REDIS_USERNAME || '';
|
|
14
|
+
const password = process.env.REDIS_PASSWORD || '';
|
|
15
|
+
|
|
16
|
+
if (password) {
|
|
17
|
+
redisOptions.url = `${protocol}://${username ? `${encodeURIComponent(username)}:` : ''}${encodeURIComponent(password)}@${host}:${port}`;
|
|
18
|
+
} else {
|
|
19
|
+
redisOptions.url = `${protocol}://${host}:${port}`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Enable secure TLS settings for enterprise Redis cloud deployments (e.g. AWS, Upstash)
|
|
24
|
+
redisOptions.socket = {
|
|
25
|
+
reconnectStrategy: (retries) => {
|
|
26
|
+
const delay = Math.min(retries * 100, 10000);
|
|
27
|
+
logger.warn(`Redis client disconnected. Retrying connection in ${delay}ms... (attempt ${retries})`);
|
|
28
|
+
return delay;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (process.env.REDIS_TLS === 'true') {
|
|
33
|
+
redisOptions.socket.tls = true;
|
|
34
|
+
if (process.env.REDIS_REJECT_UNAUTHORIZED === 'false') {
|
|
35
|
+
redisOptions.socket.rejectUnauthorized = false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const rawRedisClient = createClient(redisOptions);
|
|
40
|
+
|
|
41
|
+
rawRedisClient.on('error', (err) => {
|
|
42
|
+
logger.error('Redis client error occurred', { error: err.message, stack: err.stack });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
rawRedisClient.on('connect', () => {
|
|
46
|
+
logger.info('Connecting to Redis server...');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
rawRedisClient.on('ready', () => {
|
|
50
|
+
logger.info('Redis client connected and ready.');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Lazy connection management
|
|
54
|
+
let isConnected = false;
|
|
55
|
+
let connectingPromise = null;
|
|
56
|
+
|
|
57
|
+
const ensureConnected = async () => {
|
|
58
|
+
if (rawRedisClient.isOpen && isConnected) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (connectingPromise) {
|
|
63
|
+
return connectingPromise;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
connectingPromise = (async () => {
|
|
67
|
+
try {
|
|
68
|
+
await rawRedisClient.connect();
|
|
69
|
+
isConnected = true;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
logger.error('Failed to initialize Redis connection', { error: err.message });
|
|
72
|
+
isConnected = false;
|
|
73
|
+
throw err;
|
|
74
|
+
} finally {
|
|
75
|
+
connectingPromise = null;
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
|
|
79
|
+
return connectingPromise;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Create a proxy wrapper to dynamically connect on the first method invocation
|
|
83
|
+
const redisClient = new Proxy(rawRedisClient, {
|
|
84
|
+
get(target, prop) {
|
|
85
|
+
const origValue = target[prop];
|
|
86
|
+
if (typeof origValue === 'function') {
|
|
87
|
+
return async (...args) => {
|
|
88
|
+
// Exclude listener attachments, connection initiation, and closing from trigger
|
|
89
|
+
if (prop !== 'connect' && prop !== 'on' && prop !== 'off' && prop !== 'once' && prop !== 'quit') {
|
|
90
|
+
await ensureConnected();
|
|
91
|
+
}
|
|
92
|
+
return origValue.apply(target, args);
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return origValue;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const get = async (key) => {
|
|
100
|
+
try {
|
|
101
|
+
const data = await redisClient.get(key);
|
|
102
|
+
return data ? JSON.parse(data) : null;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger.error(`Redis GET error for key ${key}`, { error: error.message });
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const sIsMember = async (key, action) => {
|
|
110
|
+
try {
|
|
111
|
+
const data = await redisClient.sIsMember(key, action);
|
|
112
|
+
return data ? JSON.parse(data) : null;
|
|
113
|
+
} catch (error) {
|
|
114
|
+
logger.error(`Redis sIsMember error for key ${key}`, { error: error.message });
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const sAdd = async (key, action) => {
|
|
120
|
+
try {
|
|
121
|
+
await redisClient.sAdd(key, action);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
logger.error(`Redis sAdd error for key ${key}`, { error: error.message });
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const sMembers = async (key) => {
|
|
131
|
+
try {
|
|
132
|
+
await redisClient.sMembers(key);
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
logger.error(`Redis sAdd error for key ${key}`, { error: error.message });
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const set = async (key, value, ttlSeconds = null) => {
|
|
142
|
+
try {
|
|
143
|
+
const stringified = JSON.stringify(value);
|
|
144
|
+
if (ttlSeconds) {
|
|
145
|
+
await redisClient.set(key, stringified, { EX: ttlSeconds });
|
|
146
|
+
} else {
|
|
147
|
+
await redisClient.set(key, stringified);
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
logger.error(`Redis SET error for key ${key}`, { error: error.message });
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const del = async (key) => {
|
|
157
|
+
try {
|
|
158
|
+
await redisClient.del(key);
|
|
159
|
+
return true;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
logger.error(`Redis DEL error for key ${key}`, { error: error.message });
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const exists = async (key) => {
|
|
167
|
+
try {
|
|
168
|
+
return (await redisClient.exists(key)) === 1;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
logger.error(`Redis EXISTS error for key ${key}`, { error: error.message });
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const type = async (key) => {
|
|
176
|
+
try {
|
|
177
|
+
return await redisClient.type(key);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
logger.error(`Redis type error for key ${key}`, { error: error.message });
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const hSet = async (key, value) => {
|
|
186
|
+
try {
|
|
187
|
+
await redisClient.hSet(key, value);
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
logger.error(`Redis hSet error for key ${key}`, { error: error.message });
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const hGet = async (key, field) => {
|
|
197
|
+
try {
|
|
198
|
+
const data = await redisClient.hGet(key, field);
|
|
199
|
+
return data ? JSON.parse(data) : null;
|
|
200
|
+
} catch (error) {
|
|
201
|
+
logger.error(`Redis hGet error for key ${key}`, { error: error.message });
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const hGetAll = async (key) => {
|
|
207
|
+
try {
|
|
208
|
+
const data = await redisClient.hGetAll(key);
|
|
209
|
+
return data ? JSON.parse(data) : null;
|
|
210
|
+
} catch (error) {
|
|
211
|
+
logger.error(`Redis hGet error for key ${key}`, { error: error.message });
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// ==============================
|
|
216
|
+
// Redis Streams
|
|
217
|
+
// ==============================
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Add message to Redis Stream
|
|
221
|
+
*
|
|
222
|
+
* @param {string} stream
|
|
223
|
+
* @param {object} data
|
|
224
|
+
* @returns {Promise<string>}
|
|
225
|
+
*/
|
|
226
|
+
|
|
227
|
+
// ==============================
|
|
228
|
+
// Redis Streams
|
|
229
|
+
// ==============================
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Add message to Redis Stream
|
|
233
|
+
*
|
|
234
|
+
* @param {string} stream
|
|
235
|
+
* @param {object} data
|
|
236
|
+
* @returns {Promise<string>}
|
|
237
|
+
*/
|
|
238
|
+
const xAdd = async (stream, data) => {
|
|
239
|
+
try {
|
|
240
|
+
const fields = {};
|
|
241
|
+
|
|
242
|
+
for (const [key, value] of Object.entries(data)) {
|
|
243
|
+
fields[key] =
|
|
244
|
+
typeof value === 'string'
|
|
245
|
+
? value
|
|
246
|
+
: JSON.stringify(value);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return await redisClient.xAdd(
|
|
250
|
+
stream,
|
|
251
|
+
'*',
|
|
252
|
+
fields
|
|
253
|
+
);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
logger.error('Redis XADD error', {
|
|
256
|
+
stream,
|
|
257
|
+
error: error.message,
|
|
258
|
+
stack: error.stack,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Create consumer group
|
|
268
|
+
*
|
|
269
|
+
* If group already exists, that's fine.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} stream
|
|
272
|
+
* @param {string} group
|
|
273
|
+
* @param {string} startId
|
|
274
|
+
*/
|
|
275
|
+
const xGroupCreate = async (
|
|
276
|
+
stream,
|
|
277
|
+
group,
|
|
278
|
+
startId = '0'
|
|
279
|
+
) => {
|
|
280
|
+
try {
|
|
281
|
+
await redisClient.xGroupCreate(
|
|
282
|
+
stream,
|
|
283
|
+
group,
|
|
284
|
+
startId,
|
|
285
|
+
{
|
|
286
|
+
MKSTREAM: true,
|
|
287
|
+
}
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
return true;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
|
|
293
|
+
if (
|
|
294
|
+
error?.message?.includes('BUSYGROUP')
|
|
295
|
+
) {
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
logger.error('Redis XGROUP CREATE error', {
|
|
300
|
+
stream,
|
|
301
|
+
group,
|
|
302
|
+
error: error.message,
|
|
303
|
+
stack: error.stack,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Read messages from consumer group
|
|
313
|
+
*
|
|
314
|
+
* `>` = only new messages that have never
|
|
315
|
+
* been delivered to this consumer group.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} stream
|
|
318
|
+
* @param {string} group
|
|
319
|
+
* @param {string} consumer
|
|
320
|
+
* @param {number} count
|
|
321
|
+
* @param {number} block
|
|
322
|
+
*/
|
|
323
|
+
const xReadGroup = async (...args) => {
|
|
324
|
+
try {
|
|
325
|
+
let stream;
|
|
326
|
+
let group;
|
|
327
|
+
let consumer;
|
|
328
|
+
let count;
|
|
329
|
+
let block;
|
|
330
|
+
|
|
331
|
+
// OLD Redis command syntax:
|
|
332
|
+
// XREADGROUP GROUP group consumer COUNT count BLOCK block STREAMS stream >
|
|
333
|
+
if (args[0] === 'GROUP') {
|
|
334
|
+
group = args[1];
|
|
335
|
+
consumer = args[2];
|
|
336
|
+
|
|
337
|
+
const countIndex = args.indexOf('COUNT');
|
|
338
|
+
const blockIndex = args.indexOf('BLOCK');
|
|
339
|
+
const streamsIndex = args.indexOf('STREAMS');
|
|
340
|
+
|
|
341
|
+
count = Number(args[countIndex + 1]);
|
|
342
|
+
block = Number(args[blockIndex + 1]);
|
|
343
|
+
stream = args[streamsIndex + 1];
|
|
344
|
+
|
|
345
|
+
} else {
|
|
346
|
+
// New/internal syntax:
|
|
347
|
+
// stream, group, consumer, count, block
|
|
348
|
+
[
|
|
349
|
+
stream,
|
|
350
|
+
group,
|
|
351
|
+
consumer,
|
|
352
|
+
count,
|
|
353
|
+
block
|
|
354
|
+
] = args;
|
|
355
|
+
|
|
356
|
+
count = Number(count);
|
|
357
|
+
block = Number(block);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!stream) {
|
|
361
|
+
throw new TypeError('Redis XREADGROUP: stream is required');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!group) {
|
|
365
|
+
throw new TypeError('Redis XREADGROUP: group is required');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!consumer) {
|
|
369
|
+
throw new TypeError('Redis XREADGROUP: consumer is required');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!Number.isInteger(count) || count <= 0) {
|
|
373
|
+
throw new TypeError(`Invalid count: ${count}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!Number.isInteger(block) || block < 0) {
|
|
377
|
+
throw new TypeError(`Invalid block: ${block}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return await redisClient.xReadGroup(
|
|
381
|
+
group,
|
|
382
|
+
consumer,
|
|
383
|
+
[
|
|
384
|
+
{
|
|
385
|
+
key: stream,
|
|
386
|
+
id: '>',
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
{
|
|
390
|
+
COUNT: count,
|
|
391
|
+
BLOCK: block,
|
|
392
|
+
}
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
} catch (error) {
|
|
396
|
+
logger.error('Redis XREADGROUP error', {
|
|
397
|
+
args,
|
|
398
|
+
error: error.message,
|
|
399
|
+
stack: error.stack,
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Acknowledge messages
|
|
409
|
+
*
|
|
410
|
+
* @param {string} stream
|
|
411
|
+
* @param {string} group
|
|
412
|
+
* @param {string|string[]} ids
|
|
413
|
+
*/
|
|
414
|
+
const xAck = async (stream, group, ...ids) => {
|
|
415
|
+
try {
|
|
416
|
+
const messageIds = ids.flat();
|
|
417
|
+
|
|
418
|
+
if (messageIds.length === 0) {
|
|
419
|
+
return 0;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return await redisClient.xAck(
|
|
423
|
+
stream,
|
|
424
|
+
group,
|
|
425
|
+
messageIds
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
} catch (error) {
|
|
429
|
+
logger.error('Redis XACK error', {
|
|
430
|
+
stream,
|
|
431
|
+
group,
|
|
432
|
+
ids,
|
|
433
|
+
error: error.message,
|
|
434
|
+
stack: error.stack,
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Get pending summary
|
|
443
|
+
*
|
|
444
|
+
* IMPORTANT:
|
|
445
|
+
* This does NOT return an array of messages.
|
|
446
|
+
*
|
|
447
|
+
* Example:
|
|
448
|
+
*
|
|
449
|
+
* {
|
|
450
|
+
* pending: 5,
|
|
451
|
+
* firstId: '123-0',
|
|
452
|
+
* lastId: '456-0',
|
|
453
|
+
* consumers: [...]
|
|
454
|
+
* }
|
|
455
|
+
*/
|
|
456
|
+
const xPending = async (...args) => {
|
|
457
|
+
try {
|
|
458
|
+
let stream;
|
|
459
|
+
let group;
|
|
460
|
+
let minIdleTime;
|
|
461
|
+
let start = '-';
|
|
462
|
+
let end = '+';
|
|
463
|
+
let count = 10;
|
|
464
|
+
|
|
465
|
+
// Backward-compatible old syntax:
|
|
466
|
+
//
|
|
467
|
+
// xPending(
|
|
468
|
+
// stream,
|
|
469
|
+
// group,
|
|
470
|
+
// 'IDLE',
|
|
471
|
+
// minIdleTime,
|
|
472
|
+
// '-',
|
|
473
|
+
// '+',
|
|
474
|
+
// count
|
|
475
|
+
// )
|
|
476
|
+
if (args[2] === 'IDLE') {
|
|
477
|
+
stream = args[0];
|
|
478
|
+
group = args[1];
|
|
479
|
+
minIdleTime = Number(args[3]);
|
|
480
|
+
start = args[4] || '-';
|
|
481
|
+
end = args[5] || '+';
|
|
482
|
+
count = Number(args[6] ?? 10);
|
|
483
|
+
} else {
|
|
484
|
+
// New syntax:
|
|
485
|
+
//
|
|
486
|
+
// xPending(
|
|
487
|
+
// stream,
|
|
488
|
+
// group,
|
|
489
|
+
// minIdleTime,
|
|
490
|
+
// start,
|
|
491
|
+
// end,
|
|
492
|
+
// count
|
|
493
|
+
// )
|
|
494
|
+
stream = args[0];
|
|
495
|
+
group = args[1];
|
|
496
|
+
minIdleTime = Number(args[2]);
|
|
497
|
+
start = args[3] || '-';
|
|
498
|
+
end = args[4] || '+';
|
|
499
|
+
count = Number(args[5] ?? 10);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (!stream) {
|
|
503
|
+
throw new TypeError('Redis XPENDING: stream is required');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (!group) {
|
|
507
|
+
throw new TypeError('Redis XPENDING: group is required');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (!Number.isInteger(minIdleTime) || minIdleTime < 0) {
|
|
511
|
+
throw new TypeError(`Invalid minIdleTime: ${minIdleTime}`);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!Number.isInteger(count) || count <= 0) {
|
|
515
|
+
throw new TypeError(`Invalid count: ${count}`);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const result = await redisClient.xPending(
|
|
519
|
+
stream,
|
|
520
|
+
group,
|
|
521
|
+
{
|
|
522
|
+
IDLE: minIdleTime,
|
|
523
|
+
START: start,
|
|
524
|
+
END: end,
|
|
525
|
+
COUNT: count,
|
|
526
|
+
}
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
/*
|
|
530
|
+
* node-redis may return:
|
|
531
|
+
*
|
|
532
|
+
* [
|
|
533
|
+
* {
|
|
534
|
+
* id: '123-0',
|
|
535
|
+
* owner: 'consumer-1',
|
|
536
|
+
* millisecondsSinceLastDelivery: 40000,
|
|
537
|
+
* deliveriesCounter: 1
|
|
538
|
+
* }
|
|
539
|
+
* ]
|
|
540
|
+
*
|
|
541
|
+
* Normalize it to an array for existing consumers.
|
|
542
|
+
*/
|
|
543
|
+
|
|
544
|
+
if (Array.isArray(result)) {
|
|
545
|
+
return result;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Some node-redis versions / command overloads can return
|
|
549
|
+
// an object-shaped result. Normalize it.
|
|
550
|
+
if (result && typeof result === 'object') {
|
|
551
|
+
if (Array.isArray(result.entries)) {
|
|
552
|
+
return result.entries;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (Array.isArray(result.pending)) {
|
|
556
|
+
return result.pending;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// If it's a single pending entry, normalize it.
|
|
560
|
+
if (
|
|
561
|
+
result.id &&
|
|
562
|
+
(
|
|
563
|
+
result.owner ||
|
|
564
|
+
result.consumer
|
|
565
|
+
)
|
|
566
|
+
) {
|
|
567
|
+
return [result];
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Nothing pending.
|
|
572
|
+
return [];
|
|
573
|
+
|
|
574
|
+
} catch (error) {
|
|
575
|
+
logger.error('Redis XPENDING RANGE error', {
|
|
576
|
+
args,
|
|
577
|
+
error: error.message,
|
|
578
|
+
stack: error.stack,
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Reclaim stale pending messages
|
|
587
|
+
*
|
|
588
|
+
* XAUTOCLAIM returns:
|
|
589
|
+
*
|
|
590
|
+
* [
|
|
591
|
+
* nextStartId,
|
|
592
|
+
* messages,
|
|
593
|
+
* deletedMessageIds
|
|
594
|
+
* ]
|
|
595
|
+
*
|
|
596
|
+
* depending on node-redis version.
|
|
597
|
+
*/
|
|
598
|
+
const xAutoClaim = async (
|
|
599
|
+
stream,
|
|
600
|
+
group,
|
|
601
|
+
consumer,
|
|
602
|
+
minIdleTime = 30000,
|
|
603
|
+
startId = '0-0',
|
|
604
|
+
count = 10
|
|
605
|
+
) => {
|
|
606
|
+
try {
|
|
607
|
+
minIdleTime = Number(minIdleTime);
|
|
608
|
+
count = Number(count);
|
|
609
|
+
|
|
610
|
+
if (!Number.isInteger(minIdleTime) || minIdleTime < 0) {
|
|
611
|
+
throw new TypeError(
|
|
612
|
+
`Invalid minIdleTime: ${minIdleTime}`
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!Number.isInteger(count) || count <= 0) {
|
|
617
|
+
throw new TypeError(
|
|
618
|
+
`Invalid count: ${count}`
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
return await redisClient.xAutoClaim(
|
|
623
|
+
stream,
|
|
624
|
+
group,
|
|
625
|
+
consumer,
|
|
626
|
+
minIdleTime,
|
|
627
|
+
startId,
|
|
628
|
+
{
|
|
629
|
+
COUNT: count,
|
|
630
|
+
}
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
} catch (error) {
|
|
634
|
+
logger.error('Redis XAUTOCLAIM error', {
|
|
635
|
+
stream,
|
|
636
|
+
group,
|
|
637
|
+
consumer,
|
|
638
|
+
minIdleTime,
|
|
639
|
+
startId,
|
|
640
|
+
count,
|
|
641
|
+
error: error.message,
|
|
642
|
+
stack: error.stack,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Delete messages from stream
|
|
652
|
+
*
|
|
653
|
+
* Usually DON'T call this after XACK.
|
|
654
|
+
*/
|
|
655
|
+
const xDel = async (
|
|
656
|
+
stream,
|
|
657
|
+
ids
|
|
658
|
+
) => {
|
|
659
|
+
try {
|
|
660
|
+
|
|
661
|
+
if (!ids) {
|
|
662
|
+
return 0;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const messageIds = Array.isArray(ids)
|
|
666
|
+
? ids
|
|
667
|
+
: [ids];
|
|
668
|
+
|
|
669
|
+
if (messageIds.length === 0) {
|
|
670
|
+
return 0;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
return await redisClient.xDel(
|
|
674
|
+
stream,
|
|
675
|
+
messageIds
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
} catch (error) {
|
|
679
|
+
|
|
680
|
+
logger.error('Redis XDEL error', {
|
|
681
|
+
stream,
|
|
682
|
+
ids,
|
|
683
|
+
error: error.message,
|
|
684
|
+
stack: error.stack,
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
throw error;
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Trim stream
|
|
694
|
+
*/
|
|
695
|
+
const xTrim = async (
|
|
696
|
+
stream,
|
|
697
|
+
maxLen
|
|
698
|
+
) => {
|
|
699
|
+
try {
|
|
700
|
+
|
|
701
|
+
maxLen = Number(maxLen);
|
|
702
|
+
|
|
703
|
+
if (!Number.isInteger(maxLen) || maxLen <= 0) {
|
|
704
|
+
throw new TypeError(
|
|
705
|
+
`Invalid maxLen: ${maxLen}`
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return await redisClient.xTrim(
|
|
710
|
+
stream,
|
|
711
|
+
'MAXLEN',
|
|
712
|
+
maxLen,
|
|
713
|
+
{
|
|
714
|
+
strategyModifier: '~',
|
|
715
|
+
}
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
} catch (error) {
|
|
719
|
+
|
|
720
|
+
logger.error('Redis XTRIM error', {
|
|
721
|
+
stream,
|
|
722
|
+
maxLen,
|
|
723
|
+
error: error.message,
|
|
724
|
+
stack: error.stack,
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
throw error;
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
const xReadGroupNew = async (...args) => {
|
|
733
|
+
try {
|
|
734
|
+
let stream;
|
|
735
|
+
let group;
|
|
736
|
+
let consumer;
|
|
737
|
+
let count;
|
|
738
|
+
let block;
|
|
739
|
+
|
|
740
|
+
// Existing caller format:
|
|
741
|
+
//
|
|
742
|
+
// GROUP group consumer
|
|
743
|
+
// COUNT count
|
|
744
|
+
// BLOCK block
|
|
745
|
+
// STREAMS stream >
|
|
746
|
+
if (args[0] === 'GROUP') {
|
|
747
|
+
group = args[1];
|
|
748
|
+
consumer = args[2];
|
|
749
|
+
|
|
750
|
+
const countIndex = args.indexOf('COUNT');
|
|
751
|
+
const blockIndex = args.indexOf('BLOCK');
|
|
752
|
+
const streamsIndex = args.indexOf('STREAMS');
|
|
753
|
+
|
|
754
|
+
count = Number(args[countIndex + 1]);
|
|
755
|
+
block = Number(args[blockIndex + 1]);
|
|
756
|
+
stream = args[streamsIndex + 1];
|
|
757
|
+
} else {
|
|
758
|
+
stream = args[0];
|
|
759
|
+
group = args[1];
|
|
760
|
+
consumer = args[2];
|
|
761
|
+
count = Number(args[3]);
|
|
762
|
+
block = Number(args[4]);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (!stream) {
|
|
766
|
+
throw new TypeError('XREADGROUP: stream is required');
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (!group) {
|
|
770
|
+
throw new TypeError('XREADGROUP: group is required');
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (!consumer) {
|
|
774
|
+
throw new TypeError('XREADGROUP: consumer is required');
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (!Number.isInteger(count) || count <= 0) {
|
|
778
|
+
throw new TypeError(`Invalid count: ${count}`);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (!Number.isInteger(block) || block < 0) {
|
|
782
|
+
throw new TypeError(`Invalid block: ${block}`);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const result = await redisClient.xReadGroup(
|
|
786
|
+
group,
|
|
787
|
+
consumer,
|
|
788
|
+
[
|
|
789
|
+
{
|
|
790
|
+
key: stream,
|
|
791
|
+
id: '>',
|
|
792
|
+
},
|
|
793
|
+
],
|
|
794
|
+
{
|
|
795
|
+
COUNT: count,
|
|
796
|
+
BLOCK: block,
|
|
797
|
+
}
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
if (!result || result.length === 0) {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/*
|
|
805
|
+
* node-redis:
|
|
806
|
+
*
|
|
807
|
+
* [
|
|
808
|
+
* {
|
|
809
|
+
* name: 'activity-log',
|
|
810
|
+
* messages: [
|
|
811
|
+
* {
|
|
812
|
+
* id: '123-0',
|
|
813
|
+
* message: {
|
|
814
|
+
* payload: '...',
|
|
815
|
+
* event_id: '...'
|
|
816
|
+
* }
|
|
817
|
+
* }
|
|
818
|
+
* ]
|
|
819
|
+
* }
|
|
820
|
+
* ]
|
|
821
|
+
*
|
|
822
|
+
* Existing consumer expects:
|
|
823
|
+
*
|
|
824
|
+
* [
|
|
825
|
+
* [
|
|
826
|
+
* 'activity-log',
|
|
827
|
+
* [
|
|
828
|
+
* [
|
|
829
|
+
* '123-0',
|
|
830
|
+
* ['payload', '...']
|
|
831
|
+
* ]
|
|
832
|
+
* ]
|
|
833
|
+
* ]
|
|
834
|
+
* ]
|
|
835
|
+
*/
|
|
836
|
+
|
|
837
|
+
return result.map((streamResult) => {
|
|
838
|
+
const entries = (streamResult.messages || []).map((message) => {
|
|
839
|
+
const fields = [];
|
|
840
|
+
|
|
841
|
+
for (const [key, value] of Object.entries(message.message || {})) {
|
|
842
|
+
fields.push(key);
|
|
843
|
+
fields.push(value);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return [
|
|
847
|
+
message.id,
|
|
848
|
+
fields,
|
|
849
|
+
];
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
return [
|
|
853
|
+
streamResult.name,
|
|
854
|
+
entries,
|
|
855
|
+
];
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
} catch (error) {
|
|
859
|
+
logger.error('Redis XREADGROUP error', {
|
|
860
|
+
args,
|
|
861
|
+
error: error.message,
|
|
862
|
+
stack: error.stack,
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
throw error;
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
module.exports = {
|
|
870
|
+
redisClient,
|
|
871
|
+
get,
|
|
872
|
+
set,
|
|
873
|
+
del,
|
|
874
|
+
exists,
|
|
875
|
+
sIsMember,
|
|
876
|
+
sAdd,
|
|
877
|
+
sMembers,
|
|
878
|
+
type,
|
|
879
|
+
hSet,
|
|
880
|
+
hGet,
|
|
881
|
+
hGetAll,
|
|
882
|
+
|
|
883
|
+
// Redis Streams
|
|
884
|
+
xAdd,
|
|
885
|
+
xGroupCreate,
|
|
886
|
+
xReadGroup,
|
|
887
|
+
xAck,
|
|
888
|
+
xPending,
|
|
889
|
+
xAutoClaim,
|
|
890
|
+
xDel,
|
|
891
|
+
xTrim,
|
|
892
|
+
xReadGroupNew
|
|
893
|
+
};
|