pg-boss 11.1.2 → 12.0.0-beta2

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/manager.js DELETED
@@ -1,697 +0,0 @@
1
- const assert = require('node:assert')
2
- const EventEmitter = require('node:events')
3
- const { randomUUID } = require('node:crypto')
4
- const { serializeError: stringify } = require('serialize-error')
5
- const { delay, resolveWithinSeconds } = require('./tools')
6
- const Attorney = require('./attorney')
7
- const Worker = require('./worker')
8
- const plans = require('./plans')
9
-
10
- const { QUEUES: TIMEKEEPER_QUEUES } = require('./timekeeper')
11
- const { QUEUE_POLICIES } = plans
12
-
13
- const INTERNAL_QUEUES = Object.values(TIMEKEEPER_QUEUES).reduce((acc, i) => ({ ...acc, [i]: i }), {})
14
-
15
- const events = {
16
- error: 'error',
17
- wip: 'wip'
18
- }
19
-
20
- class Manager extends EventEmitter {
21
- constructor (db, config) {
22
- super()
23
-
24
- this.config = config
25
- this.db = db
26
- this.wipTs = Date.now()
27
- this.workers = new Map()
28
- this.queues = null
29
-
30
- this.events = events
31
- this.functions = [
32
- this.complete,
33
- this.cancel,
34
- this.resume,
35
- this.retry,
36
- this.fail,
37
- this.fetch,
38
- this.work,
39
- this.offWork,
40
- this.notifyWorker,
41
- this.publish,
42
- this.subscribe,
43
- this.unsubscribe,
44
- this.insert,
45
- this.send,
46
- this.sendDebounced,
47
- this.sendThrottled,
48
- this.sendAfter,
49
- this.createQueue,
50
- this.updateQueue,
51
- this.deleteQueue,
52
- this.getQueueStats,
53
- this.getQueue,
54
- this.getQueues,
55
- this.deleteQueuedJobs,
56
- this.deleteStoredJobs,
57
- this.deleteAllJobs,
58
- this.deleteJob,
59
- this.getJobById
60
- ]
61
- }
62
-
63
- async start () {
64
- this.stopped = false
65
- this.queueCacheInterval = setInterval(() => this.onCacheQueues({ emit: true }), this.config.queueCacheIntervalSeconds * 1000)
66
- await this.onCacheQueues()
67
- }
68
-
69
- async onCacheQueues ({ emit = false } = {}) {
70
- try {
71
- assert(!this.config.__test__throw_queueCache, 'test error')
72
- const queues = await this.getQueues()
73
- this.queues = queues.reduce((acc, i) => { acc[i.name] = i; return acc }, {})
74
- } catch (error) {
75
- emit && this.emit(events.error, { ...error, message: error.message, stack: error.stack })
76
- }
77
- }
78
-
79
- async getQueueCache (name) {
80
- let queue = this.queues[name]
81
-
82
- if (queue) {
83
- return queue
84
- }
85
-
86
- queue = await this.getQueue(name)
87
-
88
- if (!queue) {
89
- throw new Error(`Queue ${name} does not exist`)
90
- }
91
-
92
- this.queues[name] = queue
93
-
94
- return queue
95
- }
96
-
97
- async stop () {
98
- this.stopped = true
99
-
100
- clearInterval(this.queueCacheInterval)
101
-
102
- for (const worker of this.workers.values()) {
103
- if (!INTERNAL_QUEUES[worker.name]) {
104
- await this.offWork(worker.name)
105
- }
106
- }
107
- }
108
-
109
- async failWip () {
110
- for (const worker of this.workers.values()) {
111
- const jobIds = worker.jobs.map(j => j.id)
112
- if (jobIds.length) {
113
- await this.fail(worker.name, jobIds, 'pg-boss shut down while active')
114
- }
115
- }
116
- }
117
-
118
- async work (name, ...args) {
119
- const { options, callback } = Attorney.checkWorkArgs(name, args)
120
- return await this.watch(name, options, callback)
121
- }
122
-
123
- addWorker (worker) {
124
- this.workers.set(worker.id, worker)
125
- }
126
-
127
- removeWorker (worker) {
128
- this.workers.delete(worker.id)
129
- }
130
-
131
- getWorkers () {
132
- return Array.from(this.workers.values())
133
- }
134
-
135
- emitWip (name) {
136
- if (!INTERNAL_QUEUES[name]) {
137
- const now = Date.now()
138
-
139
- if (now - this.wipTs > 2000) {
140
- this.emit(events.wip, this.getWipData())
141
- this.wipTs = now
142
- }
143
- }
144
- }
145
-
146
- getWipData (options = {}) {
147
- const { includeInternal = false } = options
148
-
149
- const data = this.getWorkers()
150
- .map(({
151
- id,
152
- name,
153
- options,
154
- state,
155
- jobs,
156
- createdOn,
157
- lastFetchedOn,
158
- lastJobStartedOn,
159
- lastJobEndedOn,
160
- lastError,
161
- lastErrorOn
162
- }) => ({
163
- id,
164
- name,
165
- options,
166
- state,
167
- count: jobs.length,
168
- createdOn,
169
- lastFetchedOn,
170
- lastJobStartedOn,
171
- lastJobEndedOn,
172
- lastError,
173
- lastErrorOn
174
- }))
175
- .filter(i => i.count > 0 && (!INTERNAL_QUEUES[i.name] || includeInternal))
176
-
177
- return data
178
- }
179
-
180
- async watch (name, options, callback) {
181
- if (this.stopped) {
182
- throw new Error('Workers are disabled. pg-boss is stopped')
183
- }
184
-
185
- const {
186
- pollingInterval: interval = this.config.pollingInterval,
187
- batchSize,
188
- includeMetadata = false,
189
- priority = true
190
- } = options
191
-
192
- const id = randomUUID({ disableEntropyCache: true })
193
-
194
- const fetch = () => this.fetch(name, { batchSize, includeMetadata, priority })
195
-
196
- const onFetch = async (jobs) => {
197
- if (!jobs.length) {
198
- return
199
- }
200
-
201
- if (this.config.__test__throw_worker) {
202
- throw new Error('__test__throw_worker')
203
- }
204
-
205
- this.emitWip(name)
206
-
207
- const maxExpiration = jobs.reduce((acc, i) => Math.max(acc, i.expireInSeconds), 0)
208
- const jobIds = jobs.map(job => job.id)
209
-
210
- try {
211
- const result = await resolveWithinSeconds(callback(jobs), maxExpiration, `handler execution exceeded ${maxExpiration}s`)
212
- await this.complete(name, jobIds, jobIds.length === 1 ? result : undefined)
213
- } catch (err) {
214
- await this.fail(name, jobIds, err)
215
- }
216
-
217
- this.emitWip(name)
218
- }
219
-
220
- const onError = error => {
221
- this.emit(events.error, { ...error, message: error.message, stack: error.stack, queue: name, worker: id })
222
- }
223
-
224
- const worker = new Worker({ id, name, options, interval, fetch, onFetch, onError })
225
-
226
- this.addWorker(worker)
227
-
228
- worker.start()
229
-
230
- return id
231
- }
232
-
233
- async offWork (value) {
234
- assert(value, 'Missing required argument')
235
-
236
- const query = (typeof value === 'string')
237
- ? { filter: i => i.name === value }
238
- : (typeof value === 'object' && value.id)
239
- ? { filter: i => i.id === value.id }
240
- : null
241
-
242
- assert(query, 'Invalid argument. Expected string or object: { id }')
243
-
244
- const workers = this.getWorkers().filter(i => query.filter(i) && !i.stopping && !i.stopped)
245
-
246
- if (workers.length === 0) {
247
- return
248
- }
249
-
250
- for (const worker of workers) {
251
- worker.stop()
252
- }
253
-
254
- setImmediate(async () => {
255
- while (!workers.every(w => w.stopped)) {
256
- await delay(1000)
257
- }
258
-
259
- for (const worker of workers) {
260
- this.removeWorker(worker)
261
- }
262
- })
263
- }
264
-
265
- notifyWorker (workerId) {
266
- if (this.workers.has(workerId)) {
267
- this.workers.get(workerId).notify()
268
- }
269
- }
270
-
271
- async subscribe (event, name) {
272
- assert(event, 'Missing required argument')
273
- assert(name, 'Missing required argument')
274
- const sql = plans.subscribe(this.config.schema)
275
- return await this.db.executeSql(sql, [event, name])
276
- }
277
-
278
- async unsubscribe (event, name) {
279
- assert(event, 'Missing required argument')
280
- assert(name, 'Missing required argument')
281
- const sql = plans.unsubscribe(this.config.schema)
282
- return await this.db.executeSql(sql, [event, name])
283
- }
284
-
285
- async publish (event, ...args) {
286
- assert(event, 'Missing required argument')
287
- const sql = plans.getQueuesForEvent(this.config.schema)
288
- const { rows } = await this.db.executeSql(sql, [event])
289
-
290
- await Promise.allSettled(rows.map(({ name }) => this.send(name, ...args)))
291
- }
292
-
293
- async send (...args) {
294
- const { name, data, options } = Attorney.checkSendArgs(args)
295
-
296
- return await this.createJob(name, data, options)
297
- }
298
-
299
- async sendAfter (name, data, options, after) {
300
- options = options ? { ...options } : {}
301
- options.startAfter = after
302
-
303
- const result = Attorney.checkSendArgs([name, data, options])
304
-
305
- return await this.createJob(result.name, result.data, result.options)
306
- }
307
-
308
- async sendThrottled (name, data, options, seconds, key) {
309
- options = options ? { ...options } : {}
310
- options.singletonSeconds = seconds
311
- options.singletonNextSlot = false
312
- options.singletonKey = key
313
-
314
- const result = Attorney.checkSendArgs([name, data, options])
315
-
316
- return await this.createJob(result.name, result.data, result.options)
317
- }
318
-
319
- async sendDebounced (name, data, options, seconds, key) {
320
- options = options ? { ...options } : {}
321
- options.singletonSeconds = seconds
322
- options.singletonNextSlot = true
323
- options.singletonKey = key
324
-
325
- const result = Attorney.checkSendArgs([name, data, options])
326
-
327
- return await this.createJob(result.name, result.data, result.options)
328
- }
329
-
330
- async createJob (name, data, options) {
331
- const singletonOffset = 0
332
-
333
- const {
334
- id = null,
335
- db: wrapper,
336
- priority,
337
- startAfter,
338
- singletonKey = null,
339
- singletonSeconds,
340
- singletonNextSlot,
341
- expireInSeconds,
342
- deleteAfterSeconds,
343
- retentionSeconds,
344
- keepUntil,
345
- retryLimit,
346
- retryDelay,
347
- retryBackoff,
348
- retryDelayMax
349
- } = options
350
-
351
- const job = {
352
- id,
353
- name,
354
- data,
355
- priority,
356
- startAfter,
357
- singletonKey,
358
- singletonSeconds,
359
- singletonOffset,
360
- expireInSeconds,
361
- deleteAfterSeconds,
362
- retentionSeconds,
363
- keepUntil,
364
- retryLimit,
365
- retryDelay,
366
- retryBackoff,
367
- retryDelayMax
368
- }
369
-
370
- const db = wrapper || this.db
371
-
372
- const { table } = await this.getQueueCache(name)
373
-
374
- const sql = plans.insertJobs(this.config.schema, { table, name, returnId: true })
375
-
376
- const { rows: try1 } = await db.executeSql(sql, [JSON.stringify([job])])
377
-
378
- if (try1.length === 1) {
379
- return try1[0].id
380
- }
381
-
382
- if (singletonNextSlot) {
383
- // delay starting by the offset to honor throttling config
384
- job.startAfter = this.getDebounceStartAfter(singletonSeconds, this.timekeeper.clockSkew)
385
- job.singletonOffset = singletonSeconds
386
-
387
- const { rows: try2 } = await db.executeSql(sql, [JSON.stringify([job])])
388
-
389
- if (try2.length === 1) {
390
- return try2[0].id
391
- }
392
- }
393
-
394
- return null
395
- }
396
-
397
- async insert (name, jobs, options = {}) {
398
- assert(Array.isArray(jobs), 'jobs argument should be an array')
399
-
400
- const { table } = await this.getQueueCache(name)
401
-
402
- const db = this.assertDb(options)
403
-
404
- const sql = plans.insertJobs(this.config.schema, { table, name, returnId: false })
405
-
406
- const { rows } = await db.executeSql(sql, [JSON.stringify(jobs)])
407
-
408
- return (rows.length) ? rows.map(i => i.id) : null
409
- }
410
-
411
- getDebounceStartAfter (singletonSeconds, clockOffset) {
412
- const debounceInterval = singletonSeconds * 1000
413
-
414
- const now = Date.now() + clockOffset
415
-
416
- const slot = Math.floor(now / debounceInterval) * debounceInterval
417
-
418
- // prevent startAfter=0 during debouncing
419
- let startAfter = (singletonSeconds - Math.floor((now - slot) / 1000)) || 1
420
-
421
- if (singletonSeconds > 1) {
422
- startAfter++
423
- }
424
-
425
- return startAfter
426
- }
427
-
428
- async fetch (name, options = {}) {
429
- Attorney.checkFetchArgs(name, options)
430
-
431
- const db = this.assertDb(options)
432
-
433
- const { table, policy, singletonsActive } = await this.getQueueCache(name)
434
-
435
- options = {
436
- ...options,
437
- schema: this.config.schema,
438
- table,
439
- name,
440
- policy,
441
- limit: options.batchSize,
442
- ignoreSingletons: singletonsActive
443
- }
444
-
445
- const sql = plans.fetchNextJob(options)
446
-
447
- let result
448
-
449
- try {
450
- result = await db.executeSql(sql)
451
- } catch (err) {
452
- // errors from fetchquery should only be unique constraint violations
453
- }
454
-
455
- return result?.rows || []
456
- }
457
-
458
- mapCompletionIdArg (id, funcName) {
459
- const errorMessage = `${funcName}() requires an id`
460
-
461
- assert(id, errorMessage)
462
-
463
- const ids = Array.isArray(id) ? id : [id]
464
-
465
- assert(ids.length, errorMessage)
466
-
467
- return ids
468
- }
469
-
470
- mapCompletionDataArg (data) {
471
- if (data === null || typeof data === 'undefined' || typeof data === 'function') { return null }
472
-
473
- const result = (typeof data === 'object' && !Array.isArray(data))
474
- ? data
475
- : { value: data }
476
-
477
- return stringify(result)
478
- }
479
-
480
- mapCommandResponse (ids, result) {
481
- return {
482
- jobs: ids,
483
- requested: ids.length,
484
- affected: result && result.rows ? parseInt(result.rows[0].count) : 0
485
- }
486
- }
487
-
488
- async complete (name, id, data, options = {}) {
489
- Attorney.assertQueueName(name)
490
- const db = this.assertDb(options)
491
- const ids = this.mapCompletionIdArg(id, 'complete')
492
- const { table } = await this.getQueueCache(name)
493
- const sql = plans.completeJobs(this.config.schema, table)
494
- const result = await db.executeSql(sql, [name, ids, this.mapCompletionDataArg(data)])
495
- return this.mapCommandResponse(ids, result)
496
- }
497
-
498
- async fail (name, id, data, options = {}) {
499
- Attorney.assertQueueName(name)
500
- const db = this.assertDb(options)
501
- const ids = this.mapCompletionIdArg(id, 'fail')
502
- const { table } = await this.getQueueCache(name)
503
- const sql = plans.failJobsById(this.config.schema, table)
504
- const result = await db.executeSql(sql, [name, ids, this.mapCompletionDataArg(data)])
505
- return this.mapCommandResponse(ids, result)
506
- }
507
-
508
- async cancel (name, id, options = {}) {
509
- Attorney.assertQueueName(name)
510
- const db = this.assertDb(options)
511
- const ids = this.mapCompletionIdArg(id, 'cancel')
512
- const { table } = await this.getQueueCache(name)
513
- const sql = plans.cancelJobs(this.config.schema, table)
514
- const result = await db.executeSql(sql, [name, ids])
515
- return this.mapCommandResponse(ids, result)
516
- }
517
-
518
- async deleteJob (name, id, options = {}) {
519
- Attorney.assertQueueName(name)
520
- const db = this.assertDb(options)
521
- const ids = this.mapCompletionIdArg(id, 'deleteJob')
522
- const { table } = await this.getQueueCache(name)
523
- const sql = plans.deleteJobsById(this.config.schema, table)
524
- const result = await db.executeSql(sql, [name, ids])
525
- return this.mapCommandResponse(ids, result)
526
- }
527
-
528
- async resume (name, id, options = {}) {
529
- Attorney.assertQueueName(name)
530
- const db = this.assertDb(options)
531
- const ids = this.mapCompletionIdArg(id, 'resume')
532
- const { table } = await this.getQueueCache(name)
533
- const sql = plans.resumeJobs(this.config.schema, table)
534
- const result = await db.executeSql(sql, [name, ids])
535
- return this.mapCommandResponse(ids, result)
536
- }
537
-
538
- async retry (name, id, options = {}) {
539
- Attorney.assertQueueName(name)
540
- const db = options.db || this.db
541
- const ids = this.mapCompletionIdArg(id, 'retry')
542
- const { table } = await this.getQueueCache(name)
543
- const sql = plans.retryJobs(this.config.schema, table)
544
- const result = await db.executeSql(sql, [name, ids])
545
- return this.mapCommandResponse(ids, result)
546
- }
547
-
548
- async createQueue (name, options = {}) {
549
- name = name || options.name
550
-
551
- Attorney.assertQueueName(name)
552
-
553
- options.policy = options.policy || QUEUE_POLICIES.standard
554
-
555
- assert(options.policy in QUEUE_POLICIES, `${options.policy} is not a valid queue policy`)
556
-
557
- Attorney.validateQueueArgs(options)
558
-
559
- if (options.deadLetter) {
560
- Attorney.assertQueueName(options.deadLetter)
561
- assert.notStrictEqual(name, options.deadLetter, 'deadLetter cannot be itself')
562
- await this.getQueueCache(options.deadLetter)
563
- }
564
-
565
- const sql = plans.createQueue(this.config.schema, name, options)
566
- await this.db.executeSql(sql)
567
- }
568
-
569
- async getQueues (names) {
570
- if (names) {
571
- names = Array.isArray(names) ? names : [names]
572
- for (const name of names) {
573
- Attorney.assertQueueName(name)
574
- }
575
- }
576
-
577
- const sql = plans.getQueues(this.config.schema, names)
578
- const { rows } = await this.db.executeSql(sql)
579
- return rows
580
- }
581
-
582
- async updateQueue (name, options = {}) {
583
- Attorney.assertQueueName(name)
584
-
585
- assert(Object.keys(options).length > 0, 'no properties found to update')
586
-
587
- if ('policy' in options) {
588
- throw new Error('queue policy cannot be changed after creation')
589
- }
590
-
591
- if ('partition' in options) {
592
- throw new Error('queue partitioning cannot be changed after creation')
593
- }
594
-
595
- Attorney.validateQueueArgs(options)
596
-
597
- const { deadLetter } = options
598
-
599
- if (deadLetter) {
600
- Attorney.assertQueueName(deadLetter)
601
- assert.notStrictEqual(name, deadLetter, 'deadLetter cannot be itself')
602
- }
603
-
604
- const sql = plans.updateQueue(this.config.schema, { deadLetter })
605
- await this.db.executeSql(sql, [name, options])
606
- }
607
-
608
- async getQueue (name) {
609
- Attorney.assertQueueName(name)
610
-
611
- const sql = plans.getQueues(this.config.schema, [name])
612
- const { rows } = await this.db.executeSql(sql)
613
-
614
- return rows[0] || null
615
- }
616
-
617
- async deleteQueue (name) {
618
- Attorney.assertQueueName(name)
619
-
620
- try {
621
- await this.getQueueCache(name)
622
- const sql = plans.deleteQueue(this.config.schema, name)
623
- await this.db.executeSql(sql)
624
- } catch {}
625
- }
626
-
627
- async deleteQueuedJobs (name) {
628
- Attorney.assertQueueName(name)
629
- const { table } = await this.getQueueCache(name)
630
- const sql = plans.deleteQueuedJobs(this.config.schema, table)
631
- await this.db.executeSql(sql, [name])
632
- }
633
-
634
- async deleteStoredJobs (name) {
635
- Attorney.assertQueueName(name)
636
- const { table } = await this.getQueueCache(name)
637
- const sql = plans.deleteStoredJobs(this.config.schema, table)
638
- await this.db.executeSql(sql, [name])
639
- }
640
-
641
- async deleteAllJobs (name) {
642
- Attorney.assertQueueName(name)
643
- const { table, partition } = await this.getQueueCache(name)
644
-
645
- if (partition) {
646
- const sql = plans.truncateTable(this.config.schema, table)
647
- await this.db.executeSql(sql)
648
- } else {
649
- const sql = plans.deleteAllJobs(this.config.schema, table)
650
- await this.db.executeSql(sql, [name])
651
- }
652
- }
653
-
654
- async getQueueStats (name) {
655
- Attorney.assertQueueName(name)
656
-
657
- const queue = await this.getQueueCache(name)
658
-
659
- const sql = plans.getQueueStats(this.config.schema, queue.table, [name])
660
-
661
- const { rows } = await this.db.executeSql(sql)
662
-
663
- return Object.assign(queue, rows.at(0) || {})
664
- }
665
-
666
- async getJobById (name, id, options = {}) {
667
- Attorney.assertQueueName(name)
668
-
669
- const db = this.assertDb(options)
670
-
671
- const { table } = await this.getQueueCache(name)
672
-
673
- const sql = plans.getJobById(this.config.schema, table)
674
-
675
- const result1 = await db.executeSql(sql, [name, id])
676
-
677
- if (result1?.rows?.length === 1) {
678
- return result1.rows[0]
679
- } else {
680
- return null
681
- }
682
- }
683
-
684
- assertDb (options) {
685
- if (options.db) {
686
- return options.db
687
- }
688
-
689
- if (this.db._pgbdb) {
690
- assert(this.db.opened, 'Database connection is not opened')
691
- }
692
-
693
- return this.db
694
- }
695
- }
696
-
697
- module.exports = Manager