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

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,31 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 1.0.5 — 2026-08-11
6
+
7
+ ### Fixed
8
+
9
+ - `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.
10
+ - Stream consumer timing and backoff settings are bounded at runtime even when a malformed flow configuration bypasses editor validation.
11
+ - `redis sub` now rejects an empty channel list instead of remaining indefinitely in a starting state.
12
+ - `redis xadd` now reports an empty fields object before sending an invalid command to Redis.
13
+
14
+ ### Changed
15
+
16
+ - `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.
17
+
18
+ ## 1.0.4 — 2026-08-11
19
+
20
+ ### Fixed
21
+
22
+ - `redis sub` now retries its initial subscription up to five times with backoff and jitter instead of remaining permanently unsubscribed after one transient timeout.
23
+
24
+ ## 1.0.3 — 2026-08-11
25
+
26
+ ### Fixed
27
+
28
+ - `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.
29
+
5
30
  ## 1.0.2 — 2026-08-10
6
31
 
7
32
  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.5` 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,9 @@ 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.
64
65
  - `redis xautoclaim` limits each manual recovery run through `Run limit` (default 1000), avoiding oversized Node-RED payloads.
66
+ - `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
67
  - `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
68
 
67
69
  ## 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.5",
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
@@ -244,8 +244,14 @@ module.exports = function (RED) {
244
244
  node.mode = config.mode === 'psubscribe' ? 'psubscribe' : 'subscribe';
245
245
  node.channels = (config.channels || '').split(',').map((s) => s.trim()).filter(Boolean);
246
246
 
247
+ if (!node.channels.length) {
248
+ node.error('At least one Redis channel or pattern is required');
249
+ return;
250
+ }
251
+
247
252
  const client = node.server.getDedicatedClient();
248
253
  let subscribed = [];
254
+ let stopped = false;
249
255
 
250
256
  const eventName = node.mode === 'psubscribe' ? 'pmessage' : 'message';
251
257
  client.on(eventName, (a, b, c) => {
@@ -271,10 +277,40 @@ module.exports = function (RED) {
271
277
  node.status({ fill: 'green', shape: 'dot', text: `listening (${subscribed.length})` });
272
278
  }
273
279
 
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
- });
280
+ async function startSubscription() {
281
+ // SUBSCRIBE is idempotent for an already subscribed channel. Retrying it
282
+ // is therefore safe when Redis accepted the command but its reply was
283
+ // lost to a timeout during pod startup.
284
+ const maxStartupRetries = 5;
285
+ let retries = 0;
286
+ let delayMs = 500;
287
+
288
+ while (!stopped) {
289
+ try {
290
+ await doSubscribe(node.channels);
291
+ return;
292
+ } catch (err) {
293
+ if (retries >= maxStartupRetries) {
294
+ node.status({ fill: 'red', shape: 'ring', text: 'subscribe failed' });
295
+ node.error(`Redis subscribe failed after ${maxStartupRetries} retries: ${err.message}`);
296
+ return;
297
+ }
298
+
299
+ const jitter = Math.random() * delayMs * 0.3;
300
+ const delay = Math.min(delayMs, node.server.retryMaxDelay) + jitter;
301
+ retries += 1;
302
+ node.status({ fill: 'red', shape: 'ring', text: `subscribe retry ${retries}/${maxStartupRetries}` });
303
+ node.warn(
304
+ `Redis subscribe startup attempt ${retries}/${maxStartupRetries} failed: ${err.message}; ` +
305
+ `retrying in ${Math.round(delay)}ms`
306
+ );
307
+ await new Promise((resolve) => setTimeout(resolve, delay));
308
+ delayMs = Math.min(delayMs * 2, node.server.retryMaxDelay);
309
+ }
310
+ }
311
+ }
312
+
313
+ startSubscription();
278
314
 
279
315
  node.on('input', async function (msg, send, done) {
280
316
  try {
@@ -287,6 +323,7 @@ module.exports = function (RED) {
287
323
  });
288
324
 
289
325
  node.on('close', async function (done) {
326
+ stopped = true;
290
327
  try { await client.quit(); } catch (e) { /* ignore */ }
291
328
  node.status({});
292
329
  done();
@@ -346,14 +383,18 @@ module.exports = function (RED) {
346
383
  if (!node.server) return;
347
384
 
348
385
  node.scanType = config.scanType || 'SCAN';
349
- node.defaultCount = parseInt(config.count, 10) || 100;
386
+ node.defaultCount = Math.max(1, parseInt(config.count, 10) || 100);
387
+ // A cursor scan is non-blocking for Redis, but collecting an unbounded
388
+ // result into one Node-RED message can still exhaust the Node.js heap.
389
+ node.defaultMaxResults = Math.max(1, parseInt(config.maxResults, 10) || 10000);
350
390
 
351
391
  node.on('input', async function (msg, send, done) {
352
392
  send = send || function () { node.send.apply(node, arguments); };
353
393
  try {
354
394
  const scanType = (msg.scanType || node.scanType).toUpperCase();
355
395
  const match = msg.match;
356
- const count = msg.count !== undefined ? msg.count : node.defaultCount;
396
+ const count = Math.max(1, parseInt(msg.count, 10) || node.defaultCount);
397
+ const maxResults = Math.max(1, parseInt(msg.maxResults, 10) || node.defaultMaxResults);
357
398
  const client = node.server.getClient();
358
399
 
359
400
  let key = null;
@@ -381,14 +422,22 @@ module.exports = function (RED) {
381
422
  if (scanType === 'HSCAN' || scanType === 'ZSCAN') {
382
423
  for (let i = 0; i < elements.length; i += 2) {
383
424
  collected.push({ member: elements[i], value: elements[i + 1] });
425
+ if (collected.length >= maxResults) break;
384
426
  }
385
427
  } else {
386
- collected.push(...elements);
428
+ collected.push(...elements.slice(0, maxResults - collected.length));
387
429
  }
388
- } while (cursor !== '0');
430
+ } while (cursor !== '0' && collected.length < maxResults);
389
431
 
390
432
  msg.payload = collected;
391
- node.status({ fill: 'green', shape: 'dot', text: `${collected.length} items` });
433
+ msg.scanComplete = cursor === '0';
434
+ msg.scanTruncated = !msg.scanComplete;
435
+ if (msg.scanTruncated) {
436
+ node.status({ fill: 'yellow', shape: 'ring', text: `${collected.length} items (limit)` });
437
+ node.warn(`Redis ${scanType} stopped at the configured ${maxResults}-item limit`);
438
+ } else {
439
+ node.status({ fill: 'green', shape: 'dot', text: `${collected.length} items` });
440
+ }
392
441
  send(msg);
393
442
  done();
394
443
  } catch (err) {
@@ -432,6 +481,8 @@ module.exports = function (RED) {
432
481
  fields.push(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
433
482
  }
434
483
 
484
+ if (!fields.length) throw new Error('msg.payload must contain at least one field for XADD');
485
+
435
486
  const args = [streamKey];
436
487
  const maxlen = msg.maxlen !== undefined ? msg.maxlen : node.maxlen;
437
488
  const unsafeTrim = msg.allowUnsafeTrim === true || node.unsafeTrim;
@@ -477,8 +528,8 @@ module.exports = function (RED) {
477
528
  || `${process.env.HOSTNAME || RED.settings.get('flowfile') || 'nr'}-${node.id}`;
478
529
  // 100 amortises Redis/network round-trips while Max pending still limits
479
530
  // the total work allowed into Node-RED.
480
- node.count = parseInt(config.count, 10) || 100;
481
- node.blockMs = parseInt(config.blockMs, 10) || 5000;
531
+ node.count = Math.max(1, parseInt(config.count, 10) || 100);
532
+ node.blockMs = Math.max(1, parseInt(config.blockMs, 10) || 5000);
482
533
  node.readIntervalMs = Math.max(0, parseInt(config.readIntervalMs, 10) || 0);
483
534
  node.rateLimitPerSecond = Math.max(0, parseFloat(config.rateLimitPerSecond) || 0);
484
535
  node.batchWindowMs = Math.max(0, parseInt(config.batchWindowMs, 10) || 0);
@@ -508,9 +559,9 @@ module.exports = function (RED) {
508
559
  // Backoff/jitter при помилках циклу — щоб при масовому падінні Redis
509
560
  // (рестарт кластера, failover) весь HPA-пул не долбив reconnect
510
561
  // синхронно одним і тим же інтервалом ("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;
562
+ node.initialBackoffMs = Math.max(50, parseInt(config.initialBackoffMs, 10) || 500);
563
+ node.maxBackoffMs = Math.max(node.initialBackoffMs, parseInt(config.maxBackoffMs, 10) || 30000);
564
+ node.backoffMultiplier = Math.max(1, parseFloat(config.backoffMultiplier) || 2);
514
565
 
515
566
  // PEL-alert: періодична неблокуюча перевірка розміру pending list через
516
567
  // XPENDING на СПІЛЬНОМУ з'єднанні конфігу (не на blockingClient, щоб не
@@ -552,6 +603,52 @@ module.exports = function (RED) {
552
603
  }
553
604
  }
554
605
 
606
+ async function initializeBlockingClient() {
607
+ // A new pod can reach Node-RED before its Redis route, TLS handshake, or
608
+ // ElastiCache endpoint is ready. Do not permanently lose the consumer
609
+ // on one transient startup timeout. Five retries means six total tries.
610
+ const maxStartupRetries = 5;
611
+ let retries = 0;
612
+
613
+ while (!stopped) {
614
+ const candidate = node.server.getDedicatedClient({
615
+ // commandTimeout applies to each Redis command. It must be longer
616
+ // than BLOCK, otherwise a valid idle XREADGROUP is reported as a
617
+ // timeout when an operator chooses BLOCK above the config timeout.
618
+ commandTimeout: Math.max(node.server.commandTimeout, node.blockMs + 1000),
619
+ maxRetriesPerRequest: 1,
620
+ autoResendUnfulfilledCommands: false
621
+ });
622
+ candidate.on('error', (err) => {
623
+ node.status({ fill: 'red', shape: 'ring', text: 'redis error' });
624
+ node.error(`Redis stream-in error: ${err.message}`);
625
+ });
626
+
627
+ try {
628
+ await ensureGroup(candidate);
629
+ blockingClient = candidate;
630
+ return true;
631
+ } catch (err) {
632
+ try { candidate.disconnect(false); } catch (e) { /* ignore */ }
633
+ if (retries >= maxStartupRetries) {
634
+ throw new Error(`Startup failed after ${maxStartupRetries} retries: ${err.message}`);
635
+ }
636
+
637
+ const jitter = Math.random() * currentBackoffMs * 0.3;
638
+ const delay = Math.min(currentBackoffMs, node.maxBackoffMs) + jitter;
639
+ retries += 1;
640
+ node.status({ fill: 'red', shape: 'ring', text: `startup retry ${retries}/${maxStartupRetries}` });
641
+ node.warn(
642
+ `Redis stream-in startup attempt ${retries}/${maxStartupRetries} failed: ${err.message}; ` +
643
+ `retrying in ${Math.round(delay)}ms`
644
+ );
645
+ await new Promise((resolve) => setTimeout(resolve, delay));
646
+ currentBackoffMs = Math.min(currentBackoffMs * node.backoffMultiplier, node.maxBackoffMs);
647
+ }
648
+ }
649
+ return false;
650
+ }
651
+
555
652
  async function waitForReadInterval() {
556
653
  if (!node.readIntervalMs || !lastReadAt) return true;
557
654
  const waitMs = node.readIntervalMs - (Date.now() - lastReadAt);
@@ -802,18 +899,7 @@ module.exports = function (RED) {
802
899
  streamConsumers.set(node.id, node);
803
900
 
804
901
  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);
902
+ if (!await initializeBlockingClient()) return;
817
903
  node.status({
818
904
  fill: paused ? 'yellow' : 'green',
819
905
  shape: paused ? 'ring' : 'dot',