@blokcert/node-red-contrib-delayed-job 0.1.1 → 0.2.1

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.
@@ -1,5 +1,6 @@
1
1
  module.exports = function (RED) {
2
2
  const { Queue, Worker } = require('bullmq');
3
+ const crypto = require('crypto');
3
4
 
4
5
  function DelayedJobNode(config) {
5
6
  RED.nodes.createNode(this, config);
@@ -28,6 +29,10 @@ module.exports = function (RED) {
28
29
  let queue = null;
29
30
  let worker = null;
30
31
  let closing = false;
32
+ let statusTimer = null;
33
+ let fetchTimer = null;
34
+ let lastProcessedAt = 0;
35
+ const workerToken = crypto.randomUUID();
31
36
 
32
37
  // --- Serialization helpers ---
33
38
 
@@ -55,72 +60,162 @@ module.exports = function (RED) {
55
60
  return obj;
56
61
  }
57
62
 
58
- // --- Initialize Queue (Producer) ---
59
-
60
- try {
61
- queue = new Queue(node.queueName, {
62
- connection: connectionOpts,
63
- defaultJobOptions: {
64
- removeOnComplete: false,
65
- removeOnFail: false,
66
- },
67
- });
68
- } catch (err) {
69
- node.error('Failed to create queue: ' + err.message);
70
- node.status({ fill: 'red', shape: 'ring', text: 'queue init fail' });
71
- return;
63
+ // --- Job processor (shared between both modes) ---
64
+
65
+ function processJob(job) {
66
+ const ttlMs = job.data._ttl;
67
+ if (ttlMs && Date.now() - job.timestamp > ttlMs) {
68
+ node.warn('Job ' + job.id + ' expired (TTL ' + (ttlMs / 1000) + 's)');
69
+ return;
70
+ }
71
+ const msg = deserializeMsg(job.data);
72
+ delete msg._ttl;
73
+ lastProcessedAt = Date.now();
74
+ node.send(msg);
72
75
  }
73
76
 
74
- // --- Initialize Worker (Consumer) ---
75
-
76
- try {
77
- worker = new Worker(
78
- node.queueName,
79
- async (job) => {
80
- if (closing) return;
81
- const ttlMs = job.data._ttl;
82
- if (ttlMs && Date.now() - job.timestamp > ttlMs) {
83
- node.warn('Job ' + job.id + ' expired (TTL ' + (ttlMs / 1000) + 's)');
84
- return;
85
- }
86
- const msg = deserializeMsg(job.data);
87
- delete msg._ttl;
88
- node.send(msg);
89
- },
90
- {
77
+ // --- Initialize Queue & Worker ---
78
+
79
+ (async function init() {
80
+ try {
81
+ queue = new Queue(node.queueName, {
91
82
  connection: connectionOpts,
92
- concurrency: node.concurrency,
93
- stalledInterval: 30000,
94
- maxStalledCount: 2,
95
- ...(intervalMs > 0 ? { limiter: { max: node.concurrency, duration: intervalMs } } : {}),
96
- }
97
- );
98
-
99
- worker.on('ready', () => {
100
- node.status({ fill: 'green', shape: 'dot', text: 'ready' });
101
- });
102
-
103
- worker.on('failed', (job, err) => {
104
- node.warn('Job ' + (job ? job.id : '?') + ' failed: ' + err.message);
105
- });
106
-
107
- worker.on('error', (err) => {
108
- node.error('Worker error: ' + err.message);
109
- node.status({
110
- fill: 'red',
111
- shape: 'ring',
112
- text: err.message.substring(0, 30),
83
+ defaultJobOptions: {
84
+ removeOnComplete: { age: 604800 },
85
+ removeOnFail: { age: 604800 },
86
+ },
113
87
  });
114
- });
115
-
116
- worker.on('stalled', (jobId) => {
117
- node.warn('Job stalled: ' + jobId);
118
- });
119
- } catch (err) {
120
- node.error('Failed to create worker: ' + err.message);
121
- node.status({ fill: 'red', shape: 'ring', text: 'worker init fail' });
122
- return;
123
- }
88
+ } catch (err) {
89
+ node.error('Failed to create queue: ' + err.message);
90
+ node.status({ fill: 'red', shape: 'ring', text: 'queue init fail' });
91
+ return;
92
+ }
93
+
94
+ try {
95
+ if (intervalMs > 0) {
96
+ // --- Manual mode: per-worker rate limiting ---
97
+ worker = new Worker(node.queueName, null, {
98
+ connection: connectionOpts,
99
+ autorun: false,
100
+ lockDuration: 30000,
101
+ stalledInterval: 30000,
102
+ maxStalledCount: 2,
103
+ });
104
+
105
+ worker.on('error', (err) => {
106
+ node.error('Worker error: ' + err.message);
107
+ node.status({
108
+ fill: 'red',
109
+ shape: 'ring',
110
+ text: err.message.substring(0, 30),
111
+ });
112
+ });
113
+
114
+ // Start stalled job checker for crash recovery
115
+ worker.startStalledCheckTimer();
116
+
117
+ node.status({ fill: 'green', shape: 'dot', text: 'ready' });
118
+
119
+ // Fetch jobs on interval
120
+ fetchTimer = setInterval(async () => {
121
+ if (closing) return;
122
+ try {
123
+ for (let i = 0; i < node.concurrency; i++) {
124
+ if (closing) break;
125
+ const job = await worker.getNextJob(workerToken, { block: false });
126
+ if (!job) break; // no more jobs available
127
+ try {
128
+ processJob(job);
129
+ await job.moveToCompleted(undefined, workerToken, false);
130
+ } catch (err) {
131
+ node.error('Job ' + job.id + ' failed: ' + err.message);
132
+ await job.moveToFailed(err, workerToken, false);
133
+ }
134
+ }
135
+ } catch (err) {
136
+ if (!closing) {
137
+ node.error('Error fetching jobs: ' + err.message);
138
+ }
139
+ }
140
+ }, intervalMs);
141
+
142
+ } else {
143
+ // --- Automatic mode: immediate processing ---
144
+ worker = new Worker(
145
+ node.queueName,
146
+ async (job) => {
147
+ if (closing) return;
148
+ processJob(job);
149
+ },
150
+ {
151
+ connection: connectionOpts,
152
+ concurrency: node.concurrency,
153
+ stalledInterval: 30000,
154
+ maxStalledCount: 2,
155
+ }
156
+ );
157
+
158
+ worker.on('ready', () => {
159
+ node.status({ fill: 'green', shape: 'dot', text: 'ready' });
160
+ });
161
+
162
+ worker.on('failed', (job, err) => {
163
+ node.warn('Job ' + (job ? job.id : '?') + ' failed: ' + err.message);
164
+ });
165
+
166
+ worker.on('error', (err) => {
167
+ node.error('Worker error: ' + err.message);
168
+ node.status({
169
+ fill: 'red',
170
+ shape: 'ring',
171
+ text: err.message.substring(0, 30),
172
+ });
173
+ });
174
+
175
+ worker.on('stalled', (jobId) => {
176
+ node.warn('Job stalled: ' + jobId);
177
+ });
178
+ }
179
+ } catch (err) {
180
+ node.error('Failed to create worker: ' + err.message);
181
+ node.status({ fill: 'red', shape: 'ring', text: 'worker init fail' });
182
+ return;
183
+ }
184
+
185
+ // --- Status update timer ---
186
+ async function updateStatus() {
187
+ if (closing || !queue) return;
188
+ try {
189
+ const counts = await queue.getJobCounts('waiting', 'delayed', 'active');
190
+ const waiting = (counts.waiting || 0) + (counts.delayed || 0);
191
+ const active = counts.active || 0;
192
+ const total = waiting + active;
193
+
194
+ if (total === 0) {
195
+ node.status({ fill: 'green', shape: 'dot', text: 'idle' });
196
+ } else if (intervalMs > 0 && lastProcessedAt > 0) {
197
+ const elapsed = Date.now() - lastProcessedAt;
198
+ const remaining = Math.max(0, intervalMs - elapsed);
199
+ const remainSec = Math.ceil(remaining / 1000);
200
+ if (remaining > 0 && waiting > 0) {
201
+ node.status({ fill: 'blue', shape: 'dot', text: waiting + ' waiting | next: ' + remainSec + 's' });
202
+ } else {
203
+ const parts = [];
204
+ if (active > 0) parts.push(active + ' active');
205
+ if (waiting > 0) parts.push(waiting + ' waiting');
206
+ node.status({ fill: 'blue', shape: 'dot', text: parts.join(' | ') });
207
+ }
208
+ } else {
209
+ const parts = [];
210
+ if (active > 0) parts.push(active + ' active');
211
+ if (waiting > 0) parts.push(waiting + ' waiting');
212
+ node.status({ fill: 'blue', shape: 'dot', text: parts.join(' | ') });
213
+ }
214
+ } catch (_) { /* ignore status errors */ }
215
+ }
216
+
217
+ statusTimer = setInterval(updateStatus, 1000);
218
+ })();
124
219
 
125
220
  // --- Input Handler (Producer) ---
126
221
 
@@ -133,11 +228,9 @@ module.exports = function (RED) {
133
228
  }
134
229
 
135
230
  try {
136
- const ttlSec = parseInt(msg.ttl, 10) || 0;
231
+ const ttlSec = parseInt(msg.ttl, 10) || 604800; // default 7 days
137
232
  const serialized = serializeMsg(msg);
138
- if (ttlSec > 0) {
139
- serialized._ttl = ttlSec * 1000;
140
- }
233
+ serialized._ttl = ttlSec * 1000;
141
234
  const jobName = 'job';
142
235
  const jobOpts = {};
143
236
 
@@ -169,6 +262,14 @@ module.exports = function (RED) {
169
262
 
170
263
  node.on('close', async function (removed, done) {
171
264
  closing = true;
265
+ if (fetchTimer) {
266
+ clearInterval(fetchTimer);
267
+ fetchTimer = null;
268
+ }
269
+ if (statusTimer) {
270
+ clearInterval(statusTimer);
271
+ statusTimer = null;
272
+ }
172
273
  node.status({ fill: 'yellow', shape: 'ring', text: 'closing...' });
173
274
 
174
275
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blokcert/node-red-contrib-delayed-job",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "A Node-RED node for Redis-backed delayed job queues using BullMQ",
5
5
  "keywords": ["node-red"],
6
6
  "author": "caspar.wei",