@monque/core 1.5.2 → 1.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.
@@ -39,47 +39,48 @@ export class JobManager {
39
39
 
40
40
  const _id = new ObjectId(jobId);
41
41
 
42
- // Fetch job first to allow emitting the full job object in the event
43
- const jobDoc = await this.ctx.collection.findOne({ _id });
44
- if (!jobDoc) return null;
42
+ try {
43
+ const now = new Date();
44
+ const result = await this.ctx.collection.findOneAndUpdate(
45
+ { _id, status: JobStatus.PENDING },
46
+ {
47
+ $set: {
48
+ status: JobStatus.CANCELLED,
49
+ updatedAt: now,
50
+ },
51
+ },
52
+ { returnDocument: 'after' },
53
+ );
45
54
 
46
- if (jobDoc['status'] === JobStatus.CANCELLED) {
47
- return this.ctx.documentToPersistedJob(jobDoc);
48
- }
55
+ if (!result) {
56
+ const jobDoc = await this.ctx.collection.findOne({ _id });
57
+ if (!jobDoc) return null;
49
58
 
50
- if (jobDoc['status'] !== JobStatus.PENDING) {
51
- throw new JobStateError(
52
- `Cannot cancel job in status '${jobDoc['status']}'`,
53
- jobId,
54
- jobDoc['status'],
55
- 'cancel',
56
- );
57
- }
59
+ if (jobDoc['status'] === JobStatus.CANCELLED) {
60
+ return this.ctx.documentToPersistedJob(jobDoc);
61
+ }
58
62
 
59
- const result = await this.ctx.collection.findOneAndUpdate(
60
- { _id, status: JobStatus.PENDING },
61
- {
62
- $set: {
63
- status: JobStatus.CANCELLED,
64
- updatedAt: new Date(),
65
- },
66
- },
67
- { returnDocument: 'after' },
68
- );
69
-
70
- if (!result) {
71
- // Race condition: job changed state between check and update
72
- throw new JobStateError(
73
- 'Job status changed during cancellation attempt',
74
- jobId,
75
- 'unknown',
76
- 'cancel',
63
+ throw new JobStateError(
64
+ `Cannot cancel job in status '${jobDoc['status']}'`,
65
+ jobId,
66
+ jobDoc['status'],
67
+ 'cancel',
68
+ );
69
+ }
70
+
71
+ const job = this.ctx.documentToPersistedJob(result);
72
+ this.ctx.emit('job:cancelled', { job });
73
+ return job;
74
+ } catch (error) {
75
+ if (error instanceof MonqueError) {
76
+ throw error;
77
+ }
78
+ const message = error instanceof Error ? error.message : 'Unknown error during cancelJob';
79
+ throw new ConnectionError(
80
+ `Failed to cancel job: ${message}`,
81
+ error instanceof Error ? { cause: error } : undefined,
77
82
  );
78
83
  }
79
-
80
- const job = this.ctx.documentToPersistedJob(result);
81
- this.ctx.emit('job:cancelled', { job });
82
- return job;
83
84
  }
84
85
 
85
86
  /**
@@ -104,51 +105,69 @@ export class JobManager {
104
105
  if (!ObjectId.isValid(jobId)) return null;
105
106
 
106
107
  const _id = new ObjectId(jobId);
107
- const currentJob = await this.ctx.collection.findOne({ _id });
108
-
109
- if (!currentJob) return null;
110
108
 
111
- if (currentJob['status'] !== JobStatus.FAILED && currentJob['status'] !== JobStatus.CANCELLED) {
112
- throw new JobStateError(
113
- `Cannot retry job in status '${currentJob['status']}'`,
114
- jobId,
115
- currentJob['status'],
116
- 'retry',
109
+ try {
110
+ const now = new Date();
111
+ const result = await this.ctx.collection.findOneAndUpdate(
112
+ {
113
+ _id,
114
+ status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] },
115
+ },
116
+ {
117
+ $set: {
118
+ status: JobStatus.PENDING,
119
+ failCount: 0,
120
+ nextRunAt: now,
121
+ updatedAt: now,
122
+ },
123
+ $unset: {
124
+ failReason: '',
125
+ lockedAt: '',
126
+ claimedBy: '',
127
+ lastHeartbeat: '',
128
+ },
129
+ },
130
+ { returnDocument: 'before' },
117
131
  );
118
- }
119
132
 
120
- const previousStatus = currentJob['status'] as 'failed' | 'cancelled';
133
+ if (!result) {
134
+ const currentJob = await this.ctx.collection.findOne({ _id });
135
+ if (!currentJob) return null;
121
136
 
122
- const result = await this.ctx.collection.findOneAndUpdate(
123
- {
124
- _id,
125
- status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] },
126
- },
127
- {
128
- $set: {
129
- status: JobStatus.PENDING,
130
- failCount: 0,
131
- nextRunAt: new Date(),
132
- updatedAt: new Date(),
133
- },
134
- $unset: {
135
- failReason: '',
136
- lockedAt: '',
137
- claimedBy: '',
138
- lastHeartbeat: '',
139
- heartbeatInterval: '',
140
- },
141
- },
142
- { returnDocument: 'after' },
143
- );
137
+ throw new JobStateError(
138
+ `Cannot retry job in status '${currentJob['status']}'`,
139
+ jobId,
140
+ currentJob['status'],
141
+ 'retry',
142
+ );
143
+ }
144
144
 
145
- if (!result) {
146
- throw new JobStateError('Job status changed during retry attempt', jobId, 'unknown', 'retry');
145
+ const previousStatus = result['status'] as 'failed' | 'cancelled';
146
+
147
+ const updatedDoc = { ...result };
148
+ updatedDoc['status'] = JobStatus.PENDING;
149
+ updatedDoc['failCount'] = 0;
150
+ updatedDoc['nextRunAt'] = now;
151
+ updatedDoc['updatedAt'] = now;
152
+ delete updatedDoc['failReason'];
153
+ delete updatedDoc['lockedAt'];
154
+ delete updatedDoc['claimedBy'];
155
+ delete updatedDoc['lastHeartbeat'];
156
+
157
+ const job = this.ctx.documentToPersistedJob(updatedDoc);
158
+ this.ctx.notifyPendingJob(job.name, job.nextRunAt);
159
+ this.ctx.emit('job:retried', { job, previousStatus });
160
+ return job;
161
+ } catch (error) {
162
+ if (error instanceof MonqueError) {
163
+ throw error;
164
+ }
165
+ const message = error instanceof Error ? error.message : 'Unknown error during retryJob';
166
+ throw new ConnectionError(
167
+ `Failed to retry job: ${message}`,
168
+ error instanceof Error ? { cause: error } : undefined,
169
+ );
147
170
  }
148
-
149
- const job = this.ctx.documentToPersistedJob(result);
150
- this.ctx.emit('job:retried', { job, previousStatus });
151
- return job;
152
171
  }
153
172
 
154
173
  /**
@@ -171,40 +190,45 @@ export class JobManager {
171
190
  if (!ObjectId.isValid(jobId)) return null;
172
191
 
173
192
  const _id = new ObjectId(jobId);
174
- const currentJobDoc = await this.ctx.collection.findOne({ _id });
175
-
176
- if (!currentJobDoc) return null;
177
193
 
178
- if (currentJobDoc['status'] !== JobStatus.PENDING) {
179
- throw new JobStateError(
180
- `Cannot reschedule job in status '${currentJobDoc['status']}'`,
181
- jobId,
182
- currentJobDoc['status'],
183
- 'reschedule',
194
+ try {
195
+ const now = new Date();
196
+ const result = await this.ctx.collection.findOneAndUpdate(
197
+ { _id, status: JobStatus.PENDING },
198
+ {
199
+ $set: {
200
+ nextRunAt: runAt,
201
+ updatedAt: now,
202
+ },
203
+ },
204
+ { returnDocument: 'after' },
184
205
  );
185
- }
186
206
 
187
- const result = await this.ctx.collection.findOneAndUpdate(
188
- { _id, status: JobStatus.PENDING },
189
- {
190
- $set: {
191
- nextRunAt: runAt,
192
- updatedAt: new Date(),
193
- },
194
- },
195
- { returnDocument: 'after' },
196
- );
197
-
198
- if (!result) {
199
- throw new JobStateError(
200
- 'Job status changed during reschedule attempt',
201
- jobId,
202
- 'unknown',
203
- 'reschedule',
207
+ if (!result) {
208
+ const currentJobDoc = await this.ctx.collection.findOne({ _id });
209
+ if (!currentJobDoc) return null;
210
+
211
+ throw new JobStateError(
212
+ `Cannot reschedule job in status '${currentJobDoc['status']}'`,
213
+ jobId,
214
+ currentJobDoc['status'],
215
+ 'reschedule',
216
+ );
217
+ }
218
+
219
+ const job = this.ctx.documentToPersistedJob(result);
220
+ this.ctx.notifyPendingJob(job.name, job.nextRunAt);
221
+ return job;
222
+ } catch (error) {
223
+ if (error instanceof MonqueError) {
224
+ throw error;
225
+ }
226
+ const message = error instanceof Error ? error.message : 'Unknown error during rescheduleJob';
227
+ throw new ConnectionError(
228
+ `Failed to reschedule job: ${message}`,
229
+ error instanceof Error ? { cause: error } : undefined,
204
230
  );
205
231
  }
206
-
207
- return this.ctx.documentToPersistedJob(result);
208
232
  }
209
233
 
210
234
  /**
@@ -229,14 +253,25 @@ export class JobManager {
229
253
 
230
254
  const _id = new ObjectId(jobId);
231
255
 
232
- const result = await this.ctx.collection.deleteOne({ _id });
256
+ try {
257
+ const result = await this.ctx.collection.deleteOne({ _id });
233
258
 
234
- if (result.deletedCount > 0) {
235
- this.ctx.emit('job:deleted', { jobId });
236
- return true;
237
- }
259
+ if (result.deletedCount > 0) {
260
+ this.ctx.emit('job:deleted', { jobId });
261
+ return true;
262
+ }
238
263
 
239
- return false;
264
+ return false;
265
+ } catch (error) {
266
+ if (error instanceof MonqueError) {
267
+ throw error;
268
+ }
269
+ const message = error instanceof Error ? error.message : 'Unknown error during deleteJob';
270
+ throw new ConnectionError(
271
+ `Failed to delete job: ${message}`,
272
+ error instanceof Error ? { cause: error } : undefined,
273
+ );
274
+ }
240
275
  }
241
276
 
242
277
  // ─────────────────────────────────────────────────────────────────────────────
@@ -276,10 +311,11 @@ export class JobManager {
276
311
  query['status'] = JobStatus.PENDING;
277
312
 
278
313
  try {
314
+ const now = new Date();
279
315
  const result = await this.ctx.collection.updateMany(query, {
280
316
  $set: {
281
317
  status: JobStatus.CANCELLED,
282
- updatedAt: new Date(),
318
+ updatedAt: now,
283
319
  },
284
320
  });
285
321
 
@@ -343,19 +379,20 @@ export class JobManager {
343
379
  const spreadWindowMs = 30_000; // 30s max spread for staggered retry
344
380
 
345
381
  try {
382
+ const now = new Date();
346
383
  const result = await this.ctx.collection.updateMany(query, [
347
384
  {
348
385
  $set: {
349
386
  status: JobStatus.PENDING,
350
387
  failCount: 0,
351
388
  nextRunAt: {
352
- $add: [new Date(), { $multiply: [{ $rand: {} }, spreadWindowMs] }],
389
+ $add: [now, { $multiply: [{ $rand: {} }, spreadWindowMs] }],
353
390
  },
354
- updatedAt: new Date(),
391
+ updatedAt: now,
355
392
  },
356
393
  },
357
394
  {
358
- $unset: ['failReason', 'lockedAt', 'claimedBy', 'lastHeartbeat', 'heartbeatInterval'],
395
+ $unset: ['failReason', 'lockedAt', 'claimedBy', 'lastHeartbeat'],
359
396
  },
360
397
  ]);
361
398
 
@@ -16,20 +16,19 @@ export class JobProcessor {
16
16
  /** Guard flag to prevent concurrent poll() execution */
17
17
  private _isPolling = false;
18
18
 
19
- constructor(private readonly ctx: SchedulerContext) {}
19
+ /** Flag to request a re-poll after the current poll finishes */
20
+ private _repollRequested = false;
20
21
 
21
22
  /**
22
- * Get the total number of active jobs across all workers.
23
+ * O(1) counter tracking the total number of active jobs across all workers.
23
24
  *
24
- * Used for instance-level throttling when `instanceConcurrency` is configured.
25
+ * Incremented when a job is added to `worker.activeJobs` in `_doPoll`,
26
+ * decremented in the `processJob` finally block. Replaces the previous
27
+ * O(workers) loop in `getTotalActiveJobs()` for instance-level throttling.
25
28
  */
26
- private getTotalActiveJobs(): number {
27
- let total = 0;
28
- for (const worker of this.ctx.workers.values()) {
29
- total += worker.activeJobs.size;
30
- }
31
- return total;
32
- }
29
+ private _totalActiveJobs = 0;
30
+
31
+ constructor(private readonly ctx: SchedulerContext) {}
33
32
 
34
33
  /**
35
34
  * Get the number of available slots considering the global instanceConcurrency limit.
@@ -44,8 +43,7 @@ export class JobProcessor {
44
43
  return workerAvailableSlots;
45
44
  }
46
45
 
47
- const totalActive = this.getTotalActiveJobs();
48
- const globalAvailable = instanceConcurrency - totalActive;
46
+ const globalAvailable = instanceConcurrency - this._totalActiveJobs;
49
47
 
50
48
  return Math.min(workerAvailableSlots, globalAvailable);
51
49
  }
@@ -57,16 +55,34 @@ export class JobProcessor {
57
55
  * attempts to acquire jobs up to the worker's available concurrency slots.
58
56
  * Aborts early if the scheduler is stopping (`isRunning` is false) or if
59
57
  * the instance-level `instanceConcurrency` limit is reached.
58
+ *
59
+ * If a poll is requested while one is already running, it is queued and
60
+ * executed as a full poll after the current one finishes. This prevents
61
+ * change-stream-triggered polls from being silently dropped.
62
+ *
63
+ * @param targetNames - Optional set of worker names to poll. When provided, only the
64
+ * specified workers are checked. Used by change stream handler for targeted polling.
60
65
  */
61
- async poll(): Promise<void> {
62
- if (!this.ctx.isRunning() || this._isPolling) {
66
+ async poll(targetNames?: ReadonlySet<string>): Promise<void> {
67
+ if (!this.ctx.isRunning()) {
68
+ return;
69
+ }
70
+
71
+ if (this._isPolling) {
72
+ // Queue a re-poll so work discovered during this poll isn't missed
73
+ this._repollRequested = true;
63
74
  return;
64
75
  }
65
76
 
66
77
  this._isPolling = true;
67
78
 
68
79
  try {
69
- await this._doPoll();
80
+ do {
81
+ this._repollRequested = false;
82
+ await this._doPoll(targetNames);
83
+ // Re-polls are always full polls to catch all pending work
84
+ targetNames = undefined;
85
+ } while (this._repollRequested && this.ctx.isRunning());
70
86
  } finally {
71
87
  this._isPolling = false;
72
88
  }
@@ -75,15 +91,20 @@ export class JobProcessor {
75
91
  /**
76
92
  * Internal poll implementation.
77
93
  */
78
- private async _doPoll(): Promise<void> {
94
+ private async _doPoll(targetNames?: ReadonlySet<string>): Promise<void> {
79
95
  // Early exit if global instanceConcurrency is reached
80
96
  const { instanceConcurrency } = this.ctx.options;
81
97
 
82
- if (instanceConcurrency !== undefined && this.getTotalActiveJobs() >= instanceConcurrency) {
98
+ if (instanceConcurrency !== undefined && this._totalActiveJobs >= instanceConcurrency) {
83
99
  return;
84
100
  }
85
101
 
86
102
  for (const [name, worker] of this.ctx.workers) {
103
+ // Skip workers not in the target set (if provided)
104
+ if (targetNames && !targetNames.has(name)) {
105
+ continue;
106
+ }
107
+
87
108
  // Check if worker has capacity
88
109
  const workerAvailableSlots = worker.concurrency - worker.activeJobs.size;
89
110
 
@@ -99,31 +120,57 @@ export class JobProcessor {
99
120
  return;
100
121
  }
101
122
 
102
- // Try to acquire jobs up to available slots
123
+ // Try to acquire jobs up to available slots in parallel
124
+ if (!this.ctx.isRunning()) {
125
+ return;
126
+ }
127
+
128
+ const acquisitionPromises: Promise<void>[] = [];
103
129
  for (let i = 0; i < availableSlots; i++) {
104
- if (!this.ctx.isRunning()) {
105
- return;
106
- }
107
-
108
- // Re-check global limit before each acquisition
109
- if (instanceConcurrency !== undefined && this.getTotalActiveJobs() >= instanceConcurrency) {
110
- return;
111
- }
112
-
113
- const job = await this.acquireJob(name);
114
-
115
- if (job) {
116
- // Add to activeJobs immediately to correctly track concurrency
117
- worker.activeJobs.set(job._id.toString(), job);
118
-
119
- this.processJob(job, worker).catch((error: unknown) => {
120
- this.ctx.emit('job:error', { error: toError(error), job });
121
- });
122
- } else {
123
- // No more jobs available for this worker
124
- break;
125
- }
130
+ acquisitionPromises.push(
131
+ this.acquireJob(name)
132
+ .then(async (job) => {
133
+ if (!job) {
134
+ return;
135
+ }
136
+
137
+ if (this.ctx.isRunning()) {
138
+ // Add to activeJobs immediately to correctly track concurrency
139
+ worker.activeJobs.set(job._id.toString(), job);
140
+ this._totalActiveJobs++;
141
+
142
+ this.processJob(job, worker).catch((error: unknown) => {
143
+ this.ctx.emit('job:error', { error: toError(error), job });
144
+ });
145
+ } else {
146
+ // Revert claim if shut down while acquiring
147
+ try {
148
+ await this.ctx.collection.updateOne(
149
+ { _id: job._id, status: JobStatus.PROCESSING, claimedBy: this.ctx.instanceId },
150
+ {
151
+ $set: {
152
+ status: JobStatus.PENDING,
153
+ updatedAt: new Date(),
154
+ },
155
+ $unset: {
156
+ lockedAt: '',
157
+ claimedBy: '',
158
+ lastHeartbeat: '',
159
+ },
160
+ },
161
+ );
162
+ } catch (error) {
163
+ this.ctx.emit('job:error', { error: toError(error) });
164
+ }
165
+ }
166
+ })
167
+ .catch((error: unknown) => {
168
+ this.ctx.emit('job:error', { error: toError(error) });
169
+ }),
170
+ );
126
171
  }
172
+
173
+ await Promise.allSettled(acquisitionPromises);
127
174
  }
128
175
  }
129
176
 
@@ -171,10 +218,6 @@ export class JobProcessor {
171
218
  },
172
219
  );
173
220
 
174
- if (!this.ctx.isRunning()) {
175
- return null;
176
- }
177
-
178
221
  if (!result) {
179
222
  return null;
180
223
  }
@@ -222,6 +265,8 @@ export class JobProcessor {
222
265
  }
223
266
  } finally {
224
267
  worker.activeJobs.delete(jobId);
268
+ this._totalActiveJobs--;
269
+ this.ctx.notifyJobFinished();
225
270
  }
226
271
  }
227
272
 
@@ -245,6 +290,8 @@ export class JobProcessor {
245
290
  return null;
246
291
  }
247
292
 
293
+ const now = new Date();
294
+
248
295
  if (job.repeatInterval) {
249
296
  // Recurring job - schedule next run
250
297
  const nextRunAt = getNextCronDate(job.repeatInterval);
@@ -255,20 +302,25 @@ export class JobProcessor {
255
302
  status: JobStatus.PENDING,
256
303
  nextRunAt,
257
304
  failCount: 0,
258
- updatedAt: new Date(),
305
+ updatedAt: now,
259
306
  },
260
307
  $unset: {
261
308
  lockedAt: '',
262
309
  claimedBy: '',
263
310
  lastHeartbeat: '',
264
- heartbeatInterval: '',
265
311
  failReason: '',
266
312
  },
267
313
  },
268
314
  { returnDocument: 'after' },
269
315
  );
270
316
 
271
- return result ? this.ctx.documentToPersistedJob(result) : null;
317
+ if (!result) {
318
+ return null;
319
+ }
320
+
321
+ const persistedJob = this.ctx.documentToPersistedJob(result);
322
+ this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
323
+ return persistedJob;
272
324
  }
273
325
 
274
326
  // One-time job - mark as completed
@@ -277,20 +329,24 @@ export class JobProcessor {
277
329
  {
278
330
  $set: {
279
331
  status: JobStatus.COMPLETED,
280
- updatedAt: new Date(),
332
+ updatedAt: now,
281
333
  },
282
334
  $unset: {
283
335
  lockedAt: '',
284
336
  claimedBy: '',
285
337
  lastHeartbeat: '',
286
- heartbeatInterval: '',
287
338
  failReason: '',
288
339
  },
289
340
  },
290
341
  { returnDocument: 'after' },
291
342
  );
292
343
 
293
- return result ? this.ctx.documentToPersistedJob(result) : null;
344
+ if (!result) {
345
+ return null;
346
+ }
347
+
348
+ const persistedJob = this.ctx.documentToPersistedJob(result);
349
+ return persistedJob;
294
350
  }
295
351
 
296
352
  /**
@@ -316,6 +372,7 @@ export class JobProcessor {
316
372
  return null;
317
373
  }
318
374
 
375
+ const now = new Date();
319
376
  const newFailCount = job.failCount + 1;
320
377
 
321
378
  if (newFailCount >= this.ctx.options.maxRetries) {
@@ -327,13 +384,12 @@ export class JobProcessor {
327
384
  status: JobStatus.FAILED,
328
385
  failCount: newFailCount,
329
386
  failReason: error.message,
330
- updatedAt: new Date(),
387
+ updatedAt: now,
331
388
  },
332
389
  $unset: {
333
390
  lockedAt: '',
334
391
  claimedBy: '',
335
392
  lastHeartbeat: '',
336
- heartbeatInterval: '',
337
393
  },
338
394
  },
339
395
  { returnDocument: 'after' },
@@ -357,13 +413,12 @@ export class JobProcessor {
357
413
  failCount: newFailCount,
358
414
  failReason: error.message,
359
415
  nextRunAt,
360
- updatedAt: new Date(),
416
+ updatedAt: now,
361
417
  },
362
418
  $unset: {
363
419
  lockedAt: '',
364
420
  claimedBy: '',
365
421
  lastHeartbeat: '',
366
- heartbeatInterval: '',
367
422
  },
368
423
  },
369
424
  { returnDocument: 'after' },