@yroshcha/node-red-contrib-redis-full 1.0.6 → 1.0.8
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 +12 -0
- package/README.md +5 -4
- package/package.json +1 -1
- package/redis.html +26 -5
- package/redis.js +78 -14
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.8 — 2026-08-12
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `redis xreadgroup` and `redis xautoclaim` now restore nested object and array fields that were serialised by `redis xadd`, while preserving scalar Redis fields as strings.
|
|
10
|
+
|
|
11
|
+
## 1.0.7 — 2026-08-12
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
5
17
|
## 1.0.6 — 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.
|
|
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
|
|
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.
|
|
48
|
+
**`1.0.8` is the current stable release.** See [CHANGELOG.md](CHANGELOG.md) for release notes and compatibility information.
|
|
49
49
|
|
|
50
50
|
## Production profile
|
|
51
51
|
|
|
@@ -58,6 +58,7 @@ Safe consumer defaults and important settings:
|
|
|
58
58
|
- **Rate limit (msg/s) = 0** is unlimited. For example, `500` limits average downstream delivery to 500 events per second.
|
|
59
59
|
- **Read interval (ms) = 0** reads as fast as Redis responds. `1000` calls `XREADGROUP` no more often than once per second, independent of queue depth.
|
|
60
60
|
- **Batch wait (ms) = 0** emits one event at a time. With a positive value, the node emits an array once it has collected `COUNT` events or the wait expires. `msg.payload` and `msg.streamId` become arrays.
|
|
61
|
+
- Nested object and array fields written by `redis xadd` are automatically decoded from JSON by `redis xreadgroup` and `redis xautoclaim`; scalar Redis fields remain strings.
|
|
61
62
|
- **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
63
|
- **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
64
|
- **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.
|
|
@@ -116,7 +117,7 @@ Run `redis consumer gc` from an input message, typically a scheduled Inject node
|
|
|
116
117
|
## Choosing a node
|
|
117
118
|
|
|
118
119
|
- Read or write a single Redis value/command: `redis cmd`.
|
|
119
|
-
- Broadcast delivery without persistence: `redis sub
|
|
120
|
+
- 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.
|
|
120
121
|
- Atomic command list without WATCH: `redis multi`.
|
|
121
122
|
- Scan keys or fields without blocking Redis: `redis scan`.
|
|
122
123
|
- 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.
|
|
3
|
+
"version": "1.0.8",
|
|
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>
|
|
220
|
-
<p>Output: <code>msg.topic</code>, <code>msg.payload</code>, and <code>msg.pattern</code> for pattern subscriptions.</p>
|
|
221
|
-
<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,22 @@ module.exports = function (RED) {
|
|
|
173
173
|
return obj;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
function parseStreamFields(flat) {
|
|
177
|
+
const obj = parseFlatFields(flat);
|
|
178
|
+
for (const [field, value] of Object.entries(obj)) {
|
|
179
|
+
// redis xadd serialises nested objects and arrays to JSON. Restore only
|
|
180
|
+
// these compound JSON values; scalar strings such as "true", "42", or
|
|
181
|
+
// "null" remain strings and preserve Redis field semantics.
|
|
182
|
+
if (typeof value !== 'string' || !/^[\[{]/.test(value.trim())) continue;
|
|
183
|
+
try {
|
|
184
|
+
obj[field] = JSON.parse(value);
|
|
185
|
+
} catch (_) {
|
|
186
|
+
// A value that merely looks like JSON is still a valid Redis string.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return obj;
|
|
190
|
+
}
|
|
191
|
+
|
|
176
192
|
function isNoGroupError(err) {
|
|
177
193
|
return String(err && err.message ? err.message : err).includes('NOGROUP');
|
|
178
194
|
}
|
|
@@ -237,7 +253,7 @@ module.exports = function (RED) {
|
|
|
237
253
|
RED.nodes.registerType('yroshcha-redis-command', RedisCommandNode);
|
|
238
254
|
|
|
239
255
|
// ---------------------------------------------------------------------
|
|
240
|
-
// yroshcha-redis-subscribe —
|
|
256
|
+
// yroshcha-redis-subscribe — Pub/Sub and blocking List intake on a dedicated connection.
|
|
241
257
|
// ---------------------------------------------------------------------
|
|
242
258
|
function RedisSubscribeNode(config) {
|
|
243
259
|
RED.nodes.createNode(this, config);
|
|
@@ -245,25 +261,42 @@ module.exports = function (RED) {
|
|
|
245
261
|
node.server = getServer(node, config);
|
|
246
262
|
if (!node.server) return;
|
|
247
263
|
|
|
248
|
-
node.mode =
|
|
264
|
+
node.mode = ['subscribe', 'psubscribe', 'blpop', 'brpop'].includes(config.mode)
|
|
265
|
+
? config.mode
|
|
266
|
+
: 'subscribe';
|
|
249
267
|
node.channels = (config.channels || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
268
|
+
node.topic = (config.topic || '').trim();
|
|
269
|
+
// Redis list blocking timeout is expressed in seconds. Zero means block
|
|
270
|
+
// indefinitely, therefore commandTimeout must be disabled for that client.
|
|
271
|
+
node.timeout = Math.max(0, parseInt(config.timeout, 10) || 0);
|
|
272
|
+
const isListPop = node.mode === 'blpop' || node.mode === 'brpop';
|
|
250
273
|
|
|
251
|
-
if (!node.channels.length) {
|
|
274
|
+
if (!isListPop && !node.channels.length) {
|
|
252
275
|
node.error('At least one Redis channel or pattern is required');
|
|
253
276
|
return;
|
|
254
277
|
}
|
|
278
|
+
if (isListPop && !node.topic) {
|
|
279
|
+
node.error('A Redis list key (Topic) is required for BLPOP/BRPOP');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
255
282
|
|
|
256
|
-
const client = node.server.getDedicatedClient(
|
|
283
|
+
const client = node.server.getDedicatedClient(isListPop ? {
|
|
284
|
+
commandTimeout: node.timeout ? Math.max(node.server.commandTimeout, node.timeout * 1000 + 1000) : undefined,
|
|
285
|
+
maxRetriesPerRequest: 1,
|
|
286
|
+
autoResendUnfulfilledCommands: false
|
|
287
|
+
} : undefined);
|
|
257
288
|
let subscribed = [];
|
|
258
289
|
let stopped = false;
|
|
259
290
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
291
|
+
if (!isListPop) {
|
|
292
|
+
const eventName = node.mode === 'psubscribe' ? 'pmessage' : 'message';
|
|
293
|
+
client.on(eventName, (a, b, c) => {
|
|
294
|
+
const outMsg = node.mode === 'psubscribe'
|
|
295
|
+
? { pattern: a, topic: b, payload: c }
|
|
296
|
+
: { topic: a, payload: b };
|
|
297
|
+
node.send(outMsg);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
267
300
|
|
|
268
301
|
async function doSubscribe(channels) {
|
|
269
302
|
if (!channels.length) return;
|
|
@@ -314,10 +347,41 @@ module.exports = function (RED) {
|
|
|
314
347
|
}
|
|
315
348
|
}
|
|
316
349
|
|
|
317
|
-
|
|
350
|
+
async function startListPop() {
|
|
351
|
+
// A list pop is intentionally a continuous source node: Redis returns
|
|
352
|
+
// one item and the next call begins immediately. Unlike a generic cmd
|
|
353
|
+
// node, users do not need to wire an output back into its input.
|
|
354
|
+
const method = node.mode;
|
|
355
|
+
let delayMs = 500;
|
|
356
|
+
node.status({ fill: 'green', shape: 'dot', text: `waiting (${method.toUpperCase()})` });
|
|
357
|
+
while (!stopped) {
|
|
358
|
+
try {
|
|
359
|
+
const result = await client[method](node.topic, node.timeout);
|
|
360
|
+
delayMs = 500;
|
|
361
|
+
if (!result || stopped) continue;
|
|
362
|
+
const [key, value] = result;
|
|
363
|
+
node.status({ fill: 'green', shape: 'dot', text: `received (${method.toUpperCase()})` });
|
|
364
|
+
node.send({ topic: key, payload: value });
|
|
365
|
+
} catch (err) {
|
|
366
|
+
if (stopped) break;
|
|
367
|
+
const delay = Math.min(delayMs, node.server.retryMaxDelay) + Math.random() * delayMs * 0.3;
|
|
368
|
+
node.status({ fill: 'red', shape: 'ring', text: `retry in ${Math.round(delay)}ms` });
|
|
369
|
+
node.error(`Redis ${method.toUpperCase()} error: ${err.message}`);
|
|
370
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
371
|
+
delayMs = Math.min(delayMs * 2, node.server.retryMaxDelay);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (isListPop) startListPop();
|
|
377
|
+
else startSubscription();
|
|
318
378
|
|
|
319
379
|
node.on('input', async function (msg, send, done) {
|
|
320
380
|
try {
|
|
381
|
+
if (isListPop) {
|
|
382
|
+
done();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
321
385
|
if (Array.isArray(msg.subscribe) && msg.subscribe.length) await doSubscribe(msg.subscribe);
|
|
322
386
|
if (Array.isArray(msg.unsubscribe) && msg.unsubscribe.length) await doUnsubscribe(msg.unsubscribe);
|
|
323
387
|
done();
|
|
@@ -820,7 +884,7 @@ module.exports = function (RED) {
|
|
|
820
884
|
if (!Array.isArray(flat)) {
|
|
821
885
|
throw new Error(`Stream entry ${id} no longer has a payload; do not trim streams below the PEL horizon`);
|
|
822
886
|
}
|
|
823
|
-
const payload =
|
|
887
|
+
const payload = parseStreamFields(flat);
|
|
824
888
|
if (checkDeliveryCount && await shouldDeadLetter(id, payload)) return;
|
|
825
889
|
if (!await waitForDeliverySlot() || paused) return false;
|
|
826
890
|
|
|
@@ -1125,7 +1189,7 @@ module.exports = function (RED) {
|
|
|
1125
1189
|
const [nextCursor, entries] = res;
|
|
1126
1190
|
cursor = nextCursor;
|
|
1127
1191
|
for (const [id, flat] of entries || []) {
|
|
1128
|
-
claimed.push({ streamId: id, payload:
|
|
1192
|
+
claimed.push({ streamId: id, payload: parseStreamFields(flat) });
|
|
1129
1193
|
}
|
|
1130
1194
|
} while (cursor !== '0' && claimed.length < node.maxMessages);
|
|
1131
1195
|
|