@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 +25 -0
- package/README.md +3 -1
- package/package.json +1 -1
- package/redis.html +8 -3
- package/redis.js +112 -26
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.
|
|
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.
|
|
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>
|
|
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
|
@@ -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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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',
|