pg-boss 12.5.3 → 12.6.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,138 @@ 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) {
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
+ try {
183
+ const result = await resolveWithinSeconds(callback(jobs), maxExpiration, `handler execution exceeded ${maxExpiration}s`, ac);
184
+ await this.complete(name, jobIds, jobIds.length === 1 ? result : undefined);
185
+ this.#trackJobsCompleted(name, jobs, result);
186
+ }
187
+ catch (err) {
188
+ await this.fail(name, jobIds, err);
189
+ this.#trackJobsFailed(name, jobs, err);
190
+ }
191
+ }
56
192
  async start() {
57
193
  this.stopped = false;
58
194
  this.queueCacheInterval = setInterval(() => this.onCacheQueues({ emit: true }), this.config.queueCacheIntervalSeconds * 1000);
@@ -87,6 +223,9 @@ class Manager extends EventEmitter {
87
223
  await Promise.allSettled([...this.workers.values()]
88
224
  .filter(worker => !INTERNAL_QUEUES[worker.name])
89
225
  .map(async (worker) => await this.offWork(worker.name, { wait: false })));
226
+ // Clean up all local group tracking on full stop
227
+ this.#localGroupConfig.clear();
228
+ this.#localGroupActive.clear();
90
229
  }
91
230
  async failWip() {
92
231
  for (const worker of this.workers.values()) {
@@ -101,53 +240,59 @@ class Manager extends EventEmitter {
101
240
  if (this.stopped) {
102
241
  throw new Error('Workers are disabled. pg-boss is stopped');
103
242
  }
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');
243
+ const { pollingInterval: interval, batchSize = 1, includeMetadata = false, priority = true, localConcurrency = 1, localGroupConcurrency, groupConcurrency } = options;
244
+ if (localGroupConcurrency != null) {
245
+ this.#storeLocalGroupConfig(name, localGroupConcurrency);
246
+ }
247
+ const firstWorkerId = randomUUID({ disableEntropyCache: true });
248
+ const createWorker = (workerId) => {
249
+ const fetch = () => {
250
+ const ignoreGroups = localGroupConcurrency != null
251
+ ? this.#getGroupsAtLocalCapacity(name)
252
+ : undefined;
253
+ return this.fetch(name, { batchSize, includeMetadata, priority, groupConcurrency, ignoreGroups });
254
+ };
255
+ const onFetch = async (jobs) => {
256
+ if (!jobs.length)
257
+ return;
258
+ if (this.config.__test__throw_worker)
259
+ throw new Error('__test__throw_worker');
260
+ this.emitWip(name);
261
+ this.#trackJobsActive(name, jobs);
262
+ // Skip all in-memory group tracking when localGroupConcurrency is not enabled
263
+ if (localGroupConcurrency == null) {
264
+ await this.#processJobs(name, jobs, callback);
119
265
  }
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);
266
+ else {
267
+ const { allowed, excess, groupedJobs } = this.#trackLocalGroupStart(name, jobs);
268
+ if (excess.length > 0) {
269
+ const excessIds = excess.map(job => job.id);
270
+ await this.restore(name, excessIds);
131
271
  }
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 });
272
+ if (allowed.length > 0) {
273
+ try {
274
+ await this.#processJobs(name, allowed, callback);
275
+ }
276
+ finally {
277
+ this.#trackLocalGroupEnd(name, groupedJobs);
278
+ }
139
279
  }
140
280
  }
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 });
281
+ this.emitWip(name);
282
+ };
283
+ const onError = (error) => {
284
+ this.emit(events.error, { ...error, message: error.message, stack: error.stack, queue: name, worker: workerId });
285
+ };
286
+ return new Worker({ id: workerId, name, options, interval, fetch, onFetch, onError });
146
287
  };
147
- const worker = new Worker({ id, name, options, interval, fetch, onFetch, onError });
148
- this.addWorker(worker);
149
- worker.start();
150
- return id;
288
+ // Spawn workers based on localConcurrency setting
289
+ for (let i = 0; i < localConcurrency; i++) {
290
+ const workerId = i === 0 ? firstWorkerId : randomUUID({ disableEntropyCache: true });
291
+ const worker = createWorker(workerId);
292
+ this.addWorker(worker);
293
+ worker.start();
294
+ }
295
+ return firstWorkerId;
151
296
  }
152
297
  addWorker(worker) {
153
298
  this.workers.set(worker.id, worker);
@@ -191,10 +336,14 @@ class Manager extends EventEmitter {
191
336
  }));
192
337
  if (options.wait) {
193
338
  await cleanupPromise;
339
+ this.#cleanupLocalGroupTracking(name);
194
340
  }
195
341
  else {
196
342
  this.pendingOffWorkCleanups.add(cleanupPromise);
197
- cleanupPromise.finally(() => this.pendingOffWorkCleanups.delete(cleanupPromise));
343
+ cleanupPromise.finally(() => {
344
+ this.pendingOffWorkCleanups.delete(cleanupPromise);
345
+ this.#cleanupLocalGroupTracking(name);
346
+ });
198
347
  }
199
348
  }
200
349
  notifyWorker(workerId) {
@@ -246,7 +395,7 @@ class Manager extends EventEmitter {
246
395
  }
247
396
  async createJob(request) {
248
397
  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;
398
+ const { id = null, db: wrapper, priority, startAfter, singletonKey = null, singletonSeconds, singletonNextSlot, expireInSeconds, deleteAfterSeconds, retentionSeconds, keepUntil, retryLimit, retryDelay, retryBackoff, retryDelayMax, group } = options;
250
399
  const job = {
251
400
  id,
252
401
  name,
@@ -256,6 +405,8 @@ class Manager extends EventEmitter {
256
405
  singletonKey,
257
406
  singletonSeconds,
258
407
  singletonOffset: 0,
408
+ groupId: group?.id ?? null,
409
+ groupTier: group?.tier ?? null,
259
410
  expireInSeconds,
260
411
  deleteAfterSeconds,
261
412
  retentionSeconds,
@@ -391,21 +542,21 @@ class Manager extends EventEmitter {
391
542
  const result = await db.executeSql(sql, [name, ids, this.mapCompletionDataArg(data)]);
392
543
  return this.mapCommandResponse(ids, result);
393
544
  }
394
- async cancel(name, id, options = {}) {
545
+ async deleteJob(name, id, options = {}) {
395
546
  Attorney.assertQueueName(name);
396
547
  const db = this.assertDb(options);
397
- const ids = this.mapCompletionIdArg(id, 'cancel');
548
+ const ids = this.mapCompletionIdArg(id, 'deleteJob');
398
549
  const { table } = await this.getQueueCache(name);
399
- const sql = plans.cancelJobs(this.config.schema, table);
550
+ const sql = plans.deleteJobsById(this.config.schema, table);
400
551
  const result = await db.executeSql(sql, [name, ids]);
401
552
  return this.mapCommandResponse(ids, result);
402
553
  }
403
- async deleteJob(name, id, options = {}) {
554
+ async cancel(name, id, options = {}) {
404
555
  Attorney.assertQueueName(name);
405
556
  const db = this.assertDb(options);
406
- const ids = this.mapCompletionIdArg(id, 'deleteJob');
557
+ const ids = this.mapCompletionIdArg(id, 'cancel');
407
558
  const { table } = await this.getQueueCache(name);
408
- const sql = plans.deleteJobsById(this.config.schema, table);
559
+ const sql = plans.cancelJobs(this.config.schema, table);
409
560
  const result = await db.executeSql(sql, [name, ids]);
410
561
  return this.mapCommandResponse(ids, result);
411
562
  }
@@ -418,6 +569,14 @@ class Manager extends EventEmitter {
418
569
  const result = await db.executeSql(sql, [name, ids]);
419
570
  return this.mapCommandResponse(ids, result);
420
571
  }
572
+ async restore(name, id, options = {}) {
573
+ Attorney.assertQueueName(name);
574
+ const db = this.assertDb(options);
575
+ const ids = this.mapCompletionIdArg(id, 'restore');
576
+ const { table } = await this.getQueueCache(name);
577
+ const sql = plans.restoreJobs(this.config.schema, table);
578
+ await db.executeSql(sql, [name, ids]);
579
+ }
421
580
  async retry(name, id, options = {}) {
422
581
  Attorney.assertQueueName(name);
423
582
  const db = options.db || this.db;
@@ -540,6 +699,27 @@ class Manager extends EventEmitter {
540
699
  return null;
541
700
  }
542
701
  }
702
+ async findJobs(name, options = {}) {
703
+ Attorney.assertQueueName(name);
704
+ const db = this.assertDb(options);
705
+ const { table } = await this.getQueueCache(name);
706
+ const { id, key, data, queued = false } = options;
707
+ const sql = plans.findJobs(this.config.schema, table, {
708
+ byId: id !== undefined,
709
+ byKey: key !== undefined,
710
+ byData: data !== undefined,
711
+ queued
712
+ });
713
+ const values = [name];
714
+ if (id !== undefined)
715
+ values.push(id);
716
+ if (key !== undefined)
717
+ values.push(key);
718
+ if (data !== undefined)
719
+ values.push(JSON.stringify(data));
720
+ const result = await db.executeSql(sql, values);
721
+ return result?.rows || [];
722
+ }
543
723
  assertDb(options) {
544
724
  if (options.db) {
545
725
  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"}