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

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,18 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 1.0.7 — 2026-08-12
6
+
7
+ ### Added
8
+
9
+ - Restored continuous `BLPOP` and `BRPOP` modes in `redis sub`, compatible with the original Redis input node workflow. Configure a list key as Topic and an optional timeout in seconds; the node keeps reading on its dedicated connection.
10
+
11
+ ## 1.0.6 — 2026-08-11
12
+
13
+ ### Fixed
14
+
15
+ - `redis xreadgroup` now restores its consumer group with `MKSTREAM` when a running consumer receives `NOGROUP`, including a deleted or restarted DLQ stream.
16
+
5
17
  ## 1.0.5 — 2026-08-11
6
18
 
7
19
  ### Fixed
package/README.md CHANGED
@@ -17,7 +17,7 @@ Every node type is prefixed with `yroshcha-redis-*`; the palette category is uni
17
17
  | Palette label | Type | Redis command(s) | Purpose |
18
18
  |---|---|---|---|
19
19
  | `redis cmd` | `yroshcha-redis-command` | any | Runs a generic command through `.call()`. `Block` forces a dedicated connection for commands such as `BLPOP`, `BRPOP`, or `WAIT`. |
20
- | `redis sub` | `yroshcha-redis-subscribe` | `SUBSCRIBE` / `PSUBSCRIBE` | Pub/Sub on a dedicated connection. Supports dynamic subscribe/unsubscribe through `msg.subscribe` and `msg.unsubscribe`. |
20
+ | `redis sub` | `yroshcha-redis-subscribe` | `SUBSCRIBE` / `PSUBSCRIBE` / `BLPOP` / `BRPOP` | Pub/Sub or continuous Redis List intake on a dedicated connection. Pub/Sub supports dynamic subscribe/unsubscribe through `msg.subscribe` and `msg.unsubscribe`. |
21
21
  | `redis multi` | `yroshcha-redis-multi` | `MULTI` / `EXEC` | Atomic transaction from a command list. |
22
22
  | `redis scan` | `yroshcha-redis-scan` | `SCAN` / `HSCAN` / `SSCAN` / `ZSCAN` | Cursor-based non-blocking scan. |
23
23
  | `redis xadd` | `yroshcha-redis-stream-out` | `XADD` | Publishes to a stream. `MAXLEN` requires explicit unsafe confirmation. |
@@ -41,11 +41,11 @@ For a local archive, use `npm install /path/to/yroshcha-node-red-contrib-redis-f
41
41
  ## Connection model
42
42
 
43
43
  - `yroshcha-redis-config` keeps one shared ioredis client (`getClient()`) for non-blocking nodes: `redis cmd` (without `Block`), `redis multi`, `redis scan`, `redis xadd`, `redis xack`, `redis xautoclaim`, `redis consumer gc`, and `redis stream metrics`.
44
- - Blocking operations use a dedicated connection (`getDedicatedClient()`): `redis sub`, `redis xreadgroup`, and `redis cmd` when `Block` is enabled.
44
+ - Blocking operations use a dedicated connection (`getDedicatedClient()`): `redis sub` (including BLPOP/BRPOP), `redis xreadgroup`, and `redis cmd` when `Block` is enabled.
45
45
 
46
46
  ## Release status
47
47
 
48
- **`1.0.5` is the current stable release.** See [CHANGELOG.md](CHANGELOG.md) for release notes and compatibility information.
48
+ **`1.0.7` is the current stable release.** See [CHANGELOG.md](CHANGELOG.md) for release notes and compatibility information.
49
49
 
50
50
  ## Production profile
51
51
 
@@ -62,6 +62,7 @@ Safe consumer defaults and important settings:
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
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.
65
66
  - `redis xautoclaim` limits each manual recovery run through `Run limit` (default 1000), avoiding oversized Node-RED payloads.
66
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.
67
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.
@@ -115,7 +116,7 @@ Run `redis consumer gc` from an input message, typically a scheduled Inject node
115
116
  ## Choosing a node
116
117
 
117
118
  - Read or write a single Redis value/command: `redis cmd`.
118
- - Broadcast delivery without persistence: `redis sub`.
119
+ - Broadcast delivery without persistence: `redis sub` in SUBSCRIBE/PSUBSCRIBE mode. For a simple Redis List queue, select BLPOP/BRPOP in the same node; it continuously reads the configured Topic without a feedback loop.
119
120
  - Atomic command list without WATCH: `redis multi`.
120
121
  - Scan keys or fields without blocking Redis: `redis scan`.
121
122
  - Reliable delivery with consumer groups, ACK, and recovery: `redis xadd` → `redis xreadgroup` → `redis xack`. Keep `redis xautoclaim` for manual recovery and `redis consumer gc` for periodic cleanup.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yroshcha/node-red-contrib-redis-full",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
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
@@ -181,7 +181,9 @@
181
181
  name: { value: '' },
182
182
  server: { type: 'yroshcha-redis-config', required: true },
183
183
  mode: { value: 'subscribe' },
184
- channels: { value: '' }
184
+ channels: { value: '' },
185
+ topic: { value: '' },
186
+ timeout: { value: 0, validate: RED.validators.number() }
185
187
  },
186
188
  inputs: 1,
187
189
  outputs: 1,
@@ -189,6 +191,15 @@
189
191
  label: function () {
190
192
  return this.name || 'redis ' + this.mode;
191
193
  },
194
+ oneditprepare: function () {
195
+ const updateMode = () => {
196
+ const isList = ['blpop', 'brpop'].includes($('#node-input-mode').val());
197
+ $('.redis-sub-channels-row').toggle(!isList);
198
+ $('.redis-sub-list-row').toggle(isList);
199
+ };
200
+ $('#node-input-mode').on('change', updateMode);
201
+ updateMode();
202
+ },
192
203
  onadd: function () { this.l = true; }
193
204
  });
194
205
  </script>
@@ -203,12 +214,22 @@
203
214
  <select id="node-input-mode">
204
215
  <option value="subscribe">SUBSCRIBE (exact channels)</option>
205
216
  <option value="psubscribe">PSUBSCRIBE (patterns)</option>
217
+ <option value="blpop">BLPOP (left side of list)</option>
218
+ <option value="brpop">BRPOP (right side of list)</option>
206
219
  </select>
207
220
  </div>
208
- <div class="form-row">
221
+ <div class="form-row redis-sub-channels-row">
209
222
  <label for="node-input-channels">Channels</label>
210
223
  <input type="text" id="node-input-channels" placeholder="comma-separated, e.g. app:notify, app:alerts">
211
224
  </div>
225
+ <div class="form-row redis-sub-list-row">
226
+ <label for="node-input-topic">Topic</label>
227
+ <input type="text" id="node-input-topic" placeholder="Redis list key, e.g. events">
228
+ </div>
229
+ <div class="form-row redis-sub-list-row">
230
+ <label for="node-input-timeout">Timeout (sec)</label>
231
+ <input type="text" id="node-input-timeout" placeholder="0 = wait indefinitely">
232
+ </div>
212
233
  <div class="form-row">
213
234
  <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
214
235
  <input type="text" id="node-input-name">
@@ -216,9 +237,9 @@
216
237
  </script>
217
238
 
218
239
  <script type="text/html" data-help-name="yroshcha-redis-subscribe">
219
- <p>Subscribes with <code>SUBSCRIBE</code>/<code>PSUBSCRIBE</code> on a dedicated connection.</p>
220
- <p>Output: <code>msg.topic</code>, <code>msg.payload</code>, and <code>msg.pattern</code> for pattern subscriptions.</p>
221
- <p>Dynamic control: <code>msg.subscribe</code>/<code>msg.unsubscribe</code> are arrays of channels.</p>
240
+ <p>Uses a dedicated connection for <code>SUBSCRIBE</code>/<code>PSUBSCRIBE</code> or continuous <code>BLPOP</code>/<code>BRPOP</code> list intake.</p>
241
+ <p>For Pub/Sub, enter comma-separated Channels. Output: <code>msg.topic</code>, <code>msg.payload</code>, and <code>msg.pattern</code> for pattern subscriptions. Dynamic control: <code>msg.subscribe</code>/<code>msg.unsubscribe</code> are arrays of channels.</p>
242
+ <p>For BLPOP/BRPOP, enter one Redis list key as Topic. The node continuously emits each popped value as <code>msg.payload</code> and the list key as <code>msg.topic</code>. Timeout is in seconds; <code>0</code> waits indefinitely.</p>
222
243
  </script>
223
244
 
224
245
  <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 тощо).
@@ -233,7 +237,7 @@ module.exports = function (RED) {
233
237
  RED.nodes.registerType('yroshcha-redis-command', RedisCommandNode);
234
238
 
235
239
  // ---------------------------------------------------------------------
236
- // yroshcha-redis-subscribe — SUBSCRIBE/PSUBSCRIBE, завжди на окремому з'єднанні.
240
+ // yroshcha-redis-subscribe — Pub/Sub and blocking List intake on a dedicated connection.
237
241
  // ---------------------------------------------------------------------
238
242
  function RedisSubscribeNode(config) {
239
243
  RED.nodes.createNode(this, config);
@@ -241,25 +245,42 @@ module.exports = function (RED) {
241
245
  node.server = getServer(node, config);
242
246
  if (!node.server) return;
243
247
 
244
- node.mode = config.mode === 'psubscribe' ? 'psubscribe' : 'subscribe';
248
+ node.mode = ['subscribe', 'psubscribe', 'blpop', 'brpop'].includes(config.mode)
249
+ ? config.mode
250
+ : 'subscribe';
245
251
  node.channels = (config.channels || '').split(',').map((s) => s.trim()).filter(Boolean);
252
+ node.topic = (config.topic || '').trim();
253
+ // Redis list blocking timeout is expressed in seconds. Zero means block
254
+ // indefinitely, therefore commandTimeout must be disabled for that client.
255
+ node.timeout = Math.max(0, parseInt(config.timeout, 10) || 0);
256
+ const isListPop = node.mode === 'blpop' || node.mode === 'brpop';
246
257
 
247
- if (!node.channels.length) {
258
+ if (!isListPop && !node.channels.length) {
248
259
  node.error('At least one Redis channel or pattern is required');
249
260
  return;
250
261
  }
262
+ if (isListPop && !node.topic) {
263
+ node.error('A Redis list key (Topic) is required for BLPOP/BRPOP');
264
+ return;
265
+ }
251
266
 
252
- const client = node.server.getDedicatedClient();
267
+ const client = node.server.getDedicatedClient(isListPop ? {
268
+ commandTimeout: node.timeout ? Math.max(node.server.commandTimeout, node.timeout * 1000 + 1000) : undefined,
269
+ maxRetriesPerRequest: 1,
270
+ autoResendUnfulfilledCommands: false
271
+ } : undefined);
253
272
  let subscribed = [];
254
273
  let stopped = false;
255
274
 
256
- const eventName = node.mode === 'psubscribe' ? 'pmessage' : 'message';
257
- client.on(eventName, (a, b, c) => {
258
- const outMsg = node.mode === 'psubscribe'
259
- ? { pattern: a, topic: b, payload: c }
260
- : { topic: a, payload: b };
261
- node.send(outMsg);
262
- });
275
+ if (!isListPop) {
276
+ const eventName = node.mode === 'psubscribe' ? 'pmessage' : 'message';
277
+ client.on(eventName, (a, b, c) => {
278
+ const outMsg = node.mode === 'psubscribe'
279
+ ? { pattern: a, topic: b, payload: c }
280
+ : { topic: a, payload: b };
281
+ node.send(outMsg);
282
+ });
283
+ }
263
284
 
264
285
  async function doSubscribe(channels) {
265
286
  if (!channels.length) return;
@@ -310,10 +331,41 @@ module.exports = function (RED) {
310
331
  }
311
332
  }
312
333
 
313
- startSubscription();
334
+ async function startListPop() {
335
+ // A list pop is intentionally a continuous source node: Redis returns
336
+ // one item and the next call begins immediately. Unlike a generic cmd
337
+ // node, users do not need to wire an output back into its input.
338
+ const method = node.mode;
339
+ let delayMs = 500;
340
+ node.status({ fill: 'green', shape: 'dot', text: `waiting (${method.toUpperCase()})` });
341
+ while (!stopped) {
342
+ try {
343
+ const result = await client[method](node.topic, node.timeout);
344
+ delayMs = 500;
345
+ if (!result || stopped) continue;
346
+ const [key, value] = result;
347
+ node.status({ fill: 'green', shape: 'dot', text: `received (${method.toUpperCase()})` });
348
+ node.send({ topic: key, payload: value });
349
+ } catch (err) {
350
+ if (stopped) break;
351
+ const delay = Math.min(delayMs, node.server.retryMaxDelay) + Math.random() * delayMs * 0.3;
352
+ node.status({ fill: 'red', shape: 'ring', text: `retry in ${Math.round(delay)}ms` });
353
+ node.error(`Redis ${method.toUpperCase()} error: ${err.message}`);
354
+ await new Promise((resolve) => setTimeout(resolve, delay));
355
+ delayMs = Math.min(delayMs * 2, node.server.retryMaxDelay);
356
+ }
357
+ }
358
+ }
359
+
360
+ if (isListPop) startListPop();
361
+ else startSubscription();
314
362
 
315
363
  node.on('input', async function (msg, send, done) {
316
364
  try {
365
+ if (isListPop) {
366
+ done();
367
+ return;
368
+ }
317
369
  if (Array.isArray(msg.subscribe) && msg.subscribe.length) await doSubscribe(msg.subscribe);
318
370
  if (Array.isArray(msg.unsubscribe) && msg.unsubscribe.length) await doUnsubscribe(msg.unsubscribe);
319
371
  done();
@@ -962,6 +1014,22 @@ module.exports = function (RED) {
962
1014
  } catch (err) {
963
1015
  if (stopped) break;
964
1016
 
1017
+ // A Redis restart, a manual XGROUP DESTROY, or deletion of an empty
1018
+ // DLQ stream can remove the group after this node has started.
1019
+ // Recreate it before backing off; ensureGroup handles a concurrent
1020
+ // creator through BUSYGROUP and is safe to call repeatedly.
1021
+ if (isNoGroupError(err)) {
1022
+ try {
1023
+ await ensureGroup(blockingClient);
1024
+ currentBackoffMs = node.initialBackoffMs;
1025
+ node.status({ fill: 'yellow', shape: 'ring', text: 'consumer group restored' });
1026
+ node.warn(`Redis stream consumer group restored: ${node.group}@${node.streamKey}`);
1027
+ continue;
1028
+ } catch (restoreErr) {
1029
+ err = restoreErr;
1030
+ }
1031
+ }
1032
+
965
1033
  const jitter = Math.random() * currentBackoffMs * 0.3; // до +30%
966
1034
  const delay = Math.min(currentBackoffMs, node.maxBackoffMs) + jitter;
967
1035