pg-boss 12.5.4 → 12.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/manager.js CHANGED
@@ -26,6 +26,8 @@ class Manager extends EventEmitter {
26
26
  queues;
27
27
  pendingOffWorkCleanups;
28
28
  #spies;
29
+ #localGroupActive;
30
+ #localGroupConfig;
29
31
  constructor(db, config) {
30
32
  super();
31
33
  this.config = config;
@@ -35,6 +37,8 @@ class Manager extends EventEmitter {
35
37
  this.queues = null;
36
38
  this.pendingOffWorkCleanups = new Set();
37
39
  this.#spies = new Map();
40
+ this.#localGroupActive = new Map();
41
+ this.#localGroupConfig = new Map();
38
42
  }
39
43
  getSpy(name) {
40
44
  if (!this.config.__test__enableSpies) {
@@ -53,6 +57,148 @@ class Manager extends EventEmitter {
53
57
  }
54
58
  this.#spies.clear();
55
59
  }
60
+ #getLocalGroupLimit(queueName, groupTier) {
61
+ const config = this.#localGroupConfig.get(queueName);
62
+ if (!config)
63
+ return Infinity;
64
+ if (groupTier && config.tiers && groupTier in config.tiers) {
65
+ return config.tiers[groupTier];
66
+ }
67
+ return config.default;
68
+ }
69
+ #getGroupsAtLocalCapacity(queueName) {
70
+ const config = this.#localGroupConfig.get(queueName);
71
+ if (!config)
72
+ return [];
73
+ const queueGroups = this.#localGroupActive.get(queueName);
74
+ if (!queueGroups)
75
+ return [];
76
+ const atCapacity = [];
77
+ for (const [groupId, activeCount] of queueGroups.entries()) {
78
+ // We don't have tier info here, so use default limit
79
+ // Jobs with tiers will be checked individually after fetch
80
+ const limit = config.default;
81
+ if (activeCount >= limit) {
82
+ atCapacity.push(groupId);
83
+ }
84
+ }
85
+ return atCapacity;
86
+ }
87
+ #incrementLocalGroupCount(queueName, groupId) {
88
+ let queueGroups = this.#localGroupActive.get(queueName);
89
+ if (!queueGroups) {
90
+ queueGroups = new Map();
91
+ this.#localGroupActive.set(queueName, queueGroups);
92
+ }
93
+ const current = queueGroups.get(groupId) || 0;
94
+ queueGroups.set(groupId, current + 1);
95
+ }
96
+ #decrementLocalGroupCount(queueName, groupId) {
97
+ const queueGroups = this.#localGroupActive.get(queueName);
98
+ if (!queueGroups)
99
+ return;
100
+ const current = queueGroups.get(groupId) || 0;
101
+ if (current <= 1) {
102
+ queueGroups.delete(groupId);
103
+ }
104
+ else {
105
+ queueGroups.set(groupId, current - 1);
106
+ }
107
+ }
108
+ #trackJobsActive(name, jobs) {
109
+ const spy = this.config.__test__enableSpies ? this.#spies.get(name) : undefined;
110
+ if (spy) {
111
+ for (const job of jobs) {
112
+ spy.addJob(job.id, name, job.data, 'active');
113
+ }
114
+ }
115
+ }
116
+ #trackJobsCompleted(name, jobs, result) {
117
+ const spy = this.config.__test__enableSpies ? this.#spies.get(name) : undefined;
118
+ if (spy) {
119
+ const output = jobs.length === 1 ? result : undefined;
120
+ for (const job of jobs) {
121
+ spy.addJob(job.id, name, job.data, 'completed', output);
122
+ }
123
+ }
124
+ }
125
+ #trackJobsFailed(name, jobs, err) {
126
+ const spy = this.config.__test__enableSpies ? this.#spies.get(name) : undefined;
127
+ if (spy) {
128
+ for (const job of jobs) {
129
+ spy.addJob(job.id, name, job.data, 'failed', { message: err?.message, stack: err?.stack });
130
+ }
131
+ }
132
+ }
133
+ #storeLocalGroupConfig(name, localGroupConcurrency) {
134
+ const config = typeof localGroupConcurrency === 'number'
135
+ ? { default: localGroupConcurrency }
136
+ : localGroupConcurrency;
137
+ this.#localGroupConfig.set(name, config);
138
+ }
139
+ #cleanupLocalGroupTracking(name) {
140
+ // Only cleanup if no more workers exist for this queue
141
+ const hasWorkersForQueue = this.getWorkers().some(w => w.name === name && !w.stopping && !w.stopped);
142
+ if (!hasWorkersForQueue) {
143
+ this.#localGroupConfig.delete(name);
144
+ this.#localGroupActive.delete(name);
145
+ }
146
+ }
147
+ #trackLocalGroupStart(name, jobs) {
148
+ const allowed = [];
149
+ const excess = [];
150
+ const groupedJobs = [];
151
+ for (const job of jobs) {
152
+ if (!job.groupId) {
153
+ // Jobs without group bypass local group limits
154
+ allowed.push(job);
155
+ continue;
156
+ }
157
+ const currentCount = this.#localGroupActive.get(name)?.get(job.groupId) || 0;
158
+ const limit = this.#getLocalGroupLimit(name, job.groupTier);
159
+ if (currentCount < limit) {
160
+ this.#incrementLocalGroupCount(name, job.groupId);
161
+ allowed.push(job);
162
+ groupedJobs.push(job);
163
+ }
164
+ else {
165
+ excess.push(job);
166
+ }
167
+ }
168
+ return { allowed, excess, groupedJobs };
169
+ }
170
+ #trackLocalGroupEnd(name, groupedJobs) {
171
+ for (const job of groupedJobs) {
172
+ if (job.groupId) {
173
+ this.#decrementLocalGroupCount(name, job.groupId);
174
+ }
175
+ }
176
+ }
177
+ async #processJobs(name, jobs, callback, worker) {
178
+ const jobIds = jobs.map(job => job.id);
179
+ const maxExpiration = jobs.reduce((acc, i) => Math.max(acc, i.expireInSeconds), 0);
180
+ const ac = new AbortController();
181
+ jobs.forEach(job => { job.signal = ac.signal; });
182
+ // Store AbortController on worker so it can be aborted after graceful shutdown
183
+ if (worker) {
184
+ worker.abortController = ac;
185
+ }
186
+ try {
187
+ const result = await resolveWithinSeconds(callback(jobs), maxExpiration, `handler execution exceeded ${maxExpiration}s`, ac);
188
+ await this.complete(name, jobIds, jobIds.length === 1 ? result : undefined);
189
+ this.#trackJobsCompleted(name, jobs, result);
190
+ }
191
+ catch (err) {
192
+ await this.fail(name, jobIds, err);
193
+ this.#trackJobsFailed(name, jobs, err);
194
+ }
195
+ finally {
196
+ if (worker) {
197
+ // Clear between jobs
198
+ worker.abortController = null;
199
+ }
200
+ }
201
+ }
56
202
  async start() {
57
203
  this.stopped = false;
58
204
  this.queueCacheInterval = setInterval(() => this.onCacheQueues({ emit: true }), this.config.queueCacheIntervalSeconds * 1000);
@@ -87,6 +233,9 @@ class Manager extends EventEmitter {
87
233
  await Promise.allSettled([...this.workers.values()]
88
234
  .filter(worker => !INTERNAL_QUEUES[worker.name])
89
235
  .map(async (worker) => await this.offWork(worker.name, { wait: false })));
236
+ // Clean up all local group tracking on full stop
237
+ this.#localGroupConfig.clear();
238
+ this.#localGroupActive.clear();
90
239
  }
91
240
  async failWip() {
92
241
  for (const worker of this.workers.values()) {
@@ -94,6 +243,7 @@ class Manager extends EventEmitter {
94
243
  if (jobIds.length) {
95
244
  await this.fail(worker.name, jobIds, 'pg-boss shut down while active');
96
245
  }
246
+ worker.abort();
97
247
  }
98
248
  }
99
249
  async work(name, ...args) {
@@ -101,53 +251,61 @@ class Manager extends EventEmitter {
101
251
  if (this.stopped) {
102
252
  throw new Error('Workers are disabled. pg-boss is stopped');
103
253
  }
104
- const { pollingInterval: interval, batchSize = 1, includeMetadata = false, priority = true } = options;
105
- const id = randomUUID({ disableEntropyCache: true });
106
- const fetch = () => this.fetch(name, { batchSize, includeMetadata, priority });
107
- const onFetch = async (jobs) => {
108
- if (!jobs.length) {
109
- return;
110
- }
111
- if (this.config.__test__throw_worker) {
112
- throw new Error('__test__throw_worker');
113
- }
114
- this.emitWip(name);
115
- const spy = this.config.__test__enableSpies ? this.#spies.get(name) : undefined;
116
- if (spy) {
117
- for (const job of jobs) {
118
- spy.addJob(job.id, name, job.data, 'active');
254
+ const { pollingInterval: interval, batchSize = 1, includeMetadata = false, priority = true, localConcurrency = 1, localGroupConcurrency, groupConcurrency, orderByCreatedOn = true } = options;
255
+ if (localGroupConcurrency != null) {
256
+ this.#storeLocalGroupConfig(name, localGroupConcurrency);
257
+ }
258
+ const firstWorkerId = randomUUID({ disableEntropyCache: true });
259
+ const createWorker = (workerId) => {
260
+ const fetch = () => {
261
+ const ignoreGroups = localGroupConcurrency != null
262
+ ? this.#getGroupsAtLocalCapacity(name)
263
+ : undefined;
264
+ return this.fetch(name, { batchSize, includeMetadata, priority, orderByCreatedOn, groupConcurrency, ignoreGroups });
265
+ };
266
+ const onFetch = async (jobs) => {
267
+ if (!jobs.length)
268
+ return;
269
+ if (this.config.__test__throw_worker)
270
+ throw new Error('__test__throw_worker');
271
+ this.emitWip(name);
272
+ this.#trackJobsActive(name, jobs);
273
+ // Get the worker instance for abort controller tracking
274
+ const worker = this.workers.get(workerId);
275
+ // Skip all in-memory group tracking when localGroupConcurrency is not enabled
276
+ if (localGroupConcurrency == null) {
277
+ await this.#processJobs(name, jobs, callback, worker);
119
278
  }
120
- }
121
- const maxExpiration = jobs.reduce((acc, i) => Math.max(acc, i.expireInSeconds), 0);
122
- const jobIds = jobs.map(job => job.id);
123
- const ac = new AbortController();
124
- jobs.forEach(job => { job.signal = ac.signal; });
125
- try {
126
- const result = await resolveWithinSeconds(callback(jobs), maxExpiration, `handler execution exceeded ${maxExpiration}s`, ac);
127
- await this.complete(name, jobIds, jobIds.length === 1 ? result : undefined);
128
- if (spy) {
129
- for (const job of jobs) {
130
- spy.addJob(job.id, name, job.data, 'completed', jobIds.length === 1 ? result : undefined);
279
+ else {
280
+ const { allowed, excess, groupedJobs } = this.#trackLocalGroupStart(name, jobs);
281
+ if (excess.length > 0) {
282
+ const excessIds = excess.map(job => job.id);
283
+ await this.restore(name, excessIds);
131
284
  }
132
- }
133
- }
134
- catch (err) {
135
- await this.fail(name, jobIds, err);
136
- if (spy) {
137
- for (const job of jobs) {
138
- spy.addJob(job.id, name, job.data, 'failed', { message: err?.message, stack: err?.stack });
285
+ if (allowed.length > 0) {
286
+ try {
287
+ await this.#processJobs(name, allowed, callback, worker);
288
+ }
289
+ finally {
290
+ this.#trackLocalGroupEnd(name, groupedJobs);
291
+ }
139
292
  }
140
293
  }
141
- }
142
- this.emitWip(name);
143
- };
144
- const onError = (error) => {
145
- this.emit(events.error, { ...error, message: error.message, stack: error.stack, queue: name, worker: id });
294
+ this.emitWip(name);
295
+ };
296
+ const onError = (error) => {
297
+ this.emit(events.error, { ...error, message: error.message, stack: error.stack, queue: name, worker: workerId });
298
+ };
299
+ return new Worker({ id: workerId, name, options, interval, fetch, onFetch, onError });
146
300
  };
147
- const worker = new Worker({ id, name, options, interval, fetch, onFetch, onError });
148
- this.addWorker(worker);
149
- worker.start();
150
- return id;
301
+ // Spawn workers based on localConcurrency setting
302
+ for (let i = 0; i < localConcurrency; i++) {
303
+ const workerId = i === 0 ? firstWorkerId : randomUUID({ disableEntropyCache: true });
304
+ const worker = createWorker(workerId);
305
+ this.addWorker(worker);
306
+ worker.start();
307
+ }
308
+ return firstWorkerId;
151
309
  }
152
310
  addWorker(worker) {
153
311
  this.workers.set(worker.id, worker);
@@ -191,10 +349,14 @@ class Manager extends EventEmitter {
191
349
  }));
192
350
  if (options.wait) {
193
351
  await cleanupPromise;
352
+ this.#cleanupLocalGroupTracking(name);
194
353
  }
195
354
  else {
196
355
  this.pendingOffWorkCleanups.add(cleanupPromise);
197
- cleanupPromise.finally(() => this.pendingOffWorkCleanups.delete(cleanupPromise));
356
+ cleanupPromise.finally(() => {
357
+ this.pendingOffWorkCleanups.delete(cleanupPromise);
358
+ this.#cleanupLocalGroupTracking(name);
359
+ });
198
360
  }
199
361
  }
200
362
  notifyWorker(workerId) {
@@ -246,7 +408,7 @@ class Manager extends EventEmitter {
246
408
  }
247
409
  async createJob(request) {
248
410
  const { name, data = null, options = {} } = request;
249
- const { id = null, db: wrapper, priority, startAfter, singletonKey = null, singletonSeconds, singletonNextSlot, expireInSeconds, deleteAfterSeconds, retentionSeconds, keepUntil, retryLimit, retryDelay, retryBackoff, retryDelayMax } = options;
411
+ const { id = null, db: wrapper, priority, startAfter, singletonKey = null, singletonSeconds, singletonNextSlot, expireInSeconds, deleteAfterSeconds, retentionSeconds, keepUntil, retryLimit, retryDelay, retryBackoff, retryDelayMax, group } = options;
250
412
  const job = {
251
413
  id,
252
414
  name,
@@ -256,6 +418,8 @@ class Manager extends EventEmitter {
256
418
  singletonKey,
257
419
  singletonSeconds,
258
420
  singletonOffset: 0,
421
+ groupId: group?.id ?? null,
422
+ groupTier: group?.tier ?? null,
259
423
  expireInSeconds,
260
424
  deleteAfterSeconds,
261
425
  retentionSeconds,
@@ -303,7 +467,7 @@ class Manager extends EventEmitter {
303
467
  const db = this.assertDb(options);
304
468
  const spy = this.config.__test__enableSpies ? this.#spies.get(name) : undefined;
305
469
  // Return IDs if spy is active for this queue (needed for job tracking)
306
- const returnId = !!spy;
470
+ const returnId = !!spy || !!options.returnId;
307
471
  const sql = plans.insertJobs(this.config.schema, { table, name, returnId });
308
472
  const { rows } = await db.executeSql(sql, [JSON.stringify(jobs)]);
309
473
  if (rows.length) {
@@ -391,21 +555,21 @@ class Manager extends EventEmitter {
391
555
  const result = await db.executeSql(sql, [name, ids, this.mapCompletionDataArg(data)]);
392
556
  return this.mapCommandResponse(ids, result);
393
557
  }
394
- async cancel(name, id, options = {}) {
558
+ async deleteJob(name, id, options = {}) {
395
559
  Attorney.assertQueueName(name);
396
560
  const db = this.assertDb(options);
397
- const ids = this.mapCompletionIdArg(id, 'cancel');
561
+ const ids = this.mapCompletionIdArg(id, 'deleteJob');
398
562
  const { table } = await this.getQueueCache(name);
399
- const sql = plans.cancelJobs(this.config.schema, table);
563
+ const sql = plans.deleteJobsById(this.config.schema, table);
400
564
  const result = await db.executeSql(sql, [name, ids]);
401
565
  return this.mapCommandResponse(ids, result);
402
566
  }
403
- async deleteJob(name, id, options = {}) {
567
+ async cancel(name, id, options = {}) {
404
568
  Attorney.assertQueueName(name);
405
569
  const db = this.assertDb(options);
406
- const ids = this.mapCompletionIdArg(id, 'deleteJob');
570
+ const ids = this.mapCompletionIdArg(id, 'cancel');
407
571
  const { table } = await this.getQueueCache(name);
408
- const sql = plans.deleteJobsById(this.config.schema, table);
572
+ const sql = plans.cancelJobs(this.config.schema, table);
409
573
  const result = await db.executeSql(sql, [name, ids]);
410
574
  return this.mapCommandResponse(ids, result);
411
575
  }
@@ -418,6 +582,14 @@ class Manager extends EventEmitter {
418
582
  const result = await db.executeSql(sql, [name, ids]);
419
583
  return this.mapCommandResponse(ids, result);
420
584
  }
585
+ async restore(name, id, options = {}) {
586
+ Attorney.assertQueueName(name);
587
+ const db = this.assertDb(options);
588
+ const ids = this.mapCompletionIdArg(id, 'restore');
589
+ const { table } = await this.getQueueCache(name);
590
+ const sql = plans.restoreJobs(this.config.schema, table);
591
+ await db.executeSql(sql, [name, ids]);
592
+ }
421
593
  async retry(name, id, options = {}) {
422
594
  Attorney.assertQueueName(name);
423
595
  const db = options.db || this.db;
@@ -540,6 +712,27 @@ class Manager extends EventEmitter {
540
712
  return null;
541
713
  }
542
714
  }
715
+ async findJobs(name, options = {}) {
716
+ Attorney.assertQueueName(name);
717
+ const db = this.assertDb(options);
718
+ const { table } = await this.getQueueCache(name);
719
+ const { id, key, data, queued = false } = options;
720
+ const sql = plans.findJobs(this.config.schema, table, {
721
+ byId: id !== undefined,
722
+ byKey: key !== undefined,
723
+ byData: data !== undefined,
724
+ queued
725
+ });
726
+ const values = [name];
727
+ if (id !== undefined)
728
+ values.push(id);
729
+ if (key !== undefined)
730
+ values.push(key);
731
+ if (data !== undefined)
732
+ values.push(JSON.stringify(data));
733
+ const result = await db.executeSql(sql, values);
734
+ return result?.rows || [];
735
+ }
543
736
  assertDb(options) {
544
737
  if (options.db) {
545
738
  return options.db;
@@ -1 +1 @@
1
- {"version":3,"file":"migrationStore.d.ts","sourceRoot":"","sources":["../src/migrationStore.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AASnC,iBAAS,QAAQ,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,UAQjF;AAED,iBAAS,IAAI,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,CAAC,SAAS,EAAE,GAAG,SAAS,UAQxF;AAED,iBAAS,OAAO,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,UAehF;AAED,iBAAS,MAAM,CAAE,MAAM,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,EAAE,CA0FlD;AAED,OAAO,EACL,QAAQ,EACR,IAAI,EACJ,OAAO,EACP,MAAM,GACP,CAAA"}
1
+ {"version":3,"file":"migrationStore.d.ts","sourceRoot":"","sources":["../src/migrationStore.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,KAAK,MAAM,YAAY,CAAA;AASnC,iBAAS,QAAQ,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,UAQjF;AAED,iBAAS,IAAI,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,KAAK,CAAC,SAAS,EAAE,GAAG,SAAS,UAQxF;AAED,iBAAS,OAAO,CAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,KAAK,CAAC,SAAS,EAAE,UAuBhF;AAED,iBAAS,MAAM,CAAE,MAAM,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,EAAE,CAkYlD;AAED,OAAO,EACL,QAAQ,EACR,IAAI,EACJ,OAAO,EACP,MAAM,GACP,CAAA"}