@usehenri/jobs 0.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +311 -0
- package/LICENSE +21 -0
- package/README.md +8 -1
- package/index.js +45 -0
- package/module.js +8 -0
- package/package.json +50 -10
- package/src/batch.js +379 -0
- package/src/config.js +186 -0
- package/src/cron.js +237 -0
- package/src/definitions.js +236 -0
- package/src/duration.js +112 -0
- package/src/errors.js +115 -0
- package/src/jobs.js +1839 -0
- package/src/keys.js +65 -0
- package/src/module.js +442 -0
- package/src/runner.js +918 -0
- package/src/serialize.js +177 -0
- package/src/store/index.js +37 -0
- package/src/store/mongo.js +1334 -0
- package/src/store/schema.js +499 -0
- package/src/store/sql.js +1744 -0
package/src/cron.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A five field cron parser (minute hour day-of-month month day-of-week).
|
|
3
|
+
*
|
|
4
|
+
* Everything is computed in UTC: a recurring job runs at the same absolute
|
|
5
|
+
* moment wherever the runner is deployed, and no daylight saving change can
|
|
6
|
+
* make an hourly schedule fire twice or not at all. The documentation says
|
|
7
|
+
* so; if a schedule has to follow a wall clock, `every` is not the answer
|
|
8
|
+
* either -- enqueue from a job that knows the zone.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const { coded } = require('./errors');
|
|
12
|
+
|
|
13
|
+
const MONTHS = {
|
|
14
|
+
apr: 4,
|
|
15
|
+
aug: 8,
|
|
16
|
+
dec: 12,
|
|
17
|
+
feb: 2,
|
|
18
|
+
jan: 1,
|
|
19
|
+
jul: 7,
|
|
20
|
+
jun: 6,
|
|
21
|
+
mar: 3,
|
|
22
|
+
may: 5,
|
|
23
|
+
nov: 11,
|
|
24
|
+
oct: 10,
|
|
25
|
+
sep: 9,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const DAYS = { fri: 5, mon: 1, sat: 6, sun: 0, thu: 4, tue: 2, wed: 3 };
|
|
29
|
+
|
|
30
|
+
const ALIASES = {
|
|
31
|
+
'@annually': '0 0 1 1 *',
|
|
32
|
+
'@daily': '0 0 * * *',
|
|
33
|
+
'@hourly': '0 * * * *',
|
|
34
|
+
'@midnight': '0 0 * * *',
|
|
35
|
+
'@monthly': '0 0 1 * *',
|
|
36
|
+
'@weekly': '0 0 * * 0',
|
|
37
|
+
'@yearly': '0 0 1 1 *',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const MINUTE = 60000;
|
|
41
|
+
|
|
42
|
+
/** Four years of minutes is enough for `0 0 29 2 *` (a leap day) */
|
|
43
|
+
const HORIZON = 4 * 366 * 24 * 60;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The numeric value of one entry of a field
|
|
47
|
+
*
|
|
48
|
+
* @param {string} token The entry (a number or a name)
|
|
49
|
+
* @param {object} names The names this field accepts
|
|
50
|
+
* @param {string} field The field name, for the error
|
|
51
|
+
* @returns {number} The value
|
|
52
|
+
* @throws {Error} When the entry is not a number or a known name
|
|
53
|
+
*/
|
|
54
|
+
const value = (token, names, field) => {
|
|
55
|
+
const lower = token.toLowerCase();
|
|
56
|
+
|
|
57
|
+
if (Object.prototype.hasOwnProperty.call(names, lower)) {
|
|
58
|
+
return names[lower];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!/^\d+$/.test(token)) {
|
|
62
|
+
throw coded(
|
|
63
|
+
'HENRI_JOB_INVALID_CRON',
|
|
64
|
+
`Invalid ${field} "${token}" in cron expression`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Number(token);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The allowed values of one cron field
|
|
73
|
+
*
|
|
74
|
+
* @param {string} spec The field (`*`, `1-5`, `0/15`, `mon,wed`)
|
|
75
|
+
* @param {number} min The lowest value of the field
|
|
76
|
+
* @param {number} max The highest value of the field
|
|
77
|
+
* @param {string} name The field name, for the errors
|
|
78
|
+
* @param {object} [names={}] The names the field accepts
|
|
79
|
+
* @returns {Set<number>} The values that match
|
|
80
|
+
* @throws {Error} When the field cannot be parsed
|
|
81
|
+
*/
|
|
82
|
+
const field = (spec, min, max, name, names = {}) => {
|
|
83
|
+
const values = new Set();
|
|
84
|
+
|
|
85
|
+
for (const part of String(spec).split(',')) {
|
|
86
|
+
const [range, step = '1'] = part.split('/');
|
|
87
|
+
|
|
88
|
+
if (!/^\d+$/.test(step) || Number(step) < 1) {
|
|
89
|
+
throw coded(
|
|
90
|
+
'HENRI_JOB_INVALID_CRON',
|
|
91
|
+
`Invalid step "${step}" in ${name} of a cron expression`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const by = Number(step);
|
|
96
|
+
let from = min;
|
|
97
|
+
let to = max;
|
|
98
|
+
|
|
99
|
+
if (range !== '*' && range !== '') {
|
|
100
|
+
const [start, end] = range.split('-');
|
|
101
|
+
|
|
102
|
+
from = value(start, names, name);
|
|
103
|
+
to = typeof end === 'undefined' ? from : value(end, names, name);
|
|
104
|
+
|
|
105
|
+
// `5/10` means "from 5 to the end of the field, every 10"
|
|
106
|
+
if (typeof end === 'undefined' && part.includes('/')) {
|
|
107
|
+
to = max;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (from < min || to > max || from > to) {
|
|
112
|
+
throw coded(
|
|
113
|
+
'HENRI_JOB_INVALID_CRON',
|
|
114
|
+
`Out of range "${part}" in ${name} of a cron expression (${min}-${max})`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (let entry = from; entry <= to; entry += by) {
|
|
119
|
+
values.add(entry);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return values;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Parses a cron expression
|
|
128
|
+
*
|
|
129
|
+
* @param {string} expression Five fields, or `@daily`, `@hourly`, ...
|
|
130
|
+
* @returns {object} The parsed schedule
|
|
131
|
+
* @throws {Error} When the expression is invalid
|
|
132
|
+
*/
|
|
133
|
+
const parse = (expression) => {
|
|
134
|
+
const text = String(expression || '').trim();
|
|
135
|
+
const normalized = ALIASES[text.toLowerCase()] || text;
|
|
136
|
+
const parts = normalized.split(/\s+/);
|
|
137
|
+
|
|
138
|
+
if (parts.length !== 5) {
|
|
139
|
+
throw coded(
|
|
140
|
+
'HENRI_JOB_INVALID_CRON',
|
|
141
|
+
`Invalid cron expression "${expression}": expected 5 fields (minute hour day month weekday)`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const [minute, hour, day, month, weekday] = parts;
|
|
146
|
+
const weekdays = field(weekday, 0, 7, 'weekday', DAYS);
|
|
147
|
+
|
|
148
|
+
// Both 0 and 7 are Sunday
|
|
149
|
+
if (weekdays.has(7)) {
|
|
150
|
+
weekdays.add(0);
|
|
151
|
+
weekdays.delete(7);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
days: field(day, 1, 31, 'day of month'),
|
|
156
|
+
everyDay: day === '*',
|
|
157
|
+
everyWeekday: weekday === '*',
|
|
158
|
+
hours: field(hour, 0, 23, 'hour'),
|
|
159
|
+
minutes: field(minute, 0, 59, 'minute'),
|
|
160
|
+
months: field(month, 1, 12, 'month', MONTHS),
|
|
161
|
+
weekdays,
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Does this date fall on a day the schedule wants?
|
|
167
|
+
*
|
|
168
|
+
* Cron's oddity: when both the day of the month and the weekday are
|
|
169
|
+
* restricted, either one matching is enough.
|
|
170
|
+
*
|
|
171
|
+
* @param {object} schedule A parsed schedule
|
|
172
|
+
* @param {Date} date The date to test (read in UTC)
|
|
173
|
+
* @returns {boolean} Whether the day matches
|
|
174
|
+
*/
|
|
175
|
+
const matchesDay = (schedule, date) => {
|
|
176
|
+
const day = schedule.days.has(date.getUTCDate());
|
|
177
|
+
const weekday = schedule.weekdays.has(date.getUTCDay());
|
|
178
|
+
|
|
179
|
+
if (schedule.everyDay && schedule.everyWeekday) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (schedule.everyDay) {
|
|
184
|
+
return weekday;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (schedule.everyWeekday) {
|
|
188
|
+
return day;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return day || weekday;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The first moment after `from` that the expression matches
|
|
196
|
+
*
|
|
197
|
+
* @param {(string|object)} expression A cron expression or a parsed schedule
|
|
198
|
+
* @param {number} [from=Date.now()] The moment to start from (exclusive)
|
|
199
|
+
* @returns {?number} A timestamp in milliseconds, or null when the
|
|
200
|
+
* expression can never match again (`0 0 30 2 *`)
|
|
201
|
+
* @throws {Error} When the expression is invalid
|
|
202
|
+
*/
|
|
203
|
+
const next = (expression, from = Date.now()) => {
|
|
204
|
+
const schedule =
|
|
205
|
+
typeof expression === 'string' ? parse(expression) : expression;
|
|
206
|
+
const date = new Date(Math.floor(from / MINUTE) * MINUTE + MINUTE);
|
|
207
|
+
|
|
208
|
+
for (let guard = 0; guard < HORIZON; guard += 1) {
|
|
209
|
+
if (!schedule.months.has(date.getUTCMonth() + 1)) {
|
|
210
|
+
date.setUTCMonth(date.getUTCMonth() + 1, 1);
|
|
211
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!matchesDay(schedule, date)) {
|
|
216
|
+
date.setUTCDate(date.getUTCDate() + 1);
|
|
217
|
+
date.setUTCHours(0, 0, 0, 0);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!schedule.hours.has(date.getUTCHours())) {
|
|
222
|
+
date.setUTCHours(date.getUTCHours() + 1, 0, 0, 0);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!schedule.minutes.has(date.getUTCMinutes())) {
|
|
227
|
+
date.setUTCMinutes(date.getUTCMinutes() + 1, 0, 0);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return date.getTime();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return null;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
module.exports = { next, parse };
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { globSync } = require('glob');
|
|
3
|
+
|
|
4
|
+
const { duration } = require('./duration');
|
|
5
|
+
const { JobError } = require('./errors');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Job definitions live in `app/jobs`, in the shape henri already uses for
|
|
9
|
+
* models and controllers: a file exports an object. The name of a job is its
|
|
10
|
+
* path under `app/jobs` without the extension, so `app/jobs/mail/welcome.js`
|
|
11
|
+
* is the job `mail/welcome`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The widest a concurrency key may be: the column that holds it */
|
|
15
|
+
const KEY_LENGTH = 190;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reads a job's `concurrency` declaration
|
|
19
|
+
*
|
|
20
|
+
* `1` is `{ limit: 1 }`; a `key` is a field of the arguments or a function
|
|
21
|
+
* of them, and a `group` is the name several jobs share a bound under. The
|
|
22
|
+
* default group is the job's own name, so a limit is that job's alone
|
|
23
|
+
* unless it says otherwise.
|
|
24
|
+
*
|
|
25
|
+
* The limit belongs to the **job**, never to the call: an option of
|
|
26
|
+
* `perform()` would let one caller step outside a bound the job declared,
|
|
27
|
+
* which is the one thing a bound is for.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} name The job name
|
|
30
|
+
* @param {(number|object|null)} value What the file declared
|
|
31
|
+
* @returns {?object} `{ group, key, limit }`, or null
|
|
32
|
+
* @throws {JobError} HENRI_JOB_INVALID_CONCURRENCY on anything else
|
|
33
|
+
*/
|
|
34
|
+
const concurrency = (name, value) => {
|
|
35
|
+
if (value === null || typeof value === 'undefined' || value === false) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const declared = typeof value === 'number' ? { limit: value } : value;
|
|
40
|
+
const refuse = (why) => {
|
|
41
|
+
throw new JobError(
|
|
42
|
+
'HENRI_JOB_INVALID_CONCURRENCY',
|
|
43
|
+
`The job "${name}" declares a concurrency limit that cannot be read: ${why}`,
|
|
44
|
+
{
|
|
45
|
+
hint: '`concurrency: 3`, or `concurrency: { limit: 3, key: "tenantId" }` to bound each key of its own',
|
|
46
|
+
job: name,
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (typeof declared !== 'object') {
|
|
52
|
+
refuse('it is neither a number nor an object');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const limit = Number(declared.limit);
|
|
56
|
+
|
|
57
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
58
|
+
refuse(
|
|
59
|
+
`its limit is ${JSON.stringify(declared.limit)}, not a whole number above zero`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const group =
|
|
64
|
+
typeof declared.group === 'undefined' || declared.group === null
|
|
65
|
+
? name
|
|
66
|
+
: declared.group;
|
|
67
|
+
|
|
68
|
+
if (typeof group !== 'string' || group === '') {
|
|
69
|
+
refuse('its group is not a name');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (group.length > KEY_LENGTH) {
|
|
73
|
+
refuse(`its group is longer than ${KEY_LENGTH} characters`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { key } = declared;
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
typeof key !== 'undefined' &&
|
|
80
|
+
key !== null &&
|
|
81
|
+
typeof key !== 'string' &&
|
|
82
|
+
typeof key !== 'function'
|
|
83
|
+
) {
|
|
84
|
+
refuse('its key is neither the name of an argument nor a function of them');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
group,
|
|
89
|
+
key:
|
|
90
|
+
typeof key === 'string'
|
|
91
|
+
? (args) => (args ? args[key] : null)
|
|
92
|
+
: key || null,
|
|
93
|
+
limit,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The concurrency key of one call, or null when the job is unbounded
|
|
99
|
+
*
|
|
100
|
+
* A key that resolves to nothing is the group's own bucket, which is also
|
|
101
|
+
* where a job enqueued before the limit was declared sits: the two mean the
|
|
102
|
+
* same thing, so they share a bound rather than each getting one.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} definition A validated definition
|
|
105
|
+
* @param {*} args What perform() will receive
|
|
106
|
+
* @returns {?string} The key to store
|
|
107
|
+
* @throws {JobError} HENRI_JOB_INVALID_CONCURRENCY when the key cannot be read
|
|
108
|
+
*/
|
|
109
|
+
const keyOf = (definition, args) => {
|
|
110
|
+
const bound = definition && definition.concurrency;
|
|
111
|
+
|
|
112
|
+
if (!bound) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!bound.key) {
|
|
117
|
+
return bound.group;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let value;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
value = bound.key(args);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
throw new JobError(
|
|
126
|
+
'HENRI_JOB_INVALID_CONCURRENCY',
|
|
127
|
+
`The concurrency key of "${definition.name}" could not be read: ${error.message}`,
|
|
128
|
+
{ cause: error, job: definition.name }
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (value === null || typeof value === 'undefined' || value === '') {
|
|
133
|
+
return bound.group;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typeof value === 'object') {
|
|
137
|
+
throw new JobError(
|
|
138
|
+
'HENRI_JOB_INVALID_CONCURRENCY',
|
|
139
|
+
`The concurrency key of "${definition.name}" is an object; it has to be a value that names one bound`,
|
|
140
|
+
{ job: definition.name }
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const key = `${bound.group}:${String(value)}`;
|
|
145
|
+
|
|
146
|
+
if (key.length > KEY_LENGTH) {
|
|
147
|
+
throw new JobError(
|
|
148
|
+
'HENRI_JOB_INVALID_CONCURRENCY',
|
|
149
|
+
`The concurrency key of "${definition.name}" is ${key.length} characters, over the ${KEY_LENGTH} that are stored`,
|
|
150
|
+
{
|
|
151
|
+
hint: 'A key names a bound, so it is an id or a tenant name; hash it yourself if it has to be longer',
|
|
152
|
+
job: definition.name,
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return key;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Reads and checks one definition
|
|
162
|
+
*
|
|
163
|
+
* @param {string} name The job name
|
|
164
|
+
* @param {object} definition What the file exports
|
|
165
|
+
* @param {object} defaults The queue defaults (`queue`, `maxAttempts`, ...)
|
|
166
|
+
* @returns {object} The definition, with the defaults filled in
|
|
167
|
+
* @throws {JobError} HENRI_JOB_INVALID_DEFINITION without a `perform`
|
|
168
|
+
*/
|
|
169
|
+
const validate = (name, definition, defaults) => {
|
|
170
|
+
if (!definition || typeof definition.perform !== 'function') {
|
|
171
|
+
throw new JobError(
|
|
172
|
+
'HENRI_JOB_INVALID_DEFINITION',
|
|
173
|
+
`app/jobs/${name}.js does not export a perform(args, context) function`,
|
|
174
|
+
{ job: name }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const backoff = definition.backoff || {};
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
backoff: {
|
|
182
|
+
base: duration(backoff.base, defaults.backoff.base),
|
|
183
|
+
factor: Number(backoff.factor) || defaults.backoff.factor,
|
|
184
|
+
jitter:
|
|
185
|
+
typeof backoff.jitter === 'number'
|
|
186
|
+
? backoff.jitter
|
|
187
|
+
: defaults.backoff.jitter,
|
|
188
|
+
max: duration(backoff.max, defaults.backoff.max),
|
|
189
|
+
},
|
|
190
|
+
concurrency: concurrency(name, definition.concurrency),
|
|
191
|
+
maxAttempts: Math.max(
|
|
192
|
+
1,
|
|
193
|
+
Number(definition.maxAttempts) || defaults.maxAttempts
|
|
194
|
+
),
|
|
195
|
+
name,
|
|
196
|
+
perform: definition.perform,
|
|
197
|
+
priority:
|
|
198
|
+
typeof definition.priority === 'number'
|
|
199
|
+
? definition.priority
|
|
200
|
+
: defaults.priority,
|
|
201
|
+
queue: definition.queue || defaults.queue,
|
|
202
|
+
timeout: duration(definition.timeout, defaults.timeout),
|
|
203
|
+
};
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Loads every job of an application
|
|
208
|
+
*
|
|
209
|
+
* @param {string} location The `app/jobs` directory
|
|
210
|
+
* @param {object} defaults The queue defaults
|
|
211
|
+
* @returns {object} The definitions, by name
|
|
212
|
+
* @throws {JobError} BAD_JOB when a file is not a job
|
|
213
|
+
*/
|
|
214
|
+
const load = (location, defaults) => {
|
|
215
|
+
const dirname = path.resolve(location);
|
|
216
|
+
const definitions = {};
|
|
217
|
+
const files = globSync('**/*.js', {
|
|
218
|
+
cwd: dirname,
|
|
219
|
+
ignore: ['**/node_modules/**'],
|
|
220
|
+
nodir: true,
|
|
221
|
+
posix: true,
|
|
222
|
+
}).sort();
|
|
223
|
+
|
|
224
|
+
for (const file of files) {
|
|
225
|
+
const full = path.join(dirname, file);
|
|
226
|
+
const name = file.replace(/\.js$/, '');
|
|
227
|
+
|
|
228
|
+
delete require.cache[require.resolve(full)];
|
|
229
|
+
|
|
230
|
+
definitions[name] = validate(name, require(full), defaults);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return definitions;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
module.exports = { KEY_LENGTH, concurrency, keyOf, load, validate };
|
package/src/duration.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durations are written the way a human says them (`'30s'`, `'5m'`, `'2h'`,
|
|
3
|
+
* `'1d'`) or in milliseconds. Everything the queue stores is milliseconds.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { coded } = require('./errors');
|
|
7
|
+
|
|
8
|
+
// A Map, not an object: the keys are one letter long on purpose
|
|
9
|
+
const UNITS = new Map([
|
|
10
|
+
['ms', 1],
|
|
11
|
+
['s', 1000],
|
|
12
|
+
['m', 60000],
|
|
13
|
+
['h', 3600000],
|
|
14
|
+
['d', 86400000],
|
|
15
|
+
['w', 604800000],
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The written form: an amount, then a unit.
|
|
20
|
+
*
|
|
21
|
+
* The surrounding whitespace is trimmed before this runs rather than
|
|
22
|
+
* matched by it. `^\s*…\s*$` around an optional group is quadratic: on
|
|
23
|
+
* `'1' + ' '.repeat(60000) + '!'` the two star quantifiers split the run
|
|
24
|
+
* between them one position at a time, which measured 2 seconds before this
|
|
25
|
+
* was one `\s*` with nothing to share with. A duration reaches here from
|
|
26
|
+
* `--in=` and from whatever an application passes to `enqueue()`, so it is
|
|
27
|
+
* not always a value the author typed.
|
|
28
|
+
*/
|
|
29
|
+
const PATTERN = /^(\d+(?:\.\d+)?)\s*(ms|[smhdw])?$/i;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Milliseconds of a duration
|
|
33
|
+
*
|
|
34
|
+
* @param {(number|string|null)} value `'5m'`, `300000`, or nothing
|
|
35
|
+
* @param {?number} [fallback=null] What to answer for an empty value
|
|
36
|
+
* @returns {?number} The duration in milliseconds
|
|
37
|
+
* @throws {Error} When the value is not a duration
|
|
38
|
+
*/
|
|
39
|
+
const duration = (value, fallback = null) => {
|
|
40
|
+
if (value === null || typeof value === 'undefined' || value === '') {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof value === 'number') {
|
|
45
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
46
|
+
throw coded('HENRI_JOB_INVALID_DURATION', `Invalid duration: ${value}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return Math.round(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const match = PATTERN.exec(String(value).trim());
|
|
53
|
+
|
|
54
|
+
if (!match) {
|
|
55
|
+
throw coded(
|
|
56
|
+
'HENRI_JOB_INVALID_DURATION',
|
|
57
|
+
`Invalid duration "${value}": use a number of milliseconds or 30s, 5m, 2h, 1d`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const [, amount, unit] = match;
|
|
62
|
+
|
|
63
|
+
return Math.round(Number(amount) * UNITS.get((unit || 'ms').toLowerCase()));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The moment a job should run, from `at` or `wait`
|
|
68
|
+
*
|
|
69
|
+
* @param {object} [options={}] `at` (a Date, an ISO string or a timestamp)
|
|
70
|
+
* and `wait` (a duration from now)
|
|
71
|
+
* @param {number} [now=Date.now()] The current time
|
|
72
|
+
* @returns {number} A timestamp in milliseconds
|
|
73
|
+
* @throws {Error} When `at` is not a date
|
|
74
|
+
*/
|
|
75
|
+
const runAt = (options = {}, now = Date.now()) => {
|
|
76
|
+
const { at, wait } = options;
|
|
77
|
+
|
|
78
|
+
if (typeof at !== 'undefined' && at !== null) {
|
|
79
|
+
const date = at instanceof Date ? at : new Date(at);
|
|
80
|
+
const time = date.getTime();
|
|
81
|
+
|
|
82
|
+
if (Number.isNaN(time)) {
|
|
83
|
+
throw coded('HENRI_JOB_INVALID_DURATION', `Invalid date: ${String(at)}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return time;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return now + (duration(wait, 0) || 0);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A stored moment, as the API hands it out
|
|
94
|
+
*
|
|
95
|
+
* Every moment the queue stores is a BIGINT of milliseconds, and every
|
|
96
|
+
* moment it answers with is an ISO string. Some drivers read a BIGINT back
|
|
97
|
+
* as a string, so the number is taken first.
|
|
98
|
+
*
|
|
99
|
+
* @param {*} value A timestamp in milliseconds
|
|
100
|
+
* @returns {?string} An ISO string, or null
|
|
101
|
+
*/
|
|
102
|
+
const iso = (value) => {
|
|
103
|
+
if (value === null || typeof value === 'undefined' || value === '') {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const number = Number(value);
|
|
108
|
+
|
|
109
|
+
return Number.isNaN(number) ? null : new Date(number).toISOString();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
module.exports = { duration, iso, runAt };
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The errors `@usehenri/jobs` throws.
|
|
3
|
+
*
|
|
4
|
+
* Every one of them carries a `code`, so an application (or a test) can
|
|
5
|
+
* branch on the reason instead of matching the message. The codes are
|
|
6
|
+
* henri's own, from the catalogue of `@usehenri/core/error-codes.json`: a
|
|
7
|
+
* code is a string, so nothing is imported to raise one.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Base error of the package
|
|
12
|
+
*
|
|
13
|
+
* @class JobError
|
|
14
|
+
* @extends {Error}
|
|
15
|
+
*/
|
|
16
|
+
class JobError extends Error {
|
|
17
|
+
/**
|
|
18
|
+
* Creates an instance of JobError.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} code A henri error code (ex: HENRI_JOB_UNKNOWN)
|
|
21
|
+
* @param {string} message What went wrong
|
|
22
|
+
* @param {object} [options={}] `cause` and any extra property to carry
|
|
23
|
+
* @memberof JobError
|
|
24
|
+
*/
|
|
25
|
+
constructor(code, message, options = {}) {
|
|
26
|
+
const { cause, ...rest } = options;
|
|
27
|
+
|
|
28
|
+
super(message, cause ? { cause } : undefined);
|
|
29
|
+
|
|
30
|
+
this.name = 'JobError';
|
|
31
|
+
this.code = code;
|
|
32
|
+
Object.assign(this, rest);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* An argument that cannot be stored (a function, a bigint, a cycle, ...)
|
|
38
|
+
*
|
|
39
|
+
* @class JobArgumentError
|
|
40
|
+
* @extends {JobError}
|
|
41
|
+
*/
|
|
42
|
+
class JobArgumentError extends JobError {
|
|
43
|
+
/**
|
|
44
|
+
* Creates an instance of JobArgumentError.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} message What went wrong
|
|
47
|
+
* @param {object} [options={}] `path` and the usual options
|
|
48
|
+
* @memberof JobArgumentError
|
|
49
|
+
*/
|
|
50
|
+
constructor(message, options = {}) {
|
|
51
|
+
super('HENRI_JOB_INVALID_ARGUMENTS', message, options);
|
|
52
|
+
this.name = 'JobArgumentError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* An attempt that ran past the job's timeout
|
|
58
|
+
*
|
|
59
|
+
* @class JobTimeoutError
|
|
60
|
+
* @extends {JobError}
|
|
61
|
+
*/
|
|
62
|
+
class JobTimeoutError extends JobError {
|
|
63
|
+
/**
|
|
64
|
+
* Creates an instance of JobTimeoutError.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} name The job name
|
|
67
|
+
* @param {number} timeout The timeout in milliseconds
|
|
68
|
+
* @memberof JobTimeoutError
|
|
69
|
+
*/
|
|
70
|
+
constructor(name, timeout) {
|
|
71
|
+
super('HENRI_JOB_TIMEOUT', `${name} timed out after ${timeout}ms`);
|
|
72
|
+
this.name = 'JobTimeoutError';
|
|
73
|
+
this.timeout = timeout;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A store that cannot back the queue
|
|
79
|
+
*
|
|
80
|
+
* @class JobStoreError
|
|
81
|
+
* @extends {JobError}
|
|
82
|
+
*/
|
|
83
|
+
class JobStoreError extends JobError {
|
|
84
|
+
/**
|
|
85
|
+
* Creates an instance of JobStoreError.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} message What went wrong
|
|
88
|
+
* @param {object} [options={}] The usual options
|
|
89
|
+
* @memberof JobStoreError
|
|
90
|
+
*/
|
|
91
|
+
constructor(message, options = {}) {
|
|
92
|
+
super('HENRI_JOB_UNSUPPORTED_STORE', message, options);
|
|
93
|
+
this.name = 'JobStoreError';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* An Error carrying one of henri's error codes
|
|
99
|
+
*
|
|
100
|
+
* A code is a string and nothing more (`@usehenri/core/error-codes.json` is
|
|
101
|
+
* the catalogue), so a failure names itself without importing anything.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} code The henri error code (HENRI_JOB_UNKNOWN, ...)
|
|
104
|
+
* @param {string} message What went wrong
|
|
105
|
+
* @returns {Error} The error to throw
|
|
106
|
+
*/
|
|
107
|
+
const coded = (code, message) => Object.assign(new Error(message), { code });
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
JobArgumentError,
|
|
111
|
+
JobError,
|
|
112
|
+
JobStoreError,
|
|
113
|
+
JobTimeoutError,
|
|
114
|
+
coded,
|
|
115
|
+
};
|