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