@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/batch.js ADDED
@@ -0,0 +1,379 @@
1
+ const { JobError } = require('./errors');
2
+ const { iso } = require('./duration');
3
+ const { toNumber } = require('./store/sql');
4
+
5
+ /**
6
+ * A batch: a set of jobs, and one job that runs when they are all done.
7
+ *
8
+ * ## A batch finishes, it does not succeed
9
+ *
10
+ * The callback runs once every job of the batch has reached a **terminal**
11
+ * state -- `dead` included -- and it is handed the counts. A batch whose
12
+ * last job failed is a finished batch with a failure in it, and what that
13
+ * means is the application's to decide: pretending otherwise would mean a
14
+ * callback that never runs and nobody noticing.
15
+ *
16
+ * ## The counter, and why it is exactly once
17
+ *
18
+ * `total` is written **once**, when the batch is sealed, and never moves
19
+ * again; `done` is advanced by one statement per terminal outcome, guarded
20
+ * by the claim token of the attempt that wrote it (`SqlStore#advanceBatch`,
21
+ * `MongoStore#advanceBatch`). So `done` reaches `total` exactly once, after
22
+ * the last job of the batch is terminal, whatever the interleaving of the
23
+ * runners -- and a job performed twice because its first runner went quiet
24
+ * counts once, for the attempt whose outcome actually landed.
25
+ *
26
+ * The callback is then enqueued under a unique key of the batch's own,
27
+ * which is what makes settling idempotent: the sweep settles an unfinished
28
+ * batch again after a runner is killed between writing an outcome and
29
+ * counting it, and the second settle answers the callback that is already
30
+ * in the queue rather than enqueuing a second one.
31
+ *
32
+ * ## What a batch is not
33
+ *
34
+ * It is not a transaction, and it is not atomic with the application's
35
+ * database: the queue reaches its own tables through the store adapter's
36
+ * raw `query()`, which does not join an open model transaction, so a batch
37
+ * enqueued inside one that rolls back is a batch that runs. That is true of
38
+ * every enqueue and the guide says so twice.
39
+ *
40
+ * A batch is also built where it is created: `add()` is a call on the
41
+ * handle, and once the handle has sealed the batch it refuses -- there is
42
+ * no adding a job from another process, which is the race that would let a
43
+ * callback run with work still on its way in.
44
+ */
45
+
46
+ /** The widest a batch name may be: the column that holds it */
47
+ const NAME_LENGTH = 190;
48
+
49
+ /**
50
+ * The options of the callback that are passed to `perform()`.
51
+ *
52
+ * `tenant` is one of them and is the only one henri fills in by itself
53
+ * (`Jobs#batch`): the callback is enqueued by a runner long after the
54
+ * request that made the batch is gone, so the tenant has to travel with
55
+ * the batch or the callback would be performed outside every tenant.
56
+ */
57
+ const CALLBACK_OPTIONS = [
58
+ 'at',
59
+ 'maxAttempts',
60
+ 'priority',
61
+ 'queue',
62
+ 'tenant',
63
+ 'timeout',
64
+ 'wait',
65
+ ];
66
+
67
+ /**
68
+ * A stored batch, as the API hands it out
69
+ *
70
+ * @param {?object} row A row of the batches table
71
+ * @returns {?object} The batch
72
+ */
73
+ const toBatch = (row) => {
74
+ if (!row) {
75
+ return null;
76
+ }
77
+
78
+ const total = toNumber(row.total) || 0;
79
+ const done = toNumber(row.done) || 0;
80
+ const failed = toNumber(row.failed) || 0;
81
+
82
+ return {
83
+ callback: row.callback || null,
84
+ callbackId: row.callback_id || null,
85
+ createdAt: iso(row.created_at),
86
+ done,
87
+ failed,
88
+ finished: Boolean(row.finished_at),
89
+ finishedAt: iso(row.finished_at),
90
+ id: row.id,
91
+ name: row.name || null,
92
+ sealed: Boolean(row.sealed_at),
93
+ sealedAt: iso(row.sealed_at),
94
+ succeeded: Math.max(0, done - failed),
95
+ total,
96
+ updatedAt: iso(row.updated_at),
97
+ };
98
+ };
99
+
100
+ /**
101
+ * Refuses a batch declaration henri cannot read
102
+ *
103
+ * @param {string} why What is wrong with it
104
+ * @returns {void}
105
+ * @throws {JobError} HENRI_JOB_INVALID_BATCH, always
106
+ */
107
+ const refuse = (why) => {
108
+ throw new JobError(
109
+ 'HENRI_JOB_INVALID_BATCH',
110
+ `The batch cannot be read: ${why}`,
111
+ {
112
+ hint: "henri.jobs.batch({ callback: 'report/compile', jobs: [['resize', { id }]] }), or a function that adds them",
113
+ }
114
+ );
115
+ };
116
+
117
+ /**
118
+ * One job of a batch, however it was written
119
+ *
120
+ * `'resize'`, `['resize', args]`, `['resize', args, options]` and
121
+ * `{ name, args, options }` are the same thing.
122
+ *
123
+ * @param {*} entry What the application wrote
124
+ * @returns {object} `{ name, args, options }`
125
+ * @throws {JobError} HENRI_JOB_INVALID_BATCH on anything else
126
+ */
127
+ const entry = (value) => {
128
+ if (typeof value === 'string') {
129
+ return { args: null, name: value, options: {} };
130
+ }
131
+
132
+ if (Array.isArray(value)) {
133
+ const [name, args = null, options = {}] = value;
134
+
135
+ if (typeof name !== 'string' || name === '') {
136
+ refuse('a job of the list does not begin with a name');
137
+ }
138
+
139
+ return { args, name, options: options || {} };
140
+ }
141
+
142
+ if (!value || typeof value !== 'object' || typeof value.name !== 'string') {
143
+ refuse(`${JSON.stringify(value)} is not a job name, a list or an object`);
144
+ }
145
+
146
+ return {
147
+ args: typeof value.args === 'undefined' ? null : value.args,
148
+ name: value.name,
149
+ options: value.options || {},
150
+ };
151
+ };
152
+
153
+ /**
154
+ * Reads what an application asked a batch for
155
+ *
156
+ * @param {object} [options={}] What `henri.jobs.batch()` was given
157
+ * @returns {object} `{ name, callback, args, options, jobs }`
158
+ * @throws {JobError} HENRI_JOB_INVALID_BATCH when it cannot be read
159
+ */
160
+ const declaration = (options = {}) => {
161
+ const value = options || {};
162
+
163
+ if (typeof value !== 'object' || Array.isArray(value)) {
164
+ refuse('it is not an object');
165
+ }
166
+
167
+ const name = typeof value.name === 'undefined' ? null : value.name;
168
+
169
+ if (
170
+ name !== null &&
171
+ (typeof name !== 'string' || name.length > NAME_LENGTH)
172
+ ) {
173
+ refuse(`its name is not a string of at most ${NAME_LENGTH} characters`);
174
+ }
175
+
176
+ const callback =
177
+ typeof value.callback === 'undefined' || value.callback === null
178
+ ? null
179
+ : value.callback;
180
+
181
+ if (callback !== null && (typeof callback !== 'string' || callback === '')) {
182
+ refuse('its callback is not the name of a job');
183
+ }
184
+
185
+ const args = typeof value.args === 'undefined' ? null : value.args;
186
+
187
+ if (
188
+ args !== null &&
189
+ (typeof args !== 'object' || Array.isArray(args) || args instanceof Date)
190
+ ) {
191
+ // The counts are handed over under `batch`, so there has to be somewhere
192
+ // to put them
193
+ refuse('the arguments of its callback are not a plain object');
194
+ }
195
+
196
+ // `null` is "no list at all", which leaves the batch open for the caller
197
+ // to add to and seal; `[]` is a batch of nothing, which is finished the
198
+ // moment it is made
199
+ const jobs = typeof value.jobs === 'undefined' ? null : value.jobs;
200
+
201
+ if (jobs !== null && !Array.isArray(jobs)) {
202
+ refuse('its jobs are not a list');
203
+ }
204
+
205
+ const passed = {};
206
+
207
+ for (const key of CALLBACK_OPTIONS) {
208
+ if (typeof value[key] !== 'undefined') {
209
+ passed[key] = value[key];
210
+ }
211
+ }
212
+
213
+ return {
214
+ args,
215
+ callback,
216
+ jobs: jobs && jobs.map(entry),
217
+ name,
218
+ options: passed,
219
+ };
220
+ };
221
+
222
+ /**
223
+ * A batch in hand: what adds jobs to it and closes it.
224
+ *
225
+ * `henri.jobs.batch()` answers one of these. Its counters are what the
226
+ * database said when it was last read, so `reload()` is how they are
227
+ * refreshed; everything else about a batch is read back with
228
+ * `henri.jobs.batches.get(id)`.
229
+ *
230
+ * @class Batch
231
+ */
232
+ class Batch {
233
+ /**
234
+ * Creates an instance of Batch.
235
+ *
236
+ * @param {object} queue The queue that owns it
237
+ * @param {object} row The stored row
238
+ * @memberof Batch
239
+ */
240
+ constructor(queue, row) {
241
+ this.queue = queue;
242
+ /** The ids of the jobs this handle enqueued */
243
+ this.jobs = [];
244
+ this.sync(row);
245
+ }
246
+
247
+ /**
248
+ * Reads a row onto this handle
249
+ *
250
+ * @param {object} row The stored row
251
+ * @returns {Batch} This batch
252
+ * @memberof Batch
253
+ */
254
+ sync(row) {
255
+ Object.assign(this, toBatch(row));
256
+
257
+ return this;
258
+ }
259
+
260
+ /**
261
+ * Adds one job to the batch
262
+ *
263
+ * @param {string} name The job name
264
+ * @param {*} [args=null] What perform() receives
265
+ * @param {object} [options={}] The options of perform()
266
+ * @returns {Promise<object>} The enqueued job
267
+ * @throws {JobError} HENRI_JOB_BATCH_CLOSED once the batch is sealed
268
+ * @memberof Batch
269
+ */
270
+ async add(name, args = null, options = {}) {
271
+ if (this.sealed) {
272
+ throw new JobError(
273
+ 'HENRI_JOB_BATCH_CLOSED',
274
+ `The batch ${this.id} is closed: it holds ${this.total} job(s) and was sealed at ${this.sealedAt}`,
275
+ {
276
+ batch: this.id,
277
+ hint: 'A batch is built where it is created: add every job before it is sealed, or make another batch',
278
+ }
279
+ );
280
+ }
281
+
282
+ const job = await this.queue.perform(name, args, {
283
+ ...options,
284
+ batch: this.id,
285
+ });
286
+
287
+ this.jobs.push(job.id);
288
+
289
+ return job;
290
+ }
291
+
292
+ /**
293
+ * Adds several jobs to the batch
294
+ *
295
+ * @param {Array} list The jobs, in any of the shapes `batch({ jobs })`
296
+ * takes
297
+ * @returns {Promise<Array<object>>} The enqueued jobs
298
+ * @memberof Batch
299
+ */
300
+ async addAll(list) {
301
+ const enqueued = [];
302
+
303
+ for (const one of list) {
304
+ const { args, name, options } = entry(one);
305
+
306
+ enqueued.push(await this.add(name, args, options));
307
+ }
308
+
309
+ return enqueued;
310
+ }
311
+
312
+ /**
313
+ * Closes the batch, and settles it when there was nothing left to wait for
314
+ *
315
+ * @returns {Promise<Batch>} This batch
316
+ * @memberof Batch
317
+ */
318
+ async seal() {
319
+ if (this.sealed) {
320
+ return this;
321
+ }
322
+
323
+ const row = await this.queue
324
+ .storeOrDie()
325
+ .sealBatch({ id: this.id, now: Date.now(), total: this.jobs.length });
326
+
327
+ this.sync(row || (await this.queue.storeOrDie().findBatch(this.id)));
328
+
329
+ // Every job of the batch may already be terminal -- an empty batch
330
+ // certainly is -- and nothing else will look at it until the sweep does
331
+ await this.queue.settle(await this.queue.storeOrDie().findBatch(this.id));
332
+
333
+ return this.reload();
334
+ }
335
+
336
+ /**
337
+ * Reads the batch back
338
+ *
339
+ * @returns {Promise<Batch>} This batch
340
+ * @memberof Batch
341
+ */
342
+ async reload() {
343
+ return this.sync(await this.queue.storeOrDie().findBatch(this.id));
344
+ }
345
+
346
+ /**
347
+ * The batch as a plain object
348
+ *
349
+ * @returns {object} What `henri.jobs.batches.get()` answers
350
+ * @memberof Batch
351
+ */
352
+ toJSON() {
353
+ return {
354
+ callback: this.callback,
355
+ callbackId: this.callbackId,
356
+ createdAt: this.createdAt,
357
+ done: this.done,
358
+ failed: this.failed,
359
+ finished: this.finished,
360
+ finishedAt: this.finishedAt,
361
+ id: this.id,
362
+ name: this.name,
363
+ sealed: this.sealed,
364
+ sealedAt: this.sealedAt,
365
+ succeeded: this.succeeded,
366
+ total: this.total,
367
+ updatedAt: this.updatedAt,
368
+ };
369
+ }
370
+ }
371
+
372
+ module.exports = {
373
+ Batch,
374
+ CALLBACK_OPTIONS,
375
+ NAME_LENGTH,
376
+ declaration,
377
+ entry,
378
+ toBatch,
379
+ };
package/src/config.js ADDED
@@ -0,0 +1,186 @@
1
+ const { next, parse } = require('./cron');
2
+ const { coded } = require('./errors');
3
+ const { duration } = require('./duration');
4
+
5
+ /**
6
+ * The `jobs` block of `config/<env>.json`, with every default filled in.
7
+ */
8
+
9
+ const DEFAULTS = {
10
+ backoff: { base: '5s', factor: 4, jitter: 0.15, max: '1h' },
11
+ concurrency: 5,
12
+ install: true,
13
+ keepCompleted: '1d',
14
+ mailQueue: 'mailers',
15
+ maxArgsBytes: 512 * 1024,
16
+ maxAttempts: 5,
17
+ pollInterval: '1s',
18
+ priority: 0,
19
+ queue: 'default',
20
+ queues: [],
21
+ recurring: {},
22
+ store: 'default',
23
+ stuckAfter: '5m',
24
+ table: 'henri_jobs',
25
+ timeout: null,
26
+ };
27
+
28
+ /**
29
+ * A list of queue names from a string or an array
30
+ *
31
+ * @param {(string|Array<string>|null)} value `'a,b'`, `['a', 'b']` or nothing
32
+ * @returns {Array<string>} The queue names
33
+ */
34
+ const queues = (value) => {
35
+ if (Array.isArray(value)) {
36
+ return value.map((entry) => String(entry).trim()).filter(Boolean);
37
+ }
38
+
39
+ if (typeof value === 'string') {
40
+ return value
41
+ .split(',')
42
+ .map((entry) => entry.trim())
43
+ .filter(Boolean);
44
+ }
45
+
46
+ return [];
47
+ };
48
+
49
+ /**
50
+ * Reads a recurring schedule
51
+ *
52
+ * @param {string} name The schedule name
53
+ * @param {object} entry Its configuration
54
+ * @returns {object} The normalized schedule
55
+ * @throws {Error} When it has neither `cron` nor `every`, or names no job
56
+ */
57
+ const recurring = (name, entry) => {
58
+ const value = entry || {};
59
+ const job = value.job || value.name || name;
60
+
61
+ if (!value.cron && !value.every) {
62
+ throw coded(
63
+ 'HENRI_JOB_INVALID_SCHEDULE',
64
+ `@usehenri/jobs: the recurring schedule "${name}" needs a "cron" or an "every"`
65
+ );
66
+ }
67
+
68
+ if (value.cron && value.every) {
69
+ throw coded(
70
+ 'HENRI_JOB_INVALID_SCHEDULE',
71
+ `@usehenri/jobs: the recurring schedule "${name}" has both a "cron" and an "every": pick one`
72
+ );
73
+ }
74
+
75
+ // Parsed here, not on the first tick of a runner: an expression a runner
76
+ // cannot read would otherwise throw inside its loop, every second, with
77
+ // nothing being claimed while it does
78
+ if (value.cron) {
79
+ try {
80
+ if (next(parse(value.cron)) === null) {
81
+ throw new Error(`"${value.cron}" can never come round`);
82
+ }
83
+ } catch (error) {
84
+ throw Object.assign(
85
+ new Error(
86
+ `@usehenri/jobs: the recurring schedule "${name}" is invalid: ${error.message}`,
87
+ { cause: error }
88
+ ),
89
+ { code: 'HENRI_JOB_INVALID_SCHEDULE' }
90
+ );
91
+ }
92
+ }
93
+
94
+ if (value.every && duration(value.every) < 1000) {
95
+ throw coded(
96
+ 'HENRI_JOB_INVALID_SCHEDULE',
97
+ `@usehenri/jobs: the recurring schedule "${name}" runs every ${value.every}, which is under a second`
98
+ );
99
+ }
100
+
101
+ return {
102
+ args: typeof value.args === 'undefined' ? null : value.args,
103
+ cron: value.cron || null,
104
+ every: value.every ? duration(value.every) : null,
105
+ job,
106
+ name,
107
+ priority: typeof value.priority === 'number' ? value.priority : null,
108
+ queue: value.queue || null,
109
+ spec: value.cron ? `cron:${value.cron}` : `every:${duration(value.every)}`,
110
+ };
111
+ };
112
+
113
+ /**
114
+ * Checks a table name before it is written into every statement
115
+ *
116
+ * The queue interpolates its table names: they are configuration, not
117
+ * request input, but an application may now set any key from the
118
+ * environment, and a name that is not a plain identifier gives a syntax
119
+ * error deep in a query instead of a sentence here.
120
+ *
121
+ * @param {string} value The name
122
+ * @returns {string} The name
123
+ * @throws {Error} When it is not a plain identifier
124
+ */
125
+ const table = (value) => {
126
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
127
+ throw coded(
128
+ 'HENRI_CONFIG_INVALID',
129
+ `@usehenri/jobs: invalid table name "${value}": letters, digits and underscores only`
130
+ );
131
+ }
132
+
133
+ return value;
134
+ };
135
+
136
+ /**
137
+ * The jobs configuration of an application
138
+ *
139
+ * @param {object} [config={}] The `jobs` block of the configuration
140
+ * @returns {object} The configuration, with the defaults filled in
141
+ * @throws {Error} When a duration or a schedule is invalid
142
+ */
143
+ const normalize = (config = {}) => {
144
+ const value = config || {};
145
+ const backoff = { ...DEFAULTS.backoff, ...(value.backoff || {}) };
146
+ const schedules = value.recurring || DEFAULTS.recurring;
147
+
148
+ return {
149
+ backoff: {
150
+ base: duration(backoff.base, 5000),
151
+ factor: Number(backoff.factor) || 4,
152
+ jitter: Math.min(Math.max(Number(backoff.jitter) || 0, 0), 1),
153
+ max: duration(backoff.max, 3600000),
154
+ },
155
+ concurrency: Math.max(1, Number(value.concurrency) || DEFAULTS.concurrency),
156
+ install: value.install !== false,
157
+ keepCompleted: duration(
158
+ value.keepCompleted,
159
+ duration(DEFAULTS.keepCompleted)
160
+ ),
161
+ mailQueue: value.mailQueue || DEFAULTS.mailQueue,
162
+ maxArgsBytes: Number(value.maxArgsBytes) || DEFAULTS.maxArgsBytes,
163
+ maxAttempts: Math.max(1, Number(value.maxAttempts) || DEFAULTS.maxAttempts),
164
+ pollInterval: Math.max(
165
+ 50,
166
+ duration(value.pollInterval, duration(DEFAULTS.pollInterval))
167
+ ),
168
+ priority: Number(value.priority) || DEFAULTS.priority,
169
+ queue: value.queue || DEFAULTS.queue,
170
+ queues: queues(value.queues),
171
+ recurring: Object.keys(schedules)
172
+ .sort()
173
+ .map((name) => recurring(name, schedules[name])),
174
+ store: value.store || DEFAULTS.store,
175
+ stuckAfter: duration(value.stuckAfter, duration(DEFAULTS.stuckAfter)),
176
+ tables: {
177
+ batches: `${table(value.table || DEFAULTS.table)}_batches`,
178
+ jobs: table(value.table || DEFAULTS.table),
179
+ limits: `${table(value.table || DEFAULTS.table)}_limits`,
180
+ schedules: `${table(value.table || DEFAULTS.table)}_schedules`,
181
+ },
182
+ timeout: duration(value.timeout, DEFAULTS.timeout),
183
+ };
184
+ };
185
+
186
+ module.exports = { DEFAULTS, normalize, queues, recurring, table };