@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.
- package/delayed-job/delayed-job.js +167 -66
- package/package.json +1 -1
|
@@ -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
|
-
// ---
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
node.
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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) ||
|
|
231
|
+
const ttlSec = parseInt(msg.ttl, 10) || 604800; // default 7 days
|
|
137
232
|
const serialized = serializeMsg(msg);
|
|
138
|
-
|
|
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 {
|