@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/jobs.js
ADDED
|
@@ -0,0 +1,1839 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { randomUUID } = require('crypto');
|
|
3
|
+
const debug = require('debug')('henri:jobs');
|
|
4
|
+
|
|
5
|
+
const { JobError, JobStoreError, JobTimeoutError } = require('./errors');
|
|
6
|
+
const { deserialize, serialize } = require('./serialize');
|
|
7
|
+
const { callback: callbackKey, keep } = require('./keys');
|
|
8
|
+
const { duration, iso, runAt } = require('./duration');
|
|
9
|
+
const { keyOf, load, validate } = require('./definitions');
|
|
10
|
+
const { normalize, recurring } = require('./config');
|
|
11
|
+
const { storeFor } = require('./store');
|
|
12
|
+
const { toNumber, HISTORY_LIMIT } = require('./store/sql');
|
|
13
|
+
const { Batch, declaration, toBatch } = require('./batch');
|
|
14
|
+
|
|
15
|
+
/** The states a job goes through */
|
|
16
|
+
const STATES = ['pending', 'running', 'done', 'dead'];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The widest a tenant may be: the column it is indexed in.
|
|
20
|
+
*
|
|
21
|
+
* The 190 of `base/tenancy.js` and of a webhook endpoint's `owner`, for
|
|
22
|
+
* the same reason -- it is what MySQL indexes in a utf8mb4 key. The number
|
|
23
|
+
* is repeated rather than imported because this package raises core's
|
|
24
|
+
* codes without importing core (see `./errors.js`).
|
|
25
|
+
*/
|
|
26
|
+
const MAX_TENANT = 190;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The name of the job that sends a mail `deliverLater()` handed over.
|
|
30
|
+
*
|
|
31
|
+
* `henri.mailers` renders the message before it hands it to the queue, so
|
|
32
|
+
* the job is one line: the runner needs neither the models nor a view
|
|
33
|
+
* engine. An application that wants its own (tracking, a different
|
|
34
|
+
* transport) writes `app/jobs/henri/mail.js` and it wins over this one.
|
|
35
|
+
*/
|
|
36
|
+
const MAIL_JOB = 'henri/mail';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The name of the job that sweeps what the models say they keep.
|
|
40
|
+
*
|
|
41
|
+
* Retention lives in core (`base/retention.js`) and needs nothing
|
|
42
|
+
* installed; this is the queue's half of it, so an application that has
|
|
43
|
+
* `@usehenri/jobs` gets the recurring sweep for free and one that does not
|
|
44
|
+
* runs `henri retention:sweep` from cron. Like the mail job, an application
|
|
45
|
+
* that wants its own writes `app/jobs/henri/retention.js`.
|
|
46
|
+
*/
|
|
47
|
+
const RETENTION_JOB = 'henri/retention';
|
|
48
|
+
|
|
49
|
+
/** A moment, as the API hands it out */
|
|
50
|
+
const at = iso;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A stored row, as the API hands it out
|
|
54
|
+
*
|
|
55
|
+
* @param {?object} row A row of the queue
|
|
56
|
+
* @returns {?object} The job
|
|
57
|
+
*/
|
|
58
|
+
const toJob = (row) => {
|
|
59
|
+
if (!row) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const message = row.error_message || null;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
args: deserialize(row.args),
|
|
67
|
+
attempts: toNumber(row.attempts) || 0,
|
|
68
|
+
batchId: row.batch_id || null,
|
|
69
|
+
claimedAt: at(row.claimed_at),
|
|
70
|
+
claimedBy: row.claimed_by || null,
|
|
71
|
+
concurrencyKey: row.concurrency_key || null,
|
|
72
|
+
createdAt: at(row.created_at),
|
|
73
|
+
duration: toNumber(row.duration_ms),
|
|
74
|
+
error: message ? { message, stack: row.error_stack || null } : null,
|
|
75
|
+
finishedAt: at(row.finished_at),
|
|
76
|
+
history: deserialize(row.history) || [],
|
|
77
|
+
id: row.id,
|
|
78
|
+
maxAttempts: toNumber(row.max_attempts) || 0,
|
|
79
|
+
name: row.name,
|
|
80
|
+
priority: toNumber(row.priority) || 0,
|
|
81
|
+
queue: row.queue,
|
|
82
|
+
runAt: at(row.run_at),
|
|
83
|
+
startedAt: at(row.started_at),
|
|
84
|
+
state: row.state,
|
|
85
|
+
tenant: row.tenant || null,
|
|
86
|
+
timeout: toNumber(row.timeout_ms),
|
|
87
|
+
uniqueKey: row.unique_key || null,
|
|
88
|
+
updatedAt: at(row.updated_at),
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The queue.
|
|
94
|
+
*
|
|
95
|
+
* This is what an application sees as `henri.jobs`: enqueue from a
|
|
96
|
+
* controller, a model hook, another job or the console, look at what the
|
|
97
|
+
* queue holds, and drive the dead letter queue. Performing the jobs is the
|
|
98
|
+
* runner's business (`henri jobs`), never the web process's.
|
|
99
|
+
*
|
|
100
|
+
* @class Jobs
|
|
101
|
+
*/
|
|
102
|
+
class Jobs {
|
|
103
|
+
/**
|
|
104
|
+
* Creates an instance of Jobs.
|
|
105
|
+
*
|
|
106
|
+
* @param {object} henri The henri instance
|
|
107
|
+
* @param {object} [options={}] Options
|
|
108
|
+
* @param {object} [options.config] The `jobs` block of the configuration
|
|
109
|
+
* @param {string} [options.cwd] The application directory
|
|
110
|
+
* @param {object} [options.adapter] The store adapter, when it is not
|
|
111
|
+
* taken from `henri.model`
|
|
112
|
+
* @memberof Jobs
|
|
113
|
+
*/
|
|
114
|
+
constructor(henri, options = {}) {
|
|
115
|
+
this.henri = henri;
|
|
116
|
+
this.pen = (henri && henri.pen) || null;
|
|
117
|
+
this.cwd =
|
|
118
|
+
options.cwd || (henri && henri.cwd ? henri.cwd() : process.cwd());
|
|
119
|
+
this.config = normalize(options.config || {});
|
|
120
|
+
this.adapter = options.adapter || null;
|
|
121
|
+
this.ownsAdapter = false;
|
|
122
|
+
this.store = null;
|
|
123
|
+
this.definitions = {};
|
|
124
|
+
this.started = false;
|
|
125
|
+
this.runners = new Set();
|
|
126
|
+
/** Whether the store can hold a concurrency key; see start() */
|
|
127
|
+
this.concurrent = false;
|
|
128
|
+
/** Whether the store can hold a batch; see start() */
|
|
129
|
+
this.batched = false;
|
|
130
|
+
/** Whether the store can stamp a job's tenant; see start() */
|
|
131
|
+
this.tenanted = false;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The retry policy of a job whose file this runner does not have: the
|
|
135
|
+
* queue's own, so an unknown name is retried rather than buried
|
|
136
|
+
*/
|
|
137
|
+
this.unknown = { backoff: this.config.backoff, name: null };
|
|
138
|
+
|
|
139
|
+
this.dead = {
|
|
140
|
+
count: () => this.count({ state: 'dead' }),
|
|
141
|
+
discard: (id) => this.discard(id),
|
|
142
|
+
discardAll: (filter) => this.discardAll(filter),
|
|
143
|
+
get: (id) => this.get(id),
|
|
144
|
+
list: (filter) => this.list({ ...filter, state: 'dead' }),
|
|
145
|
+
retry: (id, opts) => this.retry(id, opts),
|
|
146
|
+
retryAll: (filter, opts) => this.retryAll(filter, opts),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** Reading the batches back; `batch()` is what makes one */
|
|
150
|
+
this.batches = {
|
|
151
|
+
discard: (id) => this.discardBatch(id),
|
|
152
|
+
get: (id) => this.getBatch(id),
|
|
153
|
+
jobs: (id, filter) => this.list({ ...(filter || {}), batch: id }),
|
|
154
|
+
list: (filter) => this.listBatches(filter),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Says something, when there is a pen to say it with
|
|
160
|
+
*
|
|
161
|
+
* @param {string} level info, warn or error
|
|
162
|
+
* @param {...*} args What to say
|
|
163
|
+
* @returns {void}
|
|
164
|
+
* @memberof Jobs
|
|
165
|
+
*/
|
|
166
|
+
log(level, ...args) {
|
|
167
|
+
if (this.pen && typeof this.pen[level] === 'function') {
|
|
168
|
+
this.pen[level]('jobs', ...args);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Loads `app/jobs` and prepares the tables
|
|
174
|
+
*
|
|
175
|
+
* @param {object} [options={}] Options
|
|
176
|
+
* @param {boolean} [options.install] Create the tables (defaults to the
|
|
177
|
+
* `jobs.install` configuration)
|
|
178
|
+
* @returns {Promise<Jobs>} This queue
|
|
179
|
+
* @throws {JobError} When a job file or the store is unusable
|
|
180
|
+
* @memberof Jobs
|
|
181
|
+
*/
|
|
182
|
+
async start(options = {}) {
|
|
183
|
+
this.definitions = {
|
|
184
|
+
...this.builtins(),
|
|
185
|
+
...load(path.join(this.cwd, 'app', 'jobs'), this.config),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const adapter = this.resolveAdapter();
|
|
189
|
+
|
|
190
|
+
// An application may have a queue and no model at all: the store of the
|
|
191
|
+
// configuration is then built here, and nobody has connected it yet
|
|
192
|
+
if (this.ownsAdapter) {
|
|
193
|
+
await adapter.start();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.store = storeFor(adapter, this.config.tables);
|
|
197
|
+
|
|
198
|
+
const install =
|
|
199
|
+
typeof options.install === 'boolean'
|
|
200
|
+
? options.install
|
|
201
|
+
: this.config.install;
|
|
202
|
+
|
|
203
|
+
if (install) {
|
|
204
|
+
try {
|
|
205
|
+
await this.store.install();
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw new JobStoreError(
|
|
208
|
+
`@usehenri/jobs: unable to create the queue tables in the "${this.config.store}" store: ${error.message}`,
|
|
209
|
+
{
|
|
210
|
+
cause: error,
|
|
211
|
+
hint: 'Run `henri jobs:install` once with a user that may create tables, then set "install": false in the jobs configuration',
|
|
212
|
+
}
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
this.concurrent = await this.store.concurrent();
|
|
218
|
+
this.batched = await this.store.batched();
|
|
219
|
+
this.tenanted = await this.store.tenanted();
|
|
220
|
+
|
|
221
|
+
// The upgrade has to be honest. An application that turned tenancy on
|
|
222
|
+
// and whose table cannot hold the column would enqueue rows with no
|
|
223
|
+
// tenant, and a runner would then perform every one of them outside
|
|
224
|
+
// every tenant -- which is not "unscoped", it is "wrong, quietly". The
|
|
225
|
+
// `_UNINSTALLED` precedent: fail the boot and name what is missing
|
|
226
|
+
if (this.multitenant() && !this.tenanted) {
|
|
227
|
+
throw new JobError(
|
|
228
|
+
'HENRI_JOB_TENANT_UNINSTALLED',
|
|
229
|
+
`@usehenri/jobs: config.tenancy is on and the "${this.config.store}" store has no ${this.config.tables.jobs}.tenant column to stamp a job's tenant in`,
|
|
230
|
+
{
|
|
231
|
+
hint: 'Run `henri jobs:install` once with a user that may alter the table; the queue itself keeps working without the column, and a job would then carry no tenant at all',
|
|
232
|
+
}
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const bounded = Object.values(this.definitions).filter(
|
|
237
|
+
(definition) => definition.concurrency
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
this.conflicts(bounded);
|
|
241
|
+
|
|
242
|
+
if (bounded.length > 0 && !this.concurrent) {
|
|
243
|
+
throw new JobError(
|
|
244
|
+
'HENRI_JOB_LIMIT_UNINSTALLED',
|
|
245
|
+
`@usehenri/jobs: ${bounded.map((one) => one.name).join(', ')} declare a concurrency limit, and the "${this.config.store}" store has no ${this.config.tables.jobs}.concurrency_key column to hold it`,
|
|
246
|
+
{
|
|
247
|
+
hint: 'Run `henri jobs:install` once with a user that may alter the table; the queue itself keeps working without it, and the limit would not',
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this.started = true;
|
|
253
|
+
|
|
254
|
+
const missing = this.config.recurring
|
|
255
|
+
.filter((entry) => !this.definitions[entry.job])
|
|
256
|
+
.map((entry) => `${entry.name} -> ${entry.job}`);
|
|
257
|
+
|
|
258
|
+
if (missing.length > 0) {
|
|
259
|
+
this.log(
|
|
260
|
+
'warn',
|
|
261
|
+
'recurring schedules naming a job that is not in app/jobs, skipped:',
|
|
262
|
+
missing.join(', ')
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
debug('started with %d job(s)', Object.keys(this.definitions).length);
|
|
267
|
+
|
|
268
|
+
return this;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The jobs the package ships with, which `app/jobs` may override
|
|
273
|
+
*
|
|
274
|
+
* @returns {object} The definitions, by name
|
|
275
|
+
* @memberof Jobs
|
|
276
|
+
*/
|
|
277
|
+
builtins() {
|
|
278
|
+
return {
|
|
279
|
+
[MAIL_JOB]: validate(
|
|
280
|
+
MAIL_JOB,
|
|
281
|
+
{
|
|
282
|
+
/**
|
|
283
|
+
* Sends a message `henri.mailers.deliverLater()` rendered
|
|
284
|
+
*
|
|
285
|
+
* @param {object} message A nodemailer payload
|
|
286
|
+
* @param {object} context The job context
|
|
287
|
+
* @returns {Promise<object>} nodemailer's info
|
|
288
|
+
*/
|
|
289
|
+
perform: (message, context) => context.henri.mail.send(message),
|
|
290
|
+
queue: this.config.mailQueue,
|
|
291
|
+
},
|
|
292
|
+
this.config
|
|
293
|
+
),
|
|
294
|
+
[RETENTION_JOB]: validate(
|
|
295
|
+
RETENTION_JOB,
|
|
296
|
+
{
|
|
297
|
+
/**
|
|
298
|
+
* Sweeps the retention rules of the models
|
|
299
|
+
*
|
|
300
|
+
* @param {object} args What the schedule carries (`only`)
|
|
301
|
+
* @param {object} context The job context
|
|
302
|
+
* @returns {Promise<object>} The receipt of the sweep
|
|
303
|
+
*/
|
|
304
|
+
perform: (args, context) =>
|
|
305
|
+
context.henri.retention.sweep({
|
|
306
|
+
...(args || {}),
|
|
307
|
+
source: 'job',
|
|
308
|
+
}),
|
|
309
|
+
},
|
|
310
|
+
this.config
|
|
311
|
+
),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Refuses two jobs that share a group and disagree on its limit
|
|
317
|
+
*
|
|
318
|
+
* A group is a plain string declared in a file, so this is knowable at
|
|
319
|
+
* boot -- and it has to be answered there, because the two jobs would
|
|
320
|
+
* otherwise take slots of the same key counting to different numbers and
|
|
321
|
+
* the bound would be whichever of them asked last.
|
|
322
|
+
*
|
|
323
|
+
* @param {Array<object>} bounded The definitions that declare a limit
|
|
324
|
+
* @returns {void}
|
|
325
|
+
* @throws {JobError} HENRI_JOB_CONCURRENCY_CONFLICT when two disagree
|
|
326
|
+
* @memberof Jobs
|
|
327
|
+
*/
|
|
328
|
+
conflicts(bounded) {
|
|
329
|
+
const limits = new Map();
|
|
330
|
+
|
|
331
|
+
for (const definition of bounded) {
|
|
332
|
+
const { group, limit } = definition.concurrency;
|
|
333
|
+
const first = limits.get(group);
|
|
334
|
+
|
|
335
|
+
if (first && first.limit !== limit) {
|
|
336
|
+
throw new JobError(
|
|
337
|
+
'HENRI_JOB_CONCURRENCY_CONFLICT',
|
|
338
|
+
`The jobs "${first.name}" and "${definition.name}" share the concurrency group "${group}" and ask for different limits (${first.limit} and ${limit})`,
|
|
339
|
+
{
|
|
340
|
+
hint: 'Jobs that share a group share one bound: give them the same limit, or a group each',
|
|
341
|
+
job: definition.name,
|
|
342
|
+
}
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!first) {
|
|
347
|
+
limits.set(group, { limit, name: definition.name });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The jobs that declare a concurrency limit, by group
|
|
354
|
+
*
|
|
355
|
+
* The runner asks for this every tick: the names are what partitions the
|
|
356
|
+
* claim into its two passes, and the groups are what says how many slots
|
|
357
|
+
* a key has.
|
|
358
|
+
*
|
|
359
|
+
* @returns {object} `{ names, groups }`
|
|
360
|
+
* @memberof Jobs
|
|
361
|
+
*/
|
|
362
|
+
limited() {
|
|
363
|
+
const groups = new Map();
|
|
364
|
+
const names = [];
|
|
365
|
+
|
|
366
|
+
for (const definition of Object.values(this.definitions)) {
|
|
367
|
+
if (!definition.concurrency) {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const { group, limit } = definition.concurrency;
|
|
372
|
+
const entry = groups.get(group) || { limit, names: [] };
|
|
373
|
+
|
|
374
|
+
entry.names.push(definition.name);
|
|
375
|
+
groups.set(group, entry);
|
|
376
|
+
names.push(definition.name);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return { groups, names: names.sort() };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* What a key with work waiting needs to be claimed from
|
|
384
|
+
*
|
|
385
|
+
* @param {object} entry `{ key, name }`, as the store's `waiting()` gives
|
|
386
|
+
* @param {object} [bounded] What `limited()` answered, when the caller
|
|
387
|
+
* already has it (the runner asks once per tick, not once per key)
|
|
388
|
+
* @returns {?object} `{ key, limit, names }`, or null when the job is gone
|
|
389
|
+
* @memberof Jobs
|
|
390
|
+
*/
|
|
391
|
+
bucket(entry, bounded = this.limited()) {
|
|
392
|
+
const definition = this.definitions[entry.name];
|
|
393
|
+
|
|
394
|
+
if (!definition || !definition.concurrency) {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const { group, limit } = definition.concurrency;
|
|
399
|
+
const value = entry.key || group;
|
|
400
|
+
const held = bounded.groups.get(group);
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
key: { own: value === group, value },
|
|
404
|
+
limit,
|
|
405
|
+
names: held ? held.names : [definition.name],
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Adds a recurring schedule the configuration did not write.
|
|
411
|
+
*
|
|
412
|
+
* This is how a framework module asks for something to happen on a
|
|
413
|
+
* schedule without an application having to copy a cron expression into
|
|
414
|
+
* `config.jobs.recurring` (`henri.retention` is the one that does). An
|
|
415
|
+
* entry the application declared under the same name wins: what is in
|
|
416
|
+
* `config/<env>.json` is never quietly replaced.
|
|
417
|
+
*
|
|
418
|
+
* @param {string} name The name of the schedule
|
|
419
|
+
* @param {object} entry `cron` or `every`, plus `job`, `args`, `queue`
|
|
420
|
+
* @returns {boolean} false when the configuration already names it
|
|
421
|
+
* @throws {Error} HENRI_JOB_INVALID_SCHEDULE on an unreadable expression
|
|
422
|
+
* @memberof Jobs
|
|
423
|
+
*/
|
|
424
|
+
recur(name, entry) {
|
|
425
|
+
if (this.config.recurring.some((schedule) => schedule.name === name)) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
this.config.recurring.push(recurring(name, entry));
|
|
430
|
+
this.config.recurring.sort((one, other) =>
|
|
431
|
+
one.name.localeCompare(other.name)
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Adds a job the application did not write
|
|
439
|
+
*
|
|
440
|
+
* A package that ships work of its own -- `@usehenri/webhooks` delivers a
|
|
441
|
+
* webhook this way -- registers its job here rather than asking the
|
|
442
|
+
* application to write a file that would only forward the call. The queue
|
|
443
|
+
* then has it wherever it is booted, the runner included, because the
|
|
444
|
+
* module that registers it runs at the same runlevel.
|
|
445
|
+
*
|
|
446
|
+
* A definition that came from `app/jobs` is never replaced: an
|
|
447
|
+
* application that wants its own `henri/webhook` writes
|
|
448
|
+
* `app/jobs/henri/webhook.js` and it wins, exactly as it does for
|
|
449
|
+
* `henri/mail`.
|
|
450
|
+
*
|
|
451
|
+
* @param {string} name The job name
|
|
452
|
+
* @param {object} definition `perform(args, context)` plus `queue`,
|
|
453
|
+
* `priority`, `maxAttempts`, `timeout` and `backoff`
|
|
454
|
+
* @returns {boolean} Whether it was registered
|
|
455
|
+
* @throws {JobError} HENRI_JOB_INVALID_DEFINITION without a `perform`
|
|
456
|
+
* @memberof Jobs
|
|
457
|
+
*/
|
|
458
|
+
define(name, definition) {
|
|
459
|
+
if (this.definitions[name]) {
|
|
460
|
+
debug('%s is already defined: keeping the one that is there', name);
|
|
461
|
+
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
this.definitions[name] = validate(name, definition, this.config);
|
|
466
|
+
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Stops every runner this queue started
|
|
472
|
+
*
|
|
473
|
+
* @returns {Promise<void>} Resolves when they are done
|
|
474
|
+
* @memberof Jobs
|
|
475
|
+
*/
|
|
476
|
+
async stop() {
|
|
477
|
+
await Promise.all([...this.runners].map((runner) => runner.stop()));
|
|
478
|
+
this.runners.clear();
|
|
479
|
+
this.started = false;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The store adapter backing the queue
|
|
484
|
+
*
|
|
485
|
+
* @returns {object} A henri store adapter
|
|
486
|
+
* @throws {JobError} NO_STORE when the store is unknown
|
|
487
|
+
* @memberof Jobs
|
|
488
|
+
*/
|
|
489
|
+
resolveAdapter() {
|
|
490
|
+
if (this.adapter) {
|
|
491
|
+
return this.adapter;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const model = this.henri && this.henri.model;
|
|
495
|
+
const stores = (model && model.stores) || {};
|
|
496
|
+
const name = this.config.store;
|
|
497
|
+
|
|
498
|
+
if (stores[name]) {
|
|
499
|
+
return stores[name];
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (model && typeof model.getStore === 'function') {
|
|
503
|
+
// A store no model uses has not been built yet; the model module keeps
|
|
504
|
+
// it from here on, so it is stopped with the others
|
|
505
|
+
let store = null;
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
store = model.getStore(name);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
debug('store %s cannot be built: %s', name, error.message);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (store) {
|
|
514
|
+
this.ownsAdapter = true;
|
|
515
|
+
|
|
516
|
+
return store;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
throw new JobError(
|
|
521
|
+
'HENRI_JOB_STORE_MISSING',
|
|
522
|
+
`@usehenri/jobs: no store named "${name}" in the configuration`,
|
|
523
|
+
{ hint: 'Set jobs.store to one of the stores of config/default.json' }
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The definition of a job
|
|
529
|
+
*
|
|
530
|
+
* @param {string} name The job name
|
|
531
|
+
* @returns {object} The definition
|
|
532
|
+
* @throws {JobError} HENRI_JOB_UNKNOWN when there is no such file
|
|
533
|
+
* @memberof Jobs
|
|
534
|
+
*/
|
|
535
|
+
definition(name) {
|
|
536
|
+
const found = this.definitions[name];
|
|
537
|
+
|
|
538
|
+
if (!found) {
|
|
539
|
+
const known = Object.keys(this.definitions);
|
|
540
|
+
|
|
541
|
+
throw new JobError('HENRI_JOB_UNKNOWN', `No job named "${name}"`, {
|
|
542
|
+
hint:
|
|
543
|
+
known.length > 0
|
|
544
|
+
? `The jobs of app/jobs are: ${known.join(', ')}`
|
|
545
|
+
: 'Write one with: henri generate job <name>',
|
|
546
|
+
job: name,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return found;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* The names of the jobs of the application
|
|
555
|
+
*
|
|
556
|
+
* @returns {Array<string>} The job names
|
|
557
|
+
* @memberof Jobs
|
|
558
|
+
*/
|
|
559
|
+
names() {
|
|
560
|
+
return Object.keys(this.definitions).sort();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Enqueues a job
|
|
565
|
+
*
|
|
566
|
+
* Nothing runs here: the call writes one row and returns. A runner
|
|
567
|
+
* (`henri jobs`) picks it up.
|
|
568
|
+
*
|
|
569
|
+
* @param {string} name The job name (its file under app/jobs)
|
|
570
|
+
* @param {*} [args=null] What perform() receives; it has to survive JSON
|
|
571
|
+
* @param {object} [options={}] Options
|
|
572
|
+
* @param {(number|string)} [options.wait] Run it that much later (`'5m'`)
|
|
573
|
+
* @param {(Date|string|number)} [options.at] Run it at that moment
|
|
574
|
+
* @param {string} [options.queue] Another queue than the job's
|
|
575
|
+
* @param {number} [options.priority] Lower goes first
|
|
576
|
+
* @param {number} [options.maxAttempts] How many attempts before it dies
|
|
577
|
+
* @param {(number|string)} [options.timeout] How long one attempt may take
|
|
578
|
+
* @param {string} [options.unique] A key no other waiting job may hold
|
|
579
|
+
* @param {string} [options.id] The id to give the job, so a caller racing
|
|
580
|
+
* another on the same `unique` key can tell whether it is the one that
|
|
581
|
+
* enqueued it (the recurring schedules use it)
|
|
582
|
+
* @param {string} [options.batch] The batch to count it into; `batch()`
|
|
583
|
+
* is what makes one, and a batch that is sealed refuses
|
|
584
|
+
* @param {?string} [options.tenant] The tenant this job belongs to;
|
|
585
|
+
* defaults to the tenant of the request or job it is enqueued from
|
|
586
|
+
* when the application is multi-tenant (`henri.tenancy`). `null` is a
|
|
587
|
+
* job of no tenant, and the runner enters none for it
|
|
588
|
+
* @returns {Promise<object>} The enqueued job
|
|
589
|
+
* @throws {JobError} HENRI_JOB_UNKNOWN, or HENRI_JOB_INVALID_ARGUMENTS
|
|
590
|
+
* cannot be stored
|
|
591
|
+
* @memberof Jobs
|
|
592
|
+
*/
|
|
593
|
+
async perform(name, args = null, options = {}) {
|
|
594
|
+
const definition = this.definition(name);
|
|
595
|
+
|
|
596
|
+
if (options.batch) {
|
|
597
|
+
await this.openBatch(options.batch);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// A job a package defined after the boot may declare a limit the store
|
|
601
|
+
// has no column for; the enqueue is where that is caught, because
|
|
602
|
+
// enqueuing it unbounded is the one answer that breaks the guarantee
|
|
603
|
+
if (definition.concurrency && !this.concurrent) {
|
|
604
|
+
throw new JobError(
|
|
605
|
+
'HENRI_JOB_LIMIT_UNINSTALLED',
|
|
606
|
+
`The job "${name}" declares a concurrency limit, and the "${this.config.store}" store has no ${this.config.tables.jobs}.concurrency_key column to hold it`,
|
|
607
|
+
{
|
|
608
|
+
hint: 'Run `henri jobs:install` once with a user that may alter the table',
|
|
609
|
+
job: name,
|
|
610
|
+
}
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const now = Date.now();
|
|
615
|
+
const when = runAt(options, now);
|
|
616
|
+
const row = {
|
|
617
|
+
args: serialize(args, { maxBytes: this.config.maxArgsBytes }),
|
|
618
|
+
attempts: 0,
|
|
619
|
+
batch_id: options.batch || null,
|
|
620
|
+
claim_token: null,
|
|
621
|
+
claimed_at: null,
|
|
622
|
+
claimed_by: null,
|
|
623
|
+
concurrency_key: keyOf(definition, args),
|
|
624
|
+
created_at: now,
|
|
625
|
+
duration_ms: null,
|
|
626
|
+
error_message: null,
|
|
627
|
+
error_stack: null,
|
|
628
|
+
finished_at: null,
|
|
629
|
+
heartbeat_at: null,
|
|
630
|
+
history: null,
|
|
631
|
+
id: options.id || randomUUID(),
|
|
632
|
+
max_attempts: Math.max(
|
|
633
|
+
1,
|
|
634
|
+
Number(options.maxAttempts) || definition.maxAttempts
|
|
635
|
+
),
|
|
636
|
+
name,
|
|
637
|
+
priority:
|
|
638
|
+
typeof options.priority === 'number'
|
|
639
|
+
? options.priority
|
|
640
|
+
: definition.priority,
|
|
641
|
+
queue: options.queue || definition.queue,
|
|
642
|
+
run_at: when,
|
|
643
|
+
started_at: null,
|
|
644
|
+
state: 'pending',
|
|
645
|
+
tenant: this.tenantOf(options),
|
|
646
|
+
timeout_ms: duration(options.timeout, definition.timeout),
|
|
647
|
+
unique_key: options.unique || null,
|
|
648
|
+
updated_at: now,
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
debug('enqueue %s on %s at %d', name, row.queue, when);
|
|
652
|
+
|
|
653
|
+
return toJob(await this.storeOrDie().insert(row));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Enqueues a job; the name `henri.mailers.onDeliverLater()` expects
|
|
658
|
+
*
|
|
659
|
+
* @param {string} name The job name
|
|
660
|
+
* @param {*} [args=null] What perform() receives
|
|
661
|
+
* @param {object} [options={}] The options of perform()
|
|
662
|
+
* @returns {Promise<object>} The enqueued job
|
|
663
|
+
* @memberof Jobs
|
|
664
|
+
*/
|
|
665
|
+
async enqueue(name, args = null, options = {}) {
|
|
666
|
+
return this.perform(name, args, options);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Enqueues a job to run later
|
|
671
|
+
*
|
|
672
|
+
* @param {(number|string)} wait How long to wait (`'5m'`, `300000`)
|
|
673
|
+
* @param {string} name The job name
|
|
674
|
+
* @param {*} [args=null] What perform() receives
|
|
675
|
+
* @param {object} [options={}] The options of perform()
|
|
676
|
+
* @returns {Promise<object>} The enqueued job
|
|
677
|
+
* @memberof Jobs
|
|
678
|
+
*/
|
|
679
|
+
async performIn(wait, name, args = null, options = {}) {
|
|
680
|
+
return this.perform(name, args, { ...options, at: null, wait });
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Enqueues a job to run at a given moment
|
|
685
|
+
*
|
|
686
|
+
* @param {(Date|string|number)} when The moment
|
|
687
|
+
* @param {string} name The job name
|
|
688
|
+
* @param {*} [args=null] What perform() receives
|
|
689
|
+
* @param {object} [options={}] The options of perform()
|
|
690
|
+
* @returns {Promise<object>} The enqueued job
|
|
691
|
+
* @memberof Jobs
|
|
692
|
+
*/
|
|
693
|
+
async performAt(when, name, args = null, options = {}) {
|
|
694
|
+
return this.perform(name, args, { ...options, at: when });
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Performs a job right here, right now, without the queue
|
|
699
|
+
*
|
|
700
|
+
* Handy in a test or in the console; a request should enqueue instead.
|
|
701
|
+
* The arguments go through the same serialization, so a payload the queue
|
|
702
|
+
* would refuse is refused here too.
|
|
703
|
+
*
|
|
704
|
+
* @param {string} name The job name
|
|
705
|
+
* @param {*} [args=null] What perform() receives
|
|
706
|
+
* @returns {Promise<*>} What perform() returned
|
|
707
|
+
* @throws {JobError} HENRI_JOB_UNKNOWN, or whatever the job threw
|
|
708
|
+
* @memberof Jobs
|
|
709
|
+
*/
|
|
710
|
+
async performNow(name, args = null) {
|
|
711
|
+
const definition = this.definition(name);
|
|
712
|
+
const payload = deserialize(
|
|
713
|
+
serialize(args, { maxBytes: this.config.maxArgsBytes })
|
|
714
|
+
);
|
|
715
|
+
const controller = new AbortController();
|
|
716
|
+
|
|
717
|
+
return definition.perform(payload, {
|
|
718
|
+
henri: this.henri,
|
|
719
|
+
job: {
|
|
720
|
+
attempt: 1,
|
|
721
|
+
id: randomUUID(),
|
|
722
|
+
inline: true,
|
|
723
|
+
maxAttempts: definition.maxAttempts,
|
|
724
|
+
name,
|
|
725
|
+
queue: definition.queue,
|
|
726
|
+
},
|
|
727
|
+
signal: controller.signal,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* `henri.tenancy`, when the application has one and turned it on
|
|
733
|
+
*
|
|
734
|
+
* Core is a peer dependency and the queue runs against a henri stand-in
|
|
735
|
+
* in its own suites, so every reach for a module of core's is guarded the
|
|
736
|
+
* way the reach for `henri.pen` is.
|
|
737
|
+
*
|
|
738
|
+
* @returns {?object} The tenancy module, or null
|
|
739
|
+
* @memberof Jobs
|
|
740
|
+
*/
|
|
741
|
+
tenancy() {
|
|
742
|
+
const tenancy = this.henri && this.henri.tenancy;
|
|
743
|
+
|
|
744
|
+
return tenancy && tenancy.enabled ? tenancy : null;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Is this application multi-tenant?
|
|
749
|
+
*
|
|
750
|
+
* @returns {boolean} yes or no
|
|
751
|
+
* @memberof Jobs
|
|
752
|
+
*/
|
|
753
|
+
multitenant() {
|
|
754
|
+
return Boolean(this.tenancy());
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* The tenant a job is enqueued for: what the caller named, else the
|
|
759
|
+
* tenant in scope.
|
|
760
|
+
*
|
|
761
|
+
* `henri.webhooks.emit()`'s `owner` rule, one word changed, and for the
|
|
762
|
+
* same reason: an enqueue inside a request belongs to that request's
|
|
763
|
+
* tenant without the caller repeating it, and an application that is not
|
|
764
|
+
* multi-tenant is exactly where it was -- the column holds null.
|
|
765
|
+
*
|
|
766
|
+
* Naming a tenant still wins, including naming `null`, which is how a
|
|
767
|
+
* platform-wide job is enqueued from inside a customer's request: the
|
|
768
|
+
* runner then enters no tenant and the job says `unscoped()` itself.
|
|
769
|
+
* Naming a **different** tenant while one is in scope is refused, for
|
|
770
|
+
* `HENRI_TENANT_CROSS_WRITE`'s reason one layer up: a job stamped with
|
|
771
|
+
* somebody else's tenant is performed in somebody else's data, with
|
|
772
|
+
* arguments that came from this one.
|
|
773
|
+
*
|
|
774
|
+
* @param {object} [options={}] What `perform()` was given
|
|
775
|
+
* @returns {?string} The tenant to stamp
|
|
776
|
+
* @throws {JobError} HENRI_TENANT_CROSS_WRITE, HENRI_TENANT_INVALID
|
|
777
|
+
* @memberof Jobs
|
|
778
|
+
*/
|
|
779
|
+
tenantOf(options = {}) {
|
|
780
|
+
const tenancy = this.tenancy();
|
|
781
|
+
const scope = (tenancy && tenancy.current()) || null;
|
|
782
|
+
const said = Object.prototype.hasOwnProperty.call(options, 'tenant');
|
|
783
|
+
|
|
784
|
+
if (!said) {
|
|
785
|
+
return scope;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const named =
|
|
789
|
+
options.tenant === null ||
|
|
790
|
+
typeof options.tenant === 'undefined' ||
|
|
791
|
+
options.tenant === ''
|
|
792
|
+
? null
|
|
793
|
+
: String(options.tenant);
|
|
794
|
+
|
|
795
|
+
// The width and not the shape: `base/tenancy.js` owns what a tenant may
|
|
796
|
+
// look like and has already checked anything that came from a request.
|
|
797
|
+
// What the queue owns is its column, and a value truncated to fit it is
|
|
798
|
+
// two tenants sharing a prefix and therefore sharing their jobs
|
|
799
|
+
if (named && named.length > MAX_TENANT) {
|
|
800
|
+
throw new JobError(
|
|
801
|
+
'HENRI_TENANT_INVALID',
|
|
802
|
+
`a tenant is at most ${MAX_TENANT} characters and this one is ${named.length}`,
|
|
803
|
+
{
|
|
804
|
+
hint: 'An identifier is never truncated to fit: two tenants sharing a prefix would share their jobs',
|
|
805
|
+
}
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (scope && named && named !== scope) {
|
|
810
|
+
throw new JobError(
|
|
811
|
+
'HENRI_TENANT_CROSS_WRITE',
|
|
812
|
+
`a job was enqueued for the tenant '${named}' while the tenant in scope is '${scope}'`,
|
|
813
|
+
{
|
|
814
|
+
hint: `henri refuses rather than obeying: a runner enters the tenant the row names, so this job would read and write '${named}' data with arguments that came from '${scope}'. Enqueue it inside henri.tenancy.run('${named}', () => ...), or say tenant: null when it belongs to no tenant`,
|
|
815
|
+
}
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return named;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Runs something as the tenant a job row names.
|
|
824
|
+
*
|
|
825
|
+
* **This is the half of the column that matters.** A job's `perform()`
|
|
826
|
+
* runs in another process, minutes later, with no request behind it --
|
|
827
|
+
* and a tenanted model touched with no tenant in scope raises
|
|
828
|
+
* `HENRI_TENANT_REQUIRED` rather than reading every tenant's rows. So
|
|
829
|
+
* the runner enters the tenant the *row* carries before it calls
|
|
830
|
+
* `perform()`, and a whole class of jobs stops needing a first line that
|
|
831
|
+
* says `henri.tenancy.run(args.tenant, ...)`.
|
|
832
|
+
*
|
|
833
|
+
* A row with **no** tenant enters nothing, deliberately: null is not
|
|
834
|
+
* "every tenant". That is what a job enqueued before the column existed
|
|
835
|
+
* looks like, what a recurring schedule looks like, and what
|
|
836
|
+
* `tenant: null` asked for -- and all three behave exactly as they did,
|
|
837
|
+
* which is to say the refusal fires on the first tenanted model call
|
|
838
|
+
* unless the job says `henri.tenancy.unscoped()` itself.
|
|
839
|
+
*
|
|
840
|
+
* @param {?string} tenant The tenant the row names
|
|
841
|
+
* @param {function} work What to run
|
|
842
|
+
* @returns {*} Whatever the work answered
|
|
843
|
+
* @memberof Jobs
|
|
844
|
+
*/
|
|
845
|
+
scoped(tenant, work) {
|
|
846
|
+
const tenancy = this.tenancy();
|
|
847
|
+
|
|
848
|
+
if (!tenant || !tenancy || typeof tenancy.run !== 'function') {
|
|
849
|
+
return work();
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return tenancy.run(tenant, work);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* The store, once the queue is started
|
|
857
|
+
*
|
|
858
|
+
* @returns {object} The store backend
|
|
859
|
+
* @throws {JobError} HENRI_JOB_QUEUE_NOT_STARTED before start()
|
|
860
|
+
* @memberof Jobs
|
|
861
|
+
*/
|
|
862
|
+
storeOrDie() {
|
|
863
|
+
if (!this.store) {
|
|
864
|
+
throw new JobError(
|
|
865
|
+
'HENRI_JOB_QUEUE_NOT_STARTED',
|
|
866
|
+
'@usehenri/jobs: the queue is not started',
|
|
867
|
+
{
|
|
868
|
+
hint: 'henri starts it for you; outside of henri, call await jobs.start()',
|
|
869
|
+
}
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
return this.store;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* One job
|
|
878
|
+
*
|
|
879
|
+
* @param {string} id The job id
|
|
880
|
+
* @returns {Promise<?object>} The job, or null
|
|
881
|
+
* @memberof Jobs
|
|
882
|
+
*/
|
|
883
|
+
async get(id) {
|
|
884
|
+
return toJob(await this.storeOrDie().find(id));
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* The jobs of the queue, newest change first
|
|
889
|
+
*
|
|
890
|
+
* @param {object} [filter={}] `state`, `queue`, `name`, `tenant`,
|
|
891
|
+
* `limit`, `offset`
|
|
892
|
+
* @returns {Promise<Array<object>>} The jobs
|
|
893
|
+
* @throws {JobError} HENRI_JOB_UNKNOWN_STATE for an unknown state, or
|
|
894
|
+
* HENRI_JOB_TENANT_UNINSTALLED for a tenant the table cannot hold
|
|
895
|
+
* @memberof Jobs
|
|
896
|
+
*/
|
|
897
|
+
async list(filter = {}) {
|
|
898
|
+
if (filter.state && !STATES.includes(filter.state)) {
|
|
899
|
+
throw new JobError(
|
|
900
|
+
'HENRI_JOB_UNKNOWN_STATE',
|
|
901
|
+
`No such state "${filter.state}"`,
|
|
902
|
+
{
|
|
903
|
+
hint: `The states are: ${STATES.join(', ')}`,
|
|
904
|
+
}
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const store = this.storeOrDie();
|
|
909
|
+
|
|
910
|
+
this.filterable(filter);
|
|
911
|
+
|
|
912
|
+
const rows = await store.list(filter);
|
|
913
|
+
|
|
914
|
+
return rows.map(toJob);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Refuses a tenant filter this store has no column to answer.
|
|
919
|
+
*
|
|
920
|
+
* Listing every tenant's jobs because the column is missing would answer
|
|
921
|
+
* the wrong question with a straight face, which is worse than saying
|
|
922
|
+
* the column is not there.
|
|
923
|
+
*
|
|
924
|
+
* @param {object} [filter={}] A filter
|
|
925
|
+
* @returns {object} The same filter
|
|
926
|
+
* @throws {JobError} HENRI_JOB_TENANT_UNINSTALLED
|
|
927
|
+
* @memberof Jobs
|
|
928
|
+
*/
|
|
929
|
+
filterable(filter = {}) {
|
|
930
|
+
if (filter.tenant && !this.tenanted) {
|
|
931
|
+
throw new JobError(
|
|
932
|
+
'HENRI_JOB_TENANT_UNINSTALLED',
|
|
933
|
+
`The "${this.config.store}" store has no ${this.config.tables.jobs}.tenant column, so the jobs of one tenant cannot be told from another's`,
|
|
934
|
+
{
|
|
935
|
+
hint: 'Run `henri jobs:install` once with a user that may alter the table; the rows enqueued before it ran carry no tenant and no listing will find them under one',
|
|
936
|
+
}
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return filter;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* How many jobs match a filter
|
|
945
|
+
*
|
|
946
|
+
* @param {object} [filter={}] `state` and `queue`
|
|
947
|
+
* @returns {Promise<number>} The count
|
|
948
|
+
* @memberof Jobs
|
|
949
|
+
*/
|
|
950
|
+
async count(filter = {}) {
|
|
951
|
+
const counts = await this.storeOrDie().counts();
|
|
952
|
+
|
|
953
|
+
return counts
|
|
954
|
+
.filter(
|
|
955
|
+
(entry) =>
|
|
956
|
+
(!filter.state || entry.state === filter.state) &&
|
|
957
|
+
(!filter.queue || entry.queue === filter.queue)
|
|
958
|
+
)
|
|
959
|
+
.reduce((total, entry) => total + entry.total, 0);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* What the queue holds: counts by queue and state, how long the finished
|
|
964
|
+
* jobs took, and how long the oldest job that is due has been waiting
|
|
965
|
+
*
|
|
966
|
+
* @returns {Promise<object>} `{ totals, queues, timings, jobs, runners }`
|
|
967
|
+
* @memberof Jobs
|
|
968
|
+
*/
|
|
969
|
+
async stats() {
|
|
970
|
+
const store = this.storeOrDie();
|
|
971
|
+
const now = Date.now();
|
|
972
|
+
const [counts, timings, oldest] = await Promise.all([
|
|
973
|
+
store.counts(),
|
|
974
|
+
store.timings(),
|
|
975
|
+
store.oldest(now),
|
|
976
|
+
]);
|
|
977
|
+
const totals = { dead: 0, done: 0, pending: 0, running: 0 };
|
|
978
|
+
const byQueue = new Map();
|
|
979
|
+
|
|
980
|
+
for (const entry of counts) {
|
|
981
|
+
const queue = byQueue.get(entry.queue) || {
|
|
982
|
+
dead: 0,
|
|
983
|
+
done: 0,
|
|
984
|
+
pending: 0,
|
|
985
|
+
queue: entry.queue,
|
|
986
|
+
running: 0,
|
|
987
|
+
waiting: 0,
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
queue[entry.state] = entry.total;
|
|
991
|
+
totals[entry.state] = (totals[entry.state] || 0) + entry.total;
|
|
992
|
+
byQueue.set(entry.queue, queue);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
for (const entry of oldest) {
|
|
996
|
+
const queue = byQueue.get(entry.queue);
|
|
997
|
+
|
|
998
|
+
if (queue) {
|
|
999
|
+
queue.waiting = entry.waiting;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return {
|
|
1004
|
+
jobs: this.names(),
|
|
1005
|
+
queues: [...byQueue.values()].sort((left, right) =>
|
|
1006
|
+
left.queue.localeCompare(right.queue)
|
|
1007
|
+
),
|
|
1008
|
+
timings: timings.sort((left, right) =>
|
|
1009
|
+
left.queue.localeCompare(right.queue)
|
|
1010
|
+
),
|
|
1011
|
+
totals,
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* What the concurrency limits are, and which of their slots are held
|
|
1017
|
+
*
|
|
1018
|
+
* The pair an operator needs and no log line carries: what the
|
|
1019
|
+
* application asked for, and what is holding it up right now. It carries
|
|
1020
|
+
* job ids and runner names and no arguments -- what a job was given is
|
|
1021
|
+
* the application's data, and `henri jobs:show <id>` is where it is read
|
|
1022
|
+
* by somebody who may.
|
|
1023
|
+
*
|
|
1024
|
+
* @returns {Promise<object>} `{ declared, held }`
|
|
1025
|
+
* @memberof Jobs
|
|
1026
|
+
*/
|
|
1027
|
+
async limits() {
|
|
1028
|
+
const declared = Object.values(this.definitions)
|
|
1029
|
+
.filter((definition) => definition.concurrency)
|
|
1030
|
+
.map((definition) => ({
|
|
1031
|
+
group: definition.concurrency.group,
|
|
1032
|
+
job: definition.name,
|
|
1033
|
+
keyed: Boolean(definition.concurrency.key),
|
|
1034
|
+
limit: definition.concurrency.limit,
|
|
1035
|
+
}))
|
|
1036
|
+
.sort((one, other) => one.job.localeCompare(other.job));
|
|
1037
|
+
const held = this.concurrent ? await this.storeOrDie().slots() : [];
|
|
1038
|
+
|
|
1039
|
+
return { declared, held };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Refuses to store a batch this store has nowhere to put it
|
|
1044
|
+
*
|
|
1045
|
+
* The concurrency limit's refusal, for the same reason and with the same
|
|
1046
|
+
* shape: the tables of the queue have no migration chain behind them, so
|
|
1047
|
+
* a new column and a new table arrive through the tolerated upgrade block
|
|
1048
|
+
* of the install -- and what decides at runtime is asking the table.
|
|
1049
|
+
* Running a batch that counts nothing would be worse than refusing it.
|
|
1050
|
+
*
|
|
1051
|
+
* @returns {object} The store
|
|
1052
|
+
* @throws {JobError} HENRI_JOB_BATCH_UNINSTALLED
|
|
1053
|
+
* @memberof Jobs
|
|
1054
|
+
*/
|
|
1055
|
+
batchable() {
|
|
1056
|
+
if (!this.batched) {
|
|
1057
|
+
throw new JobError(
|
|
1058
|
+
'HENRI_JOB_BATCH_UNINSTALLED',
|
|
1059
|
+
`The "${this.config.store}" store has no ${this.config.tables.batches} table (or no ${this.config.tables.jobs}.batch_id column) to hold a batch`,
|
|
1060
|
+
{
|
|
1061
|
+
hint: 'Run `henri jobs:install` once with a user that may create a table and alter one; the queue itself keeps working without it, and a batch would not',
|
|
1062
|
+
}
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
return this.storeOrDie();
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* The batch a job may still be added to
|
|
1071
|
+
*
|
|
1072
|
+
* Asked of the table rather than of the handle: "adding a job to a batch
|
|
1073
|
+
* that has finished is refused" is a promise about the batch, not about
|
|
1074
|
+
* the object in this process's memory.
|
|
1075
|
+
*
|
|
1076
|
+
* @param {string} id The batch id
|
|
1077
|
+
* @returns {Promise<object>} The stored row
|
|
1078
|
+
* @throws {JobError} HENRI_JOB_BATCH_CLOSED when it is sealed or gone
|
|
1079
|
+
* @memberof Jobs
|
|
1080
|
+
*/
|
|
1081
|
+
async openBatch(id) {
|
|
1082
|
+
const store = this.batchable();
|
|
1083
|
+
const row = await store.findBatch(id);
|
|
1084
|
+
|
|
1085
|
+
if (!row || row.sealed_at) {
|
|
1086
|
+
throw new JobError(
|
|
1087
|
+
'HENRI_JOB_BATCH_CLOSED',
|
|
1088
|
+
row
|
|
1089
|
+
? `The batch ${id} is closed: it holds ${toNumber(row.total)} job(s) and was sealed at ${at(row.sealed_at)}`
|
|
1090
|
+
: `There is no batch ${id}`,
|
|
1091
|
+
{
|
|
1092
|
+
batch: id,
|
|
1093
|
+
hint: 'A batch is built where it is created: add every job before it is sealed, or make another batch',
|
|
1094
|
+
}
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
return row;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Makes a batch: these jobs, and one that runs when they are all done
|
|
1103
|
+
*
|
|
1104
|
+
* The callback runs once every job of the batch has reached a terminal
|
|
1105
|
+
* state, `dead` included, and it is handed the counts under `batch` --
|
|
1106
|
+
* see `./batch.js` for the whole of the argument.
|
|
1107
|
+
*
|
|
1108
|
+
* @param {object} [options={}] Options
|
|
1109
|
+
* @param {string} [options.callback] The job to run when it finishes
|
|
1110
|
+
* @param {object} [options.args] The callback's own arguments; the counts
|
|
1111
|
+
* are added to them under `batch`
|
|
1112
|
+
* @param {string} [options.name] A label, for `henri jobs:batches`
|
|
1113
|
+
* @param {Array} [options.jobs] The jobs, as `'name'`, `['name', args]`,
|
|
1114
|
+
* `['name', args, options]` or `{ name, args, options }`
|
|
1115
|
+
* @param {string} [options.queue] The callback's queue; `priority`,
|
|
1116
|
+
* `maxAttempts`, `timeout`, `wait` and `at` are read the same way
|
|
1117
|
+
* @param {function} [build] Adds the jobs itself, when there are too many
|
|
1118
|
+
* to write out: it is given the batch and the batch is sealed when it
|
|
1119
|
+
* resolves
|
|
1120
|
+
* @returns {Promise<Batch>} The batch, sealed unless it was given neither
|
|
1121
|
+
* `jobs` nor a function
|
|
1122
|
+
* @throws {JobError} HENRI_JOB_BATCH_UNINSTALLED, HENRI_JOB_INVALID_BATCH
|
|
1123
|
+
* or HENRI_JOB_UNKNOWN when the callback is not a job
|
|
1124
|
+
* @memberof Jobs
|
|
1125
|
+
*/
|
|
1126
|
+
async batch(options = {}, build) {
|
|
1127
|
+
const store = this.batchable();
|
|
1128
|
+
const declared = declaration(options);
|
|
1129
|
+
|
|
1130
|
+
if (declared.jobs && typeof build === 'function') {
|
|
1131
|
+
throw new JobError(
|
|
1132
|
+
'HENRI_JOB_INVALID_BATCH',
|
|
1133
|
+
'The batch was given both a list of jobs and a function to add them',
|
|
1134
|
+
{ hint: 'Pass `jobs`, or a function, and not both' }
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// A callback nothing answers to is refused here rather than when the
|
|
1139
|
+
// last job of the batch finishes, which is minutes later and elsewhere
|
|
1140
|
+
if (declared.callback) {
|
|
1141
|
+
this.definition(declared.callback);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const now = Date.now();
|
|
1145
|
+
// The callback is enqueued by a runner, minutes later, with no request
|
|
1146
|
+
// behind it -- so the tenant of the batch travels with the batch. It
|
|
1147
|
+
// rides in `callback_options`, which is already stored and already
|
|
1148
|
+
// handed to `perform()`, rather than in a column of its own: the
|
|
1149
|
+
// batches table would need the same upgrade block for one value that
|
|
1150
|
+
// is only ever read once
|
|
1151
|
+
const scope = this.tenantOf({});
|
|
1152
|
+
const callbackOptions =
|
|
1153
|
+
scope && !Object.prototype.hasOwnProperty.call(declared.options, 'tenant')
|
|
1154
|
+
? { ...declared.options, tenant: scope }
|
|
1155
|
+
: declared.options;
|
|
1156
|
+
const row = await store.createBatch({
|
|
1157
|
+
callback: declared.callback,
|
|
1158
|
+
callback_args: serialize(declared.args, {
|
|
1159
|
+
maxBytes: this.config.maxArgsBytes,
|
|
1160
|
+
}),
|
|
1161
|
+
callback_id: null,
|
|
1162
|
+
callback_options: JSON.stringify(callbackOptions),
|
|
1163
|
+
created_at: now,
|
|
1164
|
+
done: 0,
|
|
1165
|
+
failed: 0,
|
|
1166
|
+
finished_at: null,
|
|
1167
|
+
id: randomUUID(),
|
|
1168
|
+
name: declared.name,
|
|
1169
|
+
sealed_at: null,
|
|
1170
|
+
total: 0,
|
|
1171
|
+
updated_at: now,
|
|
1172
|
+
});
|
|
1173
|
+
const batch = new Batch(this, row);
|
|
1174
|
+
|
|
1175
|
+
debug('batch %s -> %s', batch.id, declared.callback || 'no callback');
|
|
1176
|
+
|
|
1177
|
+
// A list, even an empty one, is a batch that says what it holds: it is
|
|
1178
|
+
// sealed here and now. No list at all leaves it open for the caller
|
|
1179
|
+
if (declared.jobs) {
|
|
1180
|
+
await batch.addAll(declared.jobs);
|
|
1181
|
+
|
|
1182
|
+
return batch.seal();
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
if (typeof build === 'function') {
|
|
1186
|
+
// A builder that throws leaves the batch unsealed on purpose: its
|
|
1187
|
+
// jobs run, its callback never does, and `henri jobs:batches` shows
|
|
1188
|
+
// it. Sealing what an application abandoned half way through would
|
|
1189
|
+
// call the callback for a batch that was never a batch
|
|
1190
|
+
await build(batch);
|
|
1191
|
+
|
|
1192
|
+
return batch.seal();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
return batch;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* One batch
|
|
1200
|
+
*
|
|
1201
|
+
* @param {string} id The batch id
|
|
1202
|
+
* @returns {Promise<?object>} The batch, or null
|
|
1203
|
+
* @memberof Jobs
|
|
1204
|
+
*/
|
|
1205
|
+
async getBatch(id) {
|
|
1206
|
+
return toBatch(await this.batchable().findBatch(id));
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* The batches of the queue, the newest first
|
|
1211
|
+
*
|
|
1212
|
+
* @param {object} [filter={}] `finished`, `limit`, `offset`
|
|
1213
|
+
* @returns {Promise<Array<object>>} The batches
|
|
1214
|
+
* @memberof Jobs
|
|
1215
|
+
*/
|
|
1216
|
+
async listBatches(filter = {}) {
|
|
1217
|
+
const rows = await this.batchable().listBatches(filter);
|
|
1218
|
+
|
|
1219
|
+
return rows.map(toBatch);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Forgets a batch, leaving its jobs alone
|
|
1224
|
+
*
|
|
1225
|
+
* The way out of a batch that can never finish because one of its jobs
|
|
1226
|
+
* was discarded: what is left of it counts against nothing.
|
|
1227
|
+
*
|
|
1228
|
+
* @param {string} id The batch id
|
|
1229
|
+
* @returns {Promise<boolean>} Whether there was one to forget
|
|
1230
|
+
* @memberof Jobs
|
|
1231
|
+
*/
|
|
1232
|
+
async discardBatch(id) {
|
|
1233
|
+
return this.batchable().removeBatch(id);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Counts one terminal outcome into the batch of a job, and settles it
|
|
1238
|
+
*
|
|
1239
|
+
* @param {object} row The row whose outcome was just written
|
|
1240
|
+
* @param {object} [options={}] `failed`, whether it died
|
|
1241
|
+
* @returns {Promise<?object>} The batch, when this outcome finished it
|
|
1242
|
+
* @memberof Jobs
|
|
1243
|
+
*/
|
|
1244
|
+
async advance(row, options = {}) {
|
|
1245
|
+
if (!row.batch_id || !this.batched) {
|
|
1246
|
+
return null;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
try {
|
|
1250
|
+
const batch = await this.storeOrDie().advanceBatch({
|
|
1251
|
+
failed: Boolean(options.failed),
|
|
1252
|
+
id: row.batch_id,
|
|
1253
|
+
job: row.id,
|
|
1254
|
+
now: Date.now(),
|
|
1255
|
+
token: row.claim_token,
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
return await this.settle(batch);
|
|
1259
|
+
} catch (error) {
|
|
1260
|
+
// Never fail an attempt whose outcome is already written over the
|
|
1261
|
+
// bookkeeping of its batch: the sweep settles what this missed
|
|
1262
|
+
this.log(
|
|
1263
|
+
'warn',
|
|
1264
|
+
row.name,
|
|
1265
|
+
row.id,
|
|
1266
|
+
`could not count into the batch ${row.batch_id}:`,
|
|
1267
|
+
error.message
|
|
1268
|
+
);
|
|
1269
|
+
debug('%O', error);
|
|
1270
|
+
|
|
1271
|
+
return null;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Enqueues the callback of a batch whose jobs are all terminal
|
|
1277
|
+
*
|
|
1278
|
+
* Idempotent, and that is the point: the callback is enqueued under a
|
|
1279
|
+
* unique key of the batch's own (`./keys.js`), so a second settle -- from
|
|
1280
|
+
* another runner, or from the sweep after a runner was killed between
|
|
1281
|
+
* writing an outcome and counting it -- answers the job that is already
|
|
1282
|
+
* in the queue instead of enqueuing a second one. The batch is stamped
|
|
1283
|
+
* finished **after** the enqueue, so that gap is what the sweep repairs.
|
|
1284
|
+
*
|
|
1285
|
+
* @param {?object} row A batch row
|
|
1286
|
+
* @returns {Promise<?object>} The batch, when this call finished it
|
|
1287
|
+
* @memberof Jobs
|
|
1288
|
+
*/
|
|
1289
|
+
async settle(row) {
|
|
1290
|
+
if (!row || !row.sealed_at || row.finished_at) {
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const store = this.storeOrDie();
|
|
1295
|
+
const total = toNumber(row.total) || 0;
|
|
1296
|
+
const done = toNumber(row.done) || 0;
|
|
1297
|
+
|
|
1298
|
+
if (done < total) {
|
|
1299
|
+
return null;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const failed = toNumber(row.failed) || 0;
|
|
1303
|
+
const counts = {
|
|
1304
|
+
done,
|
|
1305
|
+
failed,
|
|
1306
|
+
id: row.id,
|
|
1307
|
+
name: row.name || null,
|
|
1308
|
+
succeeded: Math.max(0, done - failed),
|
|
1309
|
+
total,
|
|
1310
|
+
};
|
|
1311
|
+
let job = null;
|
|
1312
|
+
|
|
1313
|
+
if (row.callback) {
|
|
1314
|
+
const options = deserialize(row.callback_options) || {};
|
|
1315
|
+
|
|
1316
|
+
// The unique key is the arbiter and the id is not: two runners
|
|
1317
|
+
// settling at once send the same insert, and the one the index
|
|
1318
|
+
// refuses is answered with the job the other one enqueued -- which
|
|
1319
|
+
// `insert()` only does for a row it did not write itself
|
|
1320
|
+
job = await this.perform(
|
|
1321
|
+
row.callback,
|
|
1322
|
+
{ ...(deserialize(row.callback_args) || {}), batch: counts },
|
|
1323
|
+
{ ...options, unique: callbackKey(row.id) }
|
|
1324
|
+
);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
await store.finishBatch({
|
|
1328
|
+
callback: job && job.id,
|
|
1329
|
+
id: row.id,
|
|
1330
|
+
now: Date.now(),
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
this.log(
|
|
1334
|
+
'info',
|
|
1335
|
+
'batch',
|
|
1336
|
+
row.id,
|
|
1337
|
+
`finished: ${counts.succeeded} done, ${counts.failed} dead`,
|
|
1338
|
+
job ? `-> ${row.callback} ${job.id}` : '(no callback)'
|
|
1339
|
+
);
|
|
1340
|
+
|
|
1341
|
+
return toBatch(await store.findBatch(row.id));
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Settles the batches nothing else will
|
|
1346
|
+
*
|
|
1347
|
+
* Two things leave a batch short of its total with every job of it
|
|
1348
|
+
* terminal, and one sweep answers both: a runner killed between writing
|
|
1349
|
+
* an outcome and counting it, and a job buried by the recovery of a dead
|
|
1350
|
+
* runner, whose outcome no attempt of anybody's ever wrote. Counting the
|
|
1351
|
+
* rows is what decides -- and it only ever moves a batch forward, so a
|
|
1352
|
+
* finished job pruned out of the table cannot undo one.
|
|
1353
|
+
*
|
|
1354
|
+
* @param {object} options Options
|
|
1355
|
+
* @param {number} options.before Only batches untouched since that moment
|
|
1356
|
+
* @param {number} [options.limit=50] How many one sweep looks at
|
|
1357
|
+
* @returns {Promise<Array<object>>} The batches this sweep finished
|
|
1358
|
+
* @memberof Jobs
|
|
1359
|
+
*/
|
|
1360
|
+
async reconcile({ before, limit = 50 }) {
|
|
1361
|
+
if (!this.batched) {
|
|
1362
|
+
return [];
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const store = this.storeOrDie();
|
|
1366
|
+
const open = await store.openBatches({ before, limit });
|
|
1367
|
+
const finished = [];
|
|
1368
|
+
|
|
1369
|
+
for (const row of open) {
|
|
1370
|
+
const counted = await store.countBatch(row.id);
|
|
1371
|
+
const batch = await store.syncBatch({
|
|
1372
|
+
done: counted.done,
|
|
1373
|
+
failed: counted.failed,
|
|
1374
|
+
id: row.id,
|
|
1375
|
+
now: Date.now(),
|
|
1376
|
+
});
|
|
1377
|
+
const settled = await this.settle(batch);
|
|
1378
|
+
|
|
1379
|
+
if (settled) {
|
|
1380
|
+
finished.push(settled);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
return finished;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Puts a job back in its queue
|
|
1389
|
+
*
|
|
1390
|
+
* Its attempt count starts over, so the retry policy applies again. Works
|
|
1391
|
+
* on a dead job (the point of the dead letter queue) and on one that is
|
|
1392
|
+
* still waiting (it runs now).
|
|
1393
|
+
*
|
|
1394
|
+
* A job a runner is performing right now is refused: requeuing it would
|
|
1395
|
+
* hand the same work to a second runner.
|
|
1396
|
+
*
|
|
1397
|
+
* @param {string} id The job id
|
|
1398
|
+
* @param {object} [options={}] Options
|
|
1399
|
+
* @param {(number|string)} [options.wait] Run it that much later
|
|
1400
|
+
* @param {(Date|string|number)} [options.at] Run it at that moment
|
|
1401
|
+
* @returns {Promise<?object>} The job, or null when there is no such id
|
|
1402
|
+
* @throws {JobError} RUNNING when a runner is performing it
|
|
1403
|
+
* @memberof Jobs
|
|
1404
|
+
*/
|
|
1405
|
+
async retry(id, options = {}) {
|
|
1406
|
+
const store = this.storeOrDie();
|
|
1407
|
+
const row = await store.find(id);
|
|
1408
|
+
|
|
1409
|
+
if (!row) {
|
|
1410
|
+
return null;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
if (row.state === 'running') {
|
|
1414
|
+
throw new JobError(
|
|
1415
|
+
'HENRI_JOB_RUNNING',
|
|
1416
|
+
`The job ${id} is being performed by ${row.claimed_by}`,
|
|
1417
|
+
{
|
|
1418
|
+
hint: 'Wait for it to finish, or for the runner that died on it to be recovered from (jobs.stuckAfter)',
|
|
1419
|
+
}
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const now = Date.now();
|
|
1424
|
+
|
|
1425
|
+
// A job that was counted into a batch and is put back will reach a
|
|
1426
|
+
// terminal state a second time, so it gives its slot back first: a
|
|
1427
|
+
// batch that has already finished never moves again, which is what the
|
|
1428
|
+
// guard of releaseBatch() says
|
|
1429
|
+
if (row.batch_id && this.batched && row.state !== 'pending') {
|
|
1430
|
+
await store.releaseBatch({
|
|
1431
|
+
failed: row.state === 'dead',
|
|
1432
|
+
id: row.batch_id,
|
|
1433
|
+
now,
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
await store.update(id, {
|
|
1438
|
+
attempts: 0,
|
|
1439
|
+
claim_token: null,
|
|
1440
|
+
claimed_at: null,
|
|
1441
|
+
claimed_by: null,
|
|
1442
|
+
duration_ms: null,
|
|
1443
|
+
finished_at: null,
|
|
1444
|
+
run_at: runAt(options, now),
|
|
1445
|
+
started_at: null,
|
|
1446
|
+
state: 'pending',
|
|
1447
|
+
updated_at: now,
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
return toJob(await store.find(id));
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Puts every job matching a filter back in its queue
|
|
1455
|
+
*
|
|
1456
|
+
* @param {object} [filter={}] `state` (dead by default), `queue`, `name`
|
|
1457
|
+
* @param {object} [options={}] The options of retry()
|
|
1458
|
+
* @returns {Promise<number>} How many jobs were requeued
|
|
1459
|
+
* @memberof Jobs
|
|
1460
|
+
*/
|
|
1461
|
+
async retryAll(filter = {}, options = {}) {
|
|
1462
|
+
const jobs = await this.list({
|
|
1463
|
+
limit: filter.limit || 1000,
|
|
1464
|
+
...filter,
|
|
1465
|
+
state: filter.state || 'dead',
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
for (const job of jobs) {
|
|
1469
|
+
await this.retry(job.id, options);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
return jobs.length;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/**
|
|
1476
|
+
* Deletes a job for good
|
|
1477
|
+
*
|
|
1478
|
+
* @param {string} id The job id
|
|
1479
|
+
* @returns {Promise<boolean>} Whether there was one to delete
|
|
1480
|
+
* @memberof Jobs
|
|
1481
|
+
*/
|
|
1482
|
+
async discard(id) {
|
|
1483
|
+
return (await this.storeOrDie().remove({ id })) > 0;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Deletes every job matching a filter
|
|
1488
|
+
*
|
|
1489
|
+
* @param {object} [filter={}] `state` (dead by default), `queue`, `name`
|
|
1490
|
+
* @returns {Promise<number>} How many jobs were deleted
|
|
1491
|
+
* @memberof Jobs
|
|
1492
|
+
*/
|
|
1493
|
+
async discardAll(filter = {}) {
|
|
1494
|
+
return this.storeOrDie().remove({
|
|
1495
|
+
...this.filterable(filter),
|
|
1496
|
+
state: filter.state || 'dead',
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* How long to wait before the next attempt of a job
|
|
1502
|
+
*
|
|
1503
|
+
* @param {object} definition The job definition
|
|
1504
|
+
* @param {number} attempts How many attempts have been made
|
|
1505
|
+
* @returns {number} A delay in milliseconds
|
|
1506
|
+
* @memberof Jobs
|
|
1507
|
+
*/
|
|
1508
|
+
backoff(definition, attempts) {
|
|
1509
|
+
const { base, factor, jitter, max } = definition.backoff;
|
|
1510
|
+
const delay = Math.min(
|
|
1511
|
+
base * Math.pow(factor, Math.max(0, attempts - 1)),
|
|
1512
|
+
max
|
|
1513
|
+
);
|
|
1514
|
+
|
|
1515
|
+
if (jitter <= 0) {
|
|
1516
|
+
return Math.round(delay);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
return Math.round(delay * (1 + (Math.random() * 2 - 1) * jitter));
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Performs one claimed row, inside a span when henri is tracing
|
|
1524
|
+
*
|
|
1525
|
+
* The span carries the job's name, its queue, its attempt and its id, and
|
|
1526
|
+
* nothing of its arguments: they are the application's data, and
|
|
1527
|
+
* `base/telemetry.js` in core is explicit that what leaves the process is
|
|
1528
|
+
* henri's own or an identifier that means nothing on its own. The dead
|
|
1529
|
+
* letter row already holds the arguments, durably, for whoever is allowed
|
|
1530
|
+
* to read them.
|
|
1531
|
+
*
|
|
1532
|
+
* It is a root span, not a child of whatever enqueued the job: the queue
|
|
1533
|
+
* carries no trace context on its rows, deliberately -- see the guide.
|
|
1534
|
+
*
|
|
1535
|
+
* A failed attempt is not a failed span: the queue catches the error
|
|
1536
|
+
* itself, retries it and, in the end, writes the row of the dead letter
|
|
1537
|
+
* queue that holds the arguments, every attempt and the stack. That row
|
|
1538
|
+
* is the durable record -- the same reason `base/reporting.js` gives for
|
|
1539
|
+
* not reporting a dead job -- and a second, thinner copy of it in a trace
|
|
1540
|
+
* backend would be one more thing to keep in step.
|
|
1541
|
+
*
|
|
1542
|
+
* @param {object} row A row this runner claimed
|
|
1543
|
+
* @param {object} [options={}] Options
|
|
1544
|
+
* @param {string} [options.runner] The runner id, for the logs
|
|
1545
|
+
* @returns {Promise<object>} `{ state, job, error }`
|
|
1546
|
+
* @memberof Jobs
|
|
1547
|
+
*/
|
|
1548
|
+
run(row, options = {}) {
|
|
1549
|
+
const telemetry = this.henri && this.henri.telemetry;
|
|
1550
|
+
const perform = () => this.attempt(row, options);
|
|
1551
|
+
|
|
1552
|
+
if (!telemetry || typeof telemetry.span !== 'function') {
|
|
1553
|
+
return perform();
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
return telemetry.span(
|
|
1557
|
+
`henri.job ${row.name}`,
|
|
1558
|
+
{
|
|
1559
|
+
attributes: {
|
|
1560
|
+
'henri.job.attempt': toNumber(row.attempts) || 1,
|
|
1561
|
+
'henri.job.id': row.id,
|
|
1562
|
+
'henri.job.name': row.name,
|
|
1563
|
+
'henri.job.queue': row.queue,
|
|
1564
|
+
},
|
|
1565
|
+
boundary: 'jobs',
|
|
1566
|
+
kind: 'consumer',
|
|
1567
|
+
},
|
|
1568
|
+
perform
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/**
|
|
1573
|
+
* Performs one claimed row and writes down what happened
|
|
1574
|
+
*
|
|
1575
|
+
* A job that throws goes back to its queue with an exponential backoff
|
|
1576
|
+
* until it runs out of attempts, and then to the dead letter queue with
|
|
1577
|
+
* its error, its stack and the history of every attempt.
|
|
1578
|
+
*
|
|
1579
|
+
* @param {object} row A row this runner claimed
|
|
1580
|
+
* @param {object} [options={}] Options
|
|
1581
|
+
* @param {string} [options.runner] The runner id, for the logs
|
|
1582
|
+
* @returns {Promise<object>} `{ state, job, error }`
|
|
1583
|
+
* @memberof Jobs
|
|
1584
|
+
*/
|
|
1585
|
+
async attempt(row, options = {}) {
|
|
1586
|
+
const store = this.storeOrDie();
|
|
1587
|
+
const started = Date.now();
|
|
1588
|
+
const attempts = toNumber(row.attempts) || 1;
|
|
1589
|
+
const timeout = toNumber(row.timeout_ms);
|
|
1590
|
+
const controller = new AbortController();
|
|
1591
|
+
let definition;
|
|
1592
|
+
|
|
1593
|
+
try {
|
|
1594
|
+
definition = this.definition(row.name);
|
|
1595
|
+
} catch (error) {
|
|
1596
|
+
// A runner that is older than the process that enqueued this does not
|
|
1597
|
+
// have the file yet: put the job back rather than kill it, so a
|
|
1598
|
+
// rolling deploy does not fill the dead letter queue
|
|
1599
|
+
return this.failed(row, error, {
|
|
1600
|
+
attempts,
|
|
1601
|
+
definition: this.unknown,
|
|
1602
|
+
duration: 0,
|
|
1603
|
+
store,
|
|
1604
|
+
});
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
let args;
|
|
1608
|
+
|
|
1609
|
+
try {
|
|
1610
|
+
args = deserialize(row.args, { strict: true });
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
// Performing a job with `null` where its arguments should be is worse
|
|
1613
|
+
// than failing the attempt and saying so
|
|
1614
|
+
return this.failed(row, error, {
|
|
1615
|
+
attempts,
|
|
1616
|
+
definition,
|
|
1617
|
+
duration: 0,
|
|
1618
|
+
store,
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
const context = {
|
|
1623
|
+
henri: this.henri,
|
|
1624
|
+
job: {
|
|
1625
|
+
args,
|
|
1626
|
+
attempt: attempts,
|
|
1627
|
+
enqueuedAt: at(row.created_at),
|
|
1628
|
+
id: row.id,
|
|
1629
|
+
maxAttempts: toNumber(row.max_attempts) || definition.maxAttempts,
|
|
1630
|
+
name: row.name,
|
|
1631
|
+
queue: row.queue,
|
|
1632
|
+
runner: options.runner || null,
|
|
1633
|
+
tenant: row.tenant || null,
|
|
1634
|
+
},
|
|
1635
|
+
signal: controller.signal,
|
|
1636
|
+
};
|
|
1637
|
+
|
|
1638
|
+
try {
|
|
1639
|
+
await this.invoke(definition, context, controller, timeout);
|
|
1640
|
+
} catch (error) {
|
|
1641
|
+
return this.failed(row, error, {
|
|
1642
|
+
attempts,
|
|
1643
|
+
definition,
|
|
1644
|
+
duration: Date.now() - started,
|
|
1645
|
+
store,
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const finished = Date.now();
|
|
1650
|
+
|
|
1651
|
+
await store.update(
|
|
1652
|
+
row.id,
|
|
1653
|
+
{
|
|
1654
|
+
// A job of a batch keeps the token of the claim that wrote this,
|
|
1655
|
+
// and that is what makes the counting exactly once: the batch is
|
|
1656
|
+
// advanced by the runner whose token is on the row, which is the
|
|
1657
|
+
// one whose outcome landed (see SqlStore#advanceBatch)
|
|
1658
|
+
claim_token: row.batch_id ? row.claim_token : null,
|
|
1659
|
+
duration_ms: finished - started,
|
|
1660
|
+
error_message: null,
|
|
1661
|
+
error_stack: null,
|
|
1662
|
+
finished_at: finished,
|
|
1663
|
+
state: 'done',
|
|
1664
|
+
// A finished job holds its unique key no longer, unless the queue
|
|
1665
|
+
// wrote it for itself (see ./keys.js)
|
|
1666
|
+
unique_key: keep(row.unique_key),
|
|
1667
|
+
updated_at: finished,
|
|
1668
|
+
},
|
|
1669
|
+
row.claim_token
|
|
1670
|
+
);
|
|
1671
|
+
|
|
1672
|
+
const job = toJob(await store.find(row.id));
|
|
1673
|
+
|
|
1674
|
+
this.lost(row, job);
|
|
1675
|
+
// The counter of a batch is advanced by the write above and by nothing
|
|
1676
|
+
// else: an outcome that was refused counts nothing (see advanceBatch)
|
|
1677
|
+
await this.advance(row, { failed: false });
|
|
1678
|
+
|
|
1679
|
+
return { job, state: 'done' };
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* Calls perform(), giving up after the job's timeout
|
|
1684
|
+
*
|
|
1685
|
+
* JavaScript cannot stop a function that is already running: the timeout
|
|
1686
|
+
* fails the attempt and aborts `context.signal`, so a job that watches the
|
|
1687
|
+
* signal stops on its own. One that does not keeps running until it
|
|
1688
|
+
* returns, and its result is ignored.
|
|
1689
|
+
*
|
|
1690
|
+
* @param {object} definition The job definition
|
|
1691
|
+
* @param {object} context What perform() receives as its second argument
|
|
1692
|
+
* @param {AbortController} controller The controller of `context.signal`
|
|
1693
|
+
* @param {?number} timeout The timeout in milliseconds
|
|
1694
|
+
* @returns {Promise<*>} What perform() returned
|
|
1695
|
+
* @throws {JobTimeoutError} When the attempt ran past its timeout
|
|
1696
|
+
* @memberof Jobs
|
|
1697
|
+
*/
|
|
1698
|
+
async invoke(definition, context, controller, timeout) {
|
|
1699
|
+
// The tenant of the row, entered here and nowhere else: `perform()` is
|
|
1700
|
+
// the one thing in an attempt that touches the application's models,
|
|
1701
|
+
// and everything around it (the outcome write, the batch counter) is
|
|
1702
|
+
// raw SQL through the adapter, which tenancy never narrows anyway
|
|
1703
|
+
const call = Promise.resolve().then(() =>
|
|
1704
|
+
this.scoped(context.job.tenant, () =>
|
|
1705
|
+
definition.perform(context.job.args, context)
|
|
1706
|
+
)
|
|
1707
|
+
);
|
|
1708
|
+
|
|
1709
|
+
if (!timeout) {
|
|
1710
|
+
return call;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
let timer = null;
|
|
1714
|
+
|
|
1715
|
+
try {
|
|
1716
|
+
return await Promise.race([
|
|
1717
|
+
call,
|
|
1718
|
+
new Promise((resolve, reject) => {
|
|
1719
|
+
timer = setTimeout(() => {
|
|
1720
|
+
controller.abort();
|
|
1721
|
+
reject(new JobTimeoutError(definition.name, timeout));
|
|
1722
|
+
}, timeout);
|
|
1723
|
+
}),
|
|
1724
|
+
]);
|
|
1725
|
+
} finally {
|
|
1726
|
+
clearTimeout(timer);
|
|
1727
|
+
// The job may still be running: do not leave an unhandled rejection
|
|
1728
|
+
call.catch(() => null);
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Records a failed attempt: back to the queue, or to the dead letter queue
|
|
1734
|
+
*
|
|
1735
|
+
* @param {object} row The row that failed
|
|
1736
|
+
* @param {Error} error What went wrong
|
|
1737
|
+
* @param {object} context `attempts`, `definition`, `duration` and `store`
|
|
1738
|
+
* @returns {Promise<object>} `{ state, job, error }`
|
|
1739
|
+
* @memberof Jobs
|
|
1740
|
+
*/
|
|
1741
|
+
async failed(row, error, context) {
|
|
1742
|
+
const { attempts, definition, duration: took, store } = context;
|
|
1743
|
+
const now = Date.now();
|
|
1744
|
+
const max = toNumber(row.max_attempts) || this.config.maxAttempts;
|
|
1745
|
+
// A failure that says `retryable: false` is buried now rather than
|
|
1746
|
+
// after every attempt has learned the same thing: a webhook url that
|
|
1747
|
+
// resolves to a private address, a receiver that answered `410 Gone`, a
|
|
1748
|
+
// payload a remote API will refuse in exactly the same way in six
|
|
1749
|
+
// hours. The job is in the dead letter queue with its reason, which is
|
|
1750
|
+
// where an operator would have found it anyway -- sooner
|
|
1751
|
+
const permanent = Boolean(error) && error.retryable === false;
|
|
1752
|
+
const dead = attempts >= max || !definition || permanent;
|
|
1753
|
+
const history = (deserialize(row.history) || []).slice(-HISTORY_LIMIT + 1);
|
|
1754
|
+
const message = String((error && error.message) || error);
|
|
1755
|
+
|
|
1756
|
+
history.push({
|
|
1757
|
+
at: new Date(now).toISOString(),
|
|
1758
|
+
attempt: attempts,
|
|
1759
|
+
duration: took,
|
|
1760
|
+
message,
|
|
1761
|
+
runner: row.claimed_by || null,
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
const wait = dead ? 0 : this.backoff(definition, attempts);
|
|
1765
|
+
|
|
1766
|
+
await store.update(
|
|
1767
|
+
row.id,
|
|
1768
|
+
{
|
|
1769
|
+
// Kept on a terminal row of a batch, for the reason attempt()
|
|
1770
|
+
// gives; a failure that goes back to its queue is claimed again
|
|
1771
|
+
// and gets a token of its own
|
|
1772
|
+
claim_token: dead && row.batch_id ? row.claim_token : null,
|
|
1773
|
+
duration_ms: took,
|
|
1774
|
+
error_message: message,
|
|
1775
|
+
error_stack: (error && error.stack) || null,
|
|
1776
|
+
finished_at: dead ? now : null,
|
|
1777
|
+
history: JSON.stringify(history),
|
|
1778
|
+
run_at: dead ? toNumber(row.run_at) : now + wait,
|
|
1779
|
+
state: dead ? 'dead' : 'pending',
|
|
1780
|
+
// A dead job holds its unique key no longer: the same work may be
|
|
1781
|
+
// enqueued again while this one waits in the dead letter queue
|
|
1782
|
+
unique_key: dead ? keep(row.unique_key) : row.unique_key,
|
|
1783
|
+
updated_at: now,
|
|
1784
|
+
},
|
|
1785
|
+
row.claim_token
|
|
1786
|
+
);
|
|
1787
|
+
|
|
1788
|
+
this.log(
|
|
1789
|
+
dead ? 'error' : 'warn',
|
|
1790
|
+
row.name,
|
|
1791
|
+
row.id,
|
|
1792
|
+
dead ? 'died after' : 'failed on attempt',
|
|
1793
|
+
`${attempts}/${max}`,
|
|
1794
|
+
permanent && attempts < max ? `(no retry) ${message}` : message
|
|
1795
|
+
);
|
|
1796
|
+
|
|
1797
|
+
const job = toJob(await store.find(row.id));
|
|
1798
|
+
|
|
1799
|
+
this.lost(row, job);
|
|
1800
|
+
|
|
1801
|
+
// A batch counts what is terminal: an attempt going back to its queue
|
|
1802
|
+
// with a backoff is not an outcome, and the batch waits for it
|
|
1803
|
+
if (dead) {
|
|
1804
|
+
await this.advance(row, { failed: true });
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
return { error, job, state: dead ? 'dead' : 'pending' };
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
/**
|
|
1811
|
+
* Says so when the outcome of an attempt was refused
|
|
1812
|
+
*
|
|
1813
|
+
* The write only lands while the runner still owns the row. It does not
|
|
1814
|
+
* when the runner's heartbeat went stale and someone else took the job
|
|
1815
|
+
* back, which means the job is about to be performed twice: nothing is
|
|
1816
|
+
* lost, but it is worth a line in the log.
|
|
1817
|
+
*
|
|
1818
|
+
* @param {object} row The row this runner had claimed
|
|
1819
|
+
* @param {?object} job The job as it is now
|
|
1820
|
+
* @returns {boolean} Whether the outcome was refused
|
|
1821
|
+
* @memberof Jobs
|
|
1822
|
+
*/
|
|
1823
|
+
lost(row, job) {
|
|
1824
|
+
if (!job || !row.claim_token || job.state !== 'running') {
|
|
1825
|
+
return false;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
this.log(
|
|
1829
|
+
'warn',
|
|
1830
|
+
row.name,
|
|
1831
|
+
row.id,
|
|
1832
|
+
'was taken over while it was being performed; the outcome was dropped'
|
|
1833
|
+
);
|
|
1834
|
+
|
|
1835
|
+
return true;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
module.exports = { Jobs, MAIL_JOB, RETENTION_JOB, STATES, toJob };
|