@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/keys.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The unique keys the queue writes for itself.
3
+ *
4
+ * An application's `unique` key belongs to a job only while it is waiting or
5
+ * running: once the job is done, or once it has died, the key is freed and
6
+ * the same work may be enqueued again. That is what people expect of a
7
+ * unique job, and it is what the guide says.
8
+ *
9
+ * The keys the queue writes for itself are the exception, and they have
10
+ * to be. A recurring slot is enqueued *before* its schedule moves on, so that
11
+ * an enqueue that fails leaves the schedule due and the next tick tries again;
12
+ * the row already in the queue is what stops a second runner enqueueing the
13
+ * same slot. If that key were freed the moment the job finished, a slot
14
+ * whose job ran to completion in the gap between the enqueue and the
15
+ * schedule moving on would be enqueued a second time. So a key of this
16
+ * shape is kept for the life of the row, and the row is pruned like any
17
+ * other -- by which time that slot, a specific millisecond, can never be due
18
+ * again.
19
+ *
20
+ * A batch's callback is the same argument, word for word. It is enqueued
21
+ * *before* the batch is stamped finished, so that a process dying in between
22
+ * leaves the batch unfinished and the next sweep settles it again; the row
23
+ * already in the queue is what makes that second settle answer the same job
24
+ * instead of calling the callback twice. A key freed on completion would let
25
+ * a callback that ran quickly be enqueued a second time by that sweep -- so
26
+ * the key belongs to the row for its life, and the batch is finished long
27
+ * before either is pruned.
28
+ */
29
+
30
+ /** What the recurring scheduler prefixes its slots with */
31
+ const RECURRING = 'recurring:';
32
+
33
+ /** What a batch's callback is enqueued under */
34
+ const BATCH = 'batch:';
35
+
36
+ /**
37
+ * The unique key a finished job should keep, if any
38
+ *
39
+ * @param {?string} key The key the job holds
40
+ * @returns {?string} The key to keep, or null to free it
41
+ */
42
+ const keep = (key) =>
43
+ typeof key === 'string' &&
44
+ (key.startsWith(RECURRING) || key.startsWith(BATCH))
45
+ ? key
46
+ : null;
47
+
48
+ /**
49
+ * The unique key of one occurrence of a recurring schedule
50
+ *
51
+ * @param {string} name The schedule name
52
+ * @param {number} due The moment the occurrence was due
53
+ * @returns {string} The key
54
+ */
55
+ const slot = (name, due) => `${RECURRING}${name}:${due}`;
56
+
57
+ /**
58
+ * The unique key of a batch's callback
59
+ *
60
+ * @param {string} id The batch id
61
+ * @returns {string} The key
62
+ */
63
+ const callback = (id) => `${BATCH}${id}`;
64
+
65
+ module.exports = { BATCH, RECURRING, callback, keep, slot };
package/src/module.js ADDED
@@ -0,0 +1,442 @@
1
+ const BaseModule = require('@usehenri/core/module');
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const debug = require('debug')('henri:jobs');
6
+
7
+ const { Jobs: Queue, MAIL_JOB } = require('./jobs');
8
+
9
+ /**
10
+ * Background jobs: the henri module this package ships.
11
+ *
12
+ * `package.json` points at it with `"henri": { "module": "./module.js" }`,
13
+ * so an application that depends on `@usehenri/jobs` has it in the boot as
14
+ * `henri.jobs`, with nothing else to write. One that does not has no such
15
+ * module, and core carries no queue of its own.
16
+ *
17
+ * Installing the package is not the same as using it: an application that
18
+ * has neither `app/jobs` nor a `jobs` block in its configuration keeps the
19
+ * module inert -- `henri.jobs.enabled` is false, no table is created and
20
+ * every call says what to do.
21
+ *
22
+ * It needs the models: the queue reaches its own tables through the store
23
+ * adapter. It runs after the mailers when there are any, so
24
+ * `deliverLater()` goes through the queue. Its slot is 4, not 5: `henri
25
+ * jobs` boots to that level so a runner never binds an HTTP port.
26
+ *
27
+ * @class JobsModule
28
+ * @extends {BaseModule}
29
+ */
30
+ class JobsModule extends BaseModule {
31
+ /**
32
+ * Creates an instance of JobsModule.
33
+ *
34
+ * @param {object} [henri=null] A henri instance
35
+ * @memberof JobsModule
36
+ */
37
+ constructor(henri = null) {
38
+ super();
39
+
40
+ this.reloadable = true;
41
+ this.needs = ['config', 'model'];
42
+ this.after = ['mailers'];
43
+ this.runlevel = 4;
44
+ this.name = 'jobs';
45
+ this.henri = henri;
46
+
47
+ this.queue = null;
48
+ this.enabled = false;
49
+
50
+ /** The dead letter queue, see @usehenri/jobs */
51
+ this.dead = {
52
+ count: () => this.ready().dead.count(),
53
+ discard: (id) => this.ready().dead.discard(id),
54
+ discardAll: (filter) => this.ready().dead.discardAll(filter),
55
+ get: (id) => this.ready().dead.get(id),
56
+ list: (filter) => this.ready().dead.list(filter),
57
+ retry: (id, options) => this.ready().dead.retry(id, options),
58
+ retryAll: (filter, options) =>
59
+ this.ready().dead.retryAll(filter, options),
60
+ };
61
+
62
+ /** Reading the batches back, see @usehenri/jobs */
63
+ this.batches = {
64
+ discard: (id) => this.ready().batches.discard(id),
65
+ get: (id) => this.ready().batches.get(id),
66
+ jobs: (id, filter) => this.ready().batches.jobs(id, filter),
67
+ list: (filter) => this.ready().batches.list(filter),
68
+ };
69
+
70
+ this.init = this.init.bind(this);
71
+ this.stop = this.stop.bind(this);
72
+ this.reload = this.reload.bind(this);
73
+ }
74
+
75
+ /**
76
+ * Whether this application asked for a queue
77
+ *
78
+ * @returns {boolean} true when app/jobs holds a file, or the configuration
79
+ * has a `jobs` block
80
+ * @memberof JobsModule
81
+ */
82
+ wanted() {
83
+ const { config } = this.henri;
84
+
85
+ if (config && config.has && config.has('jobs')) {
86
+ return true;
87
+ }
88
+
89
+ const location = path.join(this.henri.cwd(), 'app', 'jobs');
90
+
91
+ try {
92
+ return fs
93
+ .readdirSync(location, { recursive: true })
94
+ .some((entry) => String(entry).endsWith('.js'));
95
+ } catch (error) {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Module initialization
102
+ * Called after being loaded by Modules
103
+ *
104
+ * @async
105
+ * @returns {Promise<string>} The name of the module
106
+ * @throws when the queue cannot start, or a job file is not a job
107
+ * @memberof JobsModule
108
+ */
109
+ async init() {
110
+ const { config, pen } = this.henri;
111
+
112
+ if (!this.wanted()) {
113
+ debug('no app/jobs and no jobs configuration: staying out of the way');
114
+
115
+ return this.name;
116
+ }
117
+
118
+ const settings =
119
+ config && config.has && config.has('jobs') ? config.get('jobs') : {};
120
+
121
+ this.queue = new Queue(this.henri, {
122
+ config: settings || {},
123
+ cwd: this.henri.cwd(),
124
+ });
125
+
126
+ try {
127
+ await this.queue.start();
128
+ } catch (error) {
129
+ pen.error('jobs', 'unable to start the queue', error.message);
130
+ throw error;
131
+ }
132
+
133
+ this.enabled = true;
134
+ this.deliverMail();
135
+ this.instrument();
136
+
137
+ const names = this.queue.names();
138
+
139
+ pen.info(
140
+ 'jobs',
141
+ `${names.length} job(s)`,
142
+ names.length > 0 ? names.join(', ') : 'none in app/jobs'
143
+ );
144
+
145
+ return this.name;
146
+ }
147
+
148
+ /**
149
+ * How deep the queue is, when henri is collecting metrics.
150
+ *
151
+ * The one number about a queue that a log line cannot carry: a depth is
152
+ * true of a moment, not of an event, and the thing worth alerting on is
153
+ * that it stopped coming down. An **observable** gauge, so nothing is
154
+ * measured while a job runs -- but it does cost one `SELECT ... GROUP BY`
155
+ * per collection, which is what `telemetry.metrics: false` turns off.
156
+ *
157
+ * @returns {boolean} Whether the instrument was registered
158
+ * @memberof JobsModule
159
+ */
160
+ instrument() {
161
+ const { telemetry } = this.henri;
162
+
163
+ if (!telemetry || !telemetry.enabled) {
164
+ return false;
165
+ }
166
+
167
+ return telemetry.observe(
168
+ 'henri.jobs.queue.depth',
169
+ {
170
+ description: 'How many jobs the queue holds, by queue and state',
171
+ kind: 'gauge',
172
+ unit: '{job}',
173
+ },
174
+ async (observe) => {
175
+ for (const entry of await this.queue.storeOrDie().counts()) {
176
+ observe(entry.total, {
177
+ 'henri.job.queue': entry.queue,
178
+ 'henri.job.state': entry.state,
179
+ });
180
+ }
181
+ }
182
+ );
183
+ }
184
+
185
+ /**
186
+ * Send the mails `henri.mailers.deliverLater()` renders through the queue
187
+ *
188
+ * The mailers module hands over a rendered nodemailer payload and the
189
+ * options of the call (`wait`, `at`, `queue`, `priority`), which are the
190
+ * options of `perform()`: nothing else has to be mapped. Without the queue
191
+ * the mailers send out of band, which the mail guide is explicit about not
192
+ * being a queue.
193
+ *
194
+ * @returns {boolean} Whether the handler was registered
195
+ * @memberof JobsModule
196
+ */
197
+ deliverMail() {
198
+ const { mailers } = this.henri;
199
+
200
+ if (!mailers || typeof mailers.onDeliverLater !== 'function') {
201
+ return false;
202
+ }
203
+
204
+ return mailers.onDeliverLater((message, options) =>
205
+ this.queue.perform(MAIL_JOB, message, options || {})
206
+ );
207
+ }
208
+
209
+ /**
210
+ * Stops the runners this process started
211
+ *
212
+ * @async
213
+ * @returns {Promise<boolean>} true when there was something to stop
214
+ * @memberof JobsModule
215
+ */
216
+ async stop() {
217
+ if (!this.queue) {
218
+ return false;
219
+ }
220
+
221
+ const { mailers } = this.henri;
222
+
223
+ if (mailers && typeof mailers.onDeliverLater === 'function') {
224
+ mailers.onDeliverLater(null);
225
+ }
226
+
227
+ await this.queue.stop();
228
+ this.enabled = false;
229
+
230
+ return true;
231
+ }
232
+
233
+ /**
234
+ * Reloads the module: `app/jobs` is read again
235
+ *
236
+ * @async
237
+ * @returns {Promise<string>} Module name
238
+ * @memberof JobsModule
239
+ */
240
+ async reload() {
241
+ await this.stop();
242
+
243
+ this.queue = null;
244
+
245
+ return this.init();
246
+ }
247
+
248
+ /**
249
+ * The queue, or a readable error
250
+ *
251
+ * @returns {object} The queue
252
+ * @throws when the application has no queue
253
+ * @memberof JobsModule
254
+ */
255
+ ready() {
256
+ if (!this.queue) {
257
+ throw this.henri.pen.fatal(
258
+ 'jobs',
259
+ `
260
+ This application has no job queue: it has neither app/jobs nor a jobs
261
+ block in its configuration. Write a job with: henri generate job <name>`,
262
+ null,
263
+ null,
264
+ 'HENRI_JOB_QUEUE_UNAVAILABLE'
265
+ );
266
+ }
267
+
268
+ return this.queue;
269
+ }
270
+
271
+ /**
272
+ * Adds a recurring schedule a framework module asks for.
273
+ *
274
+ * `henri.retention` is the one that does: an application with the queue
275
+ * gets its retention sweep on a schedule with nothing to write, and an
276
+ * entry of its own under the same name still wins.
277
+ *
278
+ * @param {string} name The name of the schedule
279
+ * @param {object} entry `cron` or `every`, plus `job`, `args`, `queue`
280
+ * @returns {boolean} false when nothing was added
281
+ * @memberof JobsModule
282
+ */
283
+ recur(name, entry) {
284
+ return this.queue ? this.queue.recur(name, entry) : false;
285
+ }
286
+
287
+ /**
288
+ * Adds a job the application did not write
289
+ *
290
+ * The way a package ships work of its own: `@usehenri/webhooks` registers
291
+ * its delivery job here at boot, so every process that has the queue --
292
+ * the runner included -- can perform it. A file of `app/jobs` with the
293
+ * same name wins, and this answers false.
294
+ *
295
+ * @param {string} name The job name
296
+ * @param {object} definition `perform(args, context)` plus the options
297
+ * @returns {boolean} Whether it was registered
298
+ * @memberof JobsModule
299
+ */
300
+ define(name, definition) {
301
+ return this.ready().define(name, definition);
302
+ }
303
+
304
+ /**
305
+ * Enqueues a job
306
+ *
307
+ * @param {string} name The job name (its file under app/jobs)
308
+ * @param {*} [args] What perform() receives
309
+ * @param {object} [options] `wait`, `at`, `queue`, `priority`,
310
+ * `maxAttempts`, `timeout`, `unique`, `batch`, `tenant`
311
+ * @returns {Promise<object>} The enqueued job
312
+ * @memberof JobsModule
313
+ */
314
+ perform(name, args, options) {
315
+ return this.ready().perform(name, args, options);
316
+ }
317
+
318
+ /**
319
+ * Enqueues a job; the name `henri.mailers.onDeliverLater()` expects
320
+ *
321
+ * @param {string} name The job name
322
+ * @param {*} [args] What perform() receives
323
+ * @param {object} [options] The options of perform()
324
+ * @returns {Promise<object>} The enqueued job
325
+ * @memberof JobsModule
326
+ */
327
+ enqueue(name, args, options) {
328
+ return this.ready().perform(name, args, options);
329
+ }
330
+
331
+ /**
332
+ * Enqueues a job to run later
333
+ *
334
+ * @param {(number|string)} wait How long to wait (`'5m'`)
335
+ * @param {string} name The job name
336
+ * @param {*} [args] What perform() receives
337
+ * @param {object} [options] The options of perform()
338
+ * @returns {Promise<object>} The enqueued job
339
+ * @memberof JobsModule
340
+ */
341
+ performIn(wait, name, args, options) {
342
+ return this.ready().performIn(wait, name, args, options);
343
+ }
344
+
345
+ /**
346
+ * Enqueues a job to run at a given moment
347
+ *
348
+ * @param {(Date|string|number)} when The moment
349
+ * @param {string} name The job name
350
+ * @param {*} [args] What perform() receives
351
+ * @param {object} [options] The options of perform()
352
+ * @returns {Promise<object>} The enqueued job
353
+ * @memberof JobsModule
354
+ */
355
+ performAt(when, name, args, options) {
356
+ return this.ready().performAt(when, name, args, options);
357
+ }
358
+
359
+ /**
360
+ * Performs a job here and now, without the queue
361
+ *
362
+ * @param {string} name The job name
363
+ * @param {*} [args] What perform() receives
364
+ * @returns {Promise<*>} What perform() returned
365
+ * @memberof JobsModule
366
+ */
367
+ performNow(name, args) {
368
+ return this.ready().performNow(name, args);
369
+ }
370
+
371
+ /**
372
+ * Makes a batch: these jobs, and one that runs when they are all done
373
+ *
374
+ * The callback runs once every job has reached a terminal state, `dead`
375
+ * included, and is handed the counts under `batch`.
376
+ *
377
+ * @param {object} [options] `callback`, `args`, `name`, `jobs`, plus the
378
+ * callback's own `queue`, `priority`, `maxAttempts`, `timeout`, `wait`
379
+ * and `at`
380
+ * @param {function} [build] Adds the jobs itself; the batch is sealed
381
+ * when it resolves
382
+ * @returns {Promise<object>} The batch
383
+ * @memberof JobsModule
384
+ */
385
+ batch(options, build) {
386
+ return this.ready().batch(options, build);
387
+ }
388
+
389
+ /**
390
+ * One job
391
+ *
392
+ * @param {string} id The job id
393
+ * @returns {Promise<?object>} The job, or null
394
+ * @memberof JobsModule
395
+ */
396
+ get(id) {
397
+ return this.ready().get(id);
398
+ }
399
+
400
+ /**
401
+ * The jobs of the queue
402
+ *
403
+ * @param {object} [filter] `state`, `queue`, `name`, `limit`, `offset`
404
+ * @returns {Promise<Array<object>>} The jobs
405
+ * @memberof JobsModule
406
+ */
407
+ list(filter) {
408
+ return this.ready().list(filter);
409
+ }
410
+
411
+ /**
412
+ * What the queue holds
413
+ *
414
+ * @returns {Promise<object>} Counts, timings and waits
415
+ * @memberof JobsModule
416
+ */
417
+ stats() {
418
+ return this.ready().stats();
419
+ }
420
+
421
+ /**
422
+ * The concurrency limits of the application, and the slots being held
423
+ *
424
+ * @returns {Promise<object>} `{ declared, held }`
425
+ * @memberof JobsModule
426
+ */
427
+ limits() {
428
+ return this.ready().limits();
429
+ }
430
+
431
+ /**
432
+ * The job names of the application
433
+ *
434
+ * @returns {Array<string>} The names
435
+ * @memberof JobsModule
436
+ */
437
+ names() {
438
+ return this.queue ? this.queue.names() : [];
439
+ }
440
+ }
441
+
442
+ module.exports = JobsModule;