pg-boss 10.3.3 → 11.0.1

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/README.md CHANGED
@@ -35,7 +35,7 @@ readme()
35
35
 
36
36
  pg-boss is a job queue built in Node.js on top of PostgreSQL in order to provide background processing and reliable asynchronous execution to Node.js applications.
37
37
 
38
- pg-boss relies on [SKIP LOCKED](https://www.2ndquadrant.com/en/blog/what-is-select-skip-locked-for-in-postgresql-9-5/), a feature built specifically for message queues to resolve record locking challenges inherent with relational databases. This provides exactly-once delivery and the safety of guaranteed atomic commits to asynchronous job processing.
38
+ pg-boss relies on Postgres's SKIP LOCKED, a feature built specifically for message queues to resolve record locking challenges inherent with relational databases. This provides exactly-once delivery and the safety of guaranteed atomic commits to asynchronous job processing.
39
39
 
40
40
  This will likely cater the most to teams already familiar with the simplicity of relational database semantics and operations (SQL, querying, and backups). It will be especially useful to those already relying on PostgreSQL that want to limit how many systems are required to monitor and support in their architecture.
41
41
 
@@ -48,12 +48,12 @@ This will likely cater the most to teams already familiar with the simplicity of
48
48
  * Queue storage policies to support a variety of rate limiting, debouncing, and concurrency use cases
49
49
  * Priority queues, dead letter queues, job deferral, automatic retries with exponential backoff
50
50
  * Pub/sub API for fan-out queue relationships
51
- * Raw SQL support for non-Node.js runtimes via INSERT or COPY
51
+ * SQL support for non-Node.js runtimes for most operations
52
52
  * Serverless function compatible
53
53
  * Multi-master compatible (for example, in a Kubernetes ReplicaSet)
54
54
 
55
55
  ## Requirements
56
- * Node 20 or higher
56
+ * Node 22 or higher
57
57
  * PostgreSQL 13 or higher
58
58
 
59
59
  ## Installation
@@ -1,6 +1,6 @@
1
1
  services:
2
2
  db:
3
- image: postgres:16
3
+ image: postgres:17
4
4
  ports:
5
5
  - 5432:5432
6
6
  volumes:
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "pg-boss",
3
- "version": "10.3.3",
3
+ "version": "11.0.1",
4
4
  "description": "Queueing jobs in Postgres from Node.js like a boss",
5
5
  "main": "./src/index.js",
6
6
  "engines": {
7
- "node": ">=20"
7
+ "node": ">=22"
8
8
  },
9
9
  "dependencies": {
10
10
  "cron-parser": "^4.9.0",
@@ -12,7 +12,7 @@
12
12
  "serialize-error": "^8.1.0"
13
13
  },
14
14
  "devDependencies": {
15
- "@types/node": "^20.19.13",
15
+ "@types/node": "^22",
16
16
  "luxon": "^3.7.2",
17
17
  "mocha": "^10.8.2",
18
18
  "nyc": "^17.1.0",
package/src/attorney.js CHANGED
@@ -3,47 +3,33 @@ const { DEFAULT_SCHEMA } = require('./plans')
3
3
 
4
4
  const POLICY = {
5
5
  MAX_EXPIRATION_HOURS: 24,
6
- MIN_POLLING_INTERVAL_MS: 500
6
+ MIN_POLLING_INTERVAL_MS: 500,
7
+ MAX_RETENTION_DAYS: 365
7
8
  }
8
9
 
9
10
  module.exports = {
10
11
  POLICY,
11
12
  getConfig,
12
13
  checkSendArgs,
13
- checkQueueArgs,
14
+ validateQueueArgs,
14
15
  checkWorkArgs,
15
16
  checkFetchArgs,
16
- warnClockSkew,
17
17
  assertPostgresObjectName,
18
- assertQueueName
18
+ assertQueueName,
19
+ assertKey
19
20
  }
20
21
 
21
- const WARNINGS = {
22
- CLOCK_SKEW: {
23
- message: 'Timekeeper detected clock skew between this instance and the database server. This will not affect scheduling operations, but this warning is shown any time the skew exceeds 60 seconds.',
24
- code: 'pg-boss-w02'
25
- },
26
- CRON_DISABLED: {
27
- message: 'Archive interval is set less than 60s. Cron processing is disabled.',
28
- code: 'pg-boss-w03'
29
- },
30
- ON_COMPLETE_REMOVED: {
31
- message: '\'onComplete\' option detected. This option has been removed. Consider deadLetter if needed.',
32
- code: 'pg-boss-w04'
33
- }
34
- }
35
-
36
- function checkQueueArgs (name, options = {}) {
37
- assert(!('deadLetter' in options) || (typeof options.deadLetter === 'string'), 'deadLetter must be a string')
38
-
39
- applyRetryConfig(options)
40
- applyExpirationConfig(options)
41
- applyRetentionConfig(options)
22
+ function validateQueueArgs (config = {}) {
23
+ assert(!('deadLetter' in config) || config.deadLetter === null || (typeof config.deadLetter === 'string'), 'deadLetter must be a string')
24
+ assert(!('deadLetter' in config) || config.deadLetter === null || /[\w-]/.test(config.deadLetter), 'deadLetter can only contain alphanumeric characters, underscores, or hyphens')
42
25
 
43
- return options
26
+ validateRetryConfig(config)
27
+ validateExpirationConfig(config)
28
+ validateRetentionConfig(config)
29
+ validateDeletionConfig(config)
44
30
  }
45
31
 
46
- function checkSendArgs (args, defaults) {
32
+ function checkSendArgs (args) {
47
33
  let name, data, options
48
34
 
49
35
  if (typeof args[0] === 'string') {
@@ -75,40 +61,23 @@ function checkSendArgs (args, defaults) {
75
61
  assert(!('priority' in options) || (Number.isInteger(options.priority)), 'priority must be an integer')
76
62
  options.priority = options.priority || 0
77
63
 
78
- assert(!('deadLetter' in options) || (typeof options.deadLetter === 'string'), 'deadLetter must be a string')
79
-
80
- applyRetryConfig(options, defaults)
81
- applyExpirationConfig(options, defaults)
82
- applyRetentionConfig(options, defaults)
83
-
84
- const { startAfter, singletonSeconds, singletonMinutes, singletonHours } = options
85
-
86
- options.startAfter = (startAfter instanceof Date && typeof startAfter.toISOString === 'function')
87
- ? startAfter.toISOString()
88
- : (startAfter > 0)
89
- ? '' + startAfter
90
- : (typeof startAfter === 'string')
91
- ? startAfter
64
+ options.startAfter = (options.startAfter instanceof Date && typeof options.startAfter.toISOString === 'function')
65
+ ? options.startAfter.toISOString()
66
+ : (options.startAfter > 0)
67
+ ? '' + options.startAfter
68
+ : (typeof options.startAfter === 'string')
69
+ ? options.startAfter
92
70
  : null
93
71
 
94
- options.singletonSeconds = (singletonHours > 0)
95
- ? singletonHours * 60 * 60
96
- : (singletonMinutes > 0)
97
- ? singletonMinutes * 60
98
- : (singletonSeconds > 0)
99
- ? singletonSeconds
100
- : null
101
-
102
- assert(!singletonSeconds || singletonSeconds <= defaults.archiveSeconds, `throttling interval ${singletonSeconds}s cannot exceed archive interval ${defaults.archiveSeconds}s`)
103
-
104
- if (options.onComplete) {
105
- emitWarning(WARNINGS.ON_COMPLETE_REMOVED)
106
- }
72
+ validateRetryConfig(options)
73
+ validateExpirationConfig(options)
74
+ validateRetentionConfig(options)
75
+ validateDeletionConfig(options)
107
76
 
108
77
  return { name, data, options }
109
78
  }
110
79
 
111
- function checkWorkArgs (name, args, defaults) {
80
+ function checkWorkArgs (name, args) {
112
81
  let options, callback
113
82
 
114
83
  assert(name, 'missing job name')
@@ -126,7 +95,7 @@ function checkWorkArgs (name, args, defaults) {
126
95
 
127
96
  options = { ...options }
128
97
 
129
- applyPollingInterval(options, defaults)
98
+ applyPollingInterval(options)
130
99
 
131
100
  assert(!('batchSize' in options) || (Number.isInteger(options.batchSize) && options.batchSize >= 1), 'batchSize must be an integer > 0')
132
101
  assert(!('includeMetadata' in options) || typeof options.includeMetadata === 'boolean', 'includeMetadata must be a boolean')
@@ -162,14 +131,8 @@ function getConfig (value) {
162
131
 
163
132
  applySchemaConfig(config)
164
133
  applyMaintenanceConfig(config)
165
- applyArchiveConfig(config)
166
- applyArchiveFailedConfig(config)
167
- applyDeleteConfig(config)
168
- applyMonitoringConfig(config)
169
-
170
- applyPollingInterval(config)
171
- applyExpirationConfig(config)
172
- applyRetentionConfig(config)
134
+ applyScheduleConfig(config)
135
+ validateWarningConfig(config)
173
136
 
174
137
  return config
175
138
  }
@@ -182,6 +145,14 @@ function applySchemaConfig (config) {
182
145
  config.schema = config.schema || DEFAULT_SCHEMA
183
146
  }
184
147
 
148
+ function validateWarningConfig (config) {
149
+ assert(!('warningQueueSize' in config) || config.warningQueueSize >= 1,
150
+ 'configuration assert: warningQueueSize must be at least 1')
151
+
152
+ assert(!('warningSlowQuerySeconds' in config) || config.warningSlowQuerySeconds >= 1,
153
+ 'configuration assert: warningSlowQuerySeconds must be at least 1')
154
+ }
155
+
185
156
  function assertPostgresObjectName (name) {
186
157
  assert(typeof name === 'string', 'Name must be a string')
187
158
  assert(name.length <= 50, 'Name cannot exceed 50 characters')
@@ -195,207 +166,85 @@ function assertQueueName (name) {
195
166
  assert(/[\w-]/.test(name), 'Name can only contain alphanumeric characters, underscores, or hyphens')
196
167
  }
197
168
 
198
- function applyArchiveConfig (config) {
199
- const ARCHIVE_DEFAULT = 60 * 60 * 12
200
-
201
- assert(!('archiveCompletedAfterSeconds' in config) || config.archiveCompletedAfterSeconds >= 1,
202
- 'configuration assert: archiveCompletedAfterSeconds must be at least every second and less than ')
203
-
204
- config.archiveSeconds = config.archiveCompletedAfterSeconds || ARCHIVE_DEFAULT
205
- config.archiveInterval = `${config.archiveSeconds} seconds`
206
-
207
- if (config.archiveSeconds < 60) {
208
- emitWarning(WARNINGS.CRON_DISABLED)
209
- }
169
+ function assertKey (key) {
170
+ if (!key) return
171
+ assert(typeof key === 'string', 'Key must be a string')
172
+ assert(/[\w-]/.test(key), 'Key can only contain alphanumeric characters, underscores, or hyphens')
210
173
  }
211
174
 
212
- function applyArchiveFailedConfig (config) {
213
- assert(!('archiveFailedAfterSeconds' in config) || config.archiveFailedAfterSeconds >= 1,
214
- 'configuration assert: archiveFailedAfterSeconds must be at least every second and less than ')
215
-
216
- config.archiveFailedSeconds = config.archiveFailedAfterSeconds || config.archiveSeconds
217
- config.archiveFailedInterval = `${config.archiveFailedSeconds} seconds`
218
-
219
- // Do not emit warning twice
220
- if (config.archiveFailedSeconds < 60 && config.archiveSeconds >= 60) {
221
- emitWarning(WARNINGS.CRON_DISABLED)
222
- }
223
- }
224
-
225
- function applyRetentionConfig (config, defaults = {}) {
175
+ function validateRetentionConfig (config) {
226
176
  assert(!('retentionSeconds' in config) || config.retentionSeconds >= 1,
227
177
  'configuration assert: retentionSeconds must be at least every second')
228
-
229
- assert(!('retentionMinutes' in config) || config.retentionMinutes >= 1,
230
- 'configuration assert: retentionMinutes must be at least every minute')
231
-
232
- assert(!('retentionHours' in config) || config.retentionHours >= 1,
233
- 'configuration assert: retentionHours must be at least every hour')
234
-
235
- assert(!('retentionDays' in config) || config.retentionDays >= 1,
236
- 'configuration assert: retentionDays must be at least every day')
237
-
238
- const keepUntil = ('retentionDays' in config)
239
- ? `${config.retentionDays} days`
240
- : ('retentionHours' in config)
241
- ? `${config.retentionHours} hours`
242
- : ('retentionMinutes' in config)
243
- ? `${config.retentionMinutes} minutes`
244
- : ('retentionSeconds' in config)
245
- ? `${config.retentionSeconds} seconds`
246
- : null
247
-
248
- config.keepUntil = keepUntil
249
- config.keepUntilDefault = defaults?.keepUntil
250
178
  }
251
179
 
252
- function applyExpirationConfig (config, defaults = {}) {
180
+ function validateExpirationConfig (config) {
253
181
  assert(!('expireInSeconds' in config) || config.expireInSeconds >= 1,
254
182
  'configuration assert: expireInSeconds must be at least every second')
255
183
 
256
- assert(!('expireInMinutes' in config) || config.expireInMinutes >= 1,
257
- 'configuration assert: expireInMinutes must be at least every minute')
258
-
259
- assert(!('expireInHours' in config) || config.expireInHours >= 1,
260
- 'configuration assert: expireInHours must be at least every hour')
261
-
262
- const expireIn = ('expireInHours' in config)
263
- ? config.expireInHours * 60 * 60
264
- : ('expireInMinutes' in config)
265
- ? config.expireInMinutes * 60
266
- : ('expireInSeconds' in config)
267
- ? config.expireInSeconds
268
- : null
269
-
270
- assert(!expireIn || expireIn / 60 / 60 < POLICY.MAX_EXPIRATION_HOURS, `configuration assert: expiration cannot exceed ${POLICY.MAX_EXPIRATION_HOURS} hours`)
271
-
272
- config.expireIn = expireIn
273
- config.expireInDefault = defaults?.expireIn
184
+ assert(!config.expireInSeconds || config.expireInSeconds / 60 / 60 < POLICY.MAX_EXPIRATION_HOURS, `configuration assert: expiration cannot exceed ${POLICY.MAX_EXPIRATION_HOURS} hours`)
274
185
  }
275
186
 
276
- function applyRetryConfig (config, defaults) {
187
+ function validateRetryConfig (config) {
277
188
  assert(!('retryDelay' in config) || (Number.isInteger(config.retryDelay) && config.retryDelay >= 0), 'retryDelay must be an integer >= 0')
278
189
  assert(!('retryLimit' in config) || (Number.isInteger(config.retryLimit) && config.retryLimit >= 0), 'retryLimit must be an integer >= 0')
279
190
  assert(!('retryBackoff' in config) || (config.retryBackoff === true || config.retryBackoff === false), 'retryBackoff must be either true or false')
280
-
281
- config.retryDelayDefault = defaults?.retryDelay
282
- config.retryLimitDefault = defaults?.retryLimit
283
- config.retryBackoffDefault = defaults?.retryBackoff
191
+ assert(!('retryDelayMax' in config) || config.retryDelayMax === null || config.retryBackoff === true, 'retryDelayMax can only be set if retryBackoff is true')
192
+ assert(!('retryDelayMax' in config) || config.retryDelayMax === null || (Number.isInteger(config.retryDelayMax) && config.retryDelayMax >= 0), 'retryDelayMax must be an integer >= 0')
284
193
  }
285
194
 
286
- function applyPollingInterval (config, defaults) {
195
+ function applyPollingInterval (config) {
287
196
  assert(!('pollingIntervalSeconds' in config) || config.pollingIntervalSeconds >= POLICY.MIN_POLLING_INTERVAL_MS / 1000,
288
197
  `configuration assert: pollingIntervalSeconds must be at least every ${POLICY.MIN_POLLING_INTERVAL_MS}ms`)
289
198
 
290
199
  config.pollingInterval = ('pollingIntervalSeconds' in config)
291
200
  ? config.pollingIntervalSeconds * 1000
292
- : defaults?.pollingInterval || 2000
201
+ : 2000
293
202
  }
294
203
 
295
204
  function applyMaintenanceConfig (config) {
296
205
  assert(!('maintenanceIntervalSeconds' in config) || config.maintenanceIntervalSeconds >= 1,
297
206
  'configuration assert: maintenanceIntervalSeconds must be at least every second')
298
207
 
299
- assert(!('maintenanceIntervalMinutes' in config) || config.maintenanceIntervalMinutes >= 1,
300
- 'configuration assert: maintenanceIntervalMinutes must be at least every minute')
208
+ config.maintenanceIntervalSeconds = config.maintenanceIntervalSeconds || POLICY.MAX_EXPIRATION_HOURS * 60 * 60
301
209
 
302
- config.maintenanceIntervalSeconds = ('maintenanceIntervalMinutes' in config)
303
- ? config.maintenanceIntervalMinutes * 60
304
- : ('maintenanceIntervalSeconds' in config)
305
- ? config.maintenanceIntervalSeconds
306
- : 120
210
+ assert(config.maintenanceIntervalSeconds / 60 / 60 <= POLICY.MAX_EXPIRATION_HOURS,
211
+ `configuration assert: maintenanceIntervalSeconds cannot exceed ${POLICY.MAX_EXPIRATION_HOURS} hours`)
307
212
 
308
- assert(config.maintenanceIntervalSeconds / 60 / 60 < POLICY.MAX_EXPIRATION_HOURS,
309
- `configuration assert: maintenance interval cannot exceed ${POLICY.MAX_EXPIRATION_HOURS} hours`)
310
- }
213
+ assert(!('monitorIntervalSeconds' in config) || config.monitorIntervalSeconds >= 1,
214
+ 'configuration assert: monitorIntervalSeconds must be at least every second')
311
215
 
312
- function applyDeleteConfig (config) {
313
- assert(!('deleteAfterSeconds' in config) || config.deleteAfterSeconds >= 1,
314
- 'configuration assert: deleteAfterSeconds must be at least every second')
216
+ config.monitorIntervalSeconds = config.monitorIntervalSeconds || 60
315
217
 
316
- assert(!('deleteAfterMinutes' in config) || config.deleteAfterMinutes >= 1,
317
- 'configuration assert: deleteAfterMinutes must be at least every minute')
218
+ assert(config.monitorIntervalSeconds / 60 / 60 <= POLICY.MAX_EXPIRATION_HOURS,
219
+ `configuration assert: monitorIntervalSeconds cannot exceed ${POLICY.MAX_EXPIRATION_HOURS} hours`)
318
220
 
319
- assert(!('deleteAfterHours' in config) || config.deleteAfterHours >= 1,
320
- 'configuration assert: deleteAfterHours must be at least every hour')
221
+ assert(!('queueCacheIntervalSeconds' in config) || config.queueCacheIntervalSeconds >= 1,
222
+ 'configuration assert: queueCacheIntervalSeconds must be at least every second')
321
223
 
322
- assert(!('deleteAfterDays' in config) || config.deleteAfterDays >= 1,
323
- 'configuration assert: deleteAfterDays must be at least every day')
224
+ config.queueCacheIntervalSeconds = config.queueCacheIntervalSeconds || 60
324
225
 
325
- const deleteAfter = ('deleteAfterDays' in config)
326
- ? `${config.deleteAfterDays} days`
327
- : ('deleteAfterHours' in config)
328
- ? `${config.deleteAfterHours} hours`
329
- : ('deleteAfterMinutes' in config)
330
- ? `${config.deleteAfterMinutes} minutes`
331
- : ('deleteAfterSeconds' in config)
332
- ? `${config.deleteAfterSeconds} seconds`
333
- : '7 days'
334
-
335
- config.deleteAfter = deleteAfter
226
+ assert(config.queueCacheIntervalSeconds / 60 / 60 <= POLICY.MAX_EXPIRATION_HOURS,
227
+ `configuration assert: queueCacheIntervalSeconds cannot exceed ${POLICY.MAX_EXPIRATION_HOURS} hours`)
336
228
  }
337
229
 
338
- function applyMonitoringConfig (config) {
339
- assert(!('monitorStateIntervalSeconds' in config) || config.monitorStateIntervalSeconds >= 1,
340
- 'configuration assert: monitorStateIntervalSeconds must be at least every second')
341
-
342
- assert(!('monitorStateIntervalMinutes' in config) || config.monitorStateIntervalMinutes >= 1,
343
- 'configuration assert: monitorStateIntervalMinutes must be at least every minute')
344
-
345
- config.monitorStateIntervalSeconds =
346
- ('monitorStateIntervalMinutes' in config)
347
- ? config.monitorStateIntervalMinutes * 60
348
- : ('monitorStateIntervalSeconds' in config)
349
- ? config.monitorStateIntervalSeconds
350
- : null
351
-
352
- if (config.monitorStateIntervalSeconds) {
353
- assert(config.monitorStateIntervalSeconds / 60 / 60 < POLICY.MAX_EXPIRATION_HOURS,
354
- `configuration assert: state monitoring interval cannot exceed ${POLICY.MAX_EXPIRATION_HOURS} hours`)
355
- }
356
-
357
- const TEN_MINUTES_IN_SECONDS = 600
230
+ function validateDeletionConfig (config) {
231
+ assert(!('deleteAfterSeconds' in config) || config.deleteAfterSeconds >= 1,
232
+ 'configuration assert: deleteAfterSeconds must be at least every second')
233
+ }
358
234
 
359
- assert(!('clockMonitorIntervalSeconds' in config) || (config.clockMonitorIntervalSeconds >= 1 && config.clockMonitorIntervalSeconds <= TEN_MINUTES_IN_SECONDS),
235
+ function applyScheduleConfig (config) {
236
+ assert(!('clockMonitorIntervalSeconds' in config) || (config.clockMonitorIntervalSeconds >= 1 && config.clockMonitorIntervalSeconds <= 600),
360
237
  'configuration assert: clockMonitorIntervalSeconds must be between 1 second and 10 minutes')
361
238
 
362
- assert(!('clockMonitorIntervalMinutes' in config) || (config.clockMonitorIntervalMinutes >= 1 && config.clockMonitorIntervalMinutes <= 10),
363
- 'configuration assert: clockMonitorIntervalMinutes must be between 1 and 10')
364
-
365
- config.clockMonitorIntervalSeconds =
366
- ('clockMonitorIntervalMinutes' in config)
367
- ? config.clockMonitorIntervalMinutes * 60
368
- : ('clockMonitorIntervalSeconds' in config)
369
- ? config.clockMonitorIntervalSeconds
370
- : TEN_MINUTES_IN_SECONDS
239
+ config.clockMonitorIntervalSeconds = config.clockMonitorIntervalSeconds || 600
371
240
 
372
241
  assert(!('cronMonitorIntervalSeconds' in config) || (config.cronMonitorIntervalSeconds >= 1 && config.cronMonitorIntervalSeconds <= 45),
373
242
  'configuration assert: cronMonitorIntervalSeconds must be between 1 and 45 seconds')
374
243
 
375
- config.cronMonitorIntervalSeconds =
376
- ('cronMonitorIntervalSeconds' in config)
377
- ? config.cronMonitorIntervalSeconds
378
- : 30
244
+ config.cronMonitorIntervalSeconds = config.cronMonitorIntervalSeconds || 30
379
245
 
380
246
  assert(!('cronWorkerIntervalSeconds' in config) || (config.cronWorkerIntervalSeconds >= 1 && config.cronWorkerIntervalSeconds <= 45),
381
247
  'configuration assert: cronWorkerIntervalSeconds must be between 1 and 45 seconds')
382
248
 
383
- config.cronWorkerIntervalSeconds =
384
- ('cronWorkerIntervalSeconds' in config)
385
- ? config.cronWorkerIntervalSeconds
386
- : 5
387
- }
388
-
389
- function warnClockSkew (message) {
390
- emitWarning(WARNINGS.CLOCK_SKEW, message, { force: true })
391
- }
392
-
393
- function emitWarning (warning, message, options = {}) {
394
- const { force } = options
395
-
396
- if (force || !warning.warned) {
397
- warning.warned = true
398
- message = `${warning.message} ${message || ''}`
399
- process.emitWarning(message, warning.type, warning.code)
400
- }
249
+ config.cronWorkerIntervalSeconds = config.cronWorkerIntervalSeconds || 5
401
250
  }