@yroshcha/node-red-contrib-redis-full 1.0.0
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 +28 -0
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/icons/redis-icon.svg +7 -0
- package/icons/redis.png +0 -0
- package/package.json +38 -0
- package/redis.html +896 -0
- package/redis.js +1265 -0
package/redis.js
ADDED
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
const Redis = require('ioredis');
|
|
2
|
+
const crypto = require('node:crypto');
|
|
3
|
+
|
|
4
|
+
module.exports = function (RED) {
|
|
5
|
+
// All stream-in nodes in this Node-RED runtime. Used by the optional global
|
|
6
|
+
// control node to drain/pause/resume every consumer before a scale-down.
|
|
7
|
+
const streamConsumers = new Map();
|
|
8
|
+
// Only one API node should be enabled per runtime. Keeping it in a registry
|
|
9
|
+
// lets the fixed HTTP routes survive regular Node-RED deploys safely.
|
|
10
|
+
const streamControlApis = new Map();
|
|
11
|
+
|
|
12
|
+
async function executeStreamControl(command) {
|
|
13
|
+
const action = String(command.action || '').toLowerCase();
|
|
14
|
+
if (!['pause', 'resume', 'drain', 'status'].includes(action)) {
|
|
15
|
+
throw new Error('Control action must be pause, resume, drain or status');
|
|
16
|
+
}
|
|
17
|
+
const targetStream = command.stream;
|
|
18
|
+
const targetGroup = command.group;
|
|
19
|
+
const targetNodeId = command.nodeId;
|
|
20
|
+
const consumers = [...streamConsumers.values()]
|
|
21
|
+
.filter((consumer) => !targetStream || consumer.streamKey === targetStream)
|
|
22
|
+
.filter((consumer) => !targetGroup || consumer.group === targetGroup)
|
|
23
|
+
.filter((consumer) => !targetNodeId || consumer.id === targetNodeId);
|
|
24
|
+
const results = await Promise.all(consumers.map((consumer) => consumer.redisStreamControl({
|
|
25
|
+
action,
|
|
26
|
+
drainTimeoutMs: command.drainTimeoutMs
|
|
27
|
+
})));
|
|
28
|
+
return {
|
|
29
|
+
action,
|
|
30
|
+
target: { stream: targetStream || null, group: targetGroup || null, nodeId: targetNodeId || null },
|
|
31
|
+
consumers: results,
|
|
32
|
+
drained: results.every((result) => result.drained)
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function tokenMatches(expected, supplied) {
|
|
37
|
+
if (!expected || !supplied) return false;
|
|
38
|
+
const expectedBuffer = Buffer.from(expected);
|
|
39
|
+
const suppliedBuffer = Buffer.from(supplied);
|
|
40
|
+
return expectedBuffer.length === suppliedBuffer.length && crypto.timingSafeEqual(expectedBuffer, suppliedBuffer);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseHttpBody(body) {
|
|
44
|
+
if (body && typeof body === 'object' && !Buffer.isBuffer(body)) return body;
|
|
45
|
+
const text = Buffer.isBuffer(body) ? body.toString('utf8') : body;
|
|
46
|
+
if (typeof text !== 'string' || text.trim() === '') return {};
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(text);
|
|
49
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error();
|
|
50
|
+
return parsed;
|
|
51
|
+
} catch (_) {
|
|
52
|
+
throw new Error('POST body must be a JSON object, e.g. {"action":"pause"}');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getEnabledControlApi(req, res) {
|
|
57
|
+
const apis = [...streamControlApis.values()];
|
|
58
|
+
if (apis.length === 0) {
|
|
59
|
+
res.status(503).json({ error: 'Redis Streams API is not enabled' });
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (apis.length > 1) {
|
|
63
|
+
res.status(409).json({ error: 'More than one Redis Streams API node is enabled in this runtime' });
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const api = apis[0];
|
|
67
|
+
const authorization = req.get('authorization') || '';
|
|
68
|
+
const bearer = authorization.startsWith('Bearer ') ? authorization.slice(7) : '';
|
|
69
|
+
const supplied = req.get('x-api-key') || bearer;
|
|
70
|
+
if (api.requireToken && !tokenMatches(api.token, supplied)) {
|
|
71
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
return api;
|
|
75
|
+
}
|
|
76
|
+
// ---------------------------------------------------------------------
|
|
77
|
+
// yroshcha-redis-config — єдина config-нода. Тримає ОДИН спільний клієнт (лінькво
|
|
78
|
+
// створюваний), яким користуються всі неблокуючі ноди, що на неї
|
|
79
|
+
// посилаються через поле "server". Для блокуючих операцій (BLPOP,
|
|
80
|
+
// SUBSCRIBE, XREADGROUP BLOCK) ноди піднімають окреме з'єднання через
|
|
81
|
+
// getDedicatedClient() — так само як задумано у node-red-contrib-redis
|
|
82
|
+
// (опція "block" = "force use new connection").
|
|
83
|
+
// ---------------------------------------------------------------------
|
|
84
|
+
function RedisConfigNode(config) {
|
|
85
|
+
RED.nodes.createNode(this, config);
|
|
86
|
+
const node = this;
|
|
87
|
+
node.closing = false;
|
|
88
|
+
|
|
89
|
+
node.host = config.host || '127.0.0.1';
|
|
90
|
+
node.port = parseInt(config.port, 10) || 6379;
|
|
91
|
+
node.db = parseInt(config.db, 10) || 0;
|
|
92
|
+
node.username = (config.username || '').trim();
|
|
93
|
+
node.tls = !!config.tls;
|
|
94
|
+
node.cluster = !!config.cluster;
|
|
95
|
+
node.connectTimeout = Math.max(1000, parseInt(config.connectTimeout, 10) || 10000);
|
|
96
|
+
node.commandTimeout = Math.max(1000, parseInt(config.commandTimeout, 10) || 30000);
|
|
97
|
+
node.retryMaxDelay = Math.max(1000, parseInt(config.retryMaxDelay, 10) || 30000);
|
|
98
|
+
|
|
99
|
+
const password = (node.credentials && node.credentials.password) || undefined;
|
|
100
|
+
|
|
101
|
+
function connectionOptions(extra) {
|
|
102
|
+
return Object.assign({
|
|
103
|
+
host: node.host,
|
|
104
|
+
port: node.port,
|
|
105
|
+
db: node.db,
|
|
106
|
+
username: node.username || undefined,
|
|
107
|
+
password,
|
|
108
|
+
tls: node.tls ? {} : undefined,
|
|
109
|
+
connectTimeout: node.connectTimeout,
|
|
110
|
+
commandTimeout: node.commandTimeout,
|
|
111
|
+
// Fail a stalled request so the node can report it and the flow can
|
|
112
|
+
// apply its own retry/DLQ policy. Infinite queued commands hide outages.
|
|
113
|
+
maxRetriesPerRequest: 3,
|
|
114
|
+
retryStrategy: (attempt) => Math.min(250 * (2 ** Math.min(attempt, 7)), node.retryMaxDelay)
|
|
115
|
+
}, extra || {});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function makeClient(extra) {
|
|
119
|
+
const options = connectionOptions(extra);
|
|
120
|
+
const client = node.cluster
|
|
121
|
+
? new Redis.Cluster([{ host: node.host, port: node.port }], {
|
|
122
|
+
redisOptions: options
|
|
123
|
+
})
|
|
124
|
+
: new Redis(options);
|
|
125
|
+
client.on('error', (err) => node.error(`Redis connection error: ${err.message}`));
|
|
126
|
+
return client;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let sharedClient = null;
|
|
130
|
+
|
|
131
|
+
// Спільний клієнт "на конфіг" — усі звичайні (неблокуючі) ноди,
|
|
132
|
+
// що використовують цю ж config-ноду, діляться одним з'єднанням.
|
|
133
|
+
node.getClient = function () {
|
|
134
|
+
if (!sharedClient) {
|
|
135
|
+
sharedClient = makeClient({ enableAutoPipelining: true });
|
|
136
|
+
}
|
|
137
|
+
return sharedClient;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Окреме з'єднання — для BLPOP/BRPOP/SUBSCRIBE/XREADGROUP BLOCK тощо.
|
|
141
|
+
// Викликач відповідає за .quit() при закритті своєї ноди.
|
|
142
|
+
node.getDedicatedClient = function (extra) {
|
|
143
|
+
return makeClient(extra);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
node.on('close', function (done) {
|
|
147
|
+
node.closing = true;
|
|
148
|
+
if (sharedClient) {
|
|
149
|
+
sharedClient.quit().then(() => done()).catch(() => done());
|
|
150
|
+
} else {
|
|
151
|
+
done();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
RED.nodes.registerType('yroshcha-redis-config', RedisConfigNode, {
|
|
157
|
+
credentials: { password: { type: 'password' } }
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
function getServer(node, config) {
|
|
161
|
+
const server = RED.nodes.getNode(config.server);
|
|
162
|
+
if (!server) {
|
|
163
|
+
node.error('Redis config (server) is not set');
|
|
164
|
+
}
|
|
165
|
+
return server;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseFlatFields(flat) {
|
|
169
|
+
const obj = {};
|
|
170
|
+
for (let i = 0; i < flat.length; i += 2) {
|
|
171
|
+
obj[flat[i]] = flat[i + 1];
|
|
172
|
+
}
|
|
173
|
+
return obj;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------
|
|
177
|
+
// yroshcha-redis-command — БУДЬ-ЯКА команда Redis через ioredis .call().
|
|
178
|
+
// "block" форсує окреме з'єднання (для BLPOP/BRPOP/WAIT тощо).
|
|
179
|
+
// ---------------------------------------------------------------------
|
|
180
|
+
function RedisCommandNode(config) {
|
|
181
|
+
RED.nodes.createNode(this, config);
|
|
182
|
+
const node = this;
|
|
183
|
+
node.server = getServer(node, config);
|
|
184
|
+
if (!node.server) return;
|
|
185
|
+
|
|
186
|
+
node.command = (config.command || '').trim();
|
|
187
|
+
node.allowMsgCommand = config.allowMsgCommand !== false;
|
|
188
|
+
node.block = !!config.block;
|
|
189
|
+
|
|
190
|
+
let dedicatedClient = null;
|
|
191
|
+
function client() {
|
|
192
|
+
if (node.block) {
|
|
193
|
+
if (!dedicatedClient) dedicatedClient = node.server.getDedicatedClient();
|
|
194
|
+
return dedicatedClient;
|
|
195
|
+
}
|
|
196
|
+
return node.server.getClient();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
node.on('input', async function (msg, send, done) {
|
|
200
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
201
|
+
try {
|
|
202
|
+
const command = (node.allowMsgCommand && msg.command) ? String(msg.command) : node.command;
|
|
203
|
+
if (!command) throw new Error('Redis command is not set (node config or msg.command)');
|
|
204
|
+
|
|
205
|
+
const args = msg.args;
|
|
206
|
+
if (args !== undefined && !Array.isArray(args)) {
|
|
207
|
+
throw new Error('msg.args must be an array of arguments, e.g. ["mykey", "myvalue"]');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Складені команди (XINFO STREAM, SCRIPT LOAD) розбиваємо на токени.
|
|
211
|
+
const tokens = command.split(/\s+/);
|
|
212
|
+
const result = await client().call(...tokens, ...(args || []));
|
|
213
|
+
|
|
214
|
+
msg.payload = result;
|
|
215
|
+
msg.command = command;
|
|
216
|
+
node.status({ fill: 'green', shape: 'dot', text: command.toUpperCase() });
|
|
217
|
+
send(msg);
|
|
218
|
+
done();
|
|
219
|
+
} catch (err) {
|
|
220
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
221
|
+
done(err);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
node.on('close', async function (done) {
|
|
226
|
+
if (dedicatedClient) {
|
|
227
|
+
try { await dedicatedClient.quit(); } catch (e) { /* ignore */ }
|
|
228
|
+
}
|
|
229
|
+
done();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
RED.nodes.registerType('yroshcha-redis-command', RedisCommandNode);
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------
|
|
236
|
+
// yroshcha-redis-subscribe — SUBSCRIBE/PSUBSCRIBE, завжди на окремому з'єднанні.
|
|
237
|
+
// ---------------------------------------------------------------------
|
|
238
|
+
function RedisSubscribeNode(config) {
|
|
239
|
+
RED.nodes.createNode(this, config);
|
|
240
|
+
const node = this;
|
|
241
|
+
node.server = getServer(node, config);
|
|
242
|
+
if (!node.server) return;
|
|
243
|
+
|
|
244
|
+
node.mode = config.mode === 'psubscribe' ? 'psubscribe' : 'subscribe';
|
|
245
|
+
node.channels = (config.channels || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
246
|
+
|
|
247
|
+
const client = node.server.getDedicatedClient();
|
|
248
|
+
let subscribed = [];
|
|
249
|
+
|
|
250
|
+
const eventName = node.mode === 'psubscribe' ? 'pmessage' : 'message';
|
|
251
|
+
client.on(eventName, (a, b, c) => {
|
|
252
|
+
const outMsg = node.mode === 'psubscribe'
|
|
253
|
+
? { pattern: a, topic: b, payload: c }
|
|
254
|
+
: { topic: a, payload: b };
|
|
255
|
+
node.send(outMsg);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
async function doSubscribe(channels) {
|
|
259
|
+
if (!channels.length) return;
|
|
260
|
+
if (node.mode === 'psubscribe') await client.psubscribe(...channels);
|
|
261
|
+
else await client.subscribe(...channels);
|
|
262
|
+
subscribed = [...new Set([...subscribed, ...channels])];
|
|
263
|
+
node.status({ fill: 'green', shape: 'dot', text: `listening (${subscribed.length})` });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function doUnsubscribe(channels) {
|
|
267
|
+
if (!channels.length) return;
|
|
268
|
+
if (node.mode === 'psubscribe') await client.punsubscribe(...channels);
|
|
269
|
+
else await client.unsubscribe(...channels);
|
|
270
|
+
subscribed = subscribed.filter((c) => !channels.includes(c));
|
|
271
|
+
node.status({ fill: 'green', shape: 'dot', text: `listening (${subscribed.length})` });
|
|
272
|
+
}
|
|
273
|
+
|
|
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
|
+
});
|
|
278
|
+
|
|
279
|
+
node.on('input', async function (msg, send, done) {
|
|
280
|
+
try {
|
|
281
|
+
if (Array.isArray(msg.subscribe) && msg.subscribe.length) await doSubscribe(msg.subscribe);
|
|
282
|
+
if (Array.isArray(msg.unsubscribe) && msg.unsubscribe.length) await doUnsubscribe(msg.unsubscribe);
|
|
283
|
+
done();
|
|
284
|
+
} catch (err) {
|
|
285
|
+
done(err);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
node.on('close', async function (done) {
|
|
290
|
+
try { await client.quit(); } catch (e) { /* ignore */ }
|
|
291
|
+
node.status({});
|
|
292
|
+
done();
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
RED.nodes.registerType('yroshcha-redis-subscribe', RedisSubscribeNode);
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------
|
|
299
|
+
// yroshcha-redis-multi — атомарна транзакція MULTI/EXEC (на спільному з'єднанні).
|
|
300
|
+
// ---------------------------------------------------------------------
|
|
301
|
+
function RedisMultiNode(config) {
|
|
302
|
+
RED.nodes.createNode(this, config);
|
|
303
|
+
const node = this;
|
|
304
|
+
node.server = getServer(node, config);
|
|
305
|
+
if (!node.server) return;
|
|
306
|
+
|
|
307
|
+
node.on('input', async function (msg, send, done) {
|
|
308
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
309
|
+
try {
|
|
310
|
+
const commands = msg.commands;
|
|
311
|
+
if (!Array.isArray(commands) || !commands.length) {
|
|
312
|
+
throw new Error('msg.commands must be a non-empty array, e.g. [["set","k","v"],["incr","c"]]');
|
|
313
|
+
}
|
|
314
|
+
for (const c of commands) {
|
|
315
|
+
if (!Array.isArray(c) || !c.length) {
|
|
316
|
+
throw new Error('Each entry in msg.commands must be an array: [command, ...args]');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const results = await node.server.getClient().multi(commands).exec();
|
|
321
|
+
const errors = results.filter(([err]) => err);
|
|
322
|
+
if (errors.length) {
|
|
323
|
+
throw new Error(`Transaction failed: ${errors.map(([err]) => err.message).join('; ')}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
msg.payload = results.map(([, result]) => result);
|
|
327
|
+
node.status({ fill: 'green', shape: 'dot', text: `exec ${commands.length} cmds` });
|
|
328
|
+
send(msg);
|
|
329
|
+
done();
|
|
330
|
+
} catch (err) {
|
|
331
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
332
|
+
done(err);
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
RED.nodes.registerType('yroshcha-redis-multi', RedisMultiNode);
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------
|
|
340
|
+
// yroshcha-redis-scan — повний курсорний обхід SCAN/HSCAN/SSCAN/ZSCAN.
|
|
341
|
+
// ---------------------------------------------------------------------
|
|
342
|
+
function RedisScanNode(config) {
|
|
343
|
+
RED.nodes.createNode(this, config);
|
|
344
|
+
const node = this;
|
|
345
|
+
node.server = getServer(node, config);
|
|
346
|
+
if (!node.server) return;
|
|
347
|
+
|
|
348
|
+
node.scanType = config.scanType || 'SCAN';
|
|
349
|
+
node.defaultCount = parseInt(config.count, 10) || 100;
|
|
350
|
+
|
|
351
|
+
node.on('input', async function (msg, send, done) {
|
|
352
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
353
|
+
try {
|
|
354
|
+
const scanType = (msg.scanType || node.scanType).toUpperCase();
|
|
355
|
+
const match = msg.match;
|
|
356
|
+
const count = msg.count !== undefined ? msg.count : node.defaultCount;
|
|
357
|
+
const client = node.server.getClient();
|
|
358
|
+
|
|
359
|
+
let key = null;
|
|
360
|
+
if (scanType !== 'SCAN') {
|
|
361
|
+
key = msg.key;
|
|
362
|
+
if (!key) throw new Error(`${scanType} requires msg.key`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const methodName = scanType.toLowerCase();
|
|
366
|
+
if (typeof client[methodName] !== 'function') {
|
|
367
|
+
throw new Error(`Unsupported scanType: ${scanType}`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let cursor = '0';
|
|
371
|
+
const collected = [];
|
|
372
|
+
do {
|
|
373
|
+
const callArgs = key ? [key, cursor] : [cursor];
|
|
374
|
+
const opts = [];
|
|
375
|
+
if (match) opts.push('MATCH', match);
|
|
376
|
+
opts.push('COUNT', count);
|
|
377
|
+
|
|
378
|
+
const [nextCursor, elements] = await client[methodName](...callArgs, ...opts);
|
|
379
|
+
cursor = nextCursor;
|
|
380
|
+
|
|
381
|
+
if (scanType === 'HSCAN' || scanType === 'ZSCAN') {
|
|
382
|
+
for (let i = 0; i < elements.length; i += 2) {
|
|
383
|
+
collected.push({ member: elements[i], value: elements[i + 1] });
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
386
|
+
collected.push(...elements);
|
|
387
|
+
}
|
|
388
|
+
} while (cursor !== '0');
|
|
389
|
+
|
|
390
|
+
msg.payload = collected;
|
|
391
|
+
node.status({ fill: 'green', shape: 'dot', text: `${collected.length} items` });
|
|
392
|
+
send(msg);
|
|
393
|
+
done();
|
|
394
|
+
} catch (err) {
|
|
395
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
396
|
+
done(err);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
RED.nodes.registerType('yroshcha-redis-scan', RedisScanNode);
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------
|
|
404
|
+
// yroshcha-redis-stream-out — XADD (спрощений шар Streams, спільне з'єднання).
|
|
405
|
+
// ---------------------------------------------------------------------
|
|
406
|
+
function RedisStreamOutNode(config) {
|
|
407
|
+
RED.nodes.createNode(this, config);
|
|
408
|
+
const node = this;
|
|
409
|
+
node.server = getServer(node, config);
|
|
410
|
+
if (!node.server) return;
|
|
411
|
+
|
|
412
|
+
node.streamKey = config.streamKey || '';
|
|
413
|
+
node.maxlen = config.maxlen ? parseInt(config.maxlen, 10) : null;
|
|
414
|
+
node.approxTrim = config.approxTrim !== false;
|
|
415
|
+
node.unsafeTrim = !!config.unsafeTrim;
|
|
416
|
+
|
|
417
|
+
node.status({ fill: 'green', shape: 'dot', text: 'ready' });
|
|
418
|
+
|
|
419
|
+
node.on('input', async function (msg, send, done) {
|
|
420
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
421
|
+
try {
|
|
422
|
+
const streamKey = msg.stream || node.streamKey;
|
|
423
|
+
if (!streamKey) throw new Error('Stream key is not set (node config or msg.stream)');
|
|
424
|
+
|
|
425
|
+
const payload = msg.payload;
|
|
426
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
|
|
427
|
+
throw new Error('msg.payload must be a flat object (field -> value) for XADD');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const fields = [];
|
|
431
|
+
for (const [k, v] of Object.entries(payload)) {
|
|
432
|
+
fields.push(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const args = [streamKey];
|
|
436
|
+
const maxlen = msg.maxlen !== undefined ? msg.maxlen : node.maxlen;
|
|
437
|
+
const unsafeTrim = msg.allowUnsafeTrim === true || node.unsafeTrim;
|
|
438
|
+
if (maxlen && !unsafeTrim) {
|
|
439
|
+
throw new Error('MAXLEN trimming is disabled by default because it can remove entries still pending in a consumer group');
|
|
440
|
+
}
|
|
441
|
+
if (maxlen) args.push('MAXLEN', node.approxTrim ? '~' : '=', String(maxlen));
|
|
442
|
+
args.push('*', ...fields);
|
|
443
|
+
|
|
444
|
+
const id = await node.server.getClient().xadd(...args);
|
|
445
|
+
|
|
446
|
+
msg.streamId = id;
|
|
447
|
+
msg.stream = streamKey;
|
|
448
|
+
node.status({ fill: 'green', shape: 'dot', text: `xadd ${id}` });
|
|
449
|
+
send(msg);
|
|
450
|
+
done();
|
|
451
|
+
} catch (err) {
|
|
452
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
453
|
+
done(err);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
RED.nodes.registerType('yroshcha-redis-stream-out', RedisStreamOutNode);
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------
|
|
461
|
+
// yroshcha-redis-stream-in — XREADGROUP consumer, завжди окреме з'єднання (BLOCK).
|
|
462
|
+
// ---------------------------------------------------------------------
|
|
463
|
+
function RedisStreamInNode(config) {
|
|
464
|
+
RED.nodes.createNode(this, config);
|
|
465
|
+
const node = this;
|
|
466
|
+
node.server = getServer(node, config);
|
|
467
|
+
if (!node.server) return;
|
|
468
|
+
|
|
469
|
+
node.streamKey = config.streamKey || '';
|
|
470
|
+
node.group = config.group || '';
|
|
471
|
+
// У контейнері process.pid майже завжди 1 (PID 1 в PID-namespace) —
|
|
472
|
+
// однаковий для всіх реплік HPA-пулу, бо flows.json деплоїться ідентично
|
|
473
|
+
// на кожен под. HOSTNAME у Kubernetes — унікальне ім'я конкретного поду,
|
|
474
|
+
// тому саме він має бути пріоритетним фолбеком, а pid — лише крайній
|
|
475
|
+
// випадок для середовищ без HOSTNAME (напр. локальний запуск поза k8s).
|
|
476
|
+
node.consumer = config.consumer
|
|
477
|
+
|| `${process.env.HOSTNAME || RED.settings.get('flowfile') || 'nr'}-${node.id}`;
|
|
478
|
+
// 100 amortises Redis/network round-trips while Max pending still limits
|
|
479
|
+
// the total work allowed into Node-RED.
|
|
480
|
+
node.count = parseInt(config.count, 10) || 100;
|
|
481
|
+
node.blockMs = parseInt(config.blockMs, 10) || 5000;
|
|
482
|
+
node.readIntervalMs = Math.max(0, parseInt(config.readIntervalMs, 10) || 0);
|
|
483
|
+
node.rateLimitPerSecond = Math.max(0, parseFloat(config.rateLimitPerSecond) || 0);
|
|
484
|
+
node.batchWindowMs = Math.max(0, parseInt(config.batchWindowMs, 10) || 0);
|
|
485
|
+
node.batchIntervalMs = Math.max(0, parseInt(config.batchIntervalMs, 10) || 0);
|
|
486
|
+
if (node.batchIntervalMs && !node.batchWindowMs) node.batchWindowMs = node.batchIntervalMs;
|
|
487
|
+
node.batchingEnabled = node.batchWindowMs > 0 || node.batchIntervalMs > 0;
|
|
488
|
+
node.autoAck = !!config.autoAck;
|
|
489
|
+
// Useful for Kubernetes rollouts: the node becomes ready and creates its
|
|
490
|
+
// consumer group, but it does not reserve work until an explicit resume.
|
|
491
|
+
node.startPaused = !!config.startPaused;
|
|
492
|
+
node.startId = config.startId || '$';
|
|
493
|
+
// A group-wide PEL limit provides a real backpressure boundary even though
|
|
494
|
+
// Node-RED does not expose downstream completion to source nodes.
|
|
495
|
+
node.maxPending = Math.max(node.count, parseInt(config.maxPending, 10) || 1000);
|
|
496
|
+
node.capacityPollMs = Math.max(50, parseInt(config.capacityPollMs, 10) || 500);
|
|
497
|
+
node.capacityCheckIntervalMs = Math.max(50, parseInt(config.capacityCheckIntervalMs, 10) || 250);
|
|
498
|
+
// A message that continually fails business processing must not pin the PEL
|
|
499
|
+
// forever. Disabled only when an operator explicitly sets 0.
|
|
500
|
+
node.maxDeliveries = Math.max(0, parseInt(config.maxDeliveries, 10) || 5);
|
|
501
|
+
node.deadLetterStream = config.deadLetterStream || `${node.streamKey}:dlq`;
|
|
502
|
+
node.autoClaim = config.autoClaim !== false;
|
|
503
|
+
node.reclaimIdleMs = Math.max(1000, parseInt(config.reclaimIdleMs, 10) || 60000);
|
|
504
|
+
node.reclaimIntervalMs = Math.max(1000, parseInt(config.reclaimIntervalMs, 10) || 30000);
|
|
505
|
+
node.reclaimCount = Math.max(1, parseInt(config.reclaimCount, 10) || node.count);
|
|
506
|
+
node.drainCheckIntervalMs = Math.max(50, parseInt(config.drainCheckIntervalMs, 10) || 250);
|
|
507
|
+
|
|
508
|
+
// Backoff/jitter при помилках циклу — щоб при масовому падінні Redis
|
|
509
|
+
// (рестарт кластера, failover) весь HPA-пул не долбив reconnect
|
|
510
|
+
// синхронно одним і тим же інтервалом ("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;
|
|
514
|
+
|
|
515
|
+
// PEL-alert: періодична неблокуюча перевірка розміру pending list через
|
|
516
|
+
// XPENDING на СПІЛЬНОМУ з'єднанні конфігу (не на blockingClient, щоб не
|
|
517
|
+
// заважати BLOCK-циклу). 0 = вимкнено (за замовчуванням).
|
|
518
|
+
node.pelAlertThreshold = parseInt(config.pelAlertThreshold, 10) || 0;
|
|
519
|
+
node.pelCheckIntervalMs = parseInt(config.pelCheckIntervalMs, 10) || 30000;
|
|
520
|
+
|
|
521
|
+
if (!node.streamKey || !node.group) {
|
|
522
|
+
node.error('Stream key and consumer group are required');
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
let stopped = false;
|
|
527
|
+
let paused = node.startPaused;
|
|
528
|
+
let blockingClient = null;
|
|
529
|
+
let recoveringPending = true;
|
|
530
|
+
let currentBackoffMs = node.initialBackoffMs;
|
|
531
|
+
let lastPelCheckAt = 0;
|
|
532
|
+
let lastCapacityCheckAt = 0;
|
|
533
|
+
let lastKnownPending = 0;
|
|
534
|
+
let pelWarningActive = false;
|
|
535
|
+
let lastReclaimAt = 0;
|
|
536
|
+
let reclaimCursor = '0';
|
|
537
|
+
let rateTokens = node.rateLimitPerSecond;
|
|
538
|
+
let rateLastRefillAt = Date.now();
|
|
539
|
+
let batchTimer = null;
|
|
540
|
+
let batchFlushPromise = null;
|
|
541
|
+
let lastBatchSentAt = 0;
|
|
542
|
+
let lastReadAt = 0;
|
|
543
|
+
const batchBuffer = [];
|
|
544
|
+
|
|
545
|
+
node.status({ fill: 'yellow', shape: 'ring', text: 'starting' });
|
|
546
|
+
|
|
547
|
+
async function ensureGroup(client) {
|
|
548
|
+
try {
|
|
549
|
+
await client.xgroup('CREATE', node.streamKey, node.group, node.startId, 'MKSTREAM');
|
|
550
|
+
} catch (err) {
|
|
551
|
+
if (!String(err.message).includes('BUSYGROUP')) throw err;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function waitForReadInterval() {
|
|
556
|
+
if (!node.readIntervalMs || !lastReadAt) return true;
|
|
557
|
+
const waitMs = node.readIntervalMs - (Date.now() - lastReadAt);
|
|
558
|
+
if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
559
|
+
return !stopped && !paused;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function waitForDeliverySlot() {
|
|
563
|
+
if (!node.rateLimitPerSecond) return true;
|
|
564
|
+
while (!stopped && !paused) {
|
|
565
|
+
const now = Date.now();
|
|
566
|
+
const elapsedMs = now - rateLastRefillAt;
|
|
567
|
+
rateLastRefillAt = now;
|
|
568
|
+
rateTokens = Math.min(
|
|
569
|
+
node.rateLimitPerSecond,
|
|
570
|
+
rateTokens + (elapsedMs * node.rateLimitPerSecond / 1000)
|
|
571
|
+
);
|
|
572
|
+
if (rateTokens >= 1) {
|
|
573
|
+
rateTokens -= 1;
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
const waitMs = Math.max(1, Math.ceil((1 - rateTokens) * 1000 / node.rateLimitPerSecond));
|
|
577
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
578
|
+
}
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function sendEntries(entries) {
|
|
583
|
+
const batched = node.batchingEnabled;
|
|
584
|
+
const ids = entries.map((entry) => entry.id);
|
|
585
|
+
const msg = {
|
|
586
|
+
payload: batched ? entries.map((entry) => entry.payload) : entries[0].payload,
|
|
587
|
+
streamId: batched ? ids : ids[0],
|
|
588
|
+
stream: node.streamKey,
|
|
589
|
+
group: node.group,
|
|
590
|
+
_streamKey: node.streamKey,
|
|
591
|
+
_streamGroup: node.group,
|
|
592
|
+
consumer: node.consumer
|
|
593
|
+
};
|
|
594
|
+
node.send(msg);
|
|
595
|
+
if (node.autoAck) await node.server.getClient().xack(node.streamKey, node.group, ...ids);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function flushBatch(force) {
|
|
599
|
+
if (batchFlushPromise) return batchFlushPromise;
|
|
600
|
+
batchFlushPromise = (async () => {
|
|
601
|
+
if (!batchBuffer.length || (paused && !force)) return false;
|
|
602
|
+
if (!force && node.batchIntervalMs && lastBatchSentAt) {
|
|
603
|
+
const waitMs = node.batchIntervalMs - (Date.now() - lastBatchSentAt);
|
|
604
|
+
if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
605
|
+
if (stopped || paused) return false;
|
|
606
|
+
}
|
|
607
|
+
if (batchTimer) {
|
|
608
|
+
clearTimeout(batchTimer);
|
|
609
|
+
batchTimer = null;
|
|
610
|
+
}
|
|
611
|
+
const entries = batchBuffer.splice(0, batchBuffer.length);
|
|
612
|
+
await sendEntries(entries);
|
|
613
|
+
lastBatchSentAt = Date.now();
|
|
614
|
+
return true;
|
|
615
|
+
})();
|
|
616
|
+
try {
|
|
617
|
+
return await batchFlushPromise;
|
|
618
|
+
} finally {
|
|
619
|
+
batchFlushPromise = null;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function scheduleBatchFlush() {
|
|
624
|
+
if (!node.batchWindowMs || batchTimer || !batchBuffer.length) return;
|
|
625
|
+
batchTimer = setTimeout(() => {
|
|
626
|
+
batchTimer = null;
|
|
627
|
+
flushBatch(false).catch((err) => {
|
|
628
|
+
if (!stopped) {
|
|
629
|
+
node.status({ fill: 'red', shape: 'ring', text: 'batch error' });
|
|
630
|
+
node.error(`Redis stream batch flush error: ${err.message}`);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
}, node.batchWindowMs);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function checkPelAlert() {
|
|
637
|
+
if (!node.pelAlertThreshold) return;
|
|
638
|
+
const now = Date.now();
|
|
639
|
+
if (now - lastPelCheckAt < node.pelCheckIntervalMs) return;
|
|
640
|
+
lastPelCheckAt = now;
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
// XPENDING <key> <group> без діапазону повертає лише summary:
|
|
644
|
+
// [totalPending, minId, maxId, [[consumer, count], ...]]
|
|
645
|
+
const summary = await node.server.getClient().xpending(node.streamKey, node.group);
|
|
646
|
+
const totalPending = (summary && summary[0]) || 0;
|
|
647
|
+
|
|
648
|
+
if (totalPending > node.pelAlertThreshold) {
|
|
649
|
+
pelWarningActive = true;
|
|
650
|
+
node.warn(
|
|
651
|
+
`PEL "${node.group}"@"${node.streamKey}": ${totalPending} pending ` +
|
|
652
|
+
`(threshold ${node.pelAlertThreshold}) — consumer group is falling behind`
|
|
653
|
+
);
|
|
654
|
+
node.status({ fill: 'yellow', shape: 'dot', text: `listening (PEL ${totalPending} ⚠)` });
|
|
655
|
+
} else if (pelWarningActive) {
|
|
656
|
+
pelWarningActive = false;
|
|
657
|
+
node.status({ fill: 'green', shape: 'dot', text: 'listening' });
|
|
658
|
+
}
|
|
659
|
+
} catch (err) {
|
|
660
|
+
// Перевірка PEL — best-effort, не повинна ронити основний цикл читання.
|
|
661
|
+
node.warn(`PEL-check failed: ${err.message}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function waitForCapacity() {
|
|
666
|
+
// Auto ACK is intentionally not treated as reliable delivery. It has no
|
|
667
|
+
// PEL to gate on, so surface that fact instead of pretending it is safe.
|
|
668
|
+
if (node.autoAck) return true;
|
|
669
|
+
const now = Date.now();
|
|
670
|
+
if (now - lastCapacityCheckAt >= node.capacityCheckIntervalMs) {
|
|
671
|
+
const summary = await node.server.getClient().xpending(node.streamKey, node.group);
|
|
672
|
+
lastKnownPending = Number(summary && summary[0]) || 0;
|
|
673
|
+
lastCapacityCheckAt = now;
|
|
674
|
+
}
|
|
675
|
+
const pending = lastKnownPending;
|
|
676
|
+
if (pending < node.maxPending) return true;
|
|
677
|
+
|
|
678
|
+
node.status({ fill: 'yellow', shape: 'ring', text: `backpressure (PEL ${pending})` });
|
|
679
|
+
await new Promise((resolve) => setTimeout(resolve, node.capacityPollMs));
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function shouldDeadLetter(id, payload) {
|
|
684
|
+
if (!node.maxDeliveries) return false;
|
|
685
|
+
// XPENDING range reply: [id, consumer, idleMs, deliveries]. Querying one
|
|
686
|
+
// ID makes the decision deterministic and avoids an unbounded PEL scan.
|
|
687
|
+
const pending = await node.server.getClient().xpending(
|
|
688
|
+
node.streamKey, node.group, id, id, 1, node.consumer
|
|
689
|
+
);
|
|
690
|
+
const deliveryCount = Number(pending && pending[0] && pending[0][3]) || 0;
|
|
691
|
+
if (deliveryCount <= node.maxDeliveries) return false;
|
|
692
|
+
|
|
693
|
+
const dlqFields = [
|
|
694
|
+
'sourceStream', node.streamKey,
|
|
695
|
+
'sourceGroup', node.group,
|
|
696
|
+
'sourceId', id,
|
|
697
|
+
'consumer', node.consumer,
|
|
698
|
+
'deliveries', String(deliveryCount),
|
|
699
|
+
'payload', JSON.stringify(payload)
|
|
700
|
+
];
|
|
701
|
+
// XADD + XACK must happen atomically. In Redis Cluster the source and
|
|
702
|
+
// DLQ streams therefore need the same hash tag, e.g. orders:{eu}:dlq.
|
|
703
|
+
const result = await node.server.getClient().multi()
|
|
704
|
+
.xadd(node.deadLetterStream, '*', ...dlqFields)
|
|
705
|
+
.xack(node.streamKey, node.group, id)
|
|
706
|
+
.exec();
|
|
707
|
+
if (result.some(([err]) => err)) {
|
|
708
|
+
throw new Error(`DLQ transaction failed for ${id}`);
|
|
709
|
+
}
|
|
710
|
+
node.warn(`Moved stream entry ${id} to DLQ after ${deliveryCount} deliveries`);
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
async function deliverEntry(id, flat, checkDeliveryCount) {
|
|
715
|
+
// A BLOCK request may complete exactly while a pause command is being
|
|
716
|
+
// handled. Do not leak that already-reserved entry downstream; it stays
|
|
717
|
+
// in this consumer's PEL and will be read with ID 0 after resume.
|
|
718
|
+
if (paused) return false;
|
|
719
|
+
if (!Array.isArray(flat)) {
|
|
720
|
+
throw new Error(`Stream entry ${id} no longer has a payload; do not trim streams below the PEL horizon`);
|
|
721
|
+
}
|
|
722
|
+
const payload = parseFlatFields(flat);
|
|
723
|
+
if (checkDeliveryCount && await shouldDeadLetter(id, payload)) return;
|
|
724
|
+
if (!await waitForDeliverySlot() || paused) return false;
|
|
725
|
+
|
|
726
|
+
if (node.batchingEnabled) {
|
|
727
|
+
batchBuffer.push({ id, payload });
|
|
728
|
+
if (batchBuffer.length >= node.count) await flushBatch(false);
|
|
729
|
+
else scheduleBatchFlush();
|
|
730
|
+
} else {
|
|
731
|
+
await sendEntries([{ id, payload }]);
|
|
732
|
+
}
|
|
733
|
+
return true;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function reclaimAbandoned() {
|
|
737
|
+
// First drain this consumer's own PEL via XREADGROUP 0. Claiming before
|
|
738
|
+
// that would make the same entries appear twice in one startup cycle.
|
|
739
|
+
if (!node.autoClaim || recoveringPending) return;
|
|
740
|
+
const now = Date.now();
|
|
741
|
+
if (now - lastReclaimAt < node.reclaimIntervalMs) return;
|
|
742
|
+
lastReclaimAt = now;
|
|
743
|
+
|
|
744
|
+
const res = await node.server.getClient().xautoclaim(
|
|
745
|
+
node.streamKey, node.group, node.consumer, node.reclaimIdleMs,
|
|
746
|
+
reclaimCursor, 'COUNT', node.reclaimCount
|
|
747
|
+
);
|
|
748
|
+
const [nextCursor, entries] = res;
|
|
749
|
+
reclaimCursor = nextCursor || '0';
|
|
750
|
+
for (const [id, flat] of entries || []) {
|
|
751
|
+
if (paused) break;
|
|
752
|
+
await deliverEntry(id, flat, true);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async function pendingForThisConsumer() {
|
|
757
|
+
const summary = await node.server.getClient().xpending(node.streamKey, node.group);
|
|
758
|
+
const consumers = (summary && summary[3]) || [];
|
|
759
|
+
const own = consumers.find(([name]) => name === node.consumer);
|
|
760
|
+
return own ? Number(own[1]) || 0 : 0;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async function control(controlMsg) {
|
|
764
|
+
const action = String(controlMsg.action || (controlMsg.payload && controlMsg.payload.action) || '').toLowerCase();
|
|
765
|
+
if (!['pause', 'resume', 'drain', 'status'].includes(action)) {
|
|
766
|
+
throw new Error('Control msg.action must be pause, resume, drain or status');
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (action === 'pause' || action === 'drain') {
|
|
770
|
+
paused = true;
|
|
771
|
+
// An in-flight XREADGROUP can have reserved entries after startup
|
|
772
|
+
// recovery has finished. Resume must read this consumer's PEL first.
|
|
773
|
+
recoveringPending = true;
|
|
774
|
+
}
|
|
775
|
+
if (action === 'drain') await flushBatch(true);
|
|
776
|
+
if (action === 'resume') {
|
|
777
|
+
paused = false;
|
|
778
|
+
await flushBatch(false);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
let pending = await pendingForThisConsumer();
|
|
782
|
+
let drained = pending === 0;
|
|
783
|
+
if (action === 'drain' && !drained) {
|
|
784
|
+
const timeoutMs = Math.max(1000, parseInt(controlMsg.drainTimeoutMs, 10) || 30000);
|
|
785
|
+
const deadline = Date.now() + timeoutMs;
|
|
786
|
+
while (!stopped && pending > 0 && Date.now() < deadline) {
|
|
787
|
+
await new Promise((resolve) => setTimeout(resolve, node.drainCheckIntervalMs));
|
|
788
|
+
pending = await pendingForThisConsumer();
|
|
789
|
+
}
|
|
790
|
+
drained = pending === 0;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
node.status({
|
|
794
|
+
fill: paused ? 'yellow' : 'green',
|
|
795
|
+
shape: paused ? 'ring' : 'dot',
|
|
796
|
+
text: paused ? (drained ? 'paused (drained)' : `paused (PEL ${pending})`) : 'listening'
|
|
797
|
+
});
|
|
798
|
+
return { nodeId: node.id, stream: node.streamKey, group: node.group, action, paused, drained, pending, consumer: node.consumer };
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
node.redisStreamControl = control;
|
|
802
|
+
streamConsumers.set(node.id, node);
|
|
803
|
+
|
|
804
|
+
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);
|
|
817
|
+
node.status({
|
|
818
|
+
fill: paused ? 'yellow' : 'green',
|
|
819
|
+
shape: paused ? 'ring' : 'dot',
|
|
820
|
+
text: paused ? 'paused (startup)' : 'listening'
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
while (!stopped) {
|
|
824
|
+
try {
|
|
825
|
+
if (paused) {
|
|
826
|
+
await new Promise((resolve) => setTimeout(resolve, node.drainCheckIntervalMs));
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
await checkPelAlert();
|
|
830
|
+
await reclaimAbandoned();
|
|
831
|
+
// Pending recovery is deliberately allowed through even when the PEL
|
|
832
|
+
// is full; otherwise an already-full PEL could never drain.
|
|
833
|
+
if (!recoveringPending && !await waitForCapacity()) continue;
|
|
834
|
+
if (!await waitForReadInterval()) continue;
|
|
835
|
+
|
|
836
|
+
const readId = recoveringPending ? '0' : '>';
|
|
837
|
+
lastReadAt = Date.now();
|
|
838
|
+
const res = await blockingClient.xreadgroup(
|
|
839
|
+
'GROUP', node.group, node.consumer,
|
|
840
|
+
'COUNT', node.count,
|
|
841
|
+
'BLOCK', node.blockMs,
|
|
842
|
+
'STREAMS', node.streamKey, readId
|
|
843
|
+
);
|
|
844
|
+
|
|
845
|
+
// Успішний виклик (навіть без нових даних) — скидаємо backoff.
|
|
846
|
+
currentBackoffMs = node.initialBackoffMs;
|
|
847
|
+
|
|
848
|
+
if (!res) {
|
|
849
|
+
if (recoveringPending) recoveringPending = false;
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const [, entries] = res[0];
|
|
854
|
+
if (recoveringPending && entries.length === 0) {
|
|
855
|
+
recoveringPending = false;
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// `pause` can arrive while the blocking XREADGROUP is awaiting
|
|
860
|
+
// Redis. The reply is already in this consumer's PEL, but it must
|
|
861
|
+
// not enter business logic after the pause acknowledgement.
|
|
862
|
+
if (paused) {
|
|
863
|
+
recoveringPending = true;
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
for (const [id, flat] of entries) {
|
|
868
|
+
if (paused) {
|
|
869
|
+
recoveringPending = true;
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
// New `>` entries have one delivery. Pending recovery can cross the
|
|
873
|
+
// DLQ threshold, therefore only it needs the XPENDING check.
|
|
874
|
+
await deliverEntry(id, flat, recoveringPending);
|
|
875
|
+
}
|
|
876
|
+
} catch (err) {
|
|
877
|
+
if (stopped) break;
|
|
878
|
+
|
|
879
|
+
const jitter = Math.random() * currentBackoffMs * 0.3; // до +30%
|
|
880
|
+
const delay = Math.min(currentBackoffMs, node.maxBackoffMs) + jitter;
|
|
881
|
+
|
|
882
|
+
node.status({ fill: 'red', shape: 'ring', text: `retry in ${Math.round(delay)}ms` });
|
|
883
|
+
node.error(`Redis stream-in loop error: ${err.message}`);
|
|
884
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
885
|
+
|
|
886
|
+
currentBackoffMs = Math.min(currentBackoffMs * node.backoffMultiplier, node.maxBackoffMs);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
loop().catch((err) => {
|
|
892
|
+
node.status({ fill: 'red', shape: 'ring', text: 'failed to start' });
|
|
893
|
+
node.error(`Redis stream-in failed to start: ${err.message}`);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
node.on('close', async function (done) {
|
|
897
|
+
stopped = true;
|
|
898
|
+
streamConsumers.delete(node.id);
|
|
899
|
+
if (batchTimer) clearTimeout(batchTimer);
|
|
900
|
+
if (blockingClient) {
|
|
901
|
+
try { blockingClient.disconnect(false); } catch (e) { /* ignore */ }
|
|
902
|
+
}
|
|
903
|
+
node.status({});
|
|
904
|
+
done();
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
RED.nodes.registerType('yroshcha-redis-stream-in', RedisStreamInNode);
|
|
909
|
+
|
|
910
|
+
// ---------------------------------------------------------------------
|
|
911
|
+
// yroshcha-redis-stream-ack — XACK (спільне з'єднання).
|
|
912
|
+
// ---------------------------------------------------------------------
|
|
913
|
+
function RedisStreamAckNode(config) {
|
|
914
|
+
RED.nodes.createNode(this, config);
|
|
915
|
+
const node = this;
|
|
916
|
+
node.server = getServer(node, config);
|
|
917
|
+
if (!node.server) return;
|
|
918
|
+
|
|
919
|
+
node.streamKey = config.streamKey || '';
|
|
920
|
+
node.group = config.group || '';
|
|
921
|
+
node.deleteAfterAck = !!config.deleteAfterAck;
|
|
922
|
+
let closing = false;
|
|
923
|
+
|
|
924
|
+
node.on('input', async function (msg, send, done) {
|
|
925
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
926
|
+
try {
|
|
927
|
+
const streamKey = msg.stream || msg._streamKey || node.streamKey;
|
|
928
|
+
const group = msg.group || msg._streamGroup || node.group;
|
|
929
|
+
if (!streamKey || !group) throw new Error('Stream key and group are required');
|
|
930
|
+
|
|
931
|
+
const ids = Array.isArray(msg.streamId) ? msg.streamId : [msg.streamId];
|
|
932
|
+
if (!ids.length || !ids[0]) throw new Error('msg.streamId is required');
|
|
933
|
+
|
|
934
|
+
const acked = await node.server.getClient().xack(streamKey, group, ...ids);
|
|
935
|
+
msg.acked = acked;
|
|
936
|
+
// A flow can choose retention per event without changing the node
|
|
937
|
+
// configuration: msg.deleteAfterAck overrides the editor default.
|
|
938
|
+
const deleteAfterAck = msg.deleteAfterAck === undefined
|
|
939
|
+
? node.deleteAfterAck
|
|
940
|
+
: msg.deleteAfterAck === true;
|
|
941
|
+
if (deleteAfterAck && acked > 0) {
|
|
942
|
+
// XDEL is intentionally after XACK: a failed deletion leaves an
|
|
943
|
+
// auditable entry behind instead of risking an unacknowledged loss.
|
|
944
|
+
msg.deleted = await node.server.getClient().xdel(streamKey, ...ids);
|
|
945
|
+
}
|
|
946
|
+
node.status({
|
|
947
|
+
fill: 'green',
|
|
948
|
+
shape: 'dot',
|
|
949
|
+
text: deleteAfterAck ? `acked ${acked}, deleted ${msg.deleted}` : `acked ${acked}`
|
|
950
|
+
});
|
|
951
|
+
send(msg);
|
|
952
|
+
done();
|
|
953
|
+
} catch (err) {
|
|
954
|
+
// Node-RED may close the shared config connection before this node's
|
|
955
|
+
// final ACK finishes during a redeploy/restart. Leave the entry in PEL
|
|
956
|
+
// for recovery, without reporting an expected shutdown race as an error.
|
|
957
|
+
if (closing || node.server.closing) {
|
|
958
|
+
node.status({});
|
|
959
|
+
done();
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
963
|
+
done(err);
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
node.on('close', function (done) {
|
|
968
|
+
closing = true;
|
|
969
|
+
done();
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
RED.nodes.registerType('yroshcha-redis-stream-ack', RedisStreamAckNode);
|
|
974
|
+
|
|
975
|
+
// ---------------------------------------------------------------------
|
|
976
|
+
// yroshcha-redis-stream-claim — XAUTOCLAIM, повний прохід по курсору за виклик.
|
|
977
|
+
// ---------------------------------------------------------------------
|
|
978
|
+
function RedisStreamClaimNode(config) {
|
|
979
|
+
RED.nodes.createNode(this, config);
|
|
980
|
+
const node = this;
|
|
981
|
+
node.server = getServer(node, config);
|
|
982
|
+
if (!node.server) return;
|
|
983
|
+
|
|
984
|
+
node.streamKey = config.streamKey || '';
|
|
985
|
+
node.group = config.group || '';
|
|
986
|
+
node.consumer = config.consumer || '';
|
|
987
|
+
node.minIdleMs = parseInt(config.minIdleMs, 10) || 60000;
|
|
988
|
+
node.count = parseInt(config.count, 10) || 100;
|
|
989
|
+
// Never materialise an arbitrarily large PEL in one Node-RED message.
|
|
990
|
+
node.maxMessages = Math.max(node.count, parseInt(config.maxMessages, 10) || 1000);
|
|
991
|
+
|
|
992
|
+
node.on('input', async function (msg, send, done) {
|
|
993
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
994
|
+
try {
|
|
995
|
+
const streamKey = msg.stream || node.streamKey;
|
|
996
|
+
const group = msg.group || node.group;
|
|
997
|
+
// Той самий принцип, що й у stream-in: якщо consumer не заданий явно
|
|
998
|
+
// ні в конфігурації ноди, ні через msg — падаємо на HOSTNAME поду,
|
|
999
|
+
// а не залишаємо порожнім (інакше кожен под HPA-пулу впаде в error,
|
|
1000
|
+
// якщо оператор забув заповнити поле вручну).
|
|
1001
|
+
const consumer = msg.consumer || node.consumer
|
|
1002
|
+
|| `${process.env.HOSTNAME || RED.settings.get('flowfile') || 'nr'}-${node.id}`;
|
|
1003
|
+
const minIdleMs = msg.minIdleMs !== undefined ? msg.minIdleMs : node.minIdleMs;
|
|
1004
|
+
|
|
1005
|
+
if (!streamKey || !group || !consumer) {
|
|
1006
|
+
throw new Error('Stream key, group and consumer are required');
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const client = node.server.getClient();
|
|
1010
|
+
let cursor = '0';
|
|
1011
|
+
const claimed = [];
|
|
1012
|
+
|
|
1013
|
+
do {
|
|
1014
|
+
const remaining = node.maxMessages - claimed.length;
|
|
1015
|
+
const res = await client.xautoclaim(
|
|
1016
|
+
streamKey, group, consumer, minIdleMs, cursor,
|
|
1017
|
+
'COUNT', Math.min(node.count, remaining)
|
|
1018
|
+
);
|
|
1019
|
+
const [nextCursor, entries] = res;
|
|
1020
|
+
cursor = nextCursor;
|
|
1021
|
+
for (const [id, flat] of entries || []) {
|
|
1022
|
+
claimed.push({ streamId: id, payload: parseFlatFields(flat) });
|
|
1023
|
+
}
|
|
1024
|
+
} while (cursor !== '0' && claimed.length < node.maxMessages);
|
|
1025
|
+
|
|
1026
|
+
msg.payload = claimed;
|
|
1027
|
+
msg.stream = streamKey;
|
|
1028
|
+
msg.group = group;
|
|
1029
|
+
msg.consumer = consumer;
|
|
1030
|
+
node.status({ fill: 'green', shape: 'dot', text: `claimed ${claimed.length}${cursor !== '0' ? '+' : ''}` });
|
|
1031
|
+
send(msg);
|
|
1032
|
+
done();
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
1035
|
+
done(err);
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
RED.nodes.registerType('yroshcha-redis-stream-claim', RedisStreamClaimNode);
|
|
1041
|
+
|
|
1042
|
+
// ---------------------------------------------------------------------
|
|
1043
|
+
// yroshcha-redis-stream-gc — прибирання "мертвих" consumer-записів.
|
|
1044
|
+
// На HPA-пулі кожен рестарт поду створює нового consumer (HOSTNAME
|
|
1045
|
+
// змінюється), старі лишаються в XINFO CONSUMERS назавжди, доки їх
|
|
1046
|
+
// не видалити явно через XGROUP DELCONSUMER. Видаляємо лише consumer-ів
|
|
1047
|
+
// з pending=0 (без ризику загубити необроблені записи) і idle довше
|
|
1048
|
+
// заданого порогу (щоб не зачепити щойно перезапущений, ще активний под).
|
|
1049
|
+
// ---------------------------------------------------------------------
|
|
1050
|
+
function RedisStreamGcNode(config) {
|
|
1051
|
+
RED.nodes.createNode(this, config);
|
|
1052
|
+
const node = this;
|
|
1053
|
+
node.server = getServer(node, config);
|
|
1054
|
+
if (!node.server) return;
|
|
1055
|
+
|
|
1056
|
+
node.streamKey = config.streamKey || '';
|
|
1057
|
+
node.group = config.group || '';
|
|
1058
|
+
node.minIdleMs = parseInt(config.minIdleMs, 10) || 600000; // 10 хв за замовчуванням
|
|
1059
|
+
|
|
1060
|
+
node.on('input', async function (msg, send, done) {
|
|
1061
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
1062
|
+
try {
|
|
1063
|
+
const streamKey = msg.stream || node.streamKey;
|
|
1064
|
+
const group = msg.group || node.group;
|
|
1065
|
+
const minIdleMs = msg.minIdleMs !== undefined ? msg.minIdleMs : node.minIdleMs;
|
|
1066
|
+
|
|
1067
|
+
if (!streamKey || !group) throw new Error('Stream key and group are required');
|
|
1068
|
+
|
|
1069
|
+
const client = node.server.getClient();
|
|
1070
|
+
// XINFO CONSUMERS повертає масив "флет"-масивів на кожного consumer:
|
|
1071
|
+
// ['name', <name>, 'pending', <n>, 'idle', <ms>, 'inactive', <ms>]
|
|
1072
|
+
const consumersRaw = await client.call('XINFO', 'CONSUMERS', streamKey, group);
|
|
1073
|
+
|
|
1074
|
+
const removed = [];
|
|
1075
|
+
const kept = [];
|
|
1076
|
+
|
|
1077
|
+
for (const flat of consumersRaw) {
|
|
1078
|
+
const info = parseFlatFields(flat);
|
|
1079
|
+
const pending = parseInt(info.pending, 10) || 0;
|
|
1080
|
+
const idle = parseInt(info.idle, 10) || 0;
|
|
1081
|
+
|
|
1082
|
+
if (pending === 0 && idle >= minIdleMs) {
|
|
1083
|
+
await client.call('XGROUP', 'DELCONSUMER', streamKey, group, info.name);
|
|
1084
|
+
removed.push({ name: info.name, idleMs: idle });
|
|
1085
|
+
} else {
|
|
1086
|
+
kept.push({ name: info.name, pending, idleMs: idle });
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
msg.payload = { removed, kept };
|
|
1091
|
+
msg.stream = streamKey;
|
|
1092
|
+
msg.group = group;
|
|
1093
|
+
node.status({ fill: 'green', shape: 'dot', text: `removed ${removed.length}, kept ${kept.length}` });
|
|
1094
|
+
send(msg);
|
|
1095
|
+
done();
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
node.status({ fill: 'red', shape: 'ring', text: 'error' });
|
|
1098
|
+
done(err);
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
RED.nodes.registerType('yroshcha-redis-stream-gc', RedisStreamGcNode);
|
|
1104
|
+
|
|
1105
|
+
// ---------------------------------------------------------------------
|
|
1106
|
+
// yroshcha-redis-stream-metrics — lightweight observability snapshot.
|
|
1107
|
+
// Trigger from Inject/scheduler and forward msg.payload to the monitoring
|
|
1108
|
+
// flow of choice (Prometheus, OpenTelemetry, HTTP, etc.).
|
|
1109
|
+
// ---------------------------------------------------------------------
|
|
1110
|
+
function RedisStreamMetricsNode(config) {
|
|
1111
|
+
RED.nodes.createNode(this, config);
|
|
1112
|
+
const node = this;
|
|
1113
|
+
node.server = getServer(node, config);
|
|
1114
|
+
if (!node.server) return;
|
|
1115
|
+
|
|
1116
|
+
node.streamKey = config.streamKey || '';
|
|
1117
|
+
node.group = config.group || '';
|
|
1118
|
+
node.deadLetterStream = config.deadLetterStream || '';
|
|
1119
|
+
|
|
1120
|
+
node.on('input', async function (msg, send, done) {
|
|
1121
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
1122
|
+
try {
|
|
1123
|
+
const streamKey = msg.stream || node.streamKey;
|
|
1124
|
+
const group = msg.group || node.group;
|
|
1125
|
+
const deadLetterStream = msg.deadLetterStream || node.deadLetterStream;
|
|
1126
|
+
if (!streamKey || !group) throw new Error('Stream key and group are required');
|
|
1127
|
+
|
|
1128
|
+
const client = node.server.getClient();
|
|
1129
|
+
const groups = await client.call('XINFO', 'GROUPS', streamKey);
|
|
1130
|
+
const groupInfo = (groups || []).map(parseFlatFields).find((info) => info.name === group);
|
|
1131
|
+
if (!groupInfo) throw new Error(`Consumer group "${group}" does not exist`);
|
|
1132
|
+
const pendingSummary = await client.xpending(streamKey, group);
|
|
1133
|
+
const pending = Number(pendingSummary && pendingSummary[0]) || 0;
|
|
1134
|
+
const deadLetterLength = deadLetterStream
|
|
1135
|
+
? Number(await client.call('XLEN', deadLetterStream)) || 0
|
|
1136
|
+
: null;
|
|
1137
|
+
|
|
1138
|
+
msg.payload = {
|
|
1139
|
+
stream: streamKey,
|
|
1140
|
+
group,
|
|
1141
|
+
pending,
|
|
1142
|
+
deadLetterStream: deadLetterStream || null,
|
|
1143
|
+
deadLetterLength,
|
|
1144
|
+
consumers: Number(groupInfo.consumers) || 0,
|
|
1145
|
+
lag: groupInfo.lag === undefined || groupInfo.lag === null ? null : Number(groupInfo.lag),
|
|
1146
|
+
entriesRead: groupInfo['entries-read'] === undefined ? null : Number(groupInfo['entries-read']),
|
|
1147
|
+
lastDeliveredId: groupInfo['last-delivered-id'] || null,
|
|
1148
|
+
sampledAt: new Date().toISOString()
|
|
1149
|
+
};
|
|
1150
|
+
node.status({
|
|
1151
|
+
fill: deadLetterLength ? 'red' : (pending ? 'yellow' : 'green'),
|
|
1152
|
+
shape: 'dot',
|
|
1153
|
+
text: deadLetterLength ? `DLQ ${deadLetterLength}` : `PEL ${pending}`
|
|
1154
|
+
});
|
|
1155
|
+
send(msg);
|
|
1156
|
+
done();
|
|
1157
|
+
} catch (err) {
|
|
1158
|
+
node.status({ fill: 'red', shape: 'ring', text: 'metrics error' });
|
|
1159
|
+
done(err);
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
RED.nodes.registerType('yroshcha-redis-stream-metrics', RedisStreamMetricsNode);
|
|
1165
|
+
|
|
1166
|
+
// ---------------------------------------------------------------------
|
|
1167
|
+
// yroshcha-redis-stream-control — control every stream consumer in this
|
|
1168
|
+
// Node-RED runtime. Use one HTTP endpoint per replica during scale-down.
|
|
1169
|
+
// ---------------------------------------------------------------------
|
|
1170
|
+
function RedisStreamControlNode(config) {
|
|
1171
|
+
RED.nodes.createNode(this, config);
|
|
1172
|
+
const node = this;
|
|
1173
|
+
|
|
1174
|
+
node.on('input', async function (msg, send, done) {
|
|
1175
|
+
send = send || function () { node.send.apply(node, arguments); };
|
|
1176
|
+
try {
|
|
1177
|
+
const body = msg.payload && typeof msg.payload === 'object' ? msg.payload : {};
|
|
1178
|
+
const result = await executeStreamControl({
|
|
1179
|
+
action: msg.action || body.action,
|
|
1180
|
+
stream: msg.stream || body.stream,
|
|
1181
|
+
group: msg.group || body.group,
|
|
1182
|
+
nodeId: msg.nodeId || body.nodeId,
|
|
1183
|
+
drainTimeoutMs: msg.drainTimeoutMs || body.drainTimeoutMs
|
|
1184
|
+
});
|
|
1185
|
+
msg.payload = result;
|
|
1186
|
+
node.status({ fill: result.drained ? 'green' : 'yellow', shape: 'dot', text: `${result.action}: ${result.consumers.length} consumer(s)` });
|
|
1187
|
+
send(msg);
|
|
1188
|
+
done();
|
|
1189
|
+
} catch (err) {
|
|
1190
|
+
node.status({ fill: 'red', shape: 'ring', text: 'control error' });
|
|
1191
|
+
done(err);
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
RED.nodes.registerType('yroshcha-redis-stream-control', RedisStreamControlNode);
|
|
1197
|
+
|
|
1198
|
+
// ---------------------------------------------------------------------
|
|
1199
|
+
// yroshcha-redis-stream-api — self-contained HTTP API. It remains disabled
|
|
1200
|
+
// until explicitly enabled; inside a private network token auth is optional.
|
|
1201
|
+
// Routes are fixed so reverse proxies/firewalls can allow-list them:
|
|
1202
|
+
// GET /redis/streams/status and POST /redis/streams/control.
|
|
1203
|
+
// ---------------------------------------------------------------------
|
|
1204
|
+
function RedisStreamApiNode(config) {
|
|
1205
|
+
RED.nodes.createNode(this, config);
|
|
1206
|
+
const node = this;
|
|
1207
|
+
const token = (node.credentials && node.credentials.token) || '';
|
|
1208
|
+
const enabled = !!config.enabled;
|
|
1209
|
+
const requireToken = !!config.requireToken;
|
|
1210
|
+
|
|
1211
|
+
async function execute(command, emitResult) {
|
|
1212
|
+
const result = await executeStreamControl(command);
|
|
1213
|
+
node.status({ fill: result.drained ? 'green' : 'yellow', shape: 'dot', text: `${result.action}: ${result.consumers.length} consumer(s)` });
|
|
1214
|
+
if (emitResult) node.send({ payload: result, topic: 'redis streams api' });
|
|
1215
|
+
return result;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (enabled && (!requireToken || token)) {
|
|
1219
|
+
streamControlApis.set(node.id, { token, requireToken, execute });
|
|
1220
|
+
node.status({ fill: requireToken ? 'green' : 'yellow', shape: 'dot', text: requireToken ? 'API enabled (token)' : 'API enabled (no token)' });
|
|
1221
|
+
} else if (!enabled) {
|
|
1222
|
+
node.status({ fill: 'grey', shape: 'ring', text: 'API disabled' });
|
|
1223
|
+
} else {
|
|
1224
|
+
node.status({ fill: 'red', shape: 'ring', text: 'API token required' });
|
|
1225
|
+
node.warn('Redis Streams API is disabled: token protection is enabled but no API token is configured');
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
node.on('close', function (done) {
|
|
1229
|
+
streamControlApis.delete(node.id);
|
|
1230
|
+
done();
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
RED.nodes.registerType('yroshcha-redis-stream-api', RedisStreamApiNode, {
|
|
1235
|
+
credentials: { token: { type: 'password' } }
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
if (RED.httpNode) {
|
|
1239
|
+
RED.httpNode.get('/redis/streams/status', async function (req, res) {
|
|
1240
|
+
const api = getEnabledControlApi(req, res);
|
|
1241
|
+
if (!api) return;
|
|
1242
|
+
try {
|
|
1243
|
+
res.json(await api.execute({ action: 'status' }, true));
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
res.status(500).json({ error: err.message });
|
|
1246
|
+
}
|
|
1247
|
+
});
|
|
1248
|
+
RED.httpNode.post('/redis/streams/control', async function (req, res) {
|
|
1249
|
+
const api = getEnabledControlApi(req, res);
|
|
1250
|
+
if (!api) return;
|
|
1251
|
+
try {
|
|
1252
|
+
const body = parseHttpBody(req.body);
|
|
1253
|
+
res.json(await api.execute({
|
|
1254
|
+
action: body.action,
|
|
1255
|
+
stream: body.stream,
|
|
1256
|
+
group: body.group,
|
|
1257
|
+
nodeId: body.nodeId,
|
|
1258
|
+
drainTimeoutMs: body.drainTimeoutMs
|
|
1259
|
+
}, true));
|
|
1260
|
+
} catch (err) {
|
|
1261
|
+
res.status(400).json({ error: err.message });
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
};
|