@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 +31 -0
- package/README.md +4 -1
- package/package.json +1 -1
- package/redis.html +8 -3
- package/redis.js +132 -26
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.
|
|
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.
|
|
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>
|
|
314
|
-
<p>Output: <code>msg.payload</code> is an array; HSCAN/ZSCAN return <code>{member, value}</code
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|