@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/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 };