@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.
@@ -0,0 +1,1334 @@
1
+ const debug = require('debug')('henri:jobs:mongo');
2
+
3
+ const { JobStoreError } = require('../errors');
4
+ const { keep } = require('../keys');
5
+
6
+ /**
7
+ * The MongoDB backend of the queue.
8
+ *
9
+ * ## Claiming
10
+ *
11
+ * MongoDB has no `SELECT ... FOR UPDATE`, but it does not need one here: a
12
+ * single-document update is atomic, and `findOneAndUpdate` matches and
13
+ * writes in that one operation. The filter carries `state: 'pending'`, so
14
+ * two runners racing for the same document are resolved by the server --
15
+ * one of them writes, the other one's filter no longer matches and it is
16
+ * handed the next document instead. That is the same guarantee the SQL
17
+ * backend gets from its single-statement claim, and it holds on a
18
+ * standalone `mongod` (the `disk` adapter) as much as on a replica set, no
19
+ * transaction involved.
20
+ *
21
+ * The cost is that documents are claimed one at a time rather than in a
22
+ * batch: a runner asking for eight jobs makes eight round trips.
23
+ *
24
+ * Documents use the same field names as the SQL columns so the two backends
25
+ * hand the queue the same rows.
26
+ *
27
+ * ## Concurrency limits
28
+ *
29
+ * The same as the SQL backend, for the same reason: a bound on how many of
30
+ * a job run at once cannot be counted inside the claim, so it is held by a
31
+ * collection whose `_id` is `<key>#<slot>`. An `insertOne` either writes it
32
+ * or answers 11000, which is exactly the primitive the SQL side gets from a
33
+ * primary key -- and the one thing MongoDB has no other way to give, having
34
+ * neither `SELECT ... FOR UPDATE` nor an advisory lock.
35
+ *
36
+ * A collection needs no upgrade: a document written by an older henri
37
+ * simply has no `concurrency_key`, which is what an unlimited job's row
38
+ * looks like anyway. The same is true of `tenant`, which is why
39
+ * `tenanted()` answers yes here and asks nothing.
40
+ *
41
+ * ## Batches
42
+ *
43
+ * `$inc` is atomic on one document, so the counter of a batch is advanced
44
+ * the way the SQL backend advances its own: never read into this process to
45
+ * be written back. What the SQL backend puts in the same statement -- the
46
+ * `EXISTS` saying the job row still holds this runner's claim token -- is
47
+ * two operations here, and it is exact all the same: a job document whose
48
+ * outcome was written under this token is **terminal**, and a terminal
49
+ * document is never recovered, never re-claimed and never written by anyone
50
+ * else. So once the check passes, it stays true.
51
+ */
52
+
53
+ /** The collection is written with the SQL column names, on purpose */
54
+ const ID = 'id';
55
+
56
+ /**
57
+ * The MongoDB store
58
+ *
59
+ * @class MongoStore
60
+ */
61
+ class MongoStore {
62
+ /**
63
+ * Creates an instance of MongoStore.
64
+ *
65
+ * @param {object} adapter A henri mongoose (or disk) adapter
66
+ * @param {object} tables `{ jobs, schedules, limits, batches }` collection names
67
+ * @memberof MongoStore
68
+ */
69
+ constructor(adapter, tables) {
70
+ this.adapter = adapter;
71
+ this.tables = tables;
72
+ this.dialect = 'mongodb';
73
+ this.kind = 'mongo';
74
+ }
75
+
76
+ /**
77
+ * The database of the store
78
+ *
79
+ * @returns {object} A MongoDB database
80
+ * @throws {JobStoreError} When the store is not connected
81
+ * @memberof MongoStore
82
+ */
83
+ database() {
84
+ const connection =
85
+ this.adapter.mongoose && this.adapter.mongoose.connection;
86
+
87
+ if (!connection || connection.readyState !== 1 || !connection.db) {
88
+ throw new JobStoreError(
89
+ `@usehenri/jobs: the ${this.adapter.name} store is not connected`
90
+ );
91
+ }
92
+
93
+ return connection.db;
94
+ }
95
+
96
+ /**
97
+ * The jobs collection
98
+ *
99
+ * @returns {object} A MongoDB collection
100
+ * @memberof MongoStore
101
+ */
102
+ jobs() {
103
+ return this.database().collection(this.tables.jobs);
104
+ }
105
+
106
+ /**
107
+ * The schedules collection
108
+ *
109
+ * @returns {object} A MongoDB collection
110
+ * @memberof MongoStore
111
+ */
112
+ schedules() {
113
+ return this.database().collection(this.tables.schedules);
114
+ }
115
+
116
+ /**
117
+ * The concurrency slots collection
118
+ *
119
+ * @returns {object} A MongoDB collection
120
+ * @memberof MongoStore
121
+ */
122
+ limits() {
123
+ return this.database().collection(this.tables.limits);
124
+ }
125
+
126
+ /**
127
+ * The batches collection
128
+ *
129
+ * @returns {object} A MongoDB collection
130
+ * @memberof MongoStore
131
+ */
132
+ batches() {
133
+ return this.database().collection(this.tables.batches);
134
+ }
135
+
136
+ /**
137
+ * Whether concurrency limits can be held here; they always can
138
+ *
139
+ * A collection has no columns to be missing, so there is nothing for an
140
+ * installation to upgrade and nothing to refuse.
141
+ *
142
+ * @returns {Promise<boolean>} true
143
+ * @memberof MongoStore
144
+ */
145
+ async concurrent() {
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * Whether a batch can be held here; it always can
151
+ *
152
+ * A collection appears when something is written into it, so there is
153
+ * nothing to upgrade and nothing to refuse -- see `concurrent()`.
154
+ *
155
+ * @returns {Promise<boolean>} true
156
+ * @memberof MongoStore
157
+ */
158
+ async batched() {
159
+ return true;
160
+ }
161
+
162
+ /**
163
+ * Whether a tenant can be stamped here; it always can
164
+ *
165
+ * A field appears when it is written, so there is nothing to upgrade and
166
+ * nothing to refuse -- see `concurrent()`. A document an older henri
167
+ * wrote has no `tenant`, which is what a job of no tenant looks like
168
+ * anyway.
169
+ *
170
+ * @returns {Promise<boolean>} true
171
+ * @memberof MongoStore
172
+ */
173
+ async tenanted() {
174
+ return true;
175
+ }
176
+
177
+ /**
178
+ * A document, as the queue reads rows
179
+ *
180
+ * @param {?object} document A stored document
181
+ * @returns {?object} The row, or null
182
+ * @memberof MongoStore
183
+ */
184
+ row(document) {
185
+ if (!document) {
186
+ return null;
187
+ }
188
+
189
+ const { _id, ...rest } = document;
190
+
191
+ return { ...rest, [ID]: _id };
192
+ }
193
+
194
+ /**
195
+ * Creates the collections and their indexes; idempotent
196
+ *
197
+ * @returns {Promise<Array<string>>} What was created
198
+ * @memberof MongoStore
199
+ */
200
+ async install() {
201
+ // The key order of a compound index decides which queries it serves:
202
+ // these are index specifications, not objects to be sorted
203
+ /* eslint-disable sort-keys */
204
+ await this.index(
205
+ { state: 1, queue: 1, priority: 1, run_at: 1 },
206
+ { name: `${this.tables.jobs}_claim` }
207
+ );
208
+ await this.index(
209
+ { state: 1, finished_at: 1 },
210
+ { name: `${this.tables.jobs}_finished` }
211
+ );
212
+ /* eslint-enable sort-keys */
213
+ // A partial index, not a sparse one: a sparse unique index still holds
214
+ // every document whose `unique_key` is present and null, so the second
215
+ // job without a unique key would be refused
216
+ await this.index(
217
+ { unique_key: 1 },
218
+ {
219
+ name: `${this.tables.jobs}_unique`,
220
+ partialFilterExpression: { unique_key: { $type: 'string' } },
221
+ unique: true,
222
+ }
223
+ );
224
+
225
+ await this.index({ batch_id: 1 }, { name: `${this.tables.jobs}_batch` });
226
+
227
+ /* eslint-disable sort-keys */
228
+ await this.index(
229
+ { tenant: 1, state: 1, run_at: 1 },
230
+ { name: `${this.tables.jobs}_tenant` }
231
+ );
232
+ /* eslint-enable sort-keys */
233
+
234
+ await this.limits().createIndex(
235
+ { heartbeat_at: 1 },
236
+ { name: `${this.tables.limits}_stale` }
237
+ );
238
+
239
+ await this.batches().createIndex(
240
+ { finished_at: 1, updated_at: 1 },
241
+ { name: `${this.tables.batches}_open` }
242
+ );
243
+
244
+ return [
245
+ this.tables.jobs,
246
+ this.tables.schedules,
247
+ this.tables.limits,
248
+ this.tables.batches,
249
+ ];
250
+ }
251
+
252
+ /**
253
+ * Creates an index, replacing one an older version left with other options
254
+ *
255
+ * @param {object} keys The index keys
256
+ * @param {object} options The index options (`name` is required)
257
+ * @returns {Promise<void>} Resolves when the index is there
258
+ * @memberof MongoStore
259
+ */
260
+ async index(keys, options) {
261
+ try {
262
+ await this.jobs().createIndex(keys, options);
263
+ } catch (error) {
264
+ // IndexOptionsConflict / IndexKeySpecsConflict
265
+ if (error.code !== 85 && error.code !== 86) {
266
+ throw error;
267
+ }
268
+
269
+ debug('replacing the index %s', options.name);
270
+ await this.jobs().dropIndex(options.name);
271
+ await this.jobs().createIndex(keys, options);
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Drops the collections
277
+ *
278
+ * @returns {Promise<Array<string>>} What was dropped
279
+ * @memberof MongoStore
280
+ */
281
+ async uninstall() {
282
+ for (const name of [
283
+ this.tables.jobs,
284
+ this.tables.schedules,
285
+ this.tables.limits,
286
+ this.tables.batches,
287
+ ]) {
288
+ await this.database()
289
+ .collection(name)
290
+ .drop()
291
+ .catch(() => null);
292
+ }
293
+
294
+ return [
295
+ this.tables.batches,
296
+ this.tables.limits,
297
+ this.tables.schedules,
298
+ this.tables.jobs,
299
+ ];
300
+ }
301
+
302
+ /**
303
+ * Whether the collections can be read
304
+ *
305
+ * @returns {Promise<boolean>} true when the database answers
306
+ * @memberof MongoStore
307
+ */
308
+ async installed() {
309
+ try {
310
+ await this.jobs().estimatedDocumentCount();
311
+
312
+ return true;
313
+ } catch (error) {
314
+ return false;
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Inserts a job
320
+ *
321
+ * @param {object} job A row
322
+ * @returns {Promise<object>} The job, read back
323
+ * @memberof MongoStore
324
+ */
325
+ async insert(job) {
326
+ const { id, ...rest } = job;
327
+
328
+ // A job without a unique key has no `unique_key` field at all, so the
329
+ // partial unique index never sees it
330
+ if (rest.unique_key === null || typeof rest.unique_key === 'undefined') {
331
+ delete rest.unique_key;
332
+ }
333
+
334
+ // Likewise: an unlimited job has no `concurrency_key` field at all, and
335
+ // that is what a document an older henri wrote looks like
336
+ if (
337
+ rest.concurrency_key === null ||
338
+ typeof rest.concurrency_key === 'undefined'
339
+ ) {
340
+ delete rest.concurrency_key;
341
+ }
342
+
343
+ // And a job that belongs to no batch has no `batch_id`
344
+ if (rest.batch_id === null || typeof rest.batch_id === 'undefined') {
345
+ delete rest.batch_id;
346
+ }
347
+
348
+ // And a job of no tenant has no `tenant`, which is what every document
349
+ // an older henri wrote looks like
350
+ if (rest.tenant === null || typeof rest.tenant === 'undefined') {
351
+ delete rest.tenant;
352
+ }
353
+
354
+ try {
355
+ await this.jobs().insertOne({ _id: id, ...rest });
356
+ } catch (error) {
357
+ // Only a duplicate key is answered with the job that holds it. Any
358
+ // other failure is the caller's to see, or an enqueue would silently
359
+ // do nothing
360
+ if (job.unique_key && error.code === 11000) {
361
+ const existing = await this.findByUniqueKey(job.unique_key);
362
+
363
+ if (existing && existing.id !== id) {
364
+ return existing;
365
+ }
366
+ }
367
+
368
+ throw error;
369
+ }
370
+
371
+ return this.find(id);
372
+ }
373
+
374
+ /**
375
+ * One job by id
376
+ *
377
+ * @param {string} id The job id
378
+ * @returns {Promise<?object>} The row, or null
379
+ * @memberof MongoStore
380
+ */
381
+ async find(id) {
382
+ return this.row(await this.jobs().findOne({ _id: id }));
383
+ }
384
+
385
+ /**
386
+ * One job by unique key
387
+ *
388
+ * @param {string} key The unique key
389
+ * @returns {Promise<?object>} The row, or null
390
+ * @memberof MongoStore
391
+ */
392
+ async findByUniqueKey(key) {
393
+ return this.row(await this.jobs().findOne({ unique_key: key }));
394
+ }
395
+
396
+ /**
397
+ * Claims up to `limit` jobs for this runner
398
+ *
399
+ * Every document is taken with one atomic `findOneAndUpdate`; the loop
400
+ * stops as soon as there is nothing left to take.
401
+ *
402
+ * @param {object} options Options
403
+ * @param {Array<string>} [options.queues=[]] The queues to take from
404
+ * @param {number} [options.limit=1] How many jobs at most
405
+ * @param {string} options.runner The runner id
406
+ * @param {string} options.token A token unique to this claim
407
+ * @param {number} options.now The current time
408
+ * @param {object} [options.key] The concurrency key a slot is held for
409
+ * @param {Array<string>} [options.names] Only these job names
410
+ * @param {Array<string>} [options.except] Every name but these
411
+ * @returns {Promise<Array<object>>} The rows this runner owns
412
+ * @memberof MongoStore
413
+ */
414
+ async claim({
415
+ queues = [],
416
+ limit = 1,
417
+ runner,
418
+ token,
419
+ now,
420
+ key,
421
+ names,
422
+ except,
423
+ }) {
424
+ // `includeResultMetadata: false` is the default of the v6+ driver and is
425
+ // named here on purpose: under an older one findOneAndUpdate answers
426
+ // `{ value }`, which would read as a win for every runner
427
+
428
+ const filter = { run_at: { $lte: now }, state: 'pending' };
429
+ const claimed = [];
430
+
431
+ if (queues.length > 0) {
432
+ filter.queue = { $in: queues };
433
+ }
434
+
435
+ // The two passes partition the pending documents by **name**, exactly as
436
+ // the SQL backend does
437
+ if (except && except.length > 0) {
438
+ filter.name = { $nin: except };
439
+ }
440
+
441
+ if (names && names.length > 0) {
442
+ filter.name = { $in: names };
443
+ }
444
+
445
+ if (key) {
446
+ // A document with no `concurrency_key` field matches `null`, which is
447
+ // what a job enqueued before the limit was declared looks like
448
+ filter.concurrency_key = key.own ? { $in: [key.value, null] } : key.value;
449
+ }
450
+
451
+ for (let taken = 0; taken < limit; taken += 1) {
452
+ const document = await this.jobs().findOneAndUpdate(
453
+ filter,
454
+ {
455
+ $inc: { attempts: 1 },
456
+ $set: {
457
+ claim_token: token,
458
+ claimed_at: now,
459
+ claimed_by: runner,
460
+ heartbeat_at: now,
461
+ started_at: now,
462
+ state: 'running',
463
+ updated_at: now,
464
+ },
465
+ },
466
+ {
467
+ includeResultMetadata: false,
468
+ returnDocument: 'after',
469
+ // Ordered, like an ORDER BY
470
+ // eslint-disable-next-line sort-keys
471
+ sort: { priority: 1, run_at: 1, _id: 1 },
472
+ }
473
+ );
474
+
475
+ if (!document) {
476
+ break;
477
+ }
478
+
479
+ claimed.push(this.row(document));
480
+ }
481
+
482
+ debug('claimed %d job(s) for %s', claimed.length, runner);
483
+
484
+ return claimed;
485
+ }
486
+
487
+ /**
488
+ * The concurrency keys with work waiting, the most urgent first
489
+ *
490
+ * @param {object} options Options
491
+ * @param {number} options.now The current time
492
+ * @param {Array<string>} options.names The names of the limited jobs
493
+ * @param {Array<string>} [options.queues=[]] The queues to look at
494
+ * @param {number} [options.limit=100] How many keys at most
495
+ * @returns {Promise<Array<object>>} `{ key, name, total }` rows
496
+ * @memberof MongoStore
497
+ */
498
+ async waiting({ now, names, queues = [], limit = 100 }) {
499
+ if (!names || names.length === 0) {
500
+ return [];
501
+ }
502
+
503
+ const match = {
504
+ name: { $in: names },
505
+ run_at: { $lte: now },
506
+ state: 'pending',
507
+ };
508
+
509
+ if (queues.length > 0) {
510
+ match.queue = { $in: queues };
511
+ }
512
+
513
+ const rows = await this.jobs()
514
+ .aggregate([
515
+ { $match: match },
516
+ {
517
+ $group: {
518
+ _id: { key: '$concurrency_key', name: '$name' },
519
+ due: { $min: '$run_at' },
520
+ priority: { $min: '$priority' },
521
+ total: { $sum: 1 },
522
+ },
523
+ },
524
+ // Ordered, like an ORDER BY
525
+ // eslint-disable-next-line sort-keys
526
+ { $sort: { priority: 1, due: 1 } },
527
+ { $limit: Math.max(1, Number(limit) || 100) },
528
+ ])
529
+ .toArray();
530
+
531
+ return rows.map((row) => ({
532
+ key: row._id.key || null,
533
+ name: row._id.name,
534
+ total: row.total || 0,
535
+ }));
536
+ }
537
+
538
+ /**
539
+ * The `_id` of one slot
540
+ *
541
+ * The slot is an integer and it is always last, so the split on the final
542
+ * `#` gives the key back whatever the key holds.
543
+ *
544
+ * @param {string} key The concurrency key
545
+ * @param {number} slot The slot
546
+ * @returns {string} The document id
547
+ * @memberof MongoStore
548
+ */
549
+ slotId(key, slot) {
550
+ return `${key}#${slot}`;
551
+ }
552
+
553
+ /**
554
+ * Takes one of a key's slots, or answers null when they are all held
555
+ *
556
+ * **This is the bound.** An `insertOne` on a taken `_id` answers 11000 and
557
+ * writes nothing, which is the same refusal a primary key gives the SQL
558
+ * backend.
559
+ *
560
+ * @param {object} options Options
561
+ * @param {string} options.key The concurrency key
562
+ * @param {number} options.limit How many may run at once
563
+ * @param {string} options.runner The runner id
564
+ * @param {number} options.now The current time
565
+ * @returns {Promise<?number>} The slot this runner holds, or null
566
+ * @memberof MongoStore
567
+ */
568
+ async takeSlot({ key, limit, runner, now }) {
569
+ for (let slot = 0; slot < limit; slot += 1) {
570
+ try {
571
+ await this.limits().insertOne({
572
+ _id: this.slotId(key, slot),
573
+ heartbeat_at: now,
574
+ job_id: null,
575
+ limit_key: key,
576
+ runner,
577
+ slot,
578
+ taken_at: now,
579
+ });
580
+
581
+ return slot;
582
+ } catch (error) {
583
+ if (error.code !== 11000) {
584
+ throw error;
585
+ }
586
+
587
+ debug('slot %d of %s was taken first', slot, key);
588
+ }
589
+ }
590
+
591
+ return null;
592
+ }
593
+
594
+ /**
595
+ * Says which job a slot is being held for
596
+ *
597
+ * @param {string} key The concurrency key
598
+ * @param {number} slot The slot
599
+ * @param {?string} id The job id
600
+ * @param {number} now The current time
601
+ * @returns {Promise<void>} Resolves when written
602
+ * @memberof MongoStore
603
+ */
604
+ async holdSlot(key, slot, id, now) {
605
+ await this.limits().updateOne(
606
+ { _id: this.slotId(key, slot) },
607
+ { $set: { heartbeat_at: now, job_id: id } }
608
+ );
609
+ }
610
+
611
+ /**
612
+ * Gives a slot back
613
+ *
614
+ * @param {string} key The concurrency key
615
+ * @param {number} slot The slot
616
+ * @param {string} [runner] Only when this runner still holds it
617
+ * @returns {Promise<void>} Resolves when written
618
+ * @memberof MongoStore
619
+ */
620
+ async releaseSlot(key, slot, runner) {
621
+ const filter = { _id: this.slotId(key, slot) };
622
+
623
+ if (runner) {
624
+ filter.runner = runner;
625
+ }
626
+
627
+ await this.limits().deleteOne(filter);
628
+ }
629
+
630
+ /**
631
+ * Tells the database this runner still holds these slots
632
+ *
633
+ * @param {Array<object>} slots `{ key, slot }` entries
634
+ * @param {number} now The current time
635
+ * @param {string} runner The runner id
636
+ * @returns {Promise<void>} Resolves when written
637
+ * @memberof MongoStore
638
+ */
639
+ async heartbeatSlots(slots, now, runner) {
640
+ if (slots.length === 0) {
641
+ return;
642
+ }
643
+
644
+ await this.limits().updateMany(
645
+ {
646
+ _id: { $in: slots.map((held) => this.slotId(held.key, held.slot)) },
647
+ runner,
648
+ },
649
+ { $set: { heartbeat_at: now } }
650
+ );
651
+ }
652
+
653
+ /**
654
+ * Frees the slots of runners that stopped answering
655
+ *
656
+ * @param {object} options `now`, `stuckAfter` and `limit`
657
+ * @returns {Promise<Array<object>>} The slots that were freed
658
+ * @memberof MongoStore
659
+ */
660
+ async sweepSlots({ now, stuckAfter, limit = 100 }) {
661
+ const documents = await this.limits()
662
+ .find({ heartbeat_at: { $lt: now - stuckAfter } })
663
+ .sort({ heartbeat_at: 1 })
664
+ .limit(limit)
665
+ .toArray();
666
+
667
+ for (const document of documents) {
668
+ await this.limits().deleteOne({
669
+ _id: document._id,
670
+ heartbeat_at: document.heartbeat_at,
671
+ });
672
+ }
673
+
674
+ return documents.map((document) => ({
675
+ job: document.job_id || null,
676
+ key: document.limit_key,
677
+ runner: document.runner,
678
+ slot: document.slot,
679
+ takenAt: document.taken_at,
680
+ }));
681
+ }
682
+
683
+ /**
684
+ * Every slot being held right now
685
+ *
686
+ * @param {number} [limit=200] How many at most
687
+ * @returns {Promise<Array<object>>} The held slots
688
+ * @memberof MongoStore
689
+ */
690
+ async slots(limit = 200) {
691
+ const documents = await this.limits()
692
+ .find({})
693
+ .sort({ limit_key: 1, slot: 1 })
694
+ .limit(limit)
695
+ .toArray();
696
+
697
+ return documents.map((document) => ({
698
+ heartbeatAt: document.heartbeat_at,
699
+ job: document.job_id || null,
700
+ key: document.limit_key,
701
+ runner: document.runner,
702
+ slot: document.slot,
703
+ takenAt: document.taken_at,
704
+ }));
705
+ }
706
+
707
+ /**
708
+ * Writes the outcome of an attempt
709
+ *
710
+ * With a token the write only lands while this runner still owns the
711
+ * document, so a runner that was recovered from cannot write over the new
712
+ * owner's outcome.
713
+ *
714
+ * @param {string} id The job id
715
+ * @param {object} changes The fields to set
716
+ * @param {string} [token] The claim token this runner holds
717
+ * @returns {Promise<void>} Resolves when written
718
+ * @memberof MongoStore
719
+ */
720
+ async update(id, changes, token) {
721
+ if (Object.keys(changes).length === 0) {
722
+ return;
723
+ }
724
+
725
+ const filter = token
726
+ ? { _id: id, claim_token: token, state: 'running' }
727
+ : { _id: id };
728
+
729
+ await this.jobs().updateOne(filter, { $set: changes });
730
+ }
731
+
732
+ /**
733
+ * Puts back the jobs of runners that stopped answering
734
+ *
735
+ * @param {object} options `now`, `stuckAfter` and `limit`
736
+ * @returns {Promise<Array<object>>} The rows that were recovered
737
+ * @memberof MongoStore
738
+ */
739
+ async recover({ now, stuckAfter, limit = 100 }) {
740
+ const documents = await this.jobs()
741
+ .find({ heartbeat_at: { $lt: now - stuckAfter }, state: 'running' })
742
+ .sort({ heartbeat_at: 1 })
743
+ .limit(limit)
744
+ .toArray();
745
+ const rows = documents.map((document) => this.row(document));
746
+
747
+ for (const row of rows) {
748
+ const dead = (row.attempts || 0) >= (row.max_attempts || 0);
749
+ // A dead job holds its unique key no longer, unless the queue wrote it
750
+ // for itself (see ../keys.js)
751
+ const held = dead ? keep(row.unique_key) : row.unique_key;
752
+
753
+ await this.jobs().updateOne(
754
+ { _id: row.id, claim_token: row.claim_token, state: 'running' },
755
+ {
756
+ $set: {
757
+ error_message: `the runner ${row.claimed_by} stopped answering while performing this job`,
758
+ finished_at: dead ? now : null,
759
+ run_at: now,
760
+ state: dead ? 'dead' : 'pending',
761
+ updated_at: now,
762
+ },
763
+ $unset: held
764
+ ? { claim_token: '' }
765
+ : { claim_token: '', unique_key: '' },
766
+ }
767
+ );
768
+ }
769
+
770
+ return rows;
771
+ }
772
+
773
+ /**
774
+ * Tells the database this runner is still on these jobs
775
+ *
776
+ * A runner that was already recovered from no longer owns these documents,
777
+ * so the token is part of the filter.
778
+ *
779
+ * @param {Array<string>} ids The job ids
780
+ * @param {number} now The current time
781
+ * @param {string} [token] The claim token this runner holds
782
+ * @returns {Promise<void>} Resolves when written
783
+ * @memberof MongoStore
784
+ */
785
+ async heartbeat(ids, now, token) {
786
+ if (ids.length === 0) {
787
+ return;
788
+ }
789
+
790
+ const filter = { _id: { $in: ids } };
791
+
792
+ if (token) {
793
+ filter.claim_token = token;
794
+ }
795
+
796
+ await this.jobs().updateMany(filter, { $set: { heartbeat_at: now } });
797
+ }
798
+
799
+ /**
800
+ * Deletes the finished jobs older than a moment
801
+ *
802
+ * @param {number} before A timestamp
803
+ * @param {number} [limit=1000] How many documents one pass deletes
804
+ * @returns {Promise<number>} How many documents were deleted
805
+ * @memberof MongoStore
806
+ */
807
+ async prune(before, limit = 1000) {
808
+ const documents = await this.jobs()
809
+ .find({ finished_at: { $lt: before }, state: 'done' })
810
+ .sort({ finished_at: 1 })
811
+ .limit(limit)
812
+ .project({ _id: 1 })
813
+ .toArray();
814
+
815
+ if (documents.length === 0) {
816
+ return 0;
817
+ }
818
+
819
+ const result = await this.jobs().deleteMany({
820
+ _id: { $in: documents.map((document) => document._id) },
821
+ });
822
+
823
+ return result.deletedCount || 0;
824
+ }
825
+
826
+ /**
827
+ * Lists jobs
828
+ *
829
+ * @param {object} [options={}] `state`, `queue`, `name`, `batch`,
830
+ * `tenant`, `limit`, `offset`
831
+ * @returns {Promise<Array<object>>} The rows
832
+ * @memberof MongoStore
833
+ */
834
+ async list({
835
+ state,
836
+ queue,
837
+ name,
838
+ batch,
839
+ tenant,
840
+ limit = 50,
841
+ offset = 0,
842
+ } = {}) {
843
+ const filter = {};
844
+
845
+ if (state) {
846
+ filter.state = state;
847
+ }
848
+
849
+ if (queue) {
850
+ filter.queue = queue;
851
+ }
852
+
853
+ if (name) {
854
+ filter.name = name;
855
+ }
856
+
857
+ if (batch) {
858
+ filter.batch_id = batch;
859
+ }
860
+
861
+ if (tenant) {
862
+ filter.tenant = tenant;
863
+ }
864
+
865
+ const documents = await this.jobs()
866
+ .find(filter)
867
+ // eslint-disable-next-line sort-keys
868
+ .sort({ updated_at: -1, _id: 1 })
869
+ .skip(offset)
870
+ .limit(limit)
871
+ .toArray();
872
+
873
+ return documents.map((document) => this.row(document));
874
+ }
875
+
876
+ /**
877
+ * Deletes jobs
878
+ *
879
+ * @param {object} [options={}] `id`, `state`, `queue`, `name`, `tenant`
880
+ * @returns {Promise<number>} How many documents were deleted
881
+ * @memberof MongoStore
882
+ */
883
+ async remove({ id, state, queue, name, tenant } = {}) {
884
+ const filter = {};
885
+
886
+ if (id) {
887
+ filter._id = id;
888
+ }
889
+
890
+ if (state) {
891
+ filter.state = state;
892
+ }
893
+
894
+ if (queue) {
895
+ filter.queue = queue;
896
+ }
897
+
898
+ if (name) {
899
+ filter.name = name;
900
+ }
901
+
902
+ if (tenant) {
903
+ filter.tenant = tenant;
904
+ }
905
+
906
+ if (Object.keys(filter).length === 0) {
907
+ return 0;
908
+ }
909
+
910
+ const result = await this.jobs().deleteMany(filter);
911
+
912
+ return result.deletedCount || 0;
913
+ }
914
+
915
+ /**
916
+ * Counts the jobs of every queue and state
917
+ *
918
+ * @returns {Promise<Array<object>>} `{ queue, state, total }` rows
919
+ * @memberof MongoStore
920
+ */
921
+ async counts() {
922
+ const rows = await this.jobs()
923
+ .aggregate([
924
+ {
925
+ $group: {
926
+ _id: { queue: '$queue', state: '$state' },
927
+ total: { $sum: 1 },
928
+ },
929
+ },
930
+ ])
931
+ .toArray();
932
+
933
+ return rows.map((row) => ({
934
+ queue: row._id.queue,
935
+ state: row._id.state,
936
+ total: row.total,
937
+ }));
938
+ }
939
+
940
+ /**
941
+ * How long the finished jobs of every queue took
942
+ *
943
+ * @returns {Promise<Array<object>>} `{ queue, runs, shortest, longest, average }` rows
944
+ * @memberof MongoStore
945
+ */
946
+ async timings() {
947
+ const rows = await this.jobs()
948
+ .aggregate([
949
+ { $match: { duration_ms: { $ne: null }, state: 'done' } },
950
+ {
951
+ $group: {
952
+ _id: '$queue',
953
+ average: { $avg: '$duration_ms' },
954
+ longest: { $max: '$duration_ms' },
955
+ runs: { $sum: 1 },
956
+ shortest: { $min: '$duration_ms' },
957
+ },
958
+ },
959
+ ])
960
+ .toArray();
961
+
962
+ return rows.map((row) => ({
963
+ average: Math.round(row.average || 0),
964
+ longest: row.longest || 0,
965
+ queue: row._id,
966
+ runs: row.runs || 0,
967
+ shortest: row.shortest || 0,
968
+ }));
969
+ }
970
+
971
+ /**
972
+ * The moment the oldest job waiting in every queue was due
973
+ *
974
+ * @param {number} now The current time
975
+ * @returns {Promise<Array<object>>} `{ queue, waiting }` rows
976
+ * @memberof MongoStore
977
+ */
978
+ async oldest(now) {
979
+ const rows = await this.jobs()
980
+ .aggregate([
981
+ { $match: { run_at: { $lte: now }, state: 'pending' } },
982
+ { $group: { _id: '$queue', due: { $min: '$run_at' } } },
983
+ ])
984
+ .toArray();
985
+
986
+ return rows.map((row) => ({
987
+ queue: row._id,
988
+ waiting: Math.max(0, now - (row.due || now)),
989
+ }));
990
+ }
991
+
992
+ /**
993
+ * The schedule of a recurring job
994
+ *
995
+ * @param {string} name The schedule name
996
+ * @returns {Promise<?object>} The row, or null
997
+ * @memberof MongoStore
998
+ */
999
+ async schedule(name) {
1000
+ const document = await this.schedules().findOne({ _id: name });
1001
+
1002
+ return document ? { ...document, name: document._id } : null;
1003
+ }
1004
+
1005
+ /**
1006
+ * Records a schedule that has none yet
1007
+ *
1008
+ * @param {object} row The schedule row
1009
+ * @returns {Promise<?object>} The schedule
1010
+ * @memberof MongoStore
1011
+ */
1012
+ async addSchedule(row) {
1013
+ const { name, ...rest } = row;
1014
+
1015
+ try {
1016
+ await this.schedules().insertOne({
1017
+ _id: name,
1018
+ ...rest,
1019
+ last_run_at: null,
1020
+ token: null,
1021
+ });
1022
+ } catch (error) {
1023
+ // Another runner recording it first is the expected failure; anything
1024
+ // else is answered with null, and the runner says so
1025
+ debug('schedule %s not recorded (%s)', name, error.message);
1026
+ }
1027
+
1028
+ return this.schedule(name).catch(() => null);
1029
+ }
1030
+
1031
+ /**
1032
+ * Moves a schedule forward, if this runner is the one that got there first
1033
+ *
1034
+ * @param {object} options `name`, `spec`, `due`, `next`, `token`, `now`
1035
+ * @returns {Promise<boolean>} Whether this runner won the slot
1036
+ * @memberof MongoStore
1037
+ */
1038
+ async advanceSchedule({ name, spec, due, next, token, now }) {
1039
+ const document = await this.schedules().findOneAndUpdate(
1040
+ { _id: name, next_run_at: due },
1041
+ {
1042
+ $set: {
1043
+ last_run_at: due,
1044
+ next_run_at: next,
1045
+ spec,
1046
+ token,
1047
+ updated_at: now,
1048
+ },
1049
+ },
1050
+ { includeResultMetadata: false, returnDocument: 'after' }
1051
+ );
1052
+
1053
+ return Boolean(document);
1054
+ }
1055
+
1056
+ /**
1057
+ * Points a schedule at a new moment
1058
+ *
1059
+ * @param {object} options `name`, `spec`, `next` and `now`
1060
+ * @returns {Promise<void>} Resolves when written
1061
+ * @memberof MongoStore
1062
+ */
1063
+ async resetSchedule({ name, spec, next, now }) {
1064
+ await this.schedules().updateOne(
1065
+ { _id: name },
1066
+ { $set: { next_run_at: next, spec, updated_at: now } }
1067
+ );
1068
+ }
1069
+
1070
+ /**
1071
+ * Forgets the schedules the configuration no longer declares
1072
+ *
1073
+ * @param {Array<string>} names The schedules to keep
1074
+ * @returns {Promise<void>} Resolves when done
1075
+ * @memberof MongoStore
1076
+ */
1077
+ async pruneSchedules(names) {
1078
+ await this.schedules().deleteMany({ _id: { $nin: names } });
1079
+ }
1080
+
1081
+ /**
1082
+ * Records a batch
1083
+ *
1084
+ * @param {object} batch A batch row
1085
+ * @returns {Promise<object>} The batch, read back
1086
+ * @memberof MongoStore
1087
+ */
1088
+ async createBatch(batch) {
1089
+ const { id, ...rest } = batch;
1090
+
1091
+ await this.batches().insertOne({ _id: id, ...rest });
1092
+
1093
+ return this.findBatch(id);
1094
+ }
1095
+
1096
+ /**
1097
+ * One batch by id
1098
+ *
1099
+ * @param {string} id The batch id
1100
+ * @returns {Promise<?object>} The row, or null
1101
+ * @memberof MongoStore
1102
+ */
1103
+ async findBatch(id) {
1104
+ return this.row(await this.batches().findOne({ _id: id }));
1105
+ }
1106
+
1107
+ /**
1108
+ * Closes a batch to new jobs and writes down how many it holds
1109
+ *
1110
+ * @param {object} options `id`, `total` and `now`
1111
+ * @returns {Promise<?object>} The batch, or null when it was sealed
1112
+ * already
1113
+ * @memberof MongoStore
1114
+ */
1115
+ async sealBatch({ id, total, now }) {
1116
+ const document = await this.batches().findOneAndUpdate(
1117
+ { _id: id, sealed_at: null },
1118
+ { $set: { sealed_at: now, total, updated_at: now } },
1119
+ { includeResultMetadata: false, returnDocument: 'after' }
1120
+ );
1121
+
1122
+ return this.row(document);
1123
+ }
1124
+
1125
+ /**
1126
+ * Counts one terminal outcome into its batch
1127
+ *
1128
+ * The check and the `$inc` are two operations and the pair is still
1129
+ * exact: a job document that is terminal **and** still holds this
1130
+ * runner's claim token was written by this runner and can never be
1131
+ * claimed, recovered or written again, so nothing can make the check stale
1132
+ * between here and the increment. `$inc` itself is atomic on the document.
1133
+ *
1134
+ * @param {object} options `id`, `job`, `token`, `failed` and `now`
1135
+ * @returns {Promise<?object>} The batch as it is now, or null
1136
+ * @memberof MongoStore
1137
+ */
1138
+ async advanceBatch({ id, job, token, failed, now }) {
1139
+ const owned = await this.jobs().findOne({
1140
+ _id: job,
1141
+ batch_id: id,
1142
+ claim_token: token,
1143
+ state: { $in: ['done', 'dead'] },
1144
+ });
1145
+
1146
+ if (!owned) {
1147
+ return this.findBatch(id);
1148
+ }
1149
+
1150
+ const document = await this.batches().findOneAndUpdate(
1151
+ { _id: id, finished_at: null },
1152
+ {
1153
+ $inc: { done: 1, failed: failed ? 1 : 0 },
1154
+ $set: { updated_at: now },
1155
+ },
1156
+ { includeResultMetadata: false, returnDocument: 'after' }
1157
+ );
1158
+
1159
+ return this.row(document) || this.findBatch(id);
1160
+ }
1161
+
1162
+ /**
1163
+ * Gives a batch its slot back, when a job of it is put back in the queue
1164
+ *
1165
+ * @param {object} options `id`, `failed` and `now`
1166
+ * @returns {Promise<void>} Resolves when written
1167
+ * @memberof MongoStore
1168
+ */
1169
+ async releaseBatch({ id, failed, now }) {
1170
+ await this.batches().updateOne(
1171
+ { _id: id, done: { $gt: 0 }, finished_at: null },
1172
+ { $inc: { done: -1, failed: failed ? -1 : 0 }, $set: { updated_at: now } }
1173
+ );
1174
+ }
1175
+
1176
+ /**
1177
+ * Says a batch has finished, and what enqueued its callback
1178
+ *
1179
+ * @param {object} options `id`, `callback` and `now`
1180
+ * @returns {Promise<void>} Resolves when written
1181
+ * @memberof MongoStore
1182
+ */
1183
+ async finishBatch({ id, callback, now }) {
1184
+ await this.batches().updateOne(
1185
+ { _id: id, finished_at: null },
1186
+ {
1187
+ $set: {
1188
+ callback_id: callback || null,
1189
+ finished_at: now,
1190
+ updated_at: now,
1191
+ },
1192
+ }
1193
+ );
1194
+ }
1195
+
1196
+ /**
1197
+ * What a batch's jobs actually say, read from the queue itself
1198
+ *
1199
+ * @param {string} id The batch id
1200
+ * @returns {Promise<object>} `{ done, failed }`
1201
+ * @memberof MongoStore
1202
+ */
1203
+ async countBatch(id) {
1204
+ const rows = await this.jobs()
1205
+ .aggregate([
1206
+ { $match: { batch_id: id, state: { $in: ['done', 'dead'] } } },
1207
+ { $group: { _id: '$state', total: { $sum: 1 } } },
1208
+ ])
1209
+ .toArray();
1210
+ const counted = { done: 0, failed: 0 };
1211
+
1212
+ for (const row of rows) {
1213
+ counted.done += row.total;
1214
+
1215
+ if (row._id === 'dead') {
1216
+ counted.failed += row.total;
1217
+ }
1218
+ }
1219
+
1220
+ return counted;
1221
+ }
1222
+
1223
+ /**
1224
+ * Moves a batch's counters up to what its jobs say, never backwards
1225
+ *
1226
+ * @param {object} options `id`, `done`, `failed` and `now`
1227
+ * @returns {Promise<?object>} The batch as it is now
1228
+ * @memberof MongoStore
1229
+ */
1230
+ async syncBatch({ id, done, failed, now }) {
1231
+ await this.batches().updateOne(
1232
+ { _id: id, done: { $lt: done }, finished_at: null },
1233
+ { $set: { done, failed, updated_at: now } }
1234
+ );
1235
+
1236
+ return this.findBatch(id);
1237
+ }
1238
+
1239
+ /**
1240
+ * The batches that were sealed and have not finished
1241
+ *
1242
+ * @param {object} options `before` and `limit`
1243
+ * @returns {Promise<Array<object>>} The rows
1244
+ * @memberof MongoStore
1245
+ */
1246
+ async openBatches({ before, limit = 50 }) {
1247
+ const documents = await this.batches()
1248
+ .find({
1249
+ finished_at: null,
1250
+ sealed_at: { $ne: null },
1251
+ updated_at: { $lt: before },
1252
+ })
1253
+ .sort({ updated_at: 1 })
1254
+ .limit(Math.max(1, Number(limit) || 50))
1255
+ .toArray();
1256
+
1257
+ return documents.map((document) => this.row(document));
1258
+ }
1259
+
1260
+ /**
1261
+ * Lists batches, the newest first
1262
+ *
1263
+ * @param {object} [options={}] `finished`, `limit`, `offset`
1264
+ * @returns {Promise<Array<object>>} The rows
1265
+ * @memberof MongoStore
1266
+ */
1267
+ async listBatches({ finished, limit = 50, offset = 0 } = {}) {
1268
+ const filter =
1269
+ typeof finished === 'boolean'
1270
+ ? { finished_at: finished ? { $ne: null } : null }
1271
+ : {};
1272
+ const documents = await this.batches()
1273
+ .find(filter)
1274
+ // eslint-disable-next-line sort-keys
1275
+ .sort({ created_at: -1, _id: 1 })
1276
+ .skip(offset)
1277
+ .limit(limit)
1278
+ .toArray();
1279
+
1280
+ return documents.map((document) => this.row(document));
1281
+ }
1282
+
1283
+ /**
1284
+ * Forgets a batch
1285
+ *
1286
+ * @param {string} id The batch id
1287
+ * @returns {Promise<boolean>} Whether there was one
1288
+ * @memberof MongoStore
1289
+ */
1290
+ async removeBatch(id) {
1291
+ const result = await this.batches().deleteOne({ _id: id });
1292
+
1293
+ return (result.deletedCount || 0) > 0;
1294
+ }
1295
+
1296
+ /**
1297
+ * Deletes the batches that finished before a moment
1298
+ *
1299
+ * @param {number} before A timestamp
1300
+ * @param {number} [limit=1000] How many one pass deletes
1301
+ * @returns {Promise<number>} How many were deleted
1302
+ * @memberof MongoStore
1303
+ */
1304
+ async pruneBatches(before, limit = 1000) {
1305
+ const documents = await this.batches()
1306
+ .find({ finished_at: { $lt: before, $ne: null } })
1307
+ .sort({ finished_at: 1 })
1308
+ .limit(limit)
1309
+ .project({ _id: 1 })
1310
+ .toArray();
1311
+
1312
+ if (documents.length === 0) {
1313
+ return 0;
1314
+ }
1315
+
1316
+ const ids = documents.map((document) => document._id);
1317
+
1318
+ await this.batches().deleteMany({ _id: { $in: ids } });
1319
+
1320
+ return ids.length;
1321
+ }
1322
+ }
1323
+
1324
+ /**
1325
+ * Builds the MongoDB store of an adapter
1326
+ *
1327
+ * @param {object} adapter A henri mongoose (or disk) adapter
1328
+ * @param {object} tables `{ jobs, schedules, limits, batches }` collection
1329
+ * names
1330
+ * @returns {MongoStore} The store
1331
+ */
1332
+ const create = (adapter, tables) => new MongoStore(adapter, tables);
1333
+
1334
+ module.exports = { MongoStore, create };