pg-boss 9.0.3 → 10.0.0-beta10

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/plans.js CHANGED
@@ -1,22 +1,22 @@
1
- const assert = require('assert')
1
+ const DEFAULT_SCHEMA = 'pgboss'
2
+ const MIGRATE_RACE_MESSAGE = 'division by zero'
3
+ const CREATE_RACE_MESSAGE = 'already exists'
2
4
 
3
- const states = {
5
+ const JOB_STATES = Object.freeze({
4
6
  created: 'created',
5
7
  retry: 'retry',
6
8
  active: 'active',
7
9
  completed: 'completed',
8
- expired: 'expired',
9
10
  cancelled: 'cancelled',
10
11
  failed: 'failed'
11
- }
12
-
13
- const DEFAULT_SCHEMA = 'pgboss'
14
- const COMPLETION_JOB_PREFIX = `__state__${states.completed}__`
15
- const SINGLETON_QUEUE_KEY = '__pgboss__singleton_queue'
16
- const SINGLETON_QUEUE_KEY_ESCAPED = SINGLETON_QUEUE_KEY.replace(/_/g, '\\_')
12
+ })
17
13
 
18
- const MIGRATE_RACE_MESSAGE = 'division by zero'
19
- const CREATE_RACE_MESSAGE = 'already exists'
14
+ const QUEUE_POLICIES = Object.freeze({
15
+ standard: 'standard',
16
+ short: 'short',
17
+ singleton: 'singleton',
18
+ stately: 'stately'
19
+ })
20
20
 
21
21
  module.exports = {
22
22
  create,
@@ -28,7 +28,8 @@ module.exports = {
28
28
  completeJobs,
29
29
  cancelJobs,
30
30
  resumeJobs,
31
- failJobs,
31
+ failJobsById,
32
+ failJobsByTimeout,
32
33
  insertJob,
33
34
  insertJobs,
34
35
  getTime,
@@ -38,62 +39,61 @@ module.exports = {
38
39
  subscribe,
39
40
  unsubscribe,
40
41
  getQueuesForEvent,
41
- expire,
42
42
  archive,
43
- purge,
43
+ drop,
44
44
  countStates,
45
- deleteQueue,
46
- deleteAllQueues,
47
- clearStorage,
45
+ createQueue,
46
+ updateQueue,
47
+ createPartition,
48
+ dropPartition,
49
+ deleteQueueRecords,
50
+ getQueueByName,
48
51
  getQueueSize,
49
- getMaintenanceTime,
50
- setMaintenanceTime,
51
- getCronTime,
52
- setCronTime,
52
+ purgeQueue,
53
+ clearStorage,
54
+ trySetMaintenanceTime,
55
+ trySetMonitorTime,
56
+ trySetCronTime,
53
57
  locked,
54
58
  assertMigration,
55
59
  getArchivedJobById,
56
60
  getJobById,
57
- states: { ...states },
58
- COMPLETION_JOB_PREFIX,
59
- SINGLETON_QUEUE_KEY,
61
+ QUEUE_POLICIES,
62
+ JOB_STATES,
60
63
  MIGRATE_RACE_MESSAGE,
61
64
  CREATE_RACE_MESSAGE,
62
65
  DEFAULT_SCHEMA
63
66
  }
64
67
 
65
- function locked (schema, query) {
66
- if (Array.isArray(query)) {
67
- query = query.join(';\n')
68
- }
69
-
70
- return `
71
- BEGIN;
72
- SET LOCAL statement_timeout = '30s';
73
- ${advisoryLock(schema)};
74
- ${query};
75
- COMMIT;
76
- `
77
- }
68
+ const assert = require('assert')
78
69
 
79
70
  function create (schema, version) {
80
71
  const commands = [
81
72
  createSchema(schema),
82
- createVersionTable(schema),
83
- createJobStateEnum(schema),
84
- createJobTable(schema),
85
- cloneJobTableForArchive(schema),
86
- createScheduleTable(schema),
87
- createSubscriptionTable(schema),
88
- addIdIndexToArchive(schema),
89
- addArchivedOnToArchive(schema),
90
- addArchivedOnIndexToArchive(schema),
91
- createIndexJobName(schema),
73
+ createEnumJobState(schema),
74
+
75
+ createTableJob(schema),
92
76
  createIndexJobFetch(schema),
93
- createIndexSingletonOn(schema),
94
- createIndexSingletonKeyOn(schema),
95
- createIndexSingletonKey(schema),
96
- createIndexSingletonQueue(schema),
77
+ createIndexJobPolicyStately(schema),
78
+ createIndexJobPolicyShort(schema),
79
+ createIndexJobPolicySingleton(schema),
80
+ createIndexJobThrottleOn(schema),
81
+ createIndexJobThrottleKey(schema),
82
+
83
+ createTableArchive(schema),
84
+ createPrimaryKeyArchive(schema),
85
+ createColumnArchiveArchivedOn(schema),
86
+ createIndexArchiveArchivedOn(schema),
87
+
88
+ createTableVersion(schema),
89
+ createTableQueue(schema),
90
+ createTableSchedule(schema),
91
+ createTableSubscription(schema),
92
+
93
+ getPartitionFunction(schema),
94
+ createPartitionFunction(schema),
95
+ dropPartitionFunction(schema),
96
+
97
97
  insertVersion(schema, version)
98
98
  ]
99
99
 
@@ -106,154 +106,252 @@ function createSchema (schema) {
106
106
  `
107
107
  }
108
108
 
109
- function createVersionTable (schema) {
109
+ function createTableVersion (schema) {
110
110
  return `
111
111
  CREATE TABLE ${schema}.version (
112
112
  version int primary key,
113
113
  maintained_on timestamp with time zone,
114
- cron_on timestamp with time zone
114
+ cron_on timestamp with time zone,
115
+ monitored_on timestamp with time zone
115
116
  )
116
117
  `
117
118
  }
118
119
 
119
- function createJobStateEnum (schema) {
120
+ function createEnumJobState (schema) {
120
121
  // ENUM definition order is important
121
122
  // base type is numeric and first values are less than last values
122
123
  return `
123
124
  CREATE TYPE ${schema}.job_state AS ENUM (
124
- '${states.created}',
125
- '${states.retry}',
126
- '${states.active}',
127
- '${states.completed}',
128
- '${states.expired}',
129
- '${states.cancelled}',
130
- '${states.failed}'
125
+ '${JOB_STATES.created}',
126
+ '${JOB_STATES.retry}',
127
+ '${JOB_STATES.active}',
128
+ '${JOB_STATES.completed}',
129
+ '${JOB_STATES.cancelled}',
130
+ '${JOB_STATES.failed}'
131
131
  )
132
132
  `
133
133
  }
134
134
 
135
- function createJobTable (schema) {
135
+ function createTableJob (schema) {
136
136
  return `
137
137
  CREATE TABLE ${schema}.job (
138
- id uuid primary key not null default gen_random_uuid(),
138
+ id uuid not null default gen_random_uuid(),
139
139
  name text not null,
140
140
  priority integer not null default(0),
141
141
  data jsonb,
142
- state ${schema}.job_state not null default('${states.created}'),
143
- retryLimit integer not null default(0),
144
- retryCount integer not null default(0),
145
- retryDelay integer not null default(0),
146
- retryBackoff boolean not null default false,
147
- startAfter timestamp with time zone not null default now(),
148
- startedOn timestamp with time zone,
149
- singletonKey text,
150
- singletonOn timestamp without time zone,
151
- expireIn interval not null default interval '15 minutes',
152
- createdOn timestamp with time zone not null default now(),
153
- completedOn timestamp with time zone,
154
- keepUntil timestamp with time zone NOT NULL default now() + interval '14 days',
155
- on_complete boolean not null default false,
156
- output jsonb
157
- )
142
+ state ${schema}.job_state not null default('${JOB_STATES.created}'),
143
+ retry_limit integer not null default(0),
144
+ retry_count integer not null default(0),
145
+ retry_delay integer not null default(0),
146
+ retry_backoff boolean not null default false,
147
+ start_after timestamp with time zone not null default now(),
148
+ started_on timestamp with time zone,
149
+ singleton_key text,
150
+ singleton_on timestamp without time zone,
151
+ expire_in interval not null default interval '15 minutes',
152
+ created_on timestamp with time zone not null default now(),
153
+ completed_on timestamp with time zone,
154
+ keep_until timestamp with time zone NOT NULL default now() + interval '14 days',
155
+ output jsonb,
156
+ dead_letter text,
157
+ policy text,
158
+ CONSTRAINT job_pkey PRIMARY KEY (name, id)
159
+ ) PARTITION BY LIST (name)
158
160
  `
159
161
  }
160
162
 
161
- function cloneJobTableForArchive (schema) {
162
- return `CREATE TABLE ${schema}.archive (LIKE ${schema}.job)`
163
+ const baseJobColumns = 'id, name, data, EXTRACT(epoch FROM expire_in) as "expireInSeconds"'
164
+ const allJobColumns = `${baseJobColumns}, policy, state, priority,
165
+ retry_limit as "retryLimit",
166
+ retry_count as "retryCount",
167
+ retry_delay as "retryDelay",
168
+ retry_backoff as "retryBackoff",
169
+ start_after as "startAfter",
170
+ started_on as "startedOn",
171
+ singleton_key as "singletonKey",
172
+ singleton_on as "singletonOn",
173
+ expire_in as "expireIn",
174
+ created_on as "createdOn",
175
+ completed_on as "completedOn",
176
+ keep_until as "keepUntil",
177
+ dead_letter as "deadLetter",
178
+ output
179
+ `
180
+
181
+ function createPartition (schema, name) {
182
+ return `SELECT ${schema}.create_partition('${name}');`
183
+ }
184
+
185
+ function getPartitionFunction (schema) {
186
+ return `
187
+ CREATE FUNCTION ${schema}.get_partition(queue_name text, out name text) AS
188
+ $$
189
+ SELECT 'job_' || encode(sha224(queue_name::bytea), 'hex');
190
+ $$
191
+ LANGUAGE SQL
192
+ IMMUTABLE
193
+ `
163
194
  }
164
195
 
165
- function addArchivedOnToArchive (schema) {
166
- return `ALTER TABLE ${schema}.archive ADD archivedOn timestamptz NOT NULL DEFAULT now()`
196
+ function createPartitionFunction (schema) {
197
+ return `
198
+ CREATE FUNCTION ${schema}.create_partition(queue_name text)
199
+ RETURNS VOID AS
200
+ $$
201
+ DECLARE
202
+ table_name varchar := ${schema}.get_partition(queue_name);
203
+ BEGIN
204
+ EXECUTE format('CREATE TABLE ${schema}.%I (LIKE ${schema}.job INCLUDING DEFAULTS INCLUDING CONSTRAINTS)', table_name);
205
+ EXECUTE format('ALTER TABLE ${schema}.%I ADD CHECK (name=%L)', table_name, queue_name);
206
+ EXECUTE format('ALTER TABLE ${schema}.job ATTACH PARTITION ${schema}.%I FOR VALUES IN (%L)', table_name, queue_name);
207
+ END;
208
+ $$
209
+ LANGUAGE plpgsql;
210
+ `
167
211
  }
168
212
 
169
- function addArchivedOnIndexToArchive (schema) {
170
- return `CREATE INDEX archive_archivedon_idx ON ${schema}.archive(archivedon)`
213
+ function dropPartitionFunction (schema) {
214
+ return `
215
+ CREATE FUNCTION ${schema}.drop_partition(queue_name text)
216
+ RETURNS VOID AS
217
+ $$
218
+ BEGIN
219
+ EXECUTE format('DROP TABLE IF EXISTS %I', ${schema}.get_partition(queue_name));
220
+ END;
221
+ $$
222
+ LANGUAGE plpgsql;
223
+ `
171
224
  }
172
225
 
173
- function addIdIndexToArchive (schema) {
174
- return `CREATE INDEX archive_id_idx ON ${schema}.archive(id)`
226
+ function dropPartition (schema, name) {
227
+ return `SELECT ${schema}.drop_partition('${name}');`
175
228
  }
176
229
 
177
- function setMaintenanceTime (schema) {
178
- return `UPDATE ${schema}.version SET maintained_on = now()`
230
+ function createPrimaryKeyArchive (schema) {
231
+ return `ALTER TABLE ${schema}.archive ADD CONSTRAINT archive_pkey PRIMARY KEY (name, id)`
179
232
  }
180
233
 
181
- function getMaintenanceTime (schema) {
182
- return `SELECT maintained_on, EXTRACT( EPOCH FROM (now() - maintained_on) ) seconds_ago FROM ${schema}.version`
234
+ function createIndexJobPolicyShort (schema) {
235
+ return `CREATE UNIQUE INDEX job_policy_short ON ${schema}.job (name) WHERE state = '${JOB_STATES.created}' AND policy = '${QUEUE_POLICIES.short}'`
183
236
  }
184
237
 
185
- function setCronTime (schema, time) {
186
- time = time || 'now()'
187
- return `UPDATE ${schema}.version SET cron_on = ${time}`
238
+ function createIndexJobPolicySingleton (schema) {
239
+ return `CREATE UNIQUE INDEX job_policy_singleton ON ${schema}.job (name) WHERE state = '${JOB_STATES.active}' AND policy = '${QUEUE_POLICIES.singleton}'`
188
240
  }
189
241
 
190
- function getCronTime (schema) {
191
- return `SELECT cron_on, EXTRACT( EPOCH FROM (now() - cron_on) ) seconds_ago FROM ${schema}.version`
242
+ function createIndexJobPolicyStately (schema) {
243
+ return `CREATE UNIQUE INDEX job_policy_stately ON ${schema}.job (name, state) WHERE state <= '${JOB_STATES.active}' AND policy = '${QUEUE_POLICIES.stately}'`
192
244
  }
193
245
 
194
- function deleteQueue (schema, options = {}) {
195
- options.before = options.before || states.active
196
- assert(options.before in states, `${options.before} is not a valid state`)
197
- return `DELETE FROM ${schema}.job WHERE name = $1 and state < '${options.before}'`
246
+ function createIndexJobThrottleOn (schema) {
247
+ return `CREATE UNIQUE INDEX job_throttle_on ON ${schema}.job (name, singleton_on, COALESCE(singleton_key, '')) WHERE state <= '${JOB_STATES.completed}' AND singleton_on IS NOT NULL`
198
248
  }
199
249
 
200
- function deleteAllQueues (schema, options = {}) {
201
- options.before = options.before || states.active
202
- assert(options.before in states, `${options.before} is not a valid state`)
203
- return `DELETE FROM ${schema}.job WHERE state < '${options.before}'`
250
+ function createIndexJobThrottleKey (schema) {
251
+ return `CREATE UNIQUE INDEX job_throttle_key ON ${schema}.job (name, singleton_key) WHERE state <= '${JOB_STATES.completed}' AND singleton_on IS NULL`
204
252
  }
205
253
 
206
- function clearStorage (schema) {
207
- return `TRUNCATE ${schema}.job, ${schema}.archive`
254
+ function createIndexJobFetch (schema) {
255
+ return `CREATE INDEX job_fetch ON ${schema}.job (name, start_after) INCLUDE (priority, created_on, id) WHERE state < '${JOB_STATES.active}'`
208
256
  }
209
257
 
210
- function getQueueSize (schema, options = {}) {
211
- options.before = options.before || states.active
212
- assert(options.before in states, `${options.before} is not a valid state`)
213
- return `SELECT count(*) as count FROM ${schema}.job WHERE name = $1 AND state < '${options.before}'`
258
+ function createTableArchive (schema) {
259
+ return `CREATE TABLE ${schema}.archive (LIKE ${schema}.job)`
214
260
  }
215
261
 
216
- function createIndexSingletonKey (schema) {
217
- // anything with singletonKey means "only 1 job can be queued or active at a time"
218
- return `
219
- CREATE UNIQUE INDEX job_singletonKey ON ${schema}.job (name, singletonKey) WHERE state < '${states.completed}' AND singletonOn IS NULL AND NOT singletonKey LIKE '${SINGLETON_QUEUE_KEY_ESCAPED}%'
220
- `
262
+ function createColumnArchiveArchivedOn (schema) {
263
+ return `ALTER TABLE ${schema}.archive ADD archived_on timestamptz NOT NULL DEFAULT now()`
264
+ }
265
+
266
+ function createIndexArchiveArchivedOn (schema) {
267
+ return `CREATE INDEX archive_archived_on_idx ON ${schema}.archive(archived_on)`
268
+ }
269
+
270
+ function trySetMaintenanceTime (schema) {
271
+ return trySetTimestamp(schema, 'maintained_on')
272
+ }
273
+
274
+ function trySetMonitorTime (schema) {
275
+ return trySetTimestamp(schema, 'monitored_on')
276
+ }
277
+
278
+ function trySetCronTime (schema) {
279
+ return trySetTimestamp(schema, 'cron_on')
221
280
  }
222
281
 
223
- function createIndexSingletonQueue (schema) {
224
- // "singleton queue" means "only 1 job can be queued at a time"
282
+ function trySetTimestamp (schema, column) {
225
283
  return `
226
- CREATE UNIQUE INDEX job_singleton_queue ON ${schema}.job (name, singletonKey) WHERE state < '${states.active}' AND singletonOn IS NULL AND singletonKey LIKE '${SINGLETON_QUEUE_KEY_ESCAPED}%'
284
+ UPDATE ${schema}.version SET ${column} = now()
285
+ WHERE EXTRACT( EPOCH FROM (now() - COALESCE(${column}, now() - interval '1 week') ) ) > $1
286
+ RETURNING true
227
287
  `
228
288
  }
229
289
 
230
- function createIndexSingletonOn (schema) {
231
- // anything with singletonOn means "only 1 job within this time period, queued, active or completed"
290
+ function createQueue (schema) {
232
291
  return `
233
- CREATE UNIQUE INDEX job_singletonOn ON ${schema}.job (name, singletonOn) WHERE state < '${states.expired}' AND singletonKey IS NULL
292
+ INSERT INTO ${schema}.queue (name, policy, retry_limit, retry_delay, retry_backoff, expire_seconds, retention_minutes, dead_letter)
293
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
234
294
  `
235
295
  }
236
296
 
237
- function createIndexSingletonKeyOn (schema) {
238
- // anything with both singletonOn and singletonKey means "only 1 job within this time period with this key, queued, active or completed"
297
+ function updateQueue (schema) {
239
298
  return `
240
- CREATE UNIQUE INDEX job_singletonKeyOn ON ${schema}.job (name, singletonOn, singletonKey) WHERE state < '${states.expired}'
299
+ UPDATE ${schema}.queue SET
300
+ retry_limit = COALESCE($2, retry_limit),
301
+ retry_delay = COALESCE($3, retry_delay),
302
+ retry_backoff = COALESCE($4, retry_backoff),
303
+ expire_seconds = COALESCE($5, expire_seconds),
304
+ retention_minutes = COALESCE($6, retention_minutes),
305
+ dead_letter = COALESCE($7, dead_letter)
306
+ WHERE name = $1
241
307
  `
242
308
  }
243
309
 
244
- function createIndexJobName (schema) {
245
- return `
246
- CREATE INDEX job_name ON ${schema}.job (name text_pattern_ops)
310
+ function getQueueByName (schema) {
311
+ return `SELECT * FROM ${schema}.queue WHERE name = $1`
312
+ }
313
+
314
+ function deleteQueueRecords (schema) {
315
+ return `WITH dq AS (
316
+ DELETE FROM ${schema}.queue WHERE name = $1
317
+ ), ds AS (
318
+ DELETE FROM ${schema}.schedule WHERE name = $1
319
+ )
320
+ DELETE FROM ${schema}.job WHERE name = $1
247
321
  `
248
322
  }
249
323
 
250
- function createIndexJobFetch (schema) {
324
+ function purgeQueue (schema) {
325
+ return `DELETE from ${schema}.job WHERE name = $1 and state < '${JOB_STATES.active}'`
326
+ }
327
+
328
+ function clearStorage (schema) {
329
+ return `TRUNCATE ${schema}.job, ${schema}.archive`
330
+ }
331
+
332
+ function getQueueSize (schema, options = {}) {
333
+ options.before = options.before || JOB_STATES.active
334
+ assert(options.before in JOB_STATES, `${options.before} is not a valid state`)
335
+ return `SELECT count(*) as count FROM ${schema}.job WHERE name = $1 AND state < '${options.before}'`
336
+ }
337
+
338
+ function createTableQueue (schema) {
251
339
  return `
252
- CREATE INDEX job_fetch ON ${schema}.job (name text_pattern_ops, startAfter) WHERE state < '${states.active}'
340
+ CREATE TABLE ${schema}.queue (
341
+ name text primary key,
342
+ policy text,
343
+ retry_limit int,
344
+ retry_delay int,
345
+ retry_backoff bool,
346
+ expire_seconds int,
347
+ retention_minutes int,
348
+ dead_letter text,
349
+ created_on timestamp with time zone not null default now()
350
+ )
253
351
  `
254
352
  }
255
353
 
256
- function createScheduleTable (schema) {
354
+ function createTableSchedule (schema) {
257
355
  return `
258
356
  CREATE TABLE ${schema}.schedule (
259
357
  name text primary key,
@@ -267,7 +365,7 @@ function createScheduleTable (schema) {
267
365
  `
268
366
  }
269
367
 
270
- function createSubscriptionTable (schema) {
368
+ function createTableSubscription (schema) {
271
369
  return `
272
370
  CREATE TABLE ${schema}.subscription (
273
371
  event text not null,
@@ -281,7 +379,9 @@ function createSubscriptionTable (schema) {
281
379
 
282
380
  function getSchedules (schema) {
283
381
  return `
284
- SELECT * FROM ${schema}.schedule
382
+ SELECT s.*
383
+ FROM ${schema}.schedule s
384
+ JOIN ${schema}.queue q on s.name = q.name
285
385
  `
286
386
  }
287
387
 
@@ -351,167 +451,105 @@ function insertVersion (schema, version) {
351
451
  }
352
452
 
353
453
  function fetchNextJob (schema) {
354
- return (includeMetadata, enforceSingletonQueueActiveLimit) => `
355
- WITH nextJob as (
454
+ return ({ includeMetadata, priority = true } = {}) => `
455
+ WITH next as (
356
456
  SELECT id
357
- FROM ${schema}.job j
358
- WHERE state < '${states.active}'
359
- AND name LIKE $1
360
- AND startAfter < now()
361
- ${enforceSingletonQueueActiveLimit
362
- ? `AND (
363
- CASE
364
- WHEN singletonKey IS NOT NULL
365
- AND singletonKey LIKE '${SINGLETON_QUEUE_KEY_ESCAPED}%'
366
- THEN NOT EXISTS (
367
- SELECT 1
368
- FROM ${schema}.job active_job
369
- WHERE active_job.state = '${states.active}'
370
- AND active_job.name = j.name
371
- AND active_job.singletonKey = j.singletonKey
372
- LIMIT 1
373
- )
374
- ELSE
375
- true
376
- END
377
- )`
378
- : ''}
379
- ORDER BY priority desc, createdOn, id
457
+ FROM ${schema}.job
458
+ WHERE name = $1
459
+ AND state < '${JOB_STATES.active}'
460
+ AND start_after < now()
461
+ ORDER BY ${priority && 'priority desc, '} created_on, id
380
462
  LIMIT $2
381
463
  FOR UPDATE SKIP LOCKED
382
464
  )
383
465
  UPDATE ${schema}.job j SET
384
- state = '${states.active}',
385
- startedOn = now(),
386
- retryCount = CASE WHEN state = '${states.retry}' THEN retryCount + 1 ELSE retryCount END
387
- FROM nextJob
388
- WHERE j.id = nextJob.id
389
- RETURNING ${includeMetadata ? 'j.*' : 'j.id, name, data'}, EXTRACT(epoch FROM expireIn) as expire_in_seconds
466
+ state = '${JOB_STATES.active}',
467
+ started_on = now(),
468
+ retry_count = CASE WHEN started_on IS NOT NULL THEN retry_count + 1 ELSE retry_count END
469
+ FROM next
470
+ WHERE name = $1 AND j.id = next.id
471
+ RETURNING j.${includeMetadata ? allJobColumns : baseJobColumns}
390
472
  `
391
473
  }
392
474
 
393
- function buildJsonCompletionObject (withResponse) {
394
- // job completion contract
395
- return `jsonb_build_object(
396
- 'request', jsonb_build_object('id', id, 'name', name, 'data', data),
397
- 'response', ${withResponse ? '$2::jsonb' : 'null'},
398
- 'state', state,
399
- 'retryCount', retryCount,
400
- 'createdOn', createdOn,
401
- 'startedOn', startedOn,
402
- 'completedOn', completedOn,
403
- 'failed', CASE WHEN state = '${states.completed}' THEN false ELSE true END
404
- )`
405
- }
406
-
407
- const retryCompletedOnCase = `CASE
408
- WHEN retryCount < retryLimit
409
- THEN NULL
410
- ELSE now()
411
- END`
412
-
413
- const retryStartAfterCase = `CASE
414
- WHEN retryCount = retryLimit THEN startAfter
415
- WHEN NOT retryBackoff THEN now() + retryDelay * interval '1'
416
- ELSE now() +
417
- (
418
- retryDelay * 2 ^ LEAST(16, retryCount + 1) / 2
419
- +
420
- retryDelay * 2 ^ LEAST(16, retryCount + 1) / 2 * random()
421
- )
422
- * interval '1'
423
- END`
424
-
425
- const keepUntilInheritance = 'keepUntil + (keepUntil - startAfter)'
426
-
427
475
  function completeJobs (schema) {
428
476
  return `
429
477
  WITH results AS (
430
478
  UPDATE ${schema}.job
431
- SET completedOn = now(),
432
- state = '${states.completed}',
433
- output = $2::jsonb
434
- WHERE id IN (SELECT UNNEST($1::uuid[]))
435
- AND state = '${states.active}'
479
+ SET completed_on = now(),
480
+ state = '${JOB_STATES.completed}',
481
+ output = $3::jsonb
482
+ WHERE name = $1
483
+ AND id IN (SELECT UNNEST($2::uuid[]))
484
+ AND state = '${JOB_STATES.active}'
436
485
  RETURNING *
437
- ), completion_jobs as (
438
- INSERT INTO ${schema}.job (name, data, keepUntil)
439
- SELECT
440
- '${COMPLETION_JOB_PREFIX}' || name,
441
- ${buildJsonCompletionObject(true)},
442
- ${keepUntilInheritance}
443
- FROM results
444
- WHERE NOT name LIKE '${COMPLETION_JOB_PREFIX}%'
445
- AND on_complete
446
486
  )
447
487
  SELECT COUNT(*) FROM results
448
488
  `
449
489
  }
450
490
 
451
- function failJobs (schema) {
491
+ function failJobsById (schema) {
492
+ const where = `name = $1 AND id IN (SELECT UNNEST($2::uuid[])) AND state < '${JOB_STATES.completed}'`
493
+ const output = '$3::jsonb'
494
+
495
+ return failJobs(schema, where, output)
496
+ }
497
+
498
+ function failJobsByTimeout (schema) {
499
+ const where = `state = '${JOB_STATES.active}' AND (started_on + expire_in) < now()`
500
+ const output = '\'{ "value": { "message": "job failed by timeout in active state" } }\'::jsonb'
501
+ return failJobs(schema, where, output)
502
+ }
503
+
504
+ function failJobs (schema, where, output) {
452
505
  return `
453
506
  WITH results AS (
454
- UPDATE ${schema}.job
455
- SET state = CASE
456
- WHEN retryCount < retryLimit
457
- THEN '${states.retry}'::${schema}.job_state
458
- ELSE '${states.failed}'::${schema}.job_state
507
+ UPDATE ${schema}.job SET
508
+ state = CASE
509
+ WHEN retry_count < retry_limit THEN '${JOB_STATES.retry}'::${schema}.job_state
510
+ ELSE '${JOB_STATES.failed}'::${schema}.job_state
511
+ END,
512
+ completed_on = CASE
513
+ WHEN retry_count < retry_limit THEN NULL
514
+ ELSE now()
459
515
  END,
460
- completedOn = ${retryCompletedOnCase},
461
- startAfter = ${retryStartAfterCase},
462
- output = $2::jsonb
463
- WHERE id IN (SELECT UNNEST($1::uuid[]))
464
- AND state < '${states.completed}'
516
+ start_after = CASE
517
+ WHEN retry_count = retry_limit THEN start_after
518
+ WHEN NOT retry_backoff THEN now() + retry_delay * interval '1'
519
+ ELSE now() + (
520
+ retry_delay * 2 ^ LEAST(16, retry_count + 1) / 2 +
521
+ retry_delay * 2 ^ LEAST(16, retry_count + 1) / 2 * random()
522
+ ) * interval '1'
523
+ END,
524
+ output = ${output}
525
+ WHERE ${where}
465
526
  RETURNING *
466
- ), completion_jobs as (
467
- INSERT INTO ${schema}.job (name, data, keepUntil)
527
+ ), dlq_jobs as (
528
+ INSERT INTO ${schema}.job (name, data, output, retry_limit, keep_until)
468
529
  SELECT
469
- '${COMPLETION_JOB_PREFIX}' || name,
470
- ${buildJsonCompletionObject(true)},
471
- ${keepUntilInheritance}
530
+ dead_letter,
531
+ data,
532
+ output,
533
+ retry_limit,
534
+ keep_until + (keep_until - start_after)
472
535
  FROM results
473
- WHERE state = '${states.failed}'
474
- AND NOT name LIKE '${COMPLETION_JOB_PREFIX}%'
475
- AND on_complete
536
+ WHERE state = '${JOB_STATES.failed}'
537
+ AND dead_letter IS NOT NULL
538
+ AND NOT name = dead_letter
476
539
  )
477
540
  SELECT COUNT(*) FROM results
478
541
  `
479
542
  }
480
543
 
481
- function expire (schema) {
482
- return `
483
- WITH results AS (
484
- UPDATE ${schema}.job
485
- SET state = CASE
486
- WHEN retryCount < retryLimit THEN '${states.retry}'::${schema}.job_state
487
- ELSE '${states.expired}'::${schema}.job_state
488
- END,
489
- completedOn = ${retryCompletedOnCase},
490
- startAfter = ${retryStartAfterCase}
491
- WHERE state = '${states.active}'
492
- AND (startedOn + expireIn) < now()
493
- RETURNING *
494
- )
495
- INSERT INTO ${schema}.job (name, data, keepUntil)
496
- SELECT
497
- '${COMPLETION_JOB_PREFIX}' || name,
498
- ${buildJsonCompletionObject()},
499
- ${keepUntilInheritance}
500
- FROM results
501
- WHERE state = '${states.expired}'
502
- AND NOT name LIKE '${COMPLETION_JOB_PREFIX}%'
503
- AND on_complete
504
- `
505
- }
506
-
507
544
  function cancelJobs (schema) {
508
545
  return `
509
546
  with results as (
510
547
  UPDATE ${schema}.job
511
- SET completedOn = now(),
512
- state = '${states.cancelled}'
513
- WHERE id IN (SELECT UNNEST($1::uuid[]))
514
- AND state < '${states.completed}'
548
+ SET completed_on = now(),
549
+ state = '${JOB_STATES.cancelled}'
550
+ WHERE name = $1
551
+ AND id IN (SELECT UNNEST($2::uuid[]))
552
+ AND state < '${JOB_STATES.completed}'
515
553
  RETURNING 1
516
554
  )
517
555
  SELECT COUNT(*) from results
@@ -522,9 +560,10 @@ function resumeJobs (schema) {
522
560
  return `
523
561
  with results as (
524
562
  UPDATE ${schema}.job
525
- SET completedOn = NULL,
526
- state = '${states.created}'
527
- WHERE id IN (SELECT UNNEST($1::uuid[]))
563
+ SET completed_on = NULL,
564
+ state = '${JOB_STATES.created}'
565
+ WHERE name = $1
566
+ AND id IN (SELECT UNNEST($2::uuid[]))
528
567
  RETURNING 1
529
568
  )
530
569
  SELECT COUNT(*) from results
@@ -536,68 +575,73 @@ function insertJob (schema) {
536
575
  INSERT INTO ${schema}.job (
537
576
  id,
538
577
  name,
539
- priority,
540
- state,
541
- retryLimit,
542
- startAfter,
543
- expireIn,
544
578
  data,
545
- singletonKey,
546
- singletonOn,
547
- retryDelay,
548
- retryBackoff,
549
- keepUntil,
550
- on_complete
579
+ priority,
580
+ start_after,
581
+ singleton_key,
582
+ singleton_on,
583
+ dead_letter,
584
+ expire_in,
585
+ keep_until,
586
+ retry_limit,
587
+ retry_delay,
588
+ retry_backoff,
589
+ policy
551
590
  )
552
591
  SELECT
553
592
  id,
554
- name,
555
- priority,
556
- state,
557
- retryLimit,
558
- startAfter,
559
- expireIn,
593
+ j.name,
560
594
  data,
561
- singletonKey,
562
- singletonOn,
563
- retryDelay,
564
- retryBackoff,
565
- keepUntil,
566
- on_complete
595
+ priority,
596
+ start_after,
597
+ singleton_key,
598
+ singleton_on,
599
+ COALESCE(j.dead_letter, q.dead_letter) as dead_letter,
600
+ CASE
601
+ WHEN expire_in IS NOT NULL THEN CAST(expire_in as interval)
602
+ WHEN q.expire_seconds IS NOT NULL THEN q.expire_seconds * interval '1s'
603
+ WHEN expire_in_default IS NOT NULL THEN CAST(expire_in_default as interval)
604
+ ELSE interval '15 minutes'
605
+ END as expire_in,
606
+ CASE
607
+ WHEN right(keep_until, 1) = 'Z' THEN CAST(keep_until as timestamp with time zone)
608
+ ELSE start_after + CAST(COALESCE(keep_until, (q.retention_minutes * 60)::text, keep_until_default, '14 days') as interval)
609
+ END as keep_until,
610
+ COALESCE(j.retry_limit, q.retry_limit, retry_limit_default, 2) as retry_limit,
611
+ CASE
612
+ WHEN COALESCE(j.retry_backoff, q.retry_backoff, retry_backoff_default, false)
613
+ THEN GREATEST(COALESCE(j.retry_delay, q.retry_delay, retry_delay_default), 1)
614
+ ELSE COALESCE(j.retry_delay, q.retry_delay, retry_delay_default, 0)
615
+ END as retry_delay,
616
+ COALESCE(j.retry_backoff, q.retry_backoff, retry_backoff_default, false) as retry_backoff,
617
+ q.policy
567
618
  FROM
568
- ( SELECT *,
569
- CASE
570
- WHEN right(keepUntilValue, 1) = 'Z' THEN CAST(keepUntilValue as timestamp with time zone)
571
- ELSE startAfter + CAST(COALESCE(keepUntilValue,'0') as interval)
572
- END as keepUntil
573
- FROM
574
- ( SELECT *,
619
+ ( SELECT
620
+ COALESCE($1::uuid, gen_random_uuid()) as id,
621
+ $2 as name,
622
+ $3::jsonb as data,
623
+ COALESCE($4::int, 0) as priority,
624
+ CASE
625
+ WHEN right($5, 1) = 'Z' THEN CAST($5 as timestamp with time zone)
626
+ ELSE now() + CAST(COALESCE($5,'0') as interval)
627
+ END as start_after,
628
+ $6 as singleton_key,
575
629
  CASE
576
- WHEN right(startAfterValue, 1) = 'Z' THEN CAST(startAfterValue as timestamp with time zone)
577
- ELSE now() + CAST(COALESCE(startAfterValue,'0') as interval)
578
- END as startAfter
579
- FROM
580
- ( SELECT
581
- $1::uuid as id,
582
- $2::text as name,
583
- $3::int as priority,
584
- '${states.created}'::${schema}.job_state as state,
585
- $4::int as retryLimit,
586
- $5::text as startAfterValue,
587
- CAST($6 as interval) as expireIn,
588
- $7::jsonb as data,
589
- $8::text as singletonKey,
590
- CASE
591
- WHEN $9::integer IS NOT NULL THEN 'epoch'::timestamp + '1 second'::interval * ($9 * floor((date_part('epoch', now()) + $10) / $9))
592
- ELSE NULL
593
- END as singletonOn,
594
- $11::int as retryDelay,
595
- $12::bool as retryBackoff,
596
- $13::text as keepUntilValue,
597
- $14::boolean as on_complete
598
- ) j1
599
- ) j2
600
- ) j3
630
+ WHEN $7::integer IS NOT NULL THEN 'epoch'::timestamp + '1 second'::interval * ($7 * floor((date_part('epoch', now()) + $8) / $7))
631
+ ELSE NULL
632
+ END as singleton_on,
633
+ $9 as dead_letter,
634
+ $10 as expire_in,
635
+ $11 as expire_in_default,
636
+ $12 as keep_until,
637
+ $13 as keep_until_default,
638
+ $14::int as retry_limit,
639
+ $15::int as retry_limit_default,
640
+ $16::int as retry_delay,
641
+ $17::int as retry_delay_default,
642
+ $18::bool as retry_backoff,
643
+ $19::bool as retry_backoff_default
644
+ ) j LEFT JOIN ${schema}.queue q ON j.name = q.name
601
645
  ON CONFLICT DO NOTHING
602
646
  RETURNING id
603
647
  `
@@ -605,78 +649,95 @@ function insertJob (schema) {
605
649
 
606
650
  function insertJobs (schema) {
607
651
  return `
652
+ WITH defaults as (
653
+ SELECT
654
+ $2 as expire_in,
655
+ $3 as keep_until,
656
+ $4::int as retry_limit,
657
+ $5::int as retry_delay,
658
+ $6::bool as retry_backoff
659
+ )
608
660
  INSERT INTO ${schema}.job (
609
661
  id,
610
662
  name,
611
663
  data,
612
664
  priority,
613
- startAfter,
614
- expireIn,
615
- retryLimit,
616
- retryDelay,
617
- retryBackoff,
618
- singletonKey,
619
- keepUntil,
620
- on_complete
665
+ start_after,
666
+ singleton_key,
667
+ dead_letter,
668
+ expire_in,
669
+ keep_until,
670
+ retry_limit,
671
+ retry_delay,
672
+ retry_backoff,
673
+ policy
621
674
  )
622
675
  SELECT
623
676
  COALESCE(id, gen_random_uuid()) as id,
624
- name,
677
+ j.name,
625
678
  data,
626
- COALESCE(priority, 0) as priority,
627
- COALESCE("startAfter", now()) as startAfter,
628
- COALESCE("expireInSeconds", 15 * 60) * interval '1s' as expireIn,
629
- COALESCE("retryLimit", 0) as retryLimit,
630
- COALESCE("retryDelay", 0) as retryDelay,
631
- COALESCE("retryBackoff", false) as retryBackoff,
679
+ COALESCE(priority, 0),
680
+ COALESCE("startAfter", now()),
632
681
  "singletonKey",
633
- COALESCE("keepUntil", now() + interval '14 days') as keepUntil,
634
- COALESCE("onComplete", false) as onComplete
635
- FROM json_to_recordset($1) as x(
682
+ COALESCE("deadLetter", q.dead_letter),
683
+ CASE
684
+ WHEN "expireInSeconds" IS NOT NULL THEN "expireInSeconds" * interval '1s'
685
+ WHEN q.expire_seconds IS NOT NULL THEN q.expire_seconds * interval '1s'
686
+ WHEN defaults.expire_in IS NOT NULL THEN CAST(defaults.expire_in as interval)
687
+ ELSE interval '15 minutes'
688
+ END as expire_in,
689
+ CASE
690
+ WHEN "keepUntil" IS NOT NULL THEN "keepUntil"
691
+ ELSE COALESCE("startAfter", now()) + CAST(COALESCE((q.retention_minutes * 60)::text, defaults.keep_until, '14 days') as interval)
692
+ END as keep_until,
693
+ COALESCE("retryLimit", q.retry_limit, defaults.retry_limit, 2),
694
+ CASE
695
+ WHEN COALESCE("retryBackoff", q.retry_backoff, defaults.retry_backoff, false)
696
+ THEN GREATEST(COALESCE("retryDelay", q.retry_delay, defaults.retry_delay), 1)
697
+ ELSE COALESCE("retryDelay", q.retry_delay, defaults.retry_delay, 0)
698
+ END as retry_delay,
699
+ COALESCE("retryBackoff", q.retry_backoff, defaults.retry_backoff, false) as retry_backoff,
700
+ q.policy
701
+ FROM json_to_recordset($1) as j (
636
702
  id uuid,
637
703
  name text,
638
704
  priority integer,
639
705
  data jsonb,
706
+ "startAfter" timestamp with time zone,
640
707
  "retryLimit" integer,
641
708
  "retryDelay" integer,
642
709
  "retryBackoff" boolean,
643
- "startAfter" timestamp with time zone,
644
710
  "singletonKey" text,
645
711
  "expireInSeconds" integer,
646
712
  "keepUntil" timestamp with time zone,
647
- "onComplete" boolean
713
+ "deadLetter" text
648
714
  )
715
+ LEFT JOIN ${schema}.queue q ON j.name = q.name,
716
+ defaults
649
717
  ON CONFLICT DO NOTHING
650
718
  `
651
719
  }
652
720
 
653
- function purge (schema, interval) {
721
+ function drop (schema, interval) {
654
722
  return `
655
723
  DELETE FROM ${schema}.archive
656
- WHERE archivedOn < (now() - interval '${interval}')
724
+ WHERE archived_on < (now() - interval '${interval}')
657
725
  `
658
726
  }
659
727
 
660
728
  function archive (schema, completedInterval, failedInterval = completedInterval) {
729
+ const columns = 'id, name, priority, data, state, retry_limit, retry_count, retry_delay, retry_backoff, start_after, started_on, singleton_key, singleton_on, expire_in, created_on, completed_on, keep_until, dead_letter, policy, output'
730
+
661
731
  return `
662
732
  WITH archived_rows AS (
663
733
  DELETE FROM ${schema}.job
664
- WHERE (
665
- state <> '${states.failed}' AND completedOn < (now() - interval '${completedInterval}')
666
- )
667
- OR (
668
- state = '${states.failed}' AND completedOn < (now() - interval '${failedInterval}')
669
- )
670
- OR (
671
- state < '${states.active}' AND keepUntil < now()
672
- )
734
+ WHERE (state <> '${JOB_STATES.failed}' AND completed_on < (now() - interval '${completedInterval}'))
735
+ OR (state = '${JOB_STATES.failed}' AND completed_on < (now() - interval '${failedInterval}'))
736
+ OR (state < '${JOB_STATES.active}' AND keep_until < now())
673
737
  RETURNING *
674
738
  )
675
- INSERT INTO ${schema}.archive (
676
- id, name, priority, data, state, retryLimit, retryCount, retryDelay, retryBackoff, startAfter, startedOn, singletonKey, singletonOn, expireIn, createdOn, completedOn, keepUntil, on_complete, output
677
- )
678
- SELECT
679
- id, name, priority, data, state, retryLimit, retryCount, retryDelay, retryBackoff, startAfter, startedOn, singletonKey, singletonOn, expireIn, createdOn, completedOn, keepUntil, on_complete, output
739
+ INSERT INTO ${schema}.archive (${columns})
740
+ SELECT ${columns}
680
741
  FROM archived_rows
681
742
  `
682
743
  }
@@ -689,9 +750,24 @@ function countStates (schema) {
689
750
  `
690
751
  }
691
752
 
692
- function advisoryLock (schema) {
693
- return `SELECT pg_advisory_xact_lock(
694
- ('x' || md5(current_database() || '.pgboss.${schema}'))::bit(64)::bigint
753
+ function locked (schema, query) {
754
+ if (Array.isArray(query)) {
755
+ query = query.join(';\n')
756
+ }
757
+
758
+ return `
759
+ BEGIN;
760
+ SET LOCAL lock_timeout = '30s';
761
+ SET LOCAL idle_in_transaction_session_timeout = '30s';
762
+ ${advisoryLock(schema)};
763
+ ${query};
764
+ COMMIT;
765
+ `
766
+ }
767
+
768
+ function advisoryLock (schema, key) {
769
+ return `SELECT pg_advisory_xact_lock(
770
+ ('x' || encode(sha224((current_database() || '.pgboss.${schema}${key || ''}')::bytea), 'hex'))::bit(64)::bigint
695
771
  )`
696
772
  }
697
773
 
@@ -701,13 +777,13 @@ function assertMigration (schema, version) {
701
777
  }
702
778
 
703
779
  function getJobById (schema) {
704
- return getJobByTableAndId(schema, 'job')
780
+ return getJobByTableQueueId(schema, 'job')
705
781
  }
706
782
 
707
783
  function getArchivedJobById (schema) {
708
- return getJobByTableAndId(schema, 'archive')
784
+ return getJobByTableQueueId(schema, 'archive')
709
785
  }
710
786
 
711
- function getJobByTableAndId (schema, table) {
712
- return `SELECT * From ${schema}.${table} WHERE id = $1`
787
+ function getJobByTableQueueId (schema, table) {
788
+ return `SELECT ${allJobColumns} FROM ${schema}.${table} WHERE name = $1 AND id = $2`
713
789
  }