@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.
@@ -0,0 +1,499 @@
1
+ /**
2
+ * The tables `@usehenri/jobs` owns, and the DDL of every SQL dialect henri
3
+ * can talk to.
4
+ *
5
+ * The queue never goes through a henri model: it owns four tables of its own
6
+ * so it cannot collide with the application's schema, and so a store that
7
+ * has no models at all (a fresh application) still has a queue.
8
+ *
9
+ * ## The upgrade block
10
+ *
11
+ * These tables are `CREATE TABLE IF NOT EXISTS` and there is no migration
12
+ * chain behind them, so a table an older henri created is the table an
13
+ * installation still has. A **new table** is therefore free -- the guarded
14
+ * create makes it appear -- and a **new column** is not.
15
+ *
16
+ * `upgrade()` is the answer: the statements that bring an existing table up
17
+ * to what this version writes, every one of them idempotent, and every one
18
+ * of them **tolerated** by the store (see `SqlStore#install`). A database
19
+ * user who may not `ALTER` never fails a boot over a feature the
20
+ * application does not use; the feature itself asks whether its column is
21
+ * there (`SqlStore#concurrent`) and refuses with the install line when it is
22
+ * not. The same statements run on a fresh database, where they find their
23
+ * work already done.
24
+ *
25
+ * Every moment is stored as a BIGINT of milliseconds since the epoch rather
26
+ * than a timestamp column: sqlite has no date type, MySQL, PostgreSQL and
27
+ * MSSQL disagree on the precision and on the time zone of a bare
28
+ * `TIMESTAMP`, and the claim compares `run_at` to the runner's clock -- a
29
+ * comparison that has to mean the same thing on every dialect. Numbers read
30
+ * back as strings on some drivers (BIGINT over the pg protocol), so every
31
+ * read coerces.
32
+ */
33
+
34
+ /** Names an application may give the tables */
35
+ const { coded } = require('../errors');
36
+
37
+ const SAFE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
38
+
39
+ const DIALECTS = {
40
+ mssql: {
41
+ /**
42
+ * Adds a column, only when the table has none by that name
43
+ *
44
+ * @param {string} table The table name
45
+ * @param {string} column The column name
46
+ * @param {string} type The column type
47
+ * @returns {string} The statement
48
+ */
49
+ addColumn: (table, column, type) =>
50
+ `IF COL_LENGTH('${table}', '${column}') IS NULL ALTER TABLE [${table}] ADD ${column} ${type} NULL`,
51
+
52
+ /**
53
+ * Wraps a statement so it only runs when the index is missing
54
+ *
55
+ * @param {string} table The table name
56
+ * @param {string} index The index name
57
+ * @param {string} statement The CREATE INDEX statement
58
+ * @returns {string} The guarded statement
59
+ */
60
+ guardIndex: (table, index, statement) =>
61
+ `IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = '${index}' AND object_id = OBJECT_ID('${table}')) ${statement}`,
62
+
63
+ /**
64
+ * Wraps a CREATE TABLE so it only runs when the table is missing
65
+ *
66
+ * @param {string} table The table name
67
+ * @param {string} statement The CREATE TABLE statement
68
+ * @returns {string} The guarded statement
69
+ */
70
+ guardTable: (table, statement) =>
71
+ `IF OBJECT_ID('${table}', 'U') IS NULL ${statement}`,
72
+
73
+ ifNotExists: '',
74
+ indexIfNotExists: '',
75
+ inlineIndexes: false,
76
+ int: 'INT',
77
+ // MSSQL is the one dialect that treats NULLs as equal in a unique index
78
+ partialUnique: true,
79
+ quote: (identifier) => `[${identifier}]`,
80
+ text: 'NVARCHAR(MAX)',
81
+ },
82
+ mysql: {
83
+ // MySQL has no ADD COLUMN IF NOT EXISTS: a second run answers 1060,
84
+ // which the store tolerates like every other upgrade statement
85
+ addColumn: (table, column, type) =>
86
+ `ALTER TABLE \`${table}\` ADD COLUMN ${column} ${type} NULL`,
87
+ ifNotExists: 'IF NOT EXISTS',
88
+ // MySQL has no CREATE INDEX IF NOT EXISTS: the indexes are declared in
89
+ // the CREATE TABLE, which is guarded. An index the create cannot carry
90
+ // (a `late` one, on a table that is already there) is written bare and
91
+ // tolerated when it answers 1061
92
+ indexIfNotExists: '',
93
+ inlineIndexes: true,
94
+ int: 'INT',
95
+ partialUnique: false,
96
+ quote: (identifier) => `\`${identifier}\``,
97
+ // TEXT stops at 65535 bytes and the arguments alone may reach 65536
98
+ text: 'MEDIUMTEXT',
99
+ },
100
+ postgres: {
101
+ addColumn: (table, column, type) =>
102
+ `ALTER TABLE "${table}" ADD COLUMN IF NOT EXISTS ${column} ${type} NULL`,
103
+ ifNotExists: 'IF NOT EXISTS',
104
+ indexIfNotExists: 'IF NOT EXISTS',
105
+ inlineIndexes: false,
106
+ int: 'INTEGER',
107
+ partialUnique: false,
108
+ quote: (identifier) => `"${identifier}"`,
109
+ text: 'TEXT',
110
+ },
111
+ sqlite: {
112
+ // SQLite has no ADD COLUMN IF NOT EXISTS either: a second run answers
113
+ // "duplicate column name", which the store tolerates
114
+ addColumn: (table, column, type) =>
115
+ `ALTER TABLE "${table}" ADD COLUMN ${column} ${type} NULL`,
116
+ ifNotExists: 'IF NOT EXISTS',
117
+ indexIfNotExists: 'IF NOT EXISTS',
118
+ inlineIndexes: false,
119
+ int: 'INTEGER',
120
+ partialUnique: false,
121
+ quote: (identifier) => `"${identifier}"`,
122
+ text: 'TEXT',
123
+ },
124
+ };
125
+
126
+ /**
127
+ * The columns an older henri did not write, and the type they take.
128
+ *
129
+ * One entry per column added after a version that shipped: the create
130
+ * statement declares them and `upgrade()` adds them to a table that has
131
+ * them not.
132
+ */
133
+ const ADDED = [
134
+ { column: 'concurrency_key', type: 'VARCHAR(190)' },
135
+ { column: 'batch_id', type: 'VARCHAR(36)' },
136
+ // 190 is the width core gives a tenant (`base/tenancy.js`, MAX_TENANT)
137
+ // and the width `@usehenri/webhooks` gives an endpoint's owner: what
138
+ // MySQL indexes in a utf8mb4 key. A tenant is never truncated to fit
139
+ { column: 'tenant', type: 'VARCHAR(190)' },
140
+ ];
141
+
142
+ /**
143
+ * The columns of the jobs table, in order
144
+ *
145
+ * @param {object} dialect A dialect description
146
+ * @returns {Array<string>} The column definitions
147
+ */
148
+ const jobColumns = (dialect) => [
149
+ 'id VARCHAR(36) NOT NULL',
150
+ 'queue VARCHAR(120) NOT NULL',
151
+ 'name VARCHAR(120) NOT NULL',
152
+ `args ${dialect.text} NOT NULL`,
153
+ 'state VARCHAR(16) NOT NULL',
154
+ `priority ${dialect.int} NOT NULL`,
155
+ `attempts ${dialect.int} NOT NULL`,
156
+ `max_attempts ${dialect.int} NOT NULL`,
157
+ `timeout_ms ${dialect.int} NULL`,
158
+ 'run_at BIGINT NOT NULL',
159
+ 'created_at BIGINT NOT NULL',
160
+ 'updated_at BIGINT NOT NULL',
161
+ 'started_at BIGINT NULL',
162
+ 'finished_at BIGINT NULL',
163
+ `duration_ms ${dialect.int} NULL`,
164
+ 'claimed_by VARCHAR(120) NULL',
165
+ 'claimed_at BIGINT NULL',
166
+ 'heartbeat_at BIGINT NULL',
167
+ 'claim_token VARCHAR(36) NULL',
168
+ `error_message ${dialect.text} NULL`,
169
+ `error_stack ${dialect.text} NULL`,
170
+ `history ${dialect.text} NULL`,
171
+ 'unique_key VARCHAR(190) NULL',
172
+ 'concurrency_key VARCHAR(190) NULL',
173
+ 'batch_id VARCHAR(36) NULL',
174
+ 'tenant VARCHAR(190) NULL',
175
+ 'PRIMARY KEY (id)',
176
+ ];
177
+
178
+ /**
179
+ * The columns of the concurrency table, in order
180
+ *
181
+ * One row is one slot of one key: `(limit_key, slot)` is the primary key, so
182
+ * `slot` counts from zero to the job's limit and an INSERT is what takes it.
183
+ * That unique index is the whole bound -- see `SqlStore#takeSlot`.
184
+ *
185
+ * @param {object} dialect A dialect description
186
+ * @returns {Array<string>} The column definitions
187
+ */
188
+ const limitColumns = (dialect) => [
189
+ 'limit_key VARCHAR(190) NOT NULL',
190
+ `slot ${dialect.int} NOT NULL`,
191
+ 'job_id VARCHAR(36) NULL',
192
+ 'runner VARCHAR(120) NOT NULL',
193
+ 'taken_at BIGINT NOT NULL',
194
+ 'heartbeat_at BIGINT NOT NULL',
195
+ 'PRIMARY KEY (limit_key, slot)',
196
+ ];
197
+
198
+ /**
199
+ * The indexes of the concurrency table
200
+ *
201
+ * @param {string} table The table name
202
+ * @returns {Array<object>} `{ name, columns, unique }` entries
203
+ */
204
+ const limitIndexes = (table) => [
205
+ { columns: ['heartbeat_at'], name: `${table}_stale`, unique: false },
206
+ ];
207
+
208
+ /**
209
+ * The columns of the batches table, in order
210
+ *
211
+ * A batch counts: `total` is what was enqueued under it and is written once,
212
+ * when the batch is sealed; `done` and `failed` are advanced by the same
213
+ * token-guarded write that records an attempt's outcome, one statement per
214
+ * job. `finished_at` is stamped after the callback has been enqueued, so a
215
+ * crash in between leaves the batch unfinished and the sweep settles it
216
+ * again -- the enqueue is idempotent (see `../keys.js`).
217
+ *
218
+ * @param {object} dialect A dialect description
219
+ * @returns {Array<string>} The column definitions
220
+ */
221
+ const batchColumns = (dialect) => [
222
+ 'id VARCHAR(36) NOT NULL',
223
+ 'name VARCHAR(190) NULL',
224
+ 'callback VARCHAR(120) NULL',
225
+ `callback_args ${dialect.text} NULL`,
226
+ `callback_options ${dialect.text} NULL`,
227
+ 'callback_id VARCHAR(36) NULL',
228
+ `total ${dialect.int} NOT NULL`,
229
+ `done ${dialect.int} NOT NULL`,
230
+ `failed ${dialect.int} NOT NULL`,
231
+ 'created_at BIGINT NOT NULL',
232
+ 'updated_at BIGINT NOT NULL',
233
+ 'sealed_at BIGINT NULL',
234
+ 'finished_at BIGINT NULL',
235
+ 'PRIMARY KEY (id)',
236
+ ];
237
+
238
+ /**
239
+ * The indexes of the batches table
240
+ *
241
+ * @param {string} table The table name
242
+ * @returns {Array<object>} `{ name, columns, unique }` entries
243
+ */
244
+ const batchIndexes = (table) => [
245
+ {
246
+ columns: ['finished_at', 'updated_at'],
247
+ name: `${table}_open`,
248
+ unique: false,
249
+ },
250
+ ];
251
+
252
+ /**
253
+ * The columns of the schedules table, in order
254
+ *
255
+ * @param {object} dialect A dialect description
256
+ * @returns {Array<string>} The column definitions
257
+ */
258
+ const scheduleColumns = (dialect) => [
259
+ 'name VARCHAR(190) NOT NULL',
260
+ 'job VARCHAR(120) NOT NULL',
261
+ 'spec VARCHAR(190) NOT NULL',
262
+ 'next_run_at BIGINT NOT NULL',
263
+ 'last_run_at BIGINT NULL',
264
+ 'token VARCHAR(36) NULL',
265
+ 'created_at BIGINT NOT NULL',
266
+ 'updated_at BIGINT NOT NULL',
267
+ 'PRIMARY KEY (name)',
268
+ ];
269
+
270
+ /**
271
+ * The indexes of the jobs table
272
+ *
273
+ * @param {string} table The table name
274
+ * @returns {Array<object>} `{ name, columns, unique }` entries
275
+ */
276
+ const jobIndexes = (table) => [
277
+ {
278
+ columns: ['state', 'queue', 'priority', 'run_at'],
279
+ name: `${table}_claim`,
280
+ unique: false,
281
+ },
282
+ { columns: ['claim_token'], name: `${table}_token`, unique: false },
283
+ {
284
+ columns: ['state', 'finished_at'],
285
+ name: `${table}_finished`,
286
+ unique: false,
287
+ },
288
+ { columns: ['unique_key'], name: `${table}_unique`, unique: true },
289
+ // `late`: it arrived with a column an older table has not, so it belongs
290
+ // to the upgrade block on every dialect rather than to the create
291
+ {
292
+ columns: ['state', 'concurrency_key', 'run_at'],
293
+ late: true,
294
+ name: `${table}_limited`,
295
+ unique: false,
296
+ },
297
+ {
298
+ columns: ['batch_id'],
299
+ late: true,
300
+ name: `${table}_batch`,
301
+ unique: false,
302
+ },
303
+ // The listing `henri jobs:list --tenant` makes, and nothing else: the
304
+ // claim is deliberately **not** narrowed by tenant, so this index is
305
+ // never on the hot path (see `SqlStore#claimStatement`)
306
+ {
307
+ columns: ['tenant', 'state', 'run_at'],
308
+ late: true,
309
+ name: `${table}_tenant`,
310
+ unique: false,
311
+ },
312
+ ];
313
+
314
+ /**
315
+ * The statement that creates one index
316
+ *
317
+ * @param {object} dialect A dialect description
318
+ * @param {string} table The table name
319
+ * @param {object} index An index description
320
+ * @returns {string} The statement
321
+ */
322
+ const indexStatement = (dialect, table, index) => {
323
+ const filter =
324
+ index.unique && dialect.partialUnique
325
+ ? ` WHERE ${index.columns[0]} IS NOT NULL`
326
+ : '';
327
+ const statement = [
328
+ `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX`,
329
+ dialect.guardIndex ? '' : dialect.indexIfNotExists,
330
+ `${dialect.quote(index.name)} ON ${dialect.quote(table)} (${index.columns.join(', ')})${filter}`,
331
+ ]
332
+ .filter(Boolean)
333
+ .join(' ');
334
+
335
+ return dialect.guardIndex
336
+ ? dialect.guardIndex(table, index.name, statement)
337
+ : statement;
338
+ };
339
+
340
+ /**
341
+ * The statements that create a table and its indexes, in order
342
+ *
343
+ * An index marked `late` is left out: it names a column an older table does
344
+ * not have, so it is the upgrade block's, on every dialect at once.
345
+ *
346
+ * @param {object} dialect A dialect description
347
+ * @param {string} table The table name
348
+ * @param {Array<string>} columns The column definitions
349
+ * @param {Array<object>} all The indexes
350
+ * @returns {Array<string>} The statements to run, in order
351
+ */
352
+ const statementsFor = (dialect, table, columns, all) => {
353
+ const quoted = dialect.quote(table);
354
+ const definitions = [...columns];
355
+ const statements = [];
356
+ const indexes = all.filter((index) => !index.late);
357
+
358
+ if (dialect.inlineIndexes) {
359
+ for (const index of indexes) {
360
+ definitions.push(
361
+ `${index.unique ? 'UNIQUE KEY' : 'KEY'} ${dialect.quote(index.name)} (${index.columns.join(', ')})`
362
+ );
363
+ }
364
+ }
365
+
366
+ const create = [
367
+ 'CREATE TABLE',
368
+ dialect.ifNotExists,
369
+ `${quoted} (\n ${definitions.join(',\n ')}\n)`,
370
+ ]
371
+ .filter(Boolean)
372
+ .join(' ');
373
+
374
+ statements.push(
375
+ dialect.guardTable ? dialect.guardTable(table, create) : create
376
+ );
377
+
378
+ if (dialect.inlineIndexes) {
379
+ return statements;
380
+ }
381
+
382
+ for (const index of indexes) {
383
+ statements.push(indexStatement(dialect, table, index));
384
+ }
385
+
386
+ return statements;
387
+ };
388
+
389
+ /**
390
+ * The statements that bring a table an older henri created up to date
391
+ *
392
+ * Every one of them is idempotent and every one of them is tolerated by the
393
+ * store: a database user who may not `ALTER` never fails a boot over a
394
+ * feature the application does not use. What decides whether the feature
395
+ * works is asking the table, not whether these ran.
396
+ *
397
+ * @param {string} name The dialect (sqlite, postgres, mysql, mssql)
398
+ * @param {object} tables `{ jobs, schedules, limits, batches }` table names
399
+ * @returns {Array<string>} The statements
400
+ * @throws {Error} When the dialect is unknown
401
+ */
402
+ const upgrade = (name, tables) => {
403
+ const dialect = DIALECTS[name];
404
+
405
+ if (!dialect) {
406
+ throw coded(
407
+ 'HENRI_JOB_UNSUPPORTED_STORE',
408
+ `@usehenri/jobs: unsupported SQL dialect "${name}"`
409
+ );
410
+ }
411
+
412
+ return [
413
+ ...ADDED.map((added) =>
414
+ dialect.addColumn(tables.jobs, added.column, added.type)
415
+ ),
416
+ ...jobIndexes(tables.jobs)
417
+ .filter((index) => index.late)
418
+ .map((index) => indexStatement(dialect, tables.jobs, index)),
419
+ ];
420
+ };
421
+
422
+ /**
423
+ * Every statement `henri jobs:install` runs, in order
424
+ *
425
+ * All of them are idempotent: running the install twice, or against a
426
+ * database another runner already prepared, changes nothing.
427
+ *
428
+ * @param {string} name The dialect (sqlite, postgres, mysql, mssql)
429
+ * @param {object} tables `{ jobs, schedules, limits, batches }` table names
430
+ * @returns {Array<string>} The statements
431
+ * @throws {Error} When the dialect or a table name is unknown
432
+ */
433
+ const install = (name, tables) => {
434
+ const dialect = DIALECTS[name];
435
+
436
+ if (!dialect) {
437
+ throw coded(
438
+ 'HENRI_JOB_UNSUPPORTED_STORE',
439
+ `@usehenri/jobs: unsupported SQL dialect "${name}"`
440
+ );
441
+ }
442
+
443
+ for (const table of Object.values(tables)) {
444
+ if (!SAFE_NAME.test(table)) {
445
+ throw coded(
446
+ 'HENRI_CONFIG_INVALID',
447
+ `@usehenri/jobs: invalid table name "${table}": letters, digits and underscores only`
448
+ );
449
+ }
450
+ }
451
+
452
+ return [
453
+ ...statementsFor(
454
+ dialect,
455
+ tables.jobs,
456
+ jobColumns(dialect),
457
+ jobIndexes(tables.jobs)
458
+ ),
459
+ ...statementsFor(dialect, tables.schedules, scheduleColumns(dialect), []),
460
+ ...statementsFor(
461
+ dialect,
462
+ tables.limits,
463
+ limitColumns(dialect),
464
+ limitIndexes(tables.limits)
465
+ ),
466
+ ...statementsFor(
467
+ dialect,
468
+ tables.batches,
469
+ batchColumns(dialect),
470
+ batchIndexes(tables.batches)
471
+ ),
472
+ ...upgrade(name, tables),
473
+ ];
474
+ };
475
+
476
+ /**
477
+ * The statements that drop the tables, newest first
478
+ *
479
+ * @param {string} name The dialect
480
+ * @param {object} tables `{ jobs, schedules, limits, batches }` table names
481
+ * @returns {Array<string>} The statements
482
+ * @throws {Error} When the dialect is unknown
483
+ */
484
+ const uninstall = (name, tables) => {
485
+ const dialect = DIALECTS[name];
486
+
487
+ if (!dialect) {
488
+ throw coded(
489
+ 'HENRI_JOB_UNSUPPORTED_STORE',
490
+ `@usehenri/jobs: unsupported SQL dialect "${name}"`
491
+ );
492
+ }
493
+
494
+ return [tables.batches, tables.limits, tables.schedules, tables.jobs].map(
495
+ (table) => `DROP TABLE IF EXISTS ${dialect.quote(table)}`
496
+ );
497
+ };
498
+
499
+ module.exports = { ADDED, DIALECTS, install, uninstall, upgrade };