@usehenri/jobs 0.0.0 → 1.2.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 +311 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +45 -0
- package/module.js +8 -0
- package/package.json +50 -10
- package/src/batch.js +379 -0
- package/src/config.js +186 -0
- package/src/cron.js +237 -0
- package/src/definitions.js +236 -0
- package/src/duration.js +112 -0
- package/src/errors.js +115 -0
- package/src/jobs.js +1839 -0
- package/src/keys.js +65 -0
- package/src/module.js +442 -0
- package/src/runner.js +918 -0
- package/src/serialize.js +177 -0
- package/src/store/index.js +37 -0
- package/src/store/mongo.js +1334 -0
- package/src/store/schema.js +499 -0
- package/src/store/sql.js +1744 -0
package/src/runner.js
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
const { randomUUID: uuid } = require('crypto');
|
|
3
|
+
const debug = require('debug')('henri:jobs:runner');
|
|
4
|
+
|
|
5
|
+
const { next: nextRun } = require('./cron');
|
|
6
|
+
const { slot } = require('./keys');
|
|
7
|
+
|
|
8
|
+
/** What a schedule waits before it looks again at an expression */
|
|
9
|
+
const MINUTE = 60000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* How many concurrency keys one tick looks at.
|
|
13
|
+
*
|
|
14
|
+
* A keyed limit (`key: 'tenantId'`) makes as many keys as there are tenants
|
|
15
|
+
* with work waiting, and a runner only ever has room for a handful of them:
|
|
16
|
+
* the store hands back the most urgent, which is the order the claim would
|
|
17
|
+
* have taken them in anyway.
|
|
18
|
+
*/
|
|
19
|
+
const KEYS_PER_TICK = 100;
|
|
20
|
+
|
|
21
|
+
/** No job of this application declares a limit */
|
|
22
|
+
const UNBOUNDED = { groups: new Map(), names: [] };
|
|
23
|
+
|
|
24
|
+
/** The signals a runner stops on */
|
|
25
|
+
const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGQUIT'];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A worker process: it claims jobs, performs them, keeps the recurring
|
|
29
|
+
* schedules moving and puts back what a dead runner left behind.
|
|
30
|
+
*
|
|
31
|
+
* Several runners are meant to run at once against one database. Nothing in
|
|
32
|
+
* here assumes it is alone: the claim is atomic (see `store/sql.js` and
|
|
33
|
+
* `store/mongo.js`), and so is moving a recurring schedule forward.
|
|
34
|
+
*
|
|
35
|
+
* @class Runner
|
|
36
|
+
*/
|
|
37
|
+
class Runner {
|
|
38
|
+
/**
|
|
39
|
+
* Creates an instance of Runner.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} jobs The queue
|
|
42
|
+
* @param {object} [options={}] Options
|
|
43
|
+
* @param {Array<string>} [options.queues] The queues to take from (all of
|
|
44
|
+
* them when the list is empty)
|
|
45
|
+
* @param {number} [options.concurrency] How many jobs at once
|
|
46
|
+
* @param {boolean} [options.recurring=true] Honour the schedules
|
|
47
|
+
* @param {string} [options.id] The runner id, for the logs and the rows
|
|
48
|
+
* @memberof Runner
|
|
49
|
+
*/
|
|
50
|
+
constructor(jobs, options = {}) {
|
|
51
|
+
const { config } = jobs;
|
|
52
|
+
|
|
53
|
+
this.jobs = jobs;
|
|
54
|
+
this.pen = jobs.pen;
|
|
55
|
+
this.concurrency = Math.max(
|
|
56
|
+
1,
|
|
57
|
+
Number(options.concurrency) || config.concurrency
|
|
58
|
+
);
|
|
59
|
+
this.queues = options.queues || config.queues;
|
|
60
|
+
this.recurring = options.recurring !== false;
|
|
61
|
+
this.pollInterval = config.pollInterval;
|
|
62
|
+
this.stuckAfter = config.stuckAfter;
|
|
63
|
+
this.keepCompleted = config.keepCompleted;
|
|
64
|
+
this.id =
|
|
65
|
+
options.id || `${os.hostname()}:${process.pid}:${uuid().slice(0, 8)}`;
|
|
66
|
+
|
|
67
|
+
/** The jobs in flight: id -> { promise, token } */
|
|
68
|
+
this.running = new Map();
|
|
69
|
+
/** The concurrency slots this runner holds: job id -> { key, slot } */
|
|
70
|
+
this.slots = new Map();
|
|
71
|
+
this.stopping = false;
|
|
72
|
+
this.stopped = null;
|
|
73
|
+
this.loop = null;
|
|
74
|
+
this.timer = null;
|
|
75
|
+
this.wake = null;
|
|
76
|
+
this.heartbeatTimer = null;
|
|
77
|
+
this.maintenanceAt = 0;
|
|
78
|
+
this.sweepAt = 0;
|
|
79
|
+
this.prunedSchedules = false;
|
|
80
|
+
this.handlers = [];
|
|
81
|
+
this.performed = 0;
|
|
82
|
+
this.failed = 0;
|
|
83
|
+
this.beatFailed = false;
|
|
84
|
+
/** Schedules already reported as unrunnable, so they are said once */
|
|
85
|
+
this.warned = new Set();
|
|
86
|
+
|
|
87
|
+
// How long claiming took: the number that says a queue is contended,
|
|
88
|
+
// which no log line carries and no count can be derived from. It is a
|
|
89
|
+
// recorder that does nothing when henri is not tracing, so the loop
|
|
90
|
+
// below has nothing to test (see base/telemetry.js in core)
|
|
91
|
+
const telemetry = jobs.henri && jobs.henri.telemetry;
|
|
92
|
+
|
|
93
|
+
this.claimed =
|
|
94
|
+
telemetry && typeof telemetry.histogram === 'function'
|
|
95
|
+
? telemetry.histogram('henri.jobs.claim.duration', {
|
|
96
|
+
description: 'How long one claim took, whatever it claimed',
|
|
97
|
+
unit: 's',
|
|
98
|
+
})
|
|
99
|
+
: { record: () => {} };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Says something, when there is a pen to say it with
|
|
104
|
+
*
|
|
105
|
+
* @param {string} level info, warn or error
|
|
106
|
+
* @param {...*} args What to say
|
|
107
|
+
* @returns {void}
|
|
108
|
+
* @memberof Runner
|
|
109
|
+
*/
|
|
110
|
+
log(level, ...args) {
|
|
111
|
+
if (this.pen && typeof this.pen[level] === 'function') {
|
|
112
|
+
this.pen[level]('jobs', ...args);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Starts the loop
|
|
118
|
+
*
|
|
119
|
+
* @param {object} [options={}] Options
|
|
120
|
+
* @param {boolean} [options.signals=false] Stop on SIGINT, SIGTERM, SIGQUIT
|
|
121
|
+
* @returns {Runner} This runner
|
|
122
|
+
* @memberof Runner
|
|
123
|
+
*/
|
|
124
|
+
start({ signals = false } = {}) {
|
|
125
|
+
if (this.loop) {
|
|
126
|
+
return this;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
this.stopping = false;
|
|
130
|
+
this.jobs.runners.add(this);
|
|
131
|
+
|
|
132
|
+
if (signals) {
|
|
133
|
+
this.trap();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.beating();
|
|
137
|
+
this.loop = this.cycle();
|
|
138
|
+
|
|
139
|
+
this.log(
|
|
140
|
+
'info',
|
|
141
|
+
'runner',
|
|
142
|
+
this.id,
|
|
143
|
+
'started',
|
|
144
|
+
`concurrency ${this.concurrency}`,
|
|
145
|
+
this.queues.length > 0 ? `queues ${this.queues.join(', ')}` : 'all queues'
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return this;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Starts the heartbeat that says this runner is still on its jobs
|
|
153
|
+
*
|
|
154
|
+
* @returns {void}
|
|
155
|
+
* @memberof Runner
|
|
156
|
+
*/
|
|
157
|
+
beating() {
|
|
158
|
+
if (this.heartbeatTimer) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this.heartbeatTimer = setInterval(
|
|
163
|
+
() => this.beat(),
|
|
164
|
+
Math.max(1000, Math.floor(this.stuckAfter / 4))
|
|
165
|
+
);
|
|
166
|
+
this.heartbeatTimer.unref();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Stops the heartbeat
|
|
171
|
+
*
|
|
172
|
+
* @returns {void}
|
|
173
|
+
* @memberof Runner
|
|
174
|
+
*/
|
|
175
|
+
stopBeating() {
|
|
176
|
+
if (this.heartbeatTimer) {
|
|
177
|
+
clearInterval(this.heartbeatTimer);
|
|
178
|
+
this.heartbeatTimer = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Stops the loop and waits for the jobs in flight
|
|
184
|
+
*
|
|
185
|
+
* The jobs already claimed are performed to the end and their outcome is
|
|
186
|
+
* written down; nothing new is claimed. Every caller waits for the same
|
|
187
|
+
* shutdown: the CLI's signal handler and `henri.stop()` both call this.
|
|
188
|
+
*
|
|
189
|
+
* @returns {Promise<object>} `{ performed, failed }`
|
|
190
|
+
* @memberof Runner
|
|
191
|
+
*/
|
|
192
|
+
stop() {
|
|
193
|
+
if (!this.stopped) {
|
|
194
|
+
this.stopped = this.shutdown();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return this.stopped;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The shutdown itself
|
|
202
|
+
*
|
|
203
|
+
* @returns {Promise<object>} `{ performed, failed }`
|
|
204
|
+
* @memberof Runner
|
|
205
|
+
*/
|
|
206
|
+
async shutdown() {
|
|
207
|
+
this.stopping = true;
|
|
208
|
+
this.release();
|
|
209
|
+
|
|
210
|
+
// Wake the loop out of its poll instead of leaving it on a timer that
|
|
211
|
+
// will never fire: `await this.loop` below is what waits for it
|
|
212
|
+
if (this.timer) {
|
|
213
|
+
clearTimeout(this.timer);
|
|
214
|
+
this.timer = null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (this.wake) {
|
|
218
|
+
const wake = this.wake;
|
|
219
|
+
|
|
220
|
+
this.wake = null;
|
|
221
|
+
wake();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.stopBeating();
|
|
225
|
+
|
|
226
|
+
await this.loop;
|
|
227
|
+
await Promise.all(this.inFlight());
|
|
228
|
+
|
|
229
|
+
this.loop = null;
|
|
230
|
+
this.jobs.runners.delete(this);
|
|
231
|
+
this.log('info', 'runner', this.id, 'stopped');
|
|
232
|
+
|
|
233
|
+
return { failed: this.failed, performed: this.performed };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The promises of the jobs being performed right now
|
|
238
|
+
*
|
|
239
|
+
* @returns {Array<Promise>} The promises
|
|
240
|
+
* @memberof Runner
|
|
241
|
+
*/
|
|
242
|
+
inFlight() {
|
|
243
|
+
return [...this.running.values()].map((entry) => entry.promise);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Stops on the usual signals
|
|
248
|
+
*
|
|
249
|
+
* @returns {void}
|
|
250
|
+
* @memberof Runner
|
|
251
|
+
*/
|
|
252
|
+
trap() {
|
|
253
|
+
for (const signal of SIGNALS) {
|
|
254
|
+
const handler = () => {
|
|
255
|
+
this.log(
|
|
256
|
+
'info',
|
|
257
|
+
'runner',
|
|
258
|
+
this.id,
|
|
259
|
+
`${signal}, finishing the jobs in flight`
|
|
260
|
+
);
|
|
261
|
+
this.stop().catch((error) =>
|
|
262
|
+
this.log('error', 'runner', this.id, error.message)
|
|
263
|
+
);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
this.handlers.push([signal, handler]);
|
|
267
|
+
process.on(signal, handler);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Puts the signal handlers back
|
|
273
|
+
*
|
|
274
|
+
* @returns {void}
|
|
275
|
+
* @memberof Runner
|
|
276
|
+
*/
|
|
277
|
+
release() {
|
|
278
|
+
for (const [signal, handler] of this.handlers) {
|
|
279
|
+
process.removeListener(signal, handler);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.handlers = [];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Waits, unless the runner is stopping
|
|
287
|
+
*
|
|
288
|
+
* @param {number} ms How long to wait
|
|
289
|
+
* @returns {Promise<void>} Resolves when the time is up
|
|
290
|
+
* @memberof Runner
|
|
291
|
+
*/
|
|
292
|
+
sleep(ms) {
|
|
293
|
+
if (this.stopping) {
|
|
294
|
+
return Promise.resolve();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return new Promise((resolve) => {
|
|
298
|
+
const done = () => {
|
|
299
|
+
this.timer = null;
|
|
300
|
+
this.wake = null;
|
|
301
|
+
resolve();
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
this.wake = done;
|
|
305
|
+
this.timer = setTimeout(done, ms);
|
|
306
|
+
this.timer.unref();
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The loop: claim, perform, repeat
|
|
312
|
+
*
|
|
313
|
+
* @returns {Promise<void>} Resolves when the runner is stopped
|
|
314
|
+
* @memberof Runner
|
|
315
|
+
*/
|
|
316
|
+
async cycle() {
|
|
317
|
+
while (!this.stopping) {
|
|
318
|
+
let claimed = 0;
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
await this.maintain();
|
|
322
|
+
claimed = await this.tick();
|
|
323
|
+
} catch (error) {
|
|
324
|
+
this.log('error', 'runner', this.id, error.message);
|
|
325
|
+
debug('%O', error);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (claimed === 0) {
|
|
329
|
+
await this.sleep(this.pollInterval);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Performs everything that is due and returns, instead of looping
|
|
336
|
+
*
|
|
337
|
+
* This is what `henri jobs --once` runs: a drain. A job whose next attempt
|
|
338
|
+
* is in the future is left alone, so a drain always ends.
|
|
339
|
+
*
|
|
340
|
+
* @param {object} [options={}] Options
|
|
341
|
+
* @param {boolean} [options.maintain=true] Run the housekeeping first
|
|
342
|
+
* @returns {Promise<object>} `{ performed, failed }`
|
|
343
|
+
* @memberof Runner
|
|
344
|
+
*/
|
|
345
|
+
async once({ maintain = true } = {}) {
|
|
346
|
+
this.stopping = false;
|
|
347
|
+
this.jobs.runners.add(this);
|
|
348
|
+
// A drain can outlive `stuckAfter` as easily as the loop can: without
|
|
349
|
+
// the heartbeat its jobs would be recovered out from under it
|
|
350
|
+
this.beating();
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
if (maintain) {
|
|
354
|
+
await this.maintain();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (;;) {
|
|
358
|
+
const claimed = await this.tick();
|
|
359
|
+
|
|
360
|
+
if (claimed === 0 && this.running.size === 0) {
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (claimed === 0) {
|
|
365
|
+
await Promise.race(this.inFlight());
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
await Promise.all(this.inFlight());
|
|
370
|
+
} finally {
|
|
371
|
+
this.stopBeating();
|
|
372
|
+
this.jobs.runners.delete(this);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return { failed: this.failed, performed: this.performed };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Claims what there is room for and performs it
|
|
380
|
+
*
|
|
381
|
+
* @returns {Promise<number>} How many jobs were claimed
|
|
382
|
+
* @memberof Runner
|
|
383
|
+
*/
|
|
384
|
+
async tick() {
|
|
385
|
+
const room = this.concurrency - this.running.size;
|
|
386
|
+
|
|
387
|
+
if (room < 1) {
|
|
388
|
+
await Promise.race(this.inFlight());
|
|
389
|
+
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// The two passes partition the queue by job name: the first takes
|
|
394
|
+
// everything that declares no limit, in one statement, exactly as it
|
|
395
|
+
// always did; the second takes one row per concurrency slot it holds
|
|
396
|
+
const bounded = this.jobs.concurrent ? this.jobs.limited() : UNBOUNDED;
|
|
397
|
+
const token = uuid();
|
|
398
|
+
const now = Date.now();
|
|
399
|
+
const started = process.hrtime.bigint();
|
|
400
|
+
const rows = await this.jobs.storeOrDie().claim({
|
|
401
|
+
except: bounded.names,
|
|
402
|
+
limit: room,
|
|
403
|
+
now,
|
|
404
|
+
queues: this.queues,
|
|
405
|
+
runner: this.id,
|
|
406
|
+
token,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
for (const row of rows) {
|
|
410
|
+
this.running.set(row.id, { promise: this.hold(row), token });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const left = room - rows.length;
|
|
414
|
+
const held =
|
|
415
|
+
left > 0 && bounded.names.length > 0
|
|
416
|
+
? await this.throttled(bounded, left, now)
|
|
417
|
+
: 0;
|
|
418
|
+
|
|
419
|
+
this.claimed.record(Number(process.hrtime.bigint() - started) / 1e9, {
|
|
420
|
+
'henri.jobs.claimed': rows.length + held,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
return rows.length + held;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Claims the jobs whose concurrency limit leaves room for them
|
|
428
|
+
*
|
|
429
|
+
* The permit comes **first**: a slot is taken, and only then is one row of
|
|
430
|
+
* that key claimed. The other order -- claim, discover the key is full,
|
|
431
|
+
* put the row back -- would make a full key spin this loop, because the
|
|
432
|
+
* cycle only sleeps when a tick claimed nothing.
|
|
433
|
+
*
|
|
434
|
+
* @param {object} bounded `{ names, groups }` from the queue
|
|
435
|
+
* @param {number} room How many jobs this runner still has room for
|
|
436
|
+
* @param {number} now The current time
|
|
437
|
+
* @returns {Promise<number>} How many jobs were claimed
|
|
438
|
+
* @memberof Runner
|
|
439
|
+
*/
|
|
440
|
+
async throttled(bounded, room, now) {
|
|
441
|
+
const store = this.jobs.storeOrDie();
|
|
442
|
+
const waiting = await store.waiting({
|
|
443
|
+
limit: KEYS_PER_TICK,
|
|
444
|
+
names: bounded.names,
|
|
445
|
+
now,
|
|
446
|
+
queues: this.queues,
|
|
447
|
+
});
|
|
448
|
+
const seen = new Set();
|
|
449
|
+
let taken = 0;
|
|
450
|
+
|
|
451
|
+
for (const entry of waiting) {
|
|
452
|
+
if (taken >= room) {
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const bucket = this.jobs.bucket(entry, bounded);
|
|
457
|
+
|
|
458
|
+
// Two jobs of one group may answer for the same key: it is one bound,
|
|
459
|
+
// so it is asked for once
|
|
460
|
+
if (!bucket || seen.has(bucket.key.value)) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
seen.add(bucket.key.value);
|
|
465
|
+
|
|
466
|
+
// A key with room for three and a runner with room for three takes
|
|
467
|
+
// three: the slots run out (`takeSlot` answers null) or the key does
|
|
468
|
+
// (`claimOne` gives the permit straight back)
|
|
469
|
+
while (taken < room) {
|
|
470
|
+
const slot = await store.takeSlot({
|
|
471
|
+
key: bucket.key.value,
|
|
472
|
+
limit: bucket.limit,
|
|
473
|
+
now: Date.now(),
|
|
474
|
+
runner: this.id,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
if (slot === null || !(await this.claimOne(bucket, slot, now))) {
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
taken += 1;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return taken;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Claims one row of a key this runner holds a slot of
|
|
490
|
+
*
|
|
491
|
+
* @param {object} bucket `{ key, limit, names }`
|
|
492
|
+
* @param {number} slot The slot this runner took
|
|
493
|
+
* @param {number} now The current time
|
|
494
|
+
* @returns {Promise<boolean>} Whether a job was claimed
|
|
495
|
+
* @memberof Runner
|
|
496
|
+
*/
|
|
497
|
+
async claimOne(bucket, slot, now) {
|
|
498
|
+
const store = this.jobs.storeOrDie();
|
|
499
|
+
const key = bucket.key.value;
|
|
500
|
+
const token = uuid();
|
|
501
|
+
let rows;
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
rows = await store.claim({
|
|
505
|
+
key: bucket.key,
|
|
506
|
+
limit: 1,
|
|
507
|
+
names: bucket.names,
|
|
508
|
+
now,
|
|
509
|
+
queues: this.queues,
|
|
510
|
+
runner: this.id,
|
|
511
|
+
token,
|
|
512
|
+
});
|
|
513
|
+
} catch (error) {
|
|
514
|
+
await store.releaseSlot(key, slot, this.id).catch(() => null);
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const [row] = rows;
|
|
519
|
+
|
|
520
|
+
if (!row) {
|
|
521
|
+
// Another runner took the last row of this key in between: the permit
|
|
522
|
+
// goes back at once rather than waiting for the sweep
|
|
523
|
+
await store.releaseSlot(key, slot, this.id);
|
|
524
|
+
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
this.slots.set(row.id, { key, slot });
|
|
529
|
+
// Says what the slot is being held for, for `henri jobs:status`; the
|
|
530
|
+
// bound does not rest on it, so a failure here is a debug line
|
|
531
|
+
await store
|
|
532
|
+
.holdSlot(key, slot, row.id, Date.now())
|
|
533
|
+
.catch((error) => debug('holdSlot: %s', error.message));
|
|
534
|
+
this.running.set(row.id, { promise: this.hold(row), token });
|
|
535
|
+
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Gives back the concurrency slot a job was performed under
|
|
541
|
+
*
|
|
542
|
+
* A slot that cannot be given back is freed by the sweep once its
|
|
543
|
+
* heartbeat goes stale, like the job of a runner that died.
|
|
544
|
+
*
|
|
545
|
+
* @param {string} id The job id
|
|
546
|
+
* @returns {Promise<void>} Resolves when it is back
|
|
547
|
+
* @memberof Runner
|
|
548
|
+
*/
|
|
549
|
+
async free(id) {
|
|
550
|
+
const held = this.slots.get(id);
|
|
551
|
+
|
|
552
|
+
if (!held) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
this.slots.delete(id);
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
await this.jobs.storeOrDie().releaseSlot(held.key, held.slot, this.id);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
this.log(
|
|
562
|
+
'warn',
|
|
563
|
+
'runner',
|
|
564
|
+
this.id,
|
|
565
|
+
`could not free the concurrency slot ${held.key}#${held.slot}:`,
|
|
566
|
+
error.message
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Performs one claimed row and forgets it when it is done
|
|
573
|
+
*
|
|
574
|
+
* @param {object} row A claimed row
|
|
575
|
+
* @returns {Promise<void>} Resolves when the outcome is written
|
|
576
|
+
* @memberof Runner
|
|
577
|
+
*/
|
|
578
|
+
async hold(row) {
|
|
579
|
+
const started = Date.now();
|
|
580
|
+
|
|
581
|
+
try {
|
|
582
|
+
const result = await this.jobs.run(row, { runner: this.id });
|
|
583
|
+
|
|
584
|
+
if (result.state === 'done') {
|
|
585
|
+
this.performed += 1;
|
|
586
|
+
this.log('info', row.name, row.id, 'done', `${Date.now() - started}ms`);
|
|
587
|
+
} else {
|
|
588
|
+
this.failed += 1;
|
|
589
|
+
}
|
|
590
|
+
} catch (error) {
|
|
591
|
+
this.failed += 1;
|
|
592
|
+
this.log('error', 'runner', this.id, row.name, error.message);
|
|
593
|
+
debug('%O', error);
|
|
594
|
+
} finally {
|
|
595
|
+
// The slot goes back before the job leaves `running`, and in that
|
|
596
|
+
// order: `shutdown()` waits on the promises of what is running, so a
|
|
597
|
+
// job removed first would let the runner stop with its permit still
|
|
598
|
+
// held, to be freed by a sweep five minutes later
|
|
599
|
+
await this.free(row.id);
|
|
600
|
+
this.running.delete(row.id);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Tells the database this runner is still alive on its jobs
|
|
606
|
+
*
|
|
607
|
+
* @returns {Promise<void>} Resolves when written
|
|
608
|
+
* @memberof Runner
|
|
609
|
+
*/
|
|
610
|
+
async beat() {
|
|
611
|
+
const claims = new Map();
|
|
612
|
+
|
|
613
|
+
for (const [id, entry] of this.running) {
|
|
614
|
+
const ids = claims.get(entry.token) || [];
|
|
615
|
+
|
|
616
|
+
ids.push(id);
|
|
617
|
+
claims.set(entry.token, ids);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (claims.size === 0) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const now = Date.now();
|
|
625
|
+
|
|
626
|
+
try {
|
|
627
|
+
for (const [token, ids] of claims) {
|
|
628
|
+
await this.jobs.storeOrDie().heartbeat(ids, now, token);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// The concurrency slots are refreshed by the same beat, and the sweep
|
|
632
|
+
// that frees a stale one uses the same `stuckAfter`: a slot outlives
|
|
633
|
+
// a runner by exactly as long as its jobs do
|
|
634
|
+
if (this.slots.size > 0) {
|
|
635
|
+
await this.jobs
|
|
636
|
+
.storeOrDie()
|
|
637
|
+
.heartbeatSlots([...this.slots.values()], now, this.id);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
this.beatFailed = false;
|
|
641
|
+
} catch (error) {
|
|
642
|
+
// A heartbeat that keeps failing means these jobs are about to be
|
|
643
|
+
// recovered and performed a second time: say so once
|
|
644
|
+
if (!this.beatFailed) {
|
|
645
|
+
this.beatFailed = true;
|
|
646
|
+
this.log('warn', 'runner', this.id, 'heartbeat failed', error.message);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
debug('heartbeat failed: %s', error.message);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Housekeeping: recurring schedules, jobs left behind by a dead runner,
|
|
655
|
+
* and the finished jobs that are old enough to go
|
|
656
|
+
*
|
|
657
|
+
* @returns {Promise<void>} Resolves when done
|
|
658
|
+
* @memberof Runner
|
|
659
|
+
*/
|
|
660
|
+
async maintain() {
|
|
661
|
+
const now = Date.now();
|
|
662
|
+
|
|
663
|
+
if (now < this.maintenanceAt) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// A cron expression has a minute of resolution, so the schedules are
|
|
668
|
+
// looked at every second at most
|
|
669
|
+
this.maintenanceAt = now + Math.max(this.pollInterval, 1000);
|
|
670
|
+
|
|
671
|
+
if (now >= this.sweepAt) {
|
|
672
|
+
await this.sweep(now);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (this.recurring) {
|
|
676
|
+
await this.schedule(now);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Puts back the jobs of runners that died and prunes the finished ones
|
|
682
|
+
*
|
|
683
|
+
* @param {number} now The current time
|
|
684
|
+
* @returns {Promise<void>} Resolves when done
|
|
685
|
+
* @memberof Runner
|
|
686
|
+
*/
|
|
687
|
+
async sweep(now) {
|
|
688
|
+
// Nothing here is urgent: a job left behind is not late until
|
|
689
|
+
// `stuckAfter` has gone by, and the pruning is housekeeping
|
|
690
|
+
this.sweepAt = now + Math.max(5000, Math.floor(this.stuckAfter / 10));
|
|
691
|
+
|
|
692
|
+
const store = this.jobs.storeOrDie();
|
|
693
|
+
const recovered = await store.recover({ now, stuckAfter: this.stuckAfter });
|
|
694
|
+
|
|
695
|
+
for (const row of recovered) {
|
|
696
|
+
this.log('warn', row.name, row.id, 'recovered from', row.claimed_by);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (this.jobs.concurrent) {
|
|
700
|
+
const freed = await store.sweepSlots({
|
|
701
|
+
now,
|
|
702
|
+
stuckAfter: this.stuckAfter,
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
for (const held of freed) {
|
|
706
|
+
this.log(
|
|
707
|
+
'warn',
|
|
708
|
+
'concurrency',
|
|
709
|
+
`${held.key}#${held.slot}`,
|
|
710
|
+
'freed from',
|
|
711
|
+
held.runner
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// The batches nothing else will settle: one killed between the outcome
|
|
717
|
+
// of its last job and the counting of it, and one whose last job was
|
|
718
|
+
// buried by the recovery above, which wrote an outcome no attempt owns.
|
|
719
|
+
// The window is the same clock, for the same reason
|
|
720
|
+
if (this.jobs.batched) {
|
|
721
|
+
const settled = await this.jobs.reconcile({
|
|
722
|
+
before: now - this.stuckAfter,
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
for (const batch of settled) {
|
|
726
|
+
this.log('warn', 'batch', batch.id, 'settled by the sweep');
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (this.keepCompleted > 0) {
|
|
731
|
+
await this.jobs.storeOrDie().prune(now - this.keepCompleted);
|
|
732
|
+
|
|
733
|
+
if (this.jobs.batched) {
|
|
734
|
+
await this.jobs
|
|
735
|
+
.storeOrDie()
|
|
736
|
+
.pruneBatches(now - this.keepCompleted)
|
|
737
|
+
.catch((error) => debug('pruneBatches: %s', error.message));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Enqueues the recurring jobs that are due
|
|
744
|
+
*
|
|
745
|
+
* A schedule holds the next moment it should run. Whoever moves it forward
|
|
746
|
+
* -- one runner, never two, because the update only matches the moment it
|
|
747
|
+
* read -- is the one that enqueues the job. The new moment is computed
|
|
748
|
+
* from now, not from the moment that was missed: after an hour of
|
|
749
|
+
* downtime an hourly job runs once, not sixty times.
|
|
750
|
+
*
|
|
751
|
+
* @param {number} now The current time
|
|
752
|
+
* @returns {Promise<Array<object>>} The jobs that were enqueued
|
|
753
|
+
* @memberof Runner
|
|
754
|
+
*/
|
|
755
|
+
async schedule(now) {
|
|
756
|
+
const store = this.jobs.storeOrDie();
|
|
757
|
+
const schedules = this.jobs.config.recurring;
|
|
758
|
+
const enqueued = [];
|
|
759
|
+
|
|
760
|
+
// The schedules the configuration no longer declares only have to go
|
|
761
|
+
// once, when this runner starts
|
|
762
|
+
if (!this.prunedSchedules) {
|
|
763
|
+
this.prunedSchedules = true;
|
|
764
|
+
await store.pruneSchedules(schedules.map((entry) => entry.name));
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
for (const entry of schedules) {
|
|
768
|
+
try {
|
|
769
|
+
const job = await this.due(entry, now, store);
|
|
770
|
+
|
|
771
|
+
if (job) {
|
|
772
|
+
enqueued.push(job);
|
|
773
|
+
}
|
|
774
|
+
} catch (error) {
|
|
775
|
+
// One schedule must never stop the runner claiming: the loop that
|
|
776
|
+
// calls this is the same one that claims jobs
|
|
777
|
+
this.log('error', 'recurring', entry.name, error.message);
|
|
778
|
+
debug('%O', error);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return enqueued;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Enqueues one schedule if its moment has come
|
|
787
|
+
*
|
|
788
|
+
* The job is enqueued **before** the schedule is moved on, and it carries
|
|
789
|
+
* the slot as its unique key: whichever runner gets here, exactly one job
|
|
790
|
+
* exists for that slot, and an enqueue that fails leaves the schedule due
|
|
791
|
+
* so the next tick tries again.
|
|
792
|
+
*
|
|
793
|
+
* @param {object} entry A normalized schedule
|
|
794
|
+
* @param {number} now The current time
|
|
795
|
+
* @param {object} store The store backend
|
|
796
|
+
* @returns {Promise<?object>} The job this runner enqueued, or null
|
|
797
|
+
* @memberof Runner
|
|
798
|
+
*/
|
|
799
|
+
async due(entry, now, store) {
|
|
800
|
+
if (!this.jobs.definitions[entry.job]) {
|
|
801
|
+
return this.giveUp(entry, `no job named "${entry.job}" in app/jobs`);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const upcoming = this.nextRunOf(entry, now);
|
|
805
|
+
|
|
806
|
+
if (upcoming === null) {
|
|
807
|
+
return this.giveUp(entry, `${entry.spec} can never come round again`);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
let row = await store.schedule(entry.name);
|
|
811
|
+
|
|
812
|
+
if (!row) {
|
|
813
|
+
row = await store.addSchedule({
|
|
814
|
+
created_at: now,
|
|
815
|
+
job: entry.job,
|
|
816
|
+
name: entry.name,
|
|
817
|
+
next_run_at: upcoming,
|
|
818
|
+
spec: entry.spec,
|
|
819
|
+
updated_at: now,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (!row) {
|
|
824
|
+
return this.giveUp(
|
|
825
|
+
entry,
|
|
826
|
+
'the schedule could not be recorded; is the queue installed?'
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// The configuration changed under a schedule that was already recorded
|
|
831
|
+
if (row.spec !== entry.spec) {
|
|
832
|
+
await store.resetSchedule({
|
|
833
|
+
name: entry.name,
|
|
834
|
+
next: upcoming,
|
|
835
|
+
now,
|
|
836
|
+
spec: entry.spec,
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
const due = Number(row.next_run_at);
|
|
843
|
+
|
|
844
|
+
if (due > now) {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
const id = uuid();
|
|
849
|
+
const job = await this.jobs.perform(entry.job, entry.args, {
|
|
850
|
+
id,
|
|
851
|
+
priority: entry.priority === null ? undefined : entry.priority,
|
|
852
|
+
queue: entry.queue || undefined,
|
|
853
|
+
unique: slot(entry.name, due),
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// Now that the slot is in the queue the schedule may move on; if another
|
|
857
|
+
// runner moved it already, its own enqueue and this one are the same row
|
|
858
|
+
await store.advanceSchedule({
|
|
859
|
+
due,
|
|
860
|
+
name: entry.name,
|
|
861
|
+
next: this.nextRunOf(entry, now) || now + MINUTE,
|
|
862
|
+
now,
|
|
863
|
+
spec: entry.spec,
|
|
864
|
+
token: uuid(),
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
if (job.id !== id) {
|
|
868
|
+
// Another runner enqueued this slot first
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
this.warned.delete(entry.name);
|
|
873
|
+
this.log('info', 'recurring', entry.name, '->', entry.job, job.id);
|
|
874
|
+
|
|
875
|
+
return job;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* Says once why a schedule is being skipped
|
|
880
|
+
*
|
|
881
|
+
* @param {object} entry A normalized schedule
|
|
882
|
+
* @param {string} why What is wrong with it
|
|
883
|
+
* @returns {null} Always null, so callers can return it
|
|
884
|
+
* @memberof Runner
|
|
885
|
+
*/
|
|
886
|
+
giveUp(entry, why) {
|
|
887
|
+
if (!this.warned.has(entry.name)) {
|
|
888
|
+
this.warned.add(entry.name);
|
|
889
|
+
this.log('warn', 'recurring', entry.name, 'skipped:', why);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* When a schedule should next run
|
|
897
|
+
*
|
|
898
|
+
* `every` is anchored on the epoch, so every runner and every restart
|
|
899
|
+
* agree on the slots; `cron` is read in UTC.
|
|
900
|
+
*
|
|
901
|
+
* @param {object} entry A normalized schedule
|
|
902
|
+
* @param {number} now The current time
|
|
903
|
+
* @returns {?number} A timestamp in milliseconds, or null when the
|
|
904
|
+
* expression can never match again
|
|
905
|
+
* @memberof Runner
|
|
906
|
+
*/
|
|
907
|
+
nextRunOf(entry, now) {
|
|
908
|
+
if (entry.every) {
|
|
909
|
+
return (Math.floor(now / entry.every) + 1) * entry.every;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Null when the expression can never match again (`0 0 30 2 *`); it is
|
|
913
|
+
// not turned into some other moment, which would make "never" mean daily
|
|
914
|
+
return nextRun(entry.cron, now);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
module.exports = { KEYS_PER_TICK, Runner, SIGNALS };
|