@usehenri/jobs 0.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +311 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +45 -0
- package/module.js +8 -0
- package/package.json +50 -10
- package/src/batch.js +379 -0
- package/src/config.js +186 -0
- package/src/cron.js +237 -0
- package/src/definitions.js +236 -0
- package/src/duration.js +112 -0
- package/src/errors.js +115 -0
- package/src/jobs.js +1839 -0
- package/src/keys.js +65 -0
- package/src/module.js +442 -0
- package/src/runner.js +918 -0
- package/src/serialize.js +177 -0
- package/src/store/index.js +37 -0
- package/src/store/mongo.js +1334 -0
- package/src/store/schema.js +499 -0
- package/src/store/sql.js +1744 -0
package/src/store/sql.js
ADDED
|
@@ -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
|
+
};
|