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