@yroshcha/node-red-contrib-redis-full 1.0.2 → 1.0.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 1.0.6 — 2026-08-11
6
+
7
+ ### Fixed
8
+
9
+ - `redis xreadgroup` now restores its consumer group with `MKSTREAM` when a running consumer receives `NOGROUP`, including a deleted or restarted DLQ stream.
10
+
11
+ ## 1.0.5 — 2026-08-11
12
+
13
+ ### Fixed
14
+
15
+ - `redis xreadgroup` now raises the dedicated connection command timeout to cover its configured `BLOCK` duration, preventing a valid long poll from being reported as a timeout.
16
+ - Stream consumer timing and backoff settings are bounded at runtime even when a malformed flow configuration bypasses editor validation.
17
+ - `redis sub` now rejects an empty channel list instead of remaining indefinitely in a starting state.
18
+ - `redis xadd` now reports an empty fields object before sending an invalid command to Redis.
19
+
20
+ ### Changed
21
+
22
+ - `redis scan` now has a 10,000-item per-message safety limit by default. It exposes `msg.scanComplete` and `msg.scanTruncated` so a flow cannot mistake a bounded result for a complete scan.
23
+
24
+ ## 1.0.4 — 2026-08-11
25
+
26
+ ### Fixed
27
+
28
+ - `redis sub` now retries its initial subscription up to five times with backoff and jitter instead of remaining permanently unsubscribed after one transient timeout.
29
+
30
+ ## 1.0.3 — 2026-08-11
31
+
32
+ ### Fixed
33
+
34
+ - `redis xreadgroup` now retries dedicated-connection startup and consumer-group initialisation up to five times with backoff and jitter instead of stopping after one transient timeout.
35
+
5
36
  ## 1.0.2 — 2026-08-10
6
37
 
7
38
  Documentation-only patch release.
package/README.md CHANGED
@@ -45,7 +45,7 @@ For a local archive, use `npm install /path/to/yroshcha-node-red-contrib-redis-f
45
45
 
46
46
  ## Release status
47
47
 
48
- **`1.0.2` is the current stable release.** See [CHANGELOG.md](CHANGELOG.md) for release notes and compatibility information.
48
+ **`1.0.6` is the current stable release.** See [CHANGELOG.md](CHANGELOG.md) for release notes and compatibility information.
49
49
 
50
50
  ## Production profile
51
51
 
@@ -61,7 +61,10 @@ Safe consumer defaults and important settings:
61
61
  - **Batch interval (ms)** sets the minimum delay between emitted batches regardless of queue depth. For one batch of up to 50 entries per second, use `COUNT = 50` and `Batch interval = 1000`.
62
62
  - **Max deliveries = 5.** After the limit is exceeded, an entry is atomically moved to `<stream>:dlq` and acknowledged. In Redis Cluster, source and DLQ keys must share a hash tag, for example `orders:{eu}` and `orders:{eu}:dlq`.
63
63
  - **Start paused (wait for resume)** makes a consumer create/verify its group without calling `XREADGROUP`. It takes no work until a control-plane `resume` request arrives; use it for controlled pod startup after readiness checks.
64
+ - Startup initialisation retries a failed dedicated connection, `XGROUP CREATE`, and the initial `SUBSCRIBE` up to **five times** with exponential backoff and jitter. A transient Redis/TLS/DNS delay does not permanently stop a new consumer or subscriber.
65
+ - If a consumer group disappears after startup (for example after a Redis restart, `XGROUP DESTROY`, or deleting an empty DLQ stream), `redis xreadgroup` recreates it with `MKSTREAM` and resumes.
64
66
  - `redis xautoclaim` limits each manual recovery run through `Run limit` (default 1000), avoiding oversized Node-RED payloads.
67
+ - `redis scan` limits one emitted Node-RED message to **10,000** items by default. It sets `msg.scanTruncated = true` when that safety limit is reached; raise `Result limit` / `msg.maxResults` only after sizing the Node-RED heap for the result.
65
68
  - `MAXLEN` in `redis xadd` remains blocked until **Allow unsafe MAXLEN trim** is explicitly confirmed. Trimming can remove a payload still represented in a PEL.
66
69
 
67
70
  ## Production readiness boundaries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yroshcha/node-red-contrib-redis-full",
3
- "version": "1.0.2",
3
+ "version": "1.0.6",
4
4
  "description": "Production-oriented Redis palette for Node-RED: generic commands, Pub/Sub, scans, transactions, and Redis Streams consumer groups.",
5
5
  "keywords": ["node-red", "redis", "redis-streams", "pubsub", "xadd", "xreadgroup", "ioredis"],
6
6
  "license": "MIT",
package/redis.html CHANGED
@@ -273,7 +273,8 @@
273
273
  name: { value: '' },
274
274
  server: { type: 'yroshcha-redis-config', required: true },
275
275
  scanType: { value: 'SCAN' },
276
- count: { value: 100, validate: RED.validators.number() }
276
+ count: { value: 100, validate: RED.validators.number() },
277
+ maxResults: { value: 10000, validate: RED.validators.number() }
277
278
  },
278
279
  inputs: 1,
279
280
  outputs: 1,
@@ -303,6 +304,10 @@
303
304
  <label for="node-input-count">COUNT hint</label>
304
305
  <input type="text" id="node-input-count">
305
306
  </div>
307
+ <div class="form-row">
308
+ <label for="node-input-maxResults" title="Maximum items returned in one Node-RED message">Result limit</label>
309
+ <input type="text" id="node-input-maxResults">
310
+ </div>
306
311
  <div class="form-row">
307
312
  <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
308
313
  <input type="text" id="node-input-name">
@@ -310,8 +315,8 @@
310
315
  </script>
311
316
 
312
317
  <script type="text/html" data-help-name="yroshcha-redis-scan">
313
- <p>Full cursor-based non-blocking scan. HSCAN/SSCAN/ZSCAN require <code>msg.key</code>.</p>
314
- <p>Output: <code>msg.payload</code> is an array; HSCAN/ZSCAN return <code>{member, value}</code>.</p>
318
+ <p>Cursor-based non-blocking scan. HSCAN/SSCAN/ZSCAN require <code>msg.key</code>. To protect the Node-RED heap, one invocation returns at most <b>Result limit</b> items (default 10000); override it with <code>msg.maxResults</code>.</p>
319
+ <p>Output: <code>msg.payload</code> is an array; HSCAN/ZSCAN return <code>{member, value}</code>. <code>msg.scanComplete</code> is false and <code>msg.scanTruncated</code> is true when the result limit was reached.</p>
315
320
  </script>
316
321
 
317
322
  <script type="text/javascript">
package/redis.js CHANGED
@@ -173,6 +173,10 @@ module.exports = function (RED) {
173
173
  return obj;
174
174
  }
175
175
 
176
+ function isNoGroupError(err) {
177
+ return String(err && err.message ? err.message : err).includes('NOGROUP');
178
+ }
179
+
176
180
  // ---------------------------------------------------------------------
177
181
  // yroshcha-redis-command — БУДЬ-ЯКА команда Redis через ioredis .call().
178
182
  // "block" форсує окреме з'єднання (для BLPOP/BRPOP/WAIT тощо).
@@ -244,8 +248,14 @@ module.exports = function (RED) {
244
248
  node.mode = config.mode === 'psubscribe' ? 'psubscribe' : 'subscribe';
245
249
  node.channels = (config.channels || '').split(',').map((s) => s.trim()).filter(Boolean);
246
250
 
251
+ if (!node.channels.length) {
252
+ node.error('At least one Redis channel or pattern is required');
253
+ return;
254
+ }
255
+
247
256
  const client = node.server.getDedicatedClient();
248
257
  let subscribed = [];
258
+ let stopped = false;
249
259
 
250
260
  const eventName = node.mode === 'psubscribe' ? 'pmessage' : 'message';
251
261
  client.on(eventName, (a, b, c) => {
@@ -271,10 +281,40 @@ module.exports = function (RED) {
271
281
  node.status({ fill: 'green', shape: 'dot', text: `listening (${subscribed.length})` });
272
282
  }
273
283
 
274
- doSubscribe(node.channels).catch((err) => {
275
- node.status({ fill: 'red', shape: 'ring', text: 'subscribe failed' });
276
- node.error(`Redis subscribe failed: ${err.message}`);
277
- });
284
+ async function startSubscription() {
285
+ // SUBSCRIBE is idempotent for an already subscribed channel. Retrying it
286
+ // is therefore safe when Redis accepted the command but its reply was
287
+ // lost to a timeout during pod startup.
288
+ const maxStartupRetries = 5;
289
+ let retries = 0;
290
+ let delayMs = 500;
291
+
292
+ while (!stopped) {
293
+ try {
294
+ await doSubscribe(node.channels);
295
+ return;
296
+ } catch (err) {
297
+ if (retries >= maxStartupRetries) {
298
+ node.status({ fill: 'red', shape: 'ring', text: 'subscribe failed' });
299
+ node.error(`Redis subscribe failed after ${maxStartupRetries} retries: ${err.message}`);
300
+ return;
301
+ }
302
+
303
+ const jitter = Math.random() * delayMs * 0.3;
304
+ const delay = Math.min(delayMs, node.server.retryMaxDelay) + jitter;
305
+ retries += 1;
306
+ node.status({ fill: 'red', shape: 'ring', text: `subscribe retry ${retries}/${maxStartupRetries}` });
307
+ node.warn(
308
+ `Redis subscribe startup attempt ${retries}/${maxStartupRetries} failed: ${err.message}; ` +
309
+ `retrying in ${Math.round(delay)}ms`
310
+ );
311
+ await new Promise((resolve) => setTimeout(resolve, delay));
312
+ delayMs = Math.min(delayMs * 2, node.server.retryMaxDelay);
313
+ }
314
+ }
315
+ }
316
+
317
+ startSubscription();
278
318
 
279
319
  node.on('input', async function (msg, send, done) {
280
320
  try {
@@ -287,6 +327,7 @@ module.exports = function (RED) {
287
327
  });
288
328
 
289
329
  node.on('close', async function (done) {
330
+ stopped = true;
290
331
  try { await client.quit(); } catch (e) { /* ignore */ }
291
332
  node.status({});
292
333
  done();
@@ -346,14 +387,18 @@ module.exports = function (RED) {
346
387
  if (!node.server) return;
347
388
 
348
389
  node.scanType = config.scanType || 'SCAN';
349
- node.defaultCount = parseInt(config.count, 10) || 100;
390
+ node.defaultCount = Math.max(1, parseInt(config.count, 10) || 100);
391
+ // A cursor scan is non-blocking for Redis, but collecting an unbounded
392
+ // result into one Node-RED message can still exhaust the Node.js heap.
393
+ node.defaultMaxResults = Math.max(1, parseInt(config.maxResults, 10) || 10000);
350
394
 
351
395
  node.on('input', async function (msg, send, done) {
352
396
  send = send || function () { node.send.apply(node, arguments); };
353
397
  try {
354
398
  const scanType = (msg.scanType || node.scanType).toUpperCase();
355
399
  const match = msg.match;
356
- const count = msg.count !== undefined ? msg.count : node.defaultCount;
400
+ const count = Math.max(1, parseInt(msg.count, 10) || node.defaultCount);
401
+ const maxResults = Math.max(1, parseInt(msg.maxResults, 10) || node.defaultMaxResults);
357
402
  const client = node.server.getClient();
358
403
 
359
404
  let key = null;
@@ -381,14 +426,22 @@ module.exports = function (RED) {
381
426
  if (scanType === 'HSCAN' || scanType === 'ZSCAN') {
382
427
  for (let i = 0; i < elements.length; i += 2) {
383
428
  collected.push({ member: elements[i], value: elements[i + 1] });
429
+ if (collected.length >= maxResults) break;
384
430
  }
385
431
  } else {
386
- collected.push(...elements);
432
+ collected.push(...elements.slice(0, maxResults - collected.length));
387
433
  }
388
- } while (cursor !== '0');
434
+ } while (cursor !== '0' && collected.length < maxResults);
389
435
 
390
436
  msg.payload = collected;
391
- node.status({ fill: 'green', shape: 'dot', text: `${collected.length} items` });
437
+ msg.scanComplete = cursor === '0';
438
+ msg.scanTruncated = !msg.scanComplete;
439
+ if (msg.scanTruncated) {
440
+ node.status({ fill: 'yellow', shape: 'ring', text: `${collected.length} items (limit)` });
441
+ node.warn(`Redis ${scanType} stopped at the configured ${maxResults}-item limit`);
442
+ } else {
443
+ node.status({ fill: 'green', shape: 'dot', text: `${collected.length} items` });
444
+ }
392
445
  send(msg);
393
446
  done();
394
447
  } catch (err) {
@@ -432,6 +485,8 @@ module.exports = function (RED) {
432
485
  fields.push(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
433
486
  }
434
487
 
488
+ if (!fields.length) throw new Error('msg.payload must contain at least one field for XADD');
489
+
435
490
  const args = [streamKey];
436
491
  const maxlen = msg.maxlen !== undefined ? msg.maxlen : node.maxlen;
437
492
  const unsafeTrim = msg.allowUnsafeTrim === true || node.unsafeTrim;
@@ -477,8 +532,8 @@ module.exports = function (RED) {
477
532
  || `${process.env.HOSTNAME || RED.settings.get('flowfile') || 'nr'}-${node.id}`;
478
533
  // 100 amortises Redis/network round-trips while Max pending still limits
479
534
  // the total work allowed into Node-RED.
480
- node.count = parseInt(config.count, 10) || 100;
481
- node.blockMs = parseInt(config.blockMs, 10) || 5000;
535
+ node.count = Math.max(1, parseInt(config.count, 10) || 100);
536
+ node.blockMs = Math.max(1, parseInt(config.blockMs, 10) || 5000);
482
537
  node.readIntervalMs = Math.max(0, parseInt(config.readIntervalMs, 10) || 0);
483
538
  node.rateLimitPerSecond = Math.max(0, parseFloat(config.rateLimitPerSecond) || 0);
484
539
  node.batchWindowMs = Math.max(0, parseInt(config.batchWindowMs, 10) || 0);
@@ -508,9 +563,9 @@ module.exports = function (RED) {
508
563
  // Backoff/jitter при помилках циклу — щоб при масовому падінні Redis
509
564
  // (рестарт кластера, failover) весь HPA-пул не долбив reconnect
510
565
  // синхронно одним і тим же інтервалом ("thundering herd").
511
- node.initialBackoffMs = parseInt(config.initialBackoffMs, 10) || 500;
512
- node.maxBackoffMs = parseInt(config.maxBackoffMs, 10) || 30000;
513
- node.backoffMultiplier = parseFloat(config.backoffMultiplier) || 2;
566
+ node.initialBackoffMs = Math.max(50, parseInt(config.initialBackoffMs, 10) || 500);
567
+ node.maxBackoffMs = Math.max(node.initialBackoffMs, parseInt(config.maxBackoffMs, 10) || 30000);
568
+ node.backoffMultiplier = Math.max(1, parseFloat(config.backoffMultiplier) || 2);
514
569
 
515
570
  // PEL-alert: періодична неблокуюча перевірка розміру pending list через
516
571
  // XPENDING на СПІЛЬНОМУ з'єднанні конфігу (не на blockingClient, щоб не
@@ -552,6 +607,52 @@ module.exports = function (RED) {
552
607
  }
553
608
  }
554
609
 
610
+ async function initializeBlockingClient() {
611
+ // A new pod can reach Node-RED before its Redis route, TLS handshake, or
612
+ // ElastiCache endpoint is ready. Do not permanently lose the consumer
613
+ // on one transient startup timeout. Five retries means six total tries.
614
+ const maxStartupRetries = 5;
615
+ let retries = 0;
616
+
617
+ while (!stopped) {
618
+ const candidate = node.server.getDedicatedClient({
619
+ // commandTimeout applies to each Redis command. It must be longer
620
+ // than BLOCK, otherwise a valid idle XREADGROUP is reported as a
621
+ // timeout when an operator chooses BLOCK above the config timeout.
622
+ commandTimeout: Math.max(node.server.commandTimeout, node.blockMs + 1000),
623
+ maxRetriesPerRequest: 1,
624
+ autoResendUnfulfilledCommands: false
625
+ });
626
+ candidate.on('error', (err) => {
627
+ node.status({ fill: 'red', shape: 'ring', text: 'redis error' });
628
+ node.error(`Redis stream-in error: ${err.message}`);
629
+ });
630
+
631
+ try {
632
+ await ensureGroup(candidate);
633
+ blockingClient = candidate;
634
+ return true;
635
+ } catch (err) {
636
+ try { candidate.disconnect(false); } catch (e) { /* ignore */ }
637
+ if (retries >= maxStartupRetries) {
638
+ throw new Error(`Startup failed after ${maxStartupRetries} retries: ${err.message}`);
639
+ }
640
+
641
+ const jitter = Math.random() * currentBackoffMs * 0.3;
642
+ const delay = Math.min(currentBackoffMs, node.maxBackoffMs) + jitter;
643
+ retries += 1;
644
+ node.status({ fill: 'red', shape: 'ring', text: `startup retry ${retries}/${maxStartupRetries}` });
645
+ node.warn(
646
+ `Redis stream-in startup attempt ${retries}/${maxStartupRetries} failed: ${err.message}; ` +
647
+ `retrying in ${Math.round(delay)}ms`
648
+ );
649
+ await new Promise((resolve) => setTimeout(resolve, delay));
650
+ currentBackoffMs = Math.min(currentBackoffMs * node.backoffMultiplier, node.maxBackoffMs);
651
+ }
652
+ }
653
+ return false;
654
+ }
655
+
555
656
  async function waitForReadInterval() {
556
657
  if (!node.readIntervalMs || !lastReadAt) return true;
557
658
  const waitMs = node.readIntervalMs - (Date.now() - lastReadAt);
@@ -802,18 +903,7 @@ module.exports = function (RED) {
802
903
  streamConsumers.set(node.id, node);
803
904
 
804
905
  async function loop() {
805
- blockingClient = node.server.getDedicatedClient({
806
- // XREADGROUP BLOCK must not stay zombie after a silent network split.
807
- blockingTimeout: node.blockMs + node.server.commandTimeout + 1000,
808
- maxRetriesPerRequest: 1,
809
- autoResendUnfulfilledCommands: false
810
- });
811
- blockingClient.on('error', (err) => {
812
- node.status({ fill: 'red', shape: 'ring', text: 'redis error' });
813
- node.error(`Redis stream-in error: ${err.message}`);
814
- });
815
-
816
- await ensureGroup(blockingClient);
906
+ if (!await initializeBlockingClient()) return;
817
907
  node.status({
818
908
  fill: paused ? 'yellow' : 'green',
819
909
  shape: paused ? 'ring' : 'dot',
@@ -876,6 +966,22 @@ module.exports = function (RED) {
876
966
  } catch (err) {
877
967
  if (stopped) break;
878
968
 
969
+ // A Redis restart, a manual XGROUP DESTROY, or deletion of an empty
970
+ // DLQ stream can remove the group after this node has started.
971
+ // Recreate it before backing off; ensureGroup handles a concurrent
972
+ // creator through BUSYGROUP and is safe to call repeatedly.
973
+ if (isNoGroupError(err)) {
974
+ try {
975
+ await ensureGroup(blockingClient);
976
+ currentBackoffMs = node.initialBackoffMs;
977
+ node.status({ fill: 'yellow', shape: 'ring', text: 'consumer group restored' });
978
+ node.warn(`Redis stream consumer group restored: ${node.group}@${node.streamKey}`);
979
+ continue;
980
+ } catch (restoreErr) {
981
+ err = restoreErr;
982
+ }
983
+ }
984
+
879
985
  const jitter = Math.random() * currentBackoffMs * 0.3; // до +30%
880
986
  const delay = Math.min(currentBackoffMs, node.maxBackoffMs) + jitter;
881
987