@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,1744 @@
1
+ const debug = require('debug')('henri:jobs:sql');
2
+
3
+ const { JobStoreError } = require('../errors');
4
+ const { install, uninstall, upgrade } = require('./schema');
5
+ const { keep } = require('../keys');
6
+
7
+ /**
8
+ * The SQL backend of the queue.
9
+ *
10
+ * Everything goes through the store adapter's own `query()`: no henri model
11
+ * is involved, so the queue works on a store that has no models and cannot
12
+ * be broken by an application's model conventions.
13
+ *
14
+ * ## Claiming
15
+ *
16
+ * A job must never be performed twice at once. Every dialect claims with a
17
+ * single statement, which is therefore its own transaction, and the state
18
+ * is part of the statement's own `WHERE`: a row is claimed by the runner
19
+ * whose UPDATE flipped it out of `pending`, and by no one else.
20
+ *
21
+ * - PostgreSQL: `UPDATE ... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED)`.
22
+ * A concurrent runner steps over the rows this one locked instead of
23
+ * waiting for them.
24
+ * - MySQL: `UPDATE ... ORDER BY ... LIMIT n`. InnoDB locks the rows as it
25
+ * updates them; a concurrent runner blocks on a locked row, re-reads it
26
+ * once the lock is gone, sees it is no longer `pending` and moves on.
27
+ * - MSSQL: `UPDATE ... WHERE id IN (SELECT TOP (n) ... WITH (UPDLOCK,
28
+ * READPAST))`, the SKIP LOCKED of that dialect.
29
+ * - SQLite: `UPDATE ... WHERE id IN (SELECT ... LIMIT n)`. Writers are
30
+ * serialized by the database itself.
31
+ *
32
+ * The claim stamps a fresh `claim_token` on the rows it took, so the rows
33
+ * are read back with an exact `WHERE claim_token = ?` rather than by
34
+ * guessing which of the candidates were won.
35
+ *
36
+ * ## Concurrency limits
37
+ *
38
+ * A job may declare how many of it may run at once across every runner
39
+ * (`concurrency`). That bound is **not** in the claim statement, and it
40
+ * cannot be: a `SELECT COUNT(*) ... WHERE state = 'running'` inside the
41
+ * claim is read at the statement's own snapshot, so two runners racing both
42
+ * see the same free room, both take it and both commit. `FOR UPDATE SKIP
43
+ * LOCKED` does not help -- it locks the candidate *rows*, so the second
44
+ * runner steps over them and claims the next ones instead. Making the count
45
+ * exact needs a lock on something shared per key, and that is
46
+ * `pg_advisory_xact_lock` on PostgreSQL, `GET_LOCK` on MySQL,
47
+ * `sp_getapplock` on MSSQL and nothing at all on MongoDB: four mechanisms,
48
+ * one of them missing.
49
+ *
50
+ * So the bound lives in a table (`<jobs>_limits`), and the primitive is the
51
+ * one every backend agrees on: **a unique index refusing a duplicate**. One
52
+ * row is one slot, `(limit_key, slot)` is the primary key, and a runner
53
+ * takes a slot by inserting it -- exactly one insert per slot wins. A runner
54
+ * takes the permit *first* and claims one row of that key second, so a job
55
+ * is never claimed only to be put back, which would spin the runner's loop.
56
+ *
57
+ * The claim statement itself gains one predicate and keeps its shape:
58
+ * `name NOT IN (...)` for the pass that takes the unlimited work, and
59
+ * `name IN (...) AND concurrency_key = ?` for the pass that takes one
60
+ * limited row. With no limited job in the application the statement is what
61
+ * it always was, down to its parameters.
62
+ *
63
+ * ## Batches
64
+ *
65
+ * A batch counts its jobs, and a counter read, added to and written back is
66
+ * the lost update every textbook opens with -- two runners finishing at the
67
+ * same instant would both read 39 and both write 40. So the counter is
68
+ * **never read to be written**: `advanceBatch()` is one statement,
69
+ * `SET done = done + 1`, which every engine evaluates under a row lock of
70
+ * its own (sqlite serializes its writers outright), so the increments of
71
+ * four runners are four increments.
72
+ *
73
+ * What makes it exactly once is the `EXISTS` in that same statement: the
74
+ * counter only moves while the job row still holds **this runner's claim
75
+ * token** and is already terminal -- which is true of exactly one runner,
76
+ * the one whose token-guarded outcome write landed. A runner whose write
77
+ * was refused because it had been recovered from counts nothing, and the
78
+ * runner that took the job over counts once when it finishes.
79
+ *
80
+ * ## The tenant
81
+ *
82
+ * `tenant` is a column like `concurrency_key` and `batch_id`: it arrives
83
+ * through the tolerated upgrade block, `tenanted()` asks the table whether
84
+ * it is there, and the insert names the columns that are. It is written by
85
+ * the enqueue and read by a listing.
86
+ *
87
+ * It is deliberately **not** in the claim. A runner performs every
88
+ * tenant's work, and narrowing the claim would give one customer's backlog
89
+ * a runner of its own -- a scheduling feature, with a fairness question
90
+ * attached, and not this. What the column decides is which tenant the
91
+ * runner *enters* before it calls `perform()` (`Jobs#scoped`), which is a
92
+ * different question with a different answer.
93
+ */
94
+
95
+ /** The columns of the jobs table, in insert order */
96
+ const COLUMNS = [
97
+ 'id',
98
+ 'queue',
99
+ 'name',
100
+ 'args',
101
+ 'state',
102
+ 'priority',
103
+ 'attempts',
104
+ 'max_attempts',
105
+ 'timeout_ms',
106
+ 'run_at',
107
+ 'created_at',
108
+ 'updated_at',
109
+ 'started_at',
110
+ 'finished_at',
111
+ 'duration_ms',
112
+ 'claimed_by',
113
+ 'claimed_at',
114
+ 'heartbeat_at',
115
+ 'claim_token',
116
+ 'error_message',
117
+ 'error_stack',
118
+ 'history',
119
+ 'unique_key',
120
+ 'concurrency_key',
121
+ 'batch_id',
122
+ 'tenant',
123
+ ];
124
+
125
+ /** The columns of the batches table, in insert order */
126
+ const BATCH_COLUMNS = [
127
+ 'id',
128
+ 'name',
129
+ 'callback',
130
+ 'callback_args',
131
+ 'callback_options',
132
+ 'callback_id',
133
+ 'total',
134
+ 'done',
135
+ 'failed',
136
+ 'created_at',
137
+ 'updated_at',
138
+ 'sealed_at',
139
+ 'finished_at',
140
+ ];
141
+
142
+ /** How many attempts of a job are kept in its history */
143
+ const HISTORY_LIMIT = 10;
144
+
145
+ /**
146
+ * Errors that mean the object was created by someone else in between.
147
+ *
148
+ * `CREATE TABLE IF NOT EXISTS` is not atomic against a concurrent creation
149
+ * on PostgreSQL: two processes booting together -- a web server and a
150
+ * runner, or two runners -- can both find the table missing and one of them
151
+ * then fails on the catalogue's own unique index. The install is idempotent
152
+ * by intent, so that failure means it is done, not that it broke.
153
+ */
154
+ const ALREADY_THERE =
155
+ /already exists|duplicate key|duplicate table|duplicate column|duplicate key name|there is already an object named/i;
156
+
157
+ /**
158
+ * Errors that mean a unique index refused the row.
159
+ *
160
+ * Sequelize names its own (`SequelizeUniqueConstraintError`, whose message
161
+ * is the unhelpful `Validation error`), the drivers word theirs differently,
162
+ * and drizzle passes the driver's through.
163
+ */
164
+ const DUPLICATE =
165
+ /unique|duplicate|Validation error|SQLITE_CONSTRAINT|ER_DUP_ENTRY|23505/i;
166
+
167
+ /** Errors that mean "another writer got there first, try again" */
168
+ const RETRYABLE =
169
+ /deadlock|lock wait timeout|database is locked|database table is locked|SQLITE_BUSY/i;
170
+
171
+ /**
172
+ * Everything an error says about itself, wrappers included
173
+ *
174
+ * Sequelize keeps the driver error on `parent`, drizzle on `cause`; the
175
+ * useful words (deadlock, duplicate, already exists) are down there.
176
+ *
177
+ * @param {*} error An error
178
+ * @param {number} [depth=4] How far to unwrap
179
+ * @returns {string} The messages, joined
180
+ */
181
+ const reasons = (error, depth = 4) => {
182
+ const said = [];
183
+ let current = error;
184
+
185
+ for (let step = 0; step < depth && current; step += 1) {
186
+ said.push(String(current.message || ''), String(current.code || ''));
187
+ current = current.parent || current.original || current.cause;
188
+ }
189
+
190
+ return said.join(' ');
191
+ };
192
+
193
+ /**
194
+ * A number read back from any driver (pg hands BIGINT over as a string)
195
+ *
196
+ * @param {*} value The stored value
197
+ * @returns {?number} The number, or null
198
+ */
199
+ const toNumber = (value) => {
200
+ if (value === null || typeof value === 'undefined' || value === '') {
201
+ return null;
202
+ }
203
+
204
+ const number = Number(value);
205
+
206
+ return Number.isNaN(number) ? null : number;
207
+ };
208
+
209
+ /**
210
+ * The `?` placeholders of a list
211
+ *
212
+ * @param {Array} values The values
213
+ * @returns {string} `?, ?, ?`
214
+ */
215
+ const marks = (values) => values.map(() => '?').join(', ');
216
+
217
+ /**
218
+ * The parameters of a statement, short enough for a debug line
219
+ *
220
+ * The arguments of a job (and a rendered mail body) go through here: they
221
+ * are never printed whole, not even with DEBUG on.
222
+ *
223
+ * @param {Array} params The parameters
224
+ * @returns {Array} The parameters, the long ones cut short
225
+ */
226
+ const brief = (params) =>
227
+ params.map((value) =>
228
+ typeof value === 'string' && value.length > 80
229
+ ? `${value.slice(0, 80)}... (${value.length} chars)`
230
+ : value
231
+ );
232
+
233
+ /**
234
+ * The SQL store
235
+ *
236
+ * @class SqlStore
237
+ */
238
+ class SqlStore {
239
+ /**
240
+ * Creates an instance of SqlStore.
241
+ *
242
+ * @param {object} adapter A henri store adapter with `query()`
243
+ * @param {object} options Options
244
+ * @param {string} options.dialect sqlite, postgres, mysql or mssql
245
+ * @param {boolean} [options.dollars=false] The driver numbers its
246
+ * placeholders (`$1`), as node-postgres does
247
+ * @param {object} options.tables `{ jobs, schedules, limits, batches }` table names
248
+ * @memberof SqlStore
249
+ */
250
+ constructor(adapter, { dialect, dollars = false, tables }) {
251
+ this.adapter = adapter;
252
+ this.dialect = dialect;
253
+ this.dollars = dollars;
254
+ this.tables = tables;
255
+ this.kind = 'sql';
256
+ /** Whether the table has `concurrency_key`; asked once, see concurrent() */
257
+ this.limits = null;
258
+ /** Whether the store can hold a batch; asked once, see batched() */
259
+ this.batches = null;
260
+ /** Whether the table has `tenant`; asked once, see tenanted() */
261
+ this.tenants = null;
262
+ }
263
+
264
+ /**
265
+ * The statement with the placeholders the driver expects
266
+ *
267
+ * @param {string} sql A statement written with `?` placeholders
268
+ * @returns {string} The statement
269
+ * @memberof SqlStore
270
+ */
271
+ prepare(sql) {
272
+ if (!this.dollars) {
273
+ return sql;
274
+ }
275
+
276
+ let index = 0;
277
+
278
+ return sql.replace(/\?/g, () => {
279
+ index += 1;
280
+
281
+ return `$${index}`;
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Runs a statement that returns no rows
287
+ *
288
+ * A statement the database refused because another writer held the rows
289
+ * (a deadlock, a lock timeout, a busy sqlite file) never executed: it was
290
+ * rolled back, so running it again is safe and is what the retry does.
291
+ *
292
+ * @param {string} sql The statement, with `?` placeholders
293
+ * @param {Array} [params=[]] The parameters
294
+ * @returns {Promise<void>} Resolves when done
295
+ * @memberof SqlStore
296
+ */
297
+ async run(sql, params = []) {
298
+ debug('run %s %o', sql, brief(params));
299
+
300
+ await this.retrying(() => this.adapter.query(this.prepare(sql), params));
301
+ }
302
+
303
+ /**
304
+ * Runs a query and returns its rows
305
+ *
306
+ * `{ type: 'SELECT' }` is what the sequelize adapters need to hand back
307
+ * plain rows instead of `[rows, metadata]`; the drizzle adapter ignores
308
+ * the third argument and returns rows already.
309
+ *
310
+ * @param {string} sql The query, with `?` placeholders
311
+ * @param {Array} [params=[]] The parameters
312
+ * @returns {Promise<Array<object>>} The rows
313
+ * @memberof SqlStore
314
+ */
315
+ async select(sql, params = []) {
316
+ debug('select %s %o', sql, brief(params));
317
+
318
+ const result = await this.retrying(() =>
319
+ this.adapter.query(this.prepare(sql), params, { type: 'SELECT' })
320
+ );
321
+
322
+ return Array.isArray(result) ? result : [];
323
+ }
324
+
325
+ /**
326
+ * Runs an operation again when the database says another writer won
327
+ *
328
+ * @param {function} fn The operation
329
+ * @param {number} [attempts=5] How many times to try
330
+ * @returns {Promise<*>} What fn returns
331
+ * @throws {Error} The last error when every attempt failed
332
+ * @memberof SqlStore
333
+ */
334
+ async retrying(fn, attempts = 8) {
335
+ let last = null;
336
+
337
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
338
+ try {
339
+ return await fn();
340
+ } catch (error) {
341
+ if (!RETRYABLE.test(reasons(error))) {
342
+ throw error;
343
+ }
344
+
345
+ last = error;
346
+ debug('retrying after %s', error.message);
347
+ await new Promise((resolve) => setTimeout(resolve, 15 * (attempt + 1)));
348
+ }
349
+ }
350
+
351
+ throw last;
352
+ }
353
+
354
+ /**
355
+ * Creates the tables and the indexes; idempotent
356
+ *
357
+ * The upgrade block (`schema.upgrade()`) is tolerated whatever it answers:
358
+ * it touches a table an older henri created, and a user who may not
359
+ * `ALTER` must not fail the boot of an application that never asked for
360
+ * the column it adds. What the column is needed for asks for it by name
361
+ * (`concurrent()`), and says so with the install line.
362
+ *
363
+ * @returns {Promise<Array<string>>} The statements that ran
364
+ * @memberof SqlStore
365
+ */
366
+ async install() {
367
+ const statements = install(this.dialect, this.tables);
368
+ const soft = new Set(upgrade(this.dialect, this.tables));
369
+
370
+ for (const statement of statements) {
371
+ try {
372
+ await this.run(statement);
373
+ } catch (error) {
374
+ if (soft.has(statement)) {
375
+ debug('upgrade statement did not apply: %s', error.message);
376
+ continue;
377
+ }
378
+
379
+ if (!ALREADY_THERE.test(reasons(error))) {
380
+ throw error;
381
+ }
382
+
383
+ debug('another process created it first: %s', error.message);
384
+ }
385
+ }
386
+
387
+ this.limits = null;
388
+ this.batches = null;
389
+ this.tenants = null;
390
+
391
+ return statements;
392
+ }
393
+
394
+ /**
395
+ * Whether the jobs table has the column concurrency limits need
396
+ *
397
+ * Asked once, of the table itself rather than of what the install
398
+ * answered: an installation that upgraded henri without running the
399
+ * install, or whose database user may not `ALTER`, has the table an older
400
+ * version wrote and the queue works exactly as it did.
401
+ *
402
+ * @returns {Promise<boolean>} true when `concurrency_key` is there
403
+ * @memberof SqlStore
404
+ */
405
+ async concurrent() {
406
+ if (typeof this.limits === 'boolean') {
407
+ return this.limits;
408
+ }
409
+
410
+ try {
411
+ // Reads nothing: the planner still has to resolve the column
412
+ await this.select(
413
+ `SELECT concurrency_key FROM ${this.tables.jobs} WHERE 1 = 0`
414
+ );
415
+ this.limits = true;
416
+ } catch (error) {
417
+ debug('no concurrency_key column: %s', error.message);
418
+ this.limits = false;
419
+ }
420
+
421
+ return this.limits;
422
+ }
423
+
424
+ /**
425
+ * Whether this store can hold a batch
426
+ *
427
+ * Asked once, of the database rather than of what the install answered,
428
+ * for the reason `concurrent()` gives: an installation that upgraded
429
+ * henri without running the install, or whose database user may not
430
+ * `ALTER`, has the tables an older version wrote and the queue works
431
+ * exactly as it did. Both halves are asked, because a batch needs the
432
+ * column that ties a job to it *and* the table that counts.
433
+ *
434
+ * @returns {Promise<boolean>} true when a batch can be stored
435
+ * @memberof SqlStore
436
+ */
437
+ async batched() {
438
+ if (typeof this.batches === 'boolean') {
439
+ return this.batches;
440
+ }
441
+
442
+ try {
443
+ // Reads nothing: the planner still has to resolve both
444
+ await this.select(`SELECT batch_id FROM ${this.tables.jobs} WHERE 1 = 0`);
445
+ await this.select(`SELECT id FROM ${this.tables.batches} WHERE 1 = 0`);
446
+ this.batches = true;
447
+ } catch (error) {
448
+ debug('no batches here: %s', error.message);
449
+ this.batches = false;
450
+ }
451
+
452
+ return this.batches;
453
+ }
454
+
455
+ /**
456
+ * Whether the jobs table has the column a tenant is stamped in
457
+ *
458
+ * Asked once, of the table itself rather than of what the install
459
+ * answered, for the reason `concurrent()` gives. An application that is
460
+ * not multi-tenant never notices either answer: the column holds null
461
+ * for every row it writes.
462
+ *
463
+ * @returns {Promise<boolean>} true when `tenant` is there
464
+ * @memberof SqlStore
465
+ */
466
+ async tenanted() {
467
+ if (typeof this.tenants === 'boolean') {
468
+ return this.tenants;
469
+ }
470
+
471
+ try {
472
+ // Reads nothing: the planner still has to resolve the column
473
+ await this.select(`SELECT tenant FROM ${this.tables.jobs} WHERE 1 = 0`);
474
+ this.tenants = true;
475
+ } catch (error) {
476
+ debug('no tenant column: %s', error.message);
477
+ this.tenants = false;
478
+ }
479
+
480
+ return this.tenants;
481
+ }
482
+
483
+ /**
484
+ * The columns of the jobs table an insert may name
485
+ *
486
+ * A table an older henri wrote has none of `concurrency_key`, `batch_id`
487
+ * and `tenant`, and an application that uses none of them must not
488
+ * notice: the insert names the columns that are there, so the queue works
489
+ * exactly as it did.
490
+ *
491
+ * @returns {Promise<Array<string>>} The column names
492
+ * @memberof SqlStore
493
+ */
494
+ async columns() {
495
+ const missing = [];
496
+
497
+ if (!(await this.concurrent())) {
498
+ missing.push('concurrency_key');
499
+ }
500
+
501
+ if (!(await this.batched())) {
502
+ missing.push('batch_id');
503
+ }
504
+
505
+ if (!(await this.tenanted())) {
506
+ missing.push('tenant');
507
+ }
508
+
509
+ return missing.length === 0
510
+ ? COLUMNS
511
+ : COLUMNS.filter((column) => !missing.includes(column));
512
+ }
513
+
514
+ /**
515
+ * Drops the tables
516
+ *
517
+ * @returns {Promise<Array<string>>} The statements that ran
518
+ * @memberof SqlStore
519
+ */
520
+ async uninstall() {
521
+ const statements = uninstall(this.dialect, this.tables);
522
+
523
+ for (const statement of statements) {
524
+ await this.run(statement);
525
+ }
526
+
527
+ return statements;
528
+ }
529
+
530
+ /**
531
+ * Whether the tables are there
532
+ *
533
+ * @returns {Promise<boolean>} true when the jobs table answers
534
+ * @memberof SqlStore
535
+ */
536
+ async installed() {
537
+ try {
538
+ await this.select(`SELECT COUNT(*) AS total FROM ${this.tables.jobs}`);
539
+
540
+ return true;
541
+ } catch (error) {
542
+ return false;
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Inserts a job
548
+ *
549
+ * @param {object} job A row, in database shape
550
+ * @returns {Promise<object>} The job, read back
551
+ * @throws {JobStoreError} DUPLICATE when its unique key is taken
552
+ * @memberof SqlStore
553
+ */
554
+ async insert(job) {
555
+ const columns = await this.columns();
556
+ const values = columns.map((column) =>
557
+ typeof job[column] === 'undefined' ? null : job[column]
558
+ );
559
+
560
+ try {
561
+ await this.run(
562
+ `INSERT INTO ${this.tables.jobs} (${columns.join(', ')}) VALUES (${marks(columns)})`,
563
+ values
564
+ );
565
+ } catch (error) {
566
+ // Only a duplicate key is answered with the job that holds it. Any
567
+ // other failure -- a value too long, a connection gone -- is the
568
+ // caller's to see, or an enqueue would silently do nothing
569
+ if (job.unique_key && DUPLICATE.test(reasons(error))) {
570
+ const existing = await this.findByUniqueKey(job.unique_key);
571
+
572
+ if (existing && existing.id !== job.id) {
573
+ return existing;
574
+ }
575
+ }
576
+
577
+ throw error;
578
+ }
579
+
580
+ return this.find(job.id);
581
+ }
582
+
583
+ /**
584
+ * One job by id
585
+ *
586
+ * @param {string} id The job id
587
+ * @returns {Promise<?object>} The row, or null
588
+ * @memberof SqlStore
589
+ */
590
+ async find(id) {
591
+ const [row] = await this.select(
592
+ `SELECT * FROM ${this.tables.jobs} WHERE id = ?`,
593
+ [id]
594
+ );
595
+
596
+ return row || null;
597
+ }
598
+
599
+ /**
600
+ * One job by unique key
601
+ *
602
+ * @param {string} key The unique key
603
+ * @returns {Promise<?object>} The row, or null
604
+ * @memberof SqlStore
605
+ */
606
+ async findByUniqueKey(key) {
607
+ const [row] = await this.select(
608
+ `SELECT * FROM ${this.tables.jobs} WHERE unique_key = ?`,
609
+ [key]
610
+ );
611
+
612
+ return row || null;
613
+ }
614
+
615
+ /**
616
+ * The claim statement of this dialect, and its parameters
617
+ *
618
+ * @param {object} options Options
619
+ * @param {Array<string>} options.queues The queues to take from
620
+ * @param {number} options.limit How many rows at most
621
+ * @param {string} options.runner The runner id
622
+ * @param {string} options.token A token unique to this claim
623
+ * @param {number} options.now The current time
624
+ * @param {object} [options.key] `{ value, own }`, the concurrency key this
625
+ * pass holds a slot for; `own` when it is the group's own bucket
626
+ * @param {Array<string>} [options.names] Only these job names
627
+ * @param {Array<string>} [options.except] Every name but these
628
+ * @returns {{sql: string, params: Array}} The statement
629
+ * @memberof SqlStore
630
+ */
631
+ claimStatement({ queues, limit, runner, token, now, key, names, except }) {
632
+ const table = this.tables.jobs;
633
+ const set = [
634
+ `state = 'running'`,
635
+ 'attempts = attempts + 1',
636
+ 'claimed_by = ?',
637
+ 'claim_token = ?',
638
+ 'claimed_at = ?',
639
+ 'heartbeat_at = ?',
640
+ 'started_at = ?',
641
+ 'updated_at = ?',
642
+ ].join(', ');
643
+ const setParams = [runner, token, now, now, now, now];
644
+ const filter = [`state = 'pending'`, 'run_at <= ?'];
645
+ const filterParams = [now];
646
+
647
+ if (queues.length > 0) {
648
+ filter.push(`queue IN (${marks(queues)})`);
649
+ filterParams.push(...queues);
650
+ }
651
+
652
+ // The two passes partition the pending rows by **name**, so every row
653
+ // belongs to exactly one of them: a job that gained a limit is taken by
654
+ // the second pass from that moment on, and one that lost its limit goes
655
+ // back to the first even though its rows still carry a key
656
+ if (except && except.length > 0) {
657
+ filter.push(`name NOT IN (${marks(except)})`);
658
+ filterParams.push(...except);
659
+ }
660
+
661
+ if (names && names.length > 0) {
662
+ filter.push(`name IN (${marks(names)})`);
663
+ filterParams.push(...names);
664
+ }
665
+
666
+ if (key) {
667
+ // A row enqueued before the limit was declared carries no key at all;
668
+ // it belongs to the group's own bucket, which is what `key.own` says
669
+ filter.push(
670
+ key.own
671
+ ? '(concurrency_key = ? OR concurrency_key IS NULL)'
672
+ : 'concurrency_key = ?'
673
+ );
674
+ filterParams.push(key.value);
675
+ }
676
+
677
+ const where = filter.join(' AND ');
678
+ const order = 'priority ASC, run_at ASC, id ASC';
679
+
680
+ if (this.dialect === 'mysql') {
681
+ return {
682
+ params: [...setParams, ...filterParams, limit],
683
+ sql: `UPDATE ${table} SET ${set} WHERE ${where} ORDER BY ${order} LIMIT ?`,
684
+ };
685
+ }
686
+
687
+ if (this.dialect === 'postgres') {
688
+ return {
689
+ params: [...setParams, ...filterParams, limit],
690
+ sql: `UPDATE ${table} SET ${set} WHERE id IN (SELECT id FROM ${table} WHERE ${where} ORDER BY ${order} LIMIT ? FOR UPDATE SKIP LOCKED)`,
691
+ };
692
+ }
693
+
694
+ if (this.dialect === 'mssql') {
695
+ return {
696
+ params: [...setParams, limit, ...filterParams],
697
+ sql: `UPDATE ${table} SET ${set} WHERE id IN (SELECT TOP (?) id FROM ${table} WITH (UPDLOCK, READPAST) WHERE ${where} ORDER BY ${order})`,
698
+ };
699
+ }
700
+
701
+ return {
702
+ params: [...setParams, ...filterParams, limit],
703
+ sql: `UPDATE ${table} SET ${set} WHERE id IN (SELECT id FROM ${table} WHERE ${where} ORDER BY ${order} LIMIT ?)`,
704
+ };
705
+ }
706
+
707
+ /**
708
+ * Claims up to `limit` jobs for this runner
709
+ *
710
+ * @param {object} options Options
711
+ * @param {Array<string>} [options.queues=[]] The queues to take from
712
+ * @param {number} [options.limit=1] How many jobs at most
713
+ * @param {string} options.runner The runner id
714
+ * @param {string} options.token A token unique to this claim
715
+ * @param {number} options.now The current time
716
+ * @param {object} [options.key] The concurrency key a slot is held for
717
+ * @param {Array<string>} [options.names] Only these job names
718
+ * @param {Array<string>} [options.except] Every name but these
719
+ * @returns {Promise<Array<object>>} The rows this runner owns
720
+ * @memberof SqlStore
721
+ */
722
+ async claim({
723
+ queues = [],
724
+ limit = 1,
725
+ runner,
726
+ token,
727
+ now,
728
+ key,
729
+ names,
730
+ except,
731
+ }) {
732
+ const { params, sql } = this.claimStatement({
733
+ except,
734
+ key,
735
+ limit,
736
+ names,
737
+ now,
738
+ queues,
739
+ runner,
740
+ token,
741
+ });
742
+
743
+ await this.run(sql, params);
744
+
745
+ return this.select(
746
+ `SELECT * FROM ${this.tables.jobs} WHERE claim_token = ? AND state = 'running' ORDER BY priority ASC, run_at ASC, id ASC`,
747
+ [token]
748
+ );
749
+ }
750
+
751
+ /**
752
+ * The concurrency keys with work waiting, the most urgent first
753
+ *
754
+ * One row per `(concurrency_key, name)` pair, so the caller can map a row
755
+ * that carries no key -- enqueued before the limit was declared -- onto
756
+ * the group it belongs to, which only the definitions know.
757
+ *
758
+ * @param {object} options Options
759
+ * @param {number} options.now The current time
760
+ * @param {Array<string>} options.names The names of the limited jobs
761
+ * @param {Array<string>} [options.queues=[]] The queues to look at
762
+ * @param {number} [options.limit=100] How many keys at most
763
+ * @returns {Promise<Array<object>>} `{ key, name, total }` rows
764
+ * @memberof SqlStore
765
+ */
766
+ async waiting({ now, names, queues = [], limit = 100 }) {
767
+ if (!names || names.length === 0) {
768
+ return [];
769
+ }
770
+
771
+ const filter = [
772
+ `state = 'pending'`,
773
+ 'run_at <= ?',
774
+ `name IN (${marks(names)})`,
775
+ ];
776
+ const params = [now, ...names];
777
+
778
+ if (queues.length > 0) {
779
+ filter.push(`queue IN (${marks(queues)})`);
780
+ params.push(...queues);
781
+ }
782
+
783
+ const page =
784
+ this.dialect === 'mssql'
785
+ ? 'OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY'
786
+ : 'LIMIT ?';
787
+ const rows = await this.select(
788
+ `SELECT concurrency_key, name, COUNT(*) AS total FROM ${this.tables.jobs} WHERE ${filter.join(' AND ')} GROUP BY concurrency_key, name ORDER BY MIN(priority) ASC, MIN(run_at) ASC ${page}`,
789
+ [...params, Math.max(1, Number(limit) || 100)]
790
+ );
791
+
792
+ return rows.map((row) => ({
793
+ key: row.concurrency_key || null,
794
+ name: row.name,
795
+ total: toNumber(row.total) || 0,
796
+ }));
797
+ }
798
+
799
+ /**
800
+ * Takes one of a key's slots, or answers null when they are all held
801
+ *
802
+ * **This is the bound.** `(limit_key, slot)` is the primary key, so of
803
+ * every runner inserting the same slot exactly one succeeds and the others
804
+ * are refused by the index -- no transaction, no affected-row count, no
805
+ * dialect of its own. The slots are tried in order, so a key at its limit
806
+ * costs `limit` refused inserts and nothing else.
807
+ *
808
+ * @param {object} options Options
809
+ * @param {string} options.key The concurrency key
810
+ * @param {number} options.limit How many may run at once
811
+ * @param {string} options.runner The runner id
812
+ * @param {number} options.now The current time
813
+ * @returns {Promise<?number>} The slot this runner holds, or null
814
+ * @memberof SqlStore
815
+ */
816
+ async takeSlot({ key, limit, runner, now }) {
817
+ const held = await this.select(
818
+ `SELECT slot FROM ${this.tables.limits} WHERE limit_key = ?`,
819
+ [key]
820
+ );
821
+ const taken = new Set(held.map((row) => toNumber(row.slot)));
822
+
823
+ for (let slot = 0; slot < limit; slot += 1) {
824
+ if (taken.has(slot)) {
825
+ continue;
826
+ }
827
+
828
+ try {
829
+ await this.run(
830
+ `INSERT INTO ${this.tables.limits} (limit_key, slot, job_id, runner, taken_at, heartbeat_at) VALUES (?, ?, ?, ?, ?, ?)`,
831
+ [key, slot, null, runner, now, now]
832
+ );
833
+
834
+ return slot;
835
+ } catch (error) {
836
+ if (!DUPLICATE.test(reasons(error))) {
837
+ throw error;
838
+ }
839
+
840
+ debug('slot %d of %s was taken first', slot, key);
841
+ }
842
+ }
843
+
844
+ return null;
845
+ }
846
+
847
+ /**
848
+ * Says which job a slot is being held for
849
+ *
850
+ * @param {string} key The concurrency key
851
+ * @param {number} slot The slot
852
+ * @param {?string} id The job id
853
+ * @param {number} now The current time
854
+ * @returns {Promise<void>} Resolves when written
855
+ * @memberof SqlStore
856
+ */
857
+ async holdSlot(key, slot, id, now) {
858
+ await this.run(
859
+ `UPDATE ${this.tables.limits} SET job_id = ?, heartbeat_at = ? WHERE limit_key = ? AND slot = ?`,
860
+ [id, now, key, slot]
861
+ );
862
+ }
863
+
864
+ /**
865
+ * Gives a slot back
866
+ *
867
+ * @param {string} key The concurrency key
868
+ * @param {number} slot The slot
869
+ * @param {string} [runner] Only when this runner still holds it
870
+ * @returns {Promise<void>} Resolves when written
871
+ * @memberof SqlStore
872
+ */
873
+ async releaseSlot(key, slot, runner) {
874
+ const own = runner ? ' AND runner = ?' : '';
875
+ const params = runner ? [key, slot, runner] : [key, slot];
876
+
877
+ await this.run(
878
+ `DELETE FROM ${this.tables.limits} WHERE limit_key = ? AND slot = ?${own}`,
879
+ params
880
+ );
881
+ }
882
+
883
+ /**
884
+ * Tells the database this runner still holds these slots
885
+ *
886
+ * @param {Array<object>} slots `{ key, slot }` entries
887
+ * @param {number} now The current time
888
+ * @param {string} runner The runner id
889
+ * @returns {Promise<void>} Resolves when written
890
+ * @memberof SqlStore
891
+ */
892
+ async heartbeatSlots(slots, now, runner) {
893
+ for (const held of slots) {
894
+ await this.run(
895
+ `UPDATE ${this.tables.limits} SET heartbeat_at = ? WHERE limit_key = ? AND slot = ? AND runner = ?`,
896
+ [now, held.key, held.slot, runner]
897
+ );
898
+ }
899
+ }
900
+
901
+ /**
902
+ * Frees the slots of runners that stopped answering
903
+ *
904
+ * The bound rests on this being slower than the heartbeat: a slot is
905
+ * refreshed four times per `stuckAfter`, and freeing one that is still
906
+ * held would let a second runner perform alongside the first. It is the
907
+ * same condition the recovery of a claimed job already rests on.
908
+ *
909
+ * @param {object} options Options
910
+ * @param {number} options.now The current time
911
+ * @param {number} options.stuckAfter How long without a heartbeat is dead
912
+ * @param {number} [options.limit=100] How many one sweep frees
913
+ * @returns {Promise<Array<object>>} The slots that were freed
914
+ * @memberof SqlStore
915
+ */
916
+ async sweepSlots({ now, stuckAfter, limit = 100 }) {
917
+ const page =
918
+ this.dialect === 'mssql'
919
+ ? 'ORDER BY heartbeat_at ASC OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY'
920
+ : 'ORDER BY heartbeat_at ASC LIMIT ?';
921
+ const rows = await this.select(
922
+ `SELECT * FROM ${this.tables.limits} WHERE heartbeat_at < ? ${page}`,
923
+ [now - stuckAfter, limit]
924
+ );
925
+
926
+ for (const row of rows) {
927
+ await this.run(
928
+ `DELETE FROM ${this.tables.limits} WHERE limit_key = ? AND slot = ? AND heartbeat_at = ?`,
929
+ [row.limit_key, toNumber(row.slot), toNumber(row.heartbeat_at)]
930
+ );
931
+ }
932
+
933
+ return rows.map((row) => ({
934
+ job: row.job_id || null,
935
+ key: row.limit_key,
936
+ runner: row.runner,
937
+ slot: toNumber(row.slot),
938
+ takenAt: toNumber(row.taken_at),
939
+ }));
940
+ }
941
+
942
+ /**
943
+ * Every slot being held right now
944
+ *
945
+ * @param {number} [limit=200] How many at most
946
+ * @returns {Promise<Array<object>>} The held slots
947
+ * @memberof SqlStore
948
+ */
949
+ async slots(limit = 200) {
950
+ const page =
951
+ this.dialect === 'mssql'
952
+ ? 'OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY'
953
+ : 'LIMIT ?';
954
+ const rows = await this.select(
955
+ `SELECT * FROM ${this.tables.limits} ORDER BY limit_key ASC, slot ASC ${page}`,
956
+ [limit]
957
+ );
958
+
959
+ return rows.map((row) => ({
960
+ heartbeatAt: toNumber(row.heartbeat_at),
961
+ job: row.job_id || null,
962
+ key: row.limit_key,
963
+ runner: row.runner,
964
+ slot: toNumber(row.slot),
965
+ takenAt: toNumber(row.taken_at),
966
+ }));
967
+ }
968
+
969
+ /**
970
+ * Writes the outcome of an attempt
971
+ *
972
+ * With a token the write only lands while this runner still owns the row.
973
+ * That matters: a runner whose heartbeat went stale has had its jobs put
974
+ * back and re-claimed by someone else, and it must not write its outcome
975
+ * over the new owner's.
976
+ *
977
+ * @param {string} id The job id
978
+ * @param {object} changes The columns to set
979
+ * @param {string} [token] The claim token this runner holds
980
+ * @returns {Promise<void>} Resolves when written
981
+ * @memberof SqlStore
982
+ */
983
+ async update(id, changes, token) {
984
+ const keys = Object.keys(changes);
985
+
986
+ if (keys.length === 0) {
987
+ return;
988
+ }
989
+
990
+ const own = token ? ` AND claim_token = ? AND state = 'running'` : '';
991
+ const params = [...keys.map((key) => changes[key]), id];
992
+
993
+ if (token) {
994
+ params.push(token);
995
+ }
996
+
997
+ await this.run(
998
+ `UPDATE ${this.tables.jobs} SET ${keys.map((key) => `${key} = ?`).join(', ')} WHERE id = ?${own}`,
999
+ params
1000
+ );
1001
+ }
1002
+
1003
+ /**
1004
+ * Puts back the jobs of runners that stopped answering
1005
+ *
1006
+ * A runner that is killed between the claim and the outcome leaves a row
1007
+ * `running` forever; the heartbeat says when the row was last seen alive.
1008
+ * Jobs with attempts left go back to `pending`, the others to the dead
1009
+ * letter queue.
1010
+ *
1011
+ * The sweep is bounded: after a crash that left thousands of rows behind,
1012
+ * a runner puts back a batch and gets on with claiming rather than
1013
+ * blocking its own loop for the whole pass.
1014
+ *
1015
+ * @param {object} options Options
1016
+ * @param {number} options.now The current time
1017
+ * @param {number} options.stuckAfter How long without a heartbeat is dead
1018
+ * @param {number} [options.limit=100] How many rows one sweep puts back
1019
+ * @returns {Promise<Array<object>>} The rows that were recovered
1020
+ * @memberof SqlStore
1021
+ */
1022
+ async recover({ now, stuckAfter, limit = 100 }) {
1023
+ const table = this.tables.jobs;
1024
+ const page =
1025
+ this.dialect === 'mssql'
1026
+ ? 'ORDER BY heartbeat_at ASC OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY'
1027
+ : 'ORDER BY heartbeat_at ASC LIMIT ?';
1028
+ const rows = await this.select(
1029
+ `SELECT * FROM ${table} WHERE state = 'running' AND heartbeat_at < ? ${page}`,
1030
+ [now - stuckAfter, limit]
1031
+ );
1032
+
1033
+ for (const row of rows) {
1034
+ const attempts = toNumber(row.attempts) || 0;
1035
+ const max = toNumber(row.max_attempts) || 0;
1036
+ const dead = attempts >= max;
1037
+
1038
+ await this.run(
1039
+ `UPDATE ${table} SET state = ?, run_at = ?, claim_token = NULL, unique_key = ?, error_message = ?, finished_at = ?, updated_at = ? WHERE id = ? AND state = 'running' AND claim_token = ?`,
1040
+ [
1041
+ dead ? 'dead' : 'pending',
1042
+ now,
1043
+ // A dead job holds its unique key no longer: the same work may be
1044
+ // enqueued again while this one sits in the dead letter queue
1045
+ dead ? keep(row.unique_key) : row.unique_key,
1046
+ `the runner ${row.claimed_by} stopped answering while performing this job`,
1047
+ dead ? now : null,
1048
+ now,
1049
+ row.id,
1050
+ row.claim_token,
1051
+ ]
1052
+ );
1053
+ }
1054
+
1055
+ return rows;
1056
+ }
1057
+
1058
+ /**
1059
+ * Tells the database this runner is still on these jobs
1060
+ *
1061
+ * A runner that was already recovered from no longer owns these rows, so
1062
+ * the token is part of the filter: its heartbeats become no-ops instead of
1063
+ * hiding the staleness the recovery is there to notice.
1064
+ *
1065
+ * @param {Array<string>} ids The job ids
1066
+ * @param {number} now The current time
1067
+ * @param {string} [token] The claim token this runner holds
1068
+ * @returns {Promise<void>} Resolves when written
1069
+ * @memberof SqlStore
1070
+ */
1071
+ async heartbeat(ids, now, token) {
1072
+ if (ids.length === 0) {
1073
+ return;
1074
+ }
1075
+
1076
+ const own = token ? ' AND claim_token = ?' : '';
1077
+
1078
+ await this.run(
1079
+ `UPDATE ${this.tables.jobs} SET heartbeat_at = ? WHERE id IN (${marks(ids)})${own}`,
1080
+ token ? [now, ...ids, token] : [now, ...ids]
1081
+ );
1082
+ }
1083
+
1084
+ /**
1085
+ * Deletes the finished jobs older than a moment
1086
+ *
1087
+ * @param {number} before A timestamp
1088
+ * @param {number} [limit=1000] How many rows one pass deletes
1089
+ * @returns {Promise<number>} How many rows were deleted
1090
+ * @memberof SqlStore
1091
+ */
1092
+ async prune(before, limit = 1000) {
1093
+ const page =
1094
+ this.dialect === 'mssql'
1095
+ ? 'ORDER BY finished_at ASC OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY'
1096
+ : 'ORDER BY finished_at ASC LIMIT ?';
1097
+ const rows = await this.select(
1098
+ `SELECT id FROM ${this.tables.jobs} WHERE state = 'done' AND finished_at < ? ${page}`,
1099
+ [before, limit]
1100
+ );
1101
+
1102
+ if (rows.length === 0) {
1103
+ return 0;
1104
+ }
1105
+
1106
+ const ids = rows.map((row) => row.id);
1107
+
1108
+ await this.run(
1109
+ `DELETE FROM ${this.tables.jobs} WHERE id IN (${marks(ids)})`,
1110
+ ids
1111
+ );
1112
+
1113
+ return ids.length;
1114
+ }
1115
+
1116
+ /**
1117
+ * Lists jobs
1118
+ *
1119
+ * @param {object} [options={}] `state`, `queue`, `name`, `batch`,
1120
+ * `tenant`, `limit`, `offset`
1121
+ * @returns {Promise<Array<object>>} The rows
1122
+ * @memberof SqlStore
1123
+ */
1124
+ async list({
1125
+ state,
1126
+ queue,
1127
+ name,
1128
+ batch,
1129
+ tenant,
1130
+ limit = 50,
1131
+ offset = 0,
1132
+ } = {}) {
1133
+ const filter = [];
1134
+ const params = [];
1135
+ // The driver binds what it is given: `LIMIT '25'` is text where sqlite
1136
+ // wants an integer
1137
+ const rows = Math.max(1, Number(limit) || 50);
1138
+ const from = Math.max(0, Number(offset) || 0);
1139
+
1140
+ if (state) {
1141
+ filter.push('state = ?');
1142
+ params.push(state);
1143
+ }
1144
+
1145
+ if (queue) {
1146
+ filter.push('queue = ?');
1147
+ params.push(queue);
1148
+ }
1149
+
1150
+ if (name) {
1151
+ filter.push('name = ?');
1152
+ params.push(name);
1153
+ }
1154
+
1155
+ if (batch) {
1156
+ filter.push('batch_id = ?');
1157
+ params.push(batch);
1158
+ }
1159
+
1160
+ if (tenant) {
1161
+ filter.push('tenant = ?');
1162
+ params.push(tenant);
1163
+ }
1164
+
1165
+ const where = filter.length > 0 ? `WHERE ${filter.join(' AND ')}` : '';
1166
+ const page =
1167
+ this.dialect === 'mssql'
1168
+ ? 'OFFSET ? ROWS FETCH NEXT ? ROWS ONLY'
1169
+ : 'LIMIT ? OFFSET ?';
1170
+ const paging = this.dialect === 'mssql' ? [from, rows] : [rows, from];
1171
+
1172
+ return this.select(
1173
+ `SELECT * FROM ${this.tables.jobs} ${where} ORDER BY updated_at DESC, id ASC ${page}`,
1174
+ [...params, ...paging]
1175
+ );
1176
+ }
1177
+
1178
+ /**
1179
+ * Deletes jobs
1180
+ *
1181
+ * @param {object} [options={}] `id`, `state`, `queue`, `name`, `tenant`
1182
+ * @returns {Promise<number>} How many rows were deleted
1183
+ * @memberof SqlStore
1184
+ */
1185
+ async remove({ id, state, queue, name, tenant } = {}) {
1186
+ const filter = [];
1187
+ const params = [];
1188
+
1189
+ for (const [column, value] of [
1190
+ ['id', id],
1191
+ ['state', state],
1192
+ ['queue', queue],
1193
+ ['name', name],
1194
+ ['tenant', tenant],
1195
+ ]) {
1196
+ if (value) {
1197
+ filter.push(`${column} = ?`);
1198
+ params.push(value);
1199
+ }
1200
+ }
1201
+
1202
+ if (filter.length === 0) {
1203
+ return 0;
1204
+ }
1205
+
1206
+ const where = `WHERE ${filter.join(' AND ')}`;
1207
+ const rows = await this.select(
1208
+ `SELECT id FROM ${this.tables.jobs} ${where}`,
1209
+ params
1210
+ );
1211
+
1212
+ if (rows.length > 0) {
1213
+ await this.run(`DELETE FROM ${this.tables.jobs} ${where}`, params);
1214
+ }
1215
+
1216
+ return rows.length;
1217
+ }
1218
+
1219
+ /**
1220
+ * Counts the jobs of every queue and state
1221
+ *
1222
+ * @returns {Promise<Array<object>>} `{ queue, state, total }` rows
1223
+ * @memberof SqlStore
1224
+ */
1225
+ async counts() {
1226
+ const rows = await this.select(
1227
+ `SELECT queue, state, COUNT(*) AS total FROM ${this.tables.jobs} GROUP BY queue, state`
1228
+ );
1229
+
1230
+ return rows.map((row) => ({
1231
+ queue: row.queue,
1232
+ state: row.state,
1233
+ total: toNumber(row.total) || 0,
1234
+ }));
1235
+ }
1236
+
1237
+ /**
1238
+ * How long the finished jobs of every queue took
1239
+ *
1240
+ * @returns {Promise<Array<object>>} `{ queue, count, min, max, avg }` rows
1241
+ * @memberof SqlStore
1242
+ */
1243
+ async timings() {
1244
+ const rows = await this.select(
1245
+ `SELECT queue, COUNT(*) AS runs, MIN(duration_ms) AS shortest, MAX(duration_ms) AS longest, AVG(duration_ms) AS average FROM ${this.tables.jobs} WHERE state = 'done' AND duration_ms IS NOT NULL GROUP BY queue`
1246
+ );
1247
+
1248
+ return rows.map((row) => ({
1249
+ average: Math.round(toNumber(row.average) || 0),
1250
+ longest: toNumber(row.longest) || 0,
1251
+ queue: row.queue,
1252
+ runs: toNumber(row.runs) || 0,
1253
+ shortest: toNumber(row.shortest) || 0,
1254
+ }));
1255
+ }
1256
+
1257
+ /**
1258
+ * The moment the oldest job waiting in every queue was due
1259
+ *
1260
+ * @param {number} now The current time
1261
+ * @returns {Promise<Array<object>>} `{ queue, waiting }` rows
1262
+ * @memberof SqlStore
1263
+ */
1264
+ async oldest(now) {
1265
+ const rows = await this.select(
1266
+ `SELECT queue, MIN(run_at) AS due FROM ${this.tables.jobs} WHERE state = 'pending' AND run_at <= ? GROUP BY queue`,
1267
+ [now]
1268
+ );
1269
+
1270
+ return rows.map((row) => ({
1271
+ queue: row.queue,
1272
+ waiting: Math.max(0, now - (toNumber(row.due) || now)),
1273
+ }));
1274
+ }
1275
+
1276
+ /**
1277
+ * The schedule of a recurring job
1278
+ *
1279
+ * @param {string} name The schedule name
1280
+ * @returns {Promise<?object>} The row, or null
1281
+ * @memberof SqlStore
1282
+ */
1283
+ async schedule(name) {
1284
+ const [row] = await this.select(
1285
+ `SELECT * FROM ${this.tables.schedules} WHERE name = ?`,
1286
+ [name]
1287
+ );
1288
+
1289
+ return row || null;
1290
+ }
1291
+
1292
+ /**
1293
+ * Records a schedule that has none yet
1294
+ *
1295
+ * @param {object} row The schedule row
1296
+ * @returns {Promise<?object>} The schedule, or null when another runner
1297
+ * recorded it first
1298
+ * @memberof SqlStore
1299
+ */
1300
+ async addSchedule(row) {
1301
+ try {
1302
+ await this.run(
1303
+ `INSERT INTO ${this.tables.schedules} (name, job, spec, next_run_at, last_run_at, token, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
1304
+ [
1305
+ row.name,
1306
+ row.job,
1307
+ row.spec,
1308
+ row.next_run_at,
1309
+ null,
1310
+ null,
1311
+ row.created_at,
1312
+ row.updated_at,
1313
+ ]
1314
+ );
1315
+ } catch (error) {
1316
+ // Another runner recording it first is the expected failure; anything
1317
+ // else (no table, no permission) is answered with null, and the runner
1318
+ // says so rather than silently never running the schedule
1319
+ debug('schedule %s not recorded (%s)', row.name, error.message);
1320
+ }
1321
+
1322
+ return this.schedule(row.name).catch(() => null);
1323
+ }
1324
+
1325
+ /**
1326
+ * Moves a schedule forward, if this runner is the one that got there first
1327
+ *
1328
+ * The update only matches while `next_run_at` still holds the moment this
1329
+ * runner read: exactly one runner can move a schedule on, and it is the
1330
+ * one that enqueues the job.
1331
+ *
1332
+ * @param {object} options Options
1333
+ * @param {string} options.name The schedule name
1334
+ * @param {string} options.spec The schedule expression, refreshed
1335
+ * @param {number} options.due The moment this runner read
1336
+ * @param {number} options.next When it should run after that
1337
+ * @param {string} options.token A token unique to this attempt
1338
+ * @param {number} options.now The current time
1339
+ * @returns {Promise<boolean>} Whether this runner won the slot
1340
+ * @memberof SqlStore
1341
+ */
1342
+ async advanceSchedule({ name, spec, due, next, token, now }) {
1343
+ await this.run(
1344
+ `UPDATE ${this.tables.schedules} SET next_run_at = ?, last_run_at = ?, spec = ?, token = ?, updated_at = ? WHERE name = ? AND next_run_at = ?`,
1345
+ [next, due, spec, token, now, name, due]
1346
+ );
1347
+
1348
+ const row = await this.schedule(name);
1349
+
1350
+ return Boolean(row && row.token === token);
1351
+ }
1352
+
1353
+ /**
1354
+ * Points a schedule at a new moment, whatever it held (the expression of
1355
+ * the configuration changed)
1356
+ *
1357
+ * @param {object} options `name`, `spec`, `next` and `now`
1358
+ * @returns {Promise<void>} Resolves when written
1359
+ * @memberof SqlStore
1360
+ */
1361
+ async resetSchedule({ name, spec, next, now }) {
1362
+ await this.run(
1363
+ `UPDATE ${this.tables.schedules} SET next_run_at = ?, spec = ?, updated_at = ? WHERE name = ?`,
1364
+ [next, spec, now, name]
1365
+ );
1366
+ }
1367
+
1368
+ /**
1369
+ * Forgets the schedules the configuration no longer declares
1370
+ *
1371
+ * @param {Array<string>} names The schedules to keep
1372
+ * @returns {Promise<void>} Resolves when done
1373
+ * @memberof SqlStore
1374
+ */
1375
+ async pruneSchedules(names) {
1376
+ if (names.length === 0) {
1377
+ await this.run(`DELETE FROM ${this.tables.schedules}`);
1378
+
1379
+ return;
1380
+ }
1381
+
1382
+ await this.run(
1383
+ `DELETE FROM ${this.tables.schedules} WHERE name NOT IN (${marks(names)})`,
1384
+ names
1385
+ );
1386
+ }
1387
+
1388
+ /**
1389
+ * Records a batch
1390
+ *
1391
+ * @param {object} batch A batch row, in database shape
1392
+ * @returns {Promise<object>} The batch, read back
1393
+ * @memberof SqlStore
1394
+ */
1395
+ async createBatch(batch) {
1396
+ const values = BATCH_COLUMNS.map((column) =>
1397
+ typeof batch[column] === 'undefined' ? null : batch[column]
1398
+ );
1399
+
1400
+ await this.run(
1401
+ `INSERT INTO ${this.tables.batches} (${BATCH_COLUMNS.join(', ')}) VALUES (${marks(BATCH_COLUMNS)})`,
1402
+ values
1403
+ );
1404
+
1405
+ return this.findBatch(batch.id);
1406
+ }
1407
+
1408
+ /**
1409
+ * One batch by id
1410
+ *
1411
+ * @param {string} id The batch id
1412
+ * @returns {Promise<?object>} The row, or null
1413
+ * @memberof SqlStore
1414
+ */
1415
+ async findBatch(id) {
1416
+ const [row] = await this.select(
1417
+ `SELECT * FROM ${this.tables.batches} WHERE id = ?`,
1418
+ [id]
1419
+ );
1420
+
1421
+ return row || null;
1422
+ }
1423
+
1424
+ /**
1425
+ * Closes a batch to new jobs and writes down how many it holds
1426
+ *
1427
+ * `total` is written once and never moves again, which is what makes
1428
+ * "the counter reached the total" mean "every job of the batch is
1429
+ * terminal". Nothing settles before this: the guard of `settleBatch()`
1430
+ * asks for `sealed_at`, so a batch whose first job finished while the
1431
+ * fortieth was still being enqueued does not call its callback early.
1432
+ *
1433
+ * @param {object} options Options
1434
+ * @param {string} options.id The batch id
1435
+ * @param {number} options.total How many jobs it holds
1436
+ * @param {number} options.now The current time
1437
+ * @returns {Promise<?object>} The batch, or null when it was sealed
1438
+ * already (or is gone)
1439
+ * @memberof SqlStore
1440
+ */
1441
+ async sealBatch({ id, total, now }) {
1442
+ await this.run(
1443
+ `UPDATE ${this.tables.batches} SET total = ?, sealed_at = ?, updated_at = ? WHERE id = ? AND sealed_at IS NULL`,
1444
+ [total, now, now, id]
1445
+ );
1446
+
1447
+ const row = await this.findBatch(id);
1448
+
1449
+ return row && toNumber(row.sealed_at) === now ? row : null;
1450
+ }
1451
+
1452
+ /**
1453
+ * Counts one terminal outcome into its batch
1454
+ *
1455
+ * One statement, and it is the whole of the exactly-once claim:
1456
+ *
1457
+ * - `done = done + 1` is evaluated by the engine under its own row lock,
1458
+ * so two runners finishing at the same instant make two increments and
1459
+ * not one. It is never read into this process to be written back.
1460
+ * - the `EXISTS` is the guard: the job row has to still hold **this
1461
+ * runner's claim token** and be terminal, which is true only of the
1462
+ * runner whose token-guarded outcome write landed. A runner that was
1463
+ * recovered from wrote nothing and counts nothing.
1464
+ * - `finished_at IS NULL` stops a batch that has already called its
1465
+ * callback from counting anything more.
1466
+ *
1467
+ * @param {object} options Options
1468
+ * @param {string} options.id The batch id
1469
+ * @param {string} options.job The job that reached a terminal state
1470
+ * @param {string} options.token The claim token its outcome was written
1471
+ * under
1472
+ * @param {boolean} options.failed Whether it died rather than finished
1473
+ * @param {number} options.now The current time
1474
+ * @returns {Promise<?object>} The batch as it is now, or null
1475
+ * @memberof SqlStore
1476
+ */
1477
+ async advanceBatch({ id, job, token, failed, now }) {
1478
+ await this.run(
1479
+ `UPDATE ${this.tables.batches} SET done = done + 1, failed = failed + ?, updated_at = ? WHERE id = ? AND finished_at IS NULL AND EXISTS (SELECT 1 FROM ${this.tables.jobs} WHERE id = ? AND batch_id = ? AND claim_token = ? AND state IN ('done', 'dead'))`,
1480
+ [failed ? 1 : 0, now, id, job, id, token]
1481
+ );
1482
+
1483
+ return this.findBatch(id);
1484
+ }
1485
+
1486
+ /**
1487
+ * Gives a batch its slot back, when a job of it is put back in the queue
1488
+ *
1489
+ * A dead job that is retried will reach a terminal state a second time
1490
+ * and count a second time, which would take `done` past what the batch
1491
+ * holds. A finished batch never moves again, which is what the guard
1492
+ * says.
1493
+ *
1494
+ * @param {object} options Options
1495
+ * @param {string} options.id The batch id
1496
+ * @param {boolean} options.failed Whether the job was in the dead letter
1497
+ * queue
1498
+ * @param {number} options.now The current time
1499
+ * @returns {Promise<void>} Resolves when written
1500
+ * @memberof SqlStore
1501
+ */
1502
+ async releaseBatch({ id, failed, now }) {
1503
+ await this.run(
1504
+ `UPDATE ${this.tables.batches} SET done = done - 1, failed = ${failed ? 'failed - 1' : 'failed'}, updated_at = ? WHERE id = ? AND finished_at IS NULL AND done > 0`,
1505
+ [now, id]
1506
+ );
1507
+ }
1508
+
1509
+ /**
1510
+ * Says a batch has finished, and what enqueued its callback
1511
+ *
1512
+ * The callback is enqueued *before* this is written, so a process that
1513
+ * dies in between leaves the batch unfinished and the next sweep settles
1514
+ * it again -- the enqueue is idempotent (`../keys.js`).
1515
+ *
1516
+ * @param {object} options Options
1517
+ * @param {string} options.id The batch id
1518
+ * @param {?string} options.callback The id of the callback job
1519
+ * @param {number} options.now The current time
1520
+ * @returns {Promise<void>} Resolves when written
1521
+ * @memberof SqlStore
1522
+ */
1523
+ async finishBatch({ id, callback, now }) {
1524
+ await this.run(
1525
+ `UPDATE ${this.tables.batches} SET finished_at = ?, callback_id = ?, updated_at = ? WHERE id = ? AND finished_at IS NULL`,
1526
+ [now, callback || null, now, id]
1527
+ );
1528
+ }
1529
+
1530
+ /**
1531
+ * What a batch's jobs actually say, read from the queue itself
1532
+ *
1533
+ * The repair the sweep uses: a runner killed between writing an
1534
+ * outcome and counting it leaves a batch one short forever, and a job
1535
+ * buried by the recovery of a dead runner was never counted by anybody.
1536
+ * Counting the rows answers both.
1537
+ *
1538
+ * @param {string} id The batch id
1539
+ * @returns {Promise<object>} `{ done, failed }`
1540
+ * @memberof SqlStore
1541
+ */
1542
+ async countBatch(id) {
1543
+ const rows = await this.select(
1544
+ `SELECT state, COUNT(*) AS total FROM ${this.tables.jobs} WHERE batch_id = ? AND state IN ('done', 'dead') GROUP BY state`,
1545
+ [id]
1546
+ );
1547
+ const counted = { done: 0, failed: 0 };
1548
+
1549
+ for (const row of rows) {
1550
+ const total = toNumber(row.total) || 0;
1551
+
1552
+ counted.done += total;
1553
+
1554
+ if (row.state === 'dead') {
1555
+ counted.failed += total;
1556
+ }
1557
+ }
1558
+
1559
+ return counted;
1560
+ }
1561
+
1562
+ /**
1563
+ * Moves a batch's counters up to what its jobs say
1564
+ *
1565
+ * Only ever **forward**: a job pruned after it finished is a row that is
1566
+ * no longer counted, and a batch must not walk backwards over one.
1567
+ *
1568
+ * @param {object} options Options
1569
+ * @param {string} options.id The batch id
1570
+ * @param {number} options.done How many jobs are terminal
1571
+ * @param {number} options.failed How many of them died
1572
+ * @param {number} options.now The current time
1573
+ * @returns {Promise<?object>} The batch as it is now
1574
+ * @memberof SqlStore
1575
+ */
1576
+ async syncBatch({ id, done, failed, now }) {
1577
+ await this.run(
1578
+ `UPDATE ${this.tables.batches} SET done = ?, failed = ?, updated_at = ? WHERE id = ? AND finished_at IS NULL AND done < ?`,
1579
+ [done, failed, now, id, done]
1580
+ );
1581
+
1582
+ return this.findBatch(id);
1583
+ }
1584
+
1585
+ /**
1586
+ * The batches that were sealed and have not finished
1587
+ *
1588
+ * @param {object} options Options
1589
+ * @param {number} options.before Only those untouched since that moment
1590
+ * @param {number} [options.limit=50] How many at most
1591
+ * @returns {Promise<Array<object>>} The rows
1592
+ * @memberof SqlStore
1593
+ */
1594
+ async openBatches({ before, limit = 50 }) {
1595
+ const page =
1596
+ this.dialect === 'mssql'
1597
+ ? 'ORDER BY updated_at ASC OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY'
1598
+ : 'ORDER BY updated_at ASC LIMIT ?';
1599
+
1600
+ return this.select(
1601
+ `SELECT * FROM ${this.tables.batches} WHERE finished_at IS NULL AND sealed_at IS NOT NULL AND updated_at < ? ${page}`,
1602
+ [before, Math.max(1, Number(limit) || 50)]
1603
+ );
1604
+ }
1605
+
1606
+ /**
1607
+ * Lists batches, the ones still running first
1608
+ *
1609
+ * @param {object} [options={}] `finished`, `limit`, `offset`
1610
+ * @returns {Promise<Array<object>>} The rows
1611
+ * @memberof SqlStore
1612
+ */
1613
+ async listBatches({ finished, limit = 50, offset = 0 } = {}) {
1614
+ const rows = Math.max(1, Number(limit) || 50);
1615
+ const from = Math.max(0, Number(offset) || 0);
1616
+ const filter =
1617
+ typeof finished === 'boolean'
1618
+ ? `WHERE finished_at IS ${finished ? 'NOT NULL' : 'NULL'}`
1619
+ : '';
1620
+ const page =
1621
+ this.dialect === 'mssql'
1622
+ ? 'OFFSET ? ROWS FETCH NEXT ? ROWS ONLY'
1623
+ : 'LIMIT ? OFFSET ?';
1624
+ const paging = this.dialect === 'mssql' ? [from, rows] : [rows, from];
1625
+
1626
+ return this.select(
1627
+ `SELECT * FROM ${this.tables.batches} ${filter} ORDER BY created_at DESC, id ASC ${page}`,
1628
+ paging
1629
+ );
1630
+ }
1631
+
1632
+ /**
1633
+ * Forgets a batch
1634
+ *
1635
+ * @param {string} id The batch id
1636
+ * @returns {Promise<boolean>} Whether there was one
1637
+ * @memberof SqlStore
1638
+ */
1639
+ async removeBatch(id) {
1640
+ if (!(await this.findBatch(id))) {
1641
+ return false;
1642
+ }
1643
+
1644
+ await this.run(`DELETE FROM ${this.tables.batches} WHERE id = ?`, [id]);
1645
+
1646
+ return true;
1647
+ }
1648
+
1649
+ /**
1650
+ * Deletes the batches that finished before a moment
1651
+ *
1652
+ * @param {number} before A timestamp
1653
+ * @param {number} [limit=1000] How many one pass deletes
1654
+ * @returns {Promise<number>} How many were deleted
1655
+ * @memberof SqlStore
1656
+ */
1657
+ async pruneBatches(before, limit = 1000) {
1658
+ const page =
1659
+ this.dialect === 'mssql'
1660
+ ? 'ORDER BY finished_at ASC OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY'
1661
+ : 'ORDER BY finished_at ASC LIMIT ?';
1662
+ const rows = await this.select(
1663
+ `SELECT id FROM ${this.tables.batches} WHERE finished_at IS NOT NULL AND finished_at < ? ${page}`,
1664
+ [before, limit]
1665
+ );
1666
+
1667
+ if (rows.length === 0) {
1668
+ return 0;
1669
+ }
1670
+
1671
+ const ids = rows.map((row) => row.id);
1672
+
1673
+ await this.run(
1674
+ `DELETE FROM ${this.tables.batches} WHERE id IN (${marks(ids)})`,
1675
+ ids
1676
+ );
1677
+
1678
+ return ids.length;
1679
+ }
1680
+ }
1681
+
1682
+ /**
1683
+ * The dialect of a store adapter, or nothing when it is not SQL
1684
+ *
1685
+ * @param {object} adapter A henri store adapter
1686
+ * @returns {?{dialect: string, dollars: boolean}} How to talk to it
1687
+ */
1688
+ const describe = (adapter) => {
1689
+ // The drizzle adapter names its dialect and its placeholder style
1690
+ if (adapter.dialect && typeof adapter.dialect === 'object') {
1691
+ return {
1692
+ dialect: adapter.dialect.name,
1693
+ dollars: adapter.dialect.placeholder(1) === '$1',
1694
+ };
1695
+ }
1696
+
1697
+ // The sequelize adapters: the dialect comes from the connector, and
1698
+ // sequelize renders `?` replacements itself on every dialect
1699
+ if (typeof adapter.ensureConnector === 'function') {
1700
+ const name = adapter.ensureConnector().getDialect();
1701
+
1702
+ return { dialect: name === 'mssql' ? 'mssql' : name, dollars: false };
1703
+ }
1704
+
1705
+ return null;
1706
+ };
1707
+
1708
+ /**
1709
+ * Builds the SQL store of an adapter
1710
+ *
1711
+ * @param {object} adapter A henri store adapter
1712
+ * @param {object} tables `{ jobs, schedules }` table names
1713
+ * @returns {SqlStore} The store
1714
+ * @throws {JobStoreError} When the dialect cannot back a queue
1715
+ */
1716
+ const create = (adapter, tables) => {
1717
+ const described = describe(adapter);
1718
+
1719
+ if (!described) {
1720
+ throw new JobStoreError(
1721
+ `@usehenri/jobs: the ${adapter.adapterName} adapter has no SQL surface`
1722
+ );
1723
+ }
1724
+
1725
+ if (!['mssql', 'mysql', 'postgres', 'sqlite'].includes(described.dialect)) {
1726
+ throw new JobStoreError(
1727
+ `@usehenri/jobs: the ${described.dialect} dialect is not supported`
1728
+ );
1729
+ }
1730
+
1731
+ return new SqlStore(adapter, { ...described, tables });
1732
+ };
1733
+
1734
+ module.exports = {
1735
+ BATCH_COLUMNS,
1736
+ COLUMNS,
1737
+ DUPLICATE,
1738
+ HISTORY_LIMIT,
1739
+ SqlStore,
1740
+ create,
1741
+ describe,
1742
+ reasons,
1743
+ toNumber,
1744
+ };