@monque/core 1.2.0 → 1.4.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.
@@ -1,5 +1,5 @@
1
1
  import { isPersistedJob, type Job, JobStatus, type PersistedJob } from '@/jobs';
2
- import { calculateBackoff, getNextCronDate } from '@/shared';
2
+ import { calculateBackoff, getNextCronDate, toError } from '@/shared';
3
3
  import type { WorkerRegistration } from '@/workers';
4
4
 
5
5
  import type { SchedulerContext } from './types.js';
@@ -13,6 +13,9 @@ import type { SchedulerContext } from './types.js';
13
13
  * @internal Not part of public API.
14
14
  */
15
15
  export class JobProcessor {
16
+ /** Guard flag to prevent concurrent poll() execution */
17
+ private _isPolling = false;
18
+
16
19
  constructor(private readonly ctx: SchedulerContext) {}
17
20
 
18
21
  /**
@@ -56,10 +59,23 @@ export class JobProcessor {
56
59
  * the instance-level `instanceConcurrency` limit is reached.
57
60
  */
58
61
  async poll(): Promise<void> {
59
- if (!this.ctx.isRunning()) {
62
+ if (!this.ctx.isRunning() || this._isPolling) {
60
63
  return;
61
64
  }
62
65
 
66
+ this._isPolling = true;
67
+
68
+ try {
69
+ await this._doPoll();
70
+ } finally {
71
+ this._isPolling = false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Internal poll implementation.
77
+ */
78
+ private async _doPoll(): Promise<void> {
63
79
  // Early exit if global instanceConcurrency is reached
64
80
  const { instanceConcurrency } = this.ctx.options;
65
81
 
@@ -101,7 +117,7 @@ export class JobProcessor {
101
117
  worker.activeJobs.set(job._id.toString(), job);
102
118
 
103
119
  this.processJob(job, worker).catch((error: unknown) => {
104
- this.ctx.emit('job:error', { error: error as Error, job });
120
+ this.ctx.emit('job:error', { error: toError(error), job });
105
121
  });
106
122
  } else {
107
123
  // No more jobs available for this worker
@@ -173,6 +189,10 @@ export class JobProcessor {
173
189
  * both success and failure cases. On success, calls `completeJob()`. On failure,
174
190
  * calls `failJob()` which implements exponential backoff retry logic.
175
191
  *
192
+ * Events are only emitted when the underlying atomic status transition succeeds,
193
+ * ensuring event consumers receive reliable, consistent data backed by the actual
194
+ * database state.
195
+ *
176
196
  * @param job - The job to process
177
197
  * @param worker - The worker registration containing the handler and active job tracking
178
198
  */
@@ -186,39 +206,50 @@ export class JobProcessor {
186
206
 
187
207
  // Job completed successfully
188
208
  const duration = Date.now() - startTime;
189
- await this.completeJob(job);
190
- this.ctx.emit('job:complete', { job, duration });
209
+ const updatedJob = await this.completeJob(job);
210
+
211
+ if (updatedJob) {
212
+ this.ctx.emit('job:complete', { job: updatedJob, duration });
213
+ }
191
214
  } catch (error) {
192
215
  // Job failed
193
216
  const err = error instanceof Error ? error : new Error(String(error));
194
- await this.failJob(job, err);
217
+ const updatedJob = await this.failJob(job, err);
195
218
 
196
- const willRetry = job.failCount + 1 < this.ctx.options.maxRetries;
197
- this.ctx.emit('job:fail', { job, error: err, willRetry });
219
+ if (updatedJob) {
220
+ const willRetry = updatedJob.status === JobStatus.PENDING;
221
+ this.ctx.emit('job:fail', { job: updatedJob, error: err, willRetry });
222
+ }
198
223
  } finally {
199
224
  worker.activeJobs.delete(jobId);
200
225
  }
201
226
  }
202
227
 
203
228
  /**
204
- * Mark a job as completed successfully.
229
+ * Mark a job as completed successfully using an atomic status transition.
230
+ *
231
+ * Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
232
+ * preconditions to ensure the transition only occurs if the job is still owned by this
233
+ * scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
234
+ * by another instance after stale recovery).
205
235
  *
206
236
  * For recurring jobs (with `repeatInterval`), schedules the next run based on the cron
207
237
  * expression and resets `failCount` to 0. For one-time jobs, sets status to `completed`.
208
238
  * Clears `lockedAt` and `failReason` fields in both cases.
209
239
  *
210
240
  * @param job - The job that completed successfully
241
+ * @returns The updated job document, or `null` if the transition could not be applied
211
242
  */
212
- async completeJob(job: Job): Promise<void> {
243
+ async completeJob(job: Job): Promise<PersistedJob | null> {
213
244
  if (!isPersistedJob(job)) {
214
- return;
245
+ return null;
215
246
  }
216
247
 
217
248
  if (job.repeatInterval) {
218
249
  // Recurring job - schedule next run
219
250
  const nextRunAt = getNextCronDate(job.repeatInterval);
220
- await this.ctx.collection.updateOne(
221
- { _id: job._id },
251
+ const result = await this.ctx.collection.findOneAndUpdate(
252
+ { _id: job._id, status: JobStatus.PROCESSING, claimedBy: this.ctx.instanceId },
222
253
  {
223
254
  $set: {
224
255
  status: JobStatus.PENDING,
@@ -234,52 +265,63 @@ export class JobProcessor {
234
265
  failReason: '',
235
266
  },
236
267
  },
268
+ { returnDocument: 'after' },
237
269
  );
238
- } else {
239
- // One-time job - mark as completed
240
- await this.ctx.collection.updateOne(
241
- { _id: job._id },
242
- {
243
- $set: {
244
- status: JobStatus.COMPLETED,
245
- updatedAt: new Date(),
246
- },
247
- $unset: {
248
- lockedAt: '',
249
- claimedBy: '',
250
- lastHeartbeat: '',
251
- heartbeatInterval: '',
252
- failReason: '',
253
- },
254
- },
255
- );
256
- job.status = JobStatus.COMPLETED;
270
+
271
+ return result ? this.ctx.documentToPersistedJob(result) : null;
257
272
  }
273
+
274
+ // One-time job - mark as completed
275
+ const result = await this.ctx.collection.findOneAndUpdate(
276
+ { _id: job._id, status: JobStatus.PROCESSING, claimedBy: this.ctx.instanceId },
277
+ {
278
+ $set: {
279
+ status: JobStatus.COMPLETED,
280
+ updatedAt: new Date(),
281
+ },
282
+ $unset: {
283
+ lockedAt: '',
284
+ claimedBy: '',
285
+ lastHeartbeat: '',
286
+ heartbeatInterval: '',
287
+ failReason: '',
288
+ },
289
+ },
290
+ { returnDocument: 'after' },
291
+ );
292
+
293
+ return result ? this.ctx.documentToPersistedJob(result) : null;
258
294
  }
259
295
 
260
296
  /**
261
- * Handle job failure with exponential backoff retry logic.
297
+ * Handle job failure with exponential backoff retry logic using an atomic status transition.
298
+ *
299
+ * Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
300
+ * preconditions to ensure the transition only occurs if the job is still owned by this
301
+ * scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
302
+ * by another instance after stale recovery).
262
303
  *
263
304
  * Increments `failCount` and calculates next retry time using exponential backoff:
264
- * `nextRunAt = 2^failCount × baseRetryInterval` (capped by optional `maxBackoffDelay`).
305
+ * `nextRunAt = 2^failCount * baseRetryInterval` (capped by optional `maxBackoffDelay`).
265
306
  *
266
307
  * If `failCount >= maxRetries`, marks job as permanently `failed`. Otherwise, resets
267
308
  * to `pending` status for retry. Stores error message in `failReason` field.
268
309
  *
269
310
  * @param job - The job that failed
270
311
  * @param error - The error that caused the failure
312
+ * @returns The updated job document, or `null` if the transition could not be applied
271
313
  */
272
- async failJob(job: Job, error: Error): Promise<void> {
314
+ async failJob(job: Job, error: Error): Promise<PersistedJob | null> {
273
315
  if (!isPersistedJob(job)) {
274
- return;
316
+ return null;
275
317
  }
276
318
 
277
319
  const newFailCount = job.failCount + 1;
278
320
 
279
321
  if (newFailCount >= this.ctx.options.maxRetries) {
280
322
  // Permanent failure
281
- await this.ctx.collection.updateOne(
282
- { _id: job._id },
323
+ const result = await this.ctx.collection.findOneAndUpdate(
324
+ { _id: job._id, status: JobStatus.PROCESSING, claimedBy: this.ctx.instanceId },
283
325
  {
284
326
  $set: {
285
327
  status: JobStatus.FAILED,
@@ -294,34 +336,40 @@ export class JobProcessor {
294
336
  heartbeatInterval: '',
295
337
  },
296
338
  },
297
- );
298
- } else {
299
- // Schedule retry with exponential backoff
300
- const nextRunAt = calculateBackoff(
301
- newFailCount,
302
- this.ctx.options.baseRetryInterval,
303
- this.ctx.options.maxBackoffDelay,
339
+ { returnDocument: 'after' },
304
340
  );
305
341
 
306
- await this.ctx.collection.updateOne(
307
- { _id: job._id },
308
- {
309
- $set: {
310
- status: JobStatus.PENDING,
311
- failCount: newFailCount,
312
- failReason: error.message,
313
- nextRunAt,
314
- updatedAt: new Date(),
315
- },
316
- $unset: {
317
- lockedAt: '',
318
- claimedBy: '',
319
- lastHeartbeat: '',
320
- heartbeatInterval: '',
321
- },
322
- },
323
- );
342
+ return result ? this.ctx.documentToPersistedJob(result) : null;
324
343
  }
344
+
345
+ // Schedule retry with exponential backoff
346
+ const nextRunAt = calculateBackoff(
347
+ newFailCount,
348
+ this.ctx.options.baseRetryInterval,
349
+ this.ctx.options.maxBackoffDelay,
350
+ );
351
+
352
+ const result = await this.ctx.collection.findOneAndUpdate(
353
+ { _id: job._id, status: JobStatus.PROCESSING, claimedBy: this.ctx.instanceId },
354
+ {
355
+ $set: {
356
+ status: JobStatus.PENDING,
357
+ failCount: newFailCount,
358
+ failReason: error.message,
359
+ nextRunAt,
360
+ updatedAt: new Date(),
361
+ },
362
+ $unset: {
363
+ lockedAt: '',
364
+ claimedBy: '',
365
+ lastHeartbeat: '',
366
+ heartbeatInterval: '',
367
+ },
368
+ },
369
+ { returnDocument: 'after' },
370
+ );
371
+
372
+ return result ? this.ctx.documentToPersistedJob(result) : null;
325
373
  }
326
374
 
327
375
  /**
@@ -162,4 +162,15 @@ export interface MonqueOptions {
162
162
  * @deprecated Use `instanceConcurrency` instead. Will be removed in a future major version.
163
163
  */
164
164
  maxConcurrency?: number | undefined;
165
+
166
+ /**
167
+ * Skip automatic index creation during initialization.
168
+ *
169
+ * When `true`, `initialize()` will not create MongoDB indexes. Use this in production
170
+ * environments where indexes are managed externally (e.g., via migration scripts or DBA
171
+ * tooling). See the production checklist for the full list of required indexes.
172
+ *
173
+ * @default false
174
+ */
175
+ skipIndexCreation?: boolean;
165
176
  }
@@ -14,5 +14,6 @@ export {
14
14
  DEFAULT_BASE_INTERVAL,
15
15
  DEFAULT_MAX_BACKOFF_DELAY,
16
16
  getNextCronDate,
17
+ toError,
17
18
  validateCronExpression,
18
19
  } from './utils/index.js';
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Normalize an unknown caught value into a proper `Error` instance.
3
+ *
4
+ * In JavaScript, any value can be thrown — strings, numbers, objects, `undefined`, etc.
5
+ * This function ensures we always have a real `Error` with a proper stack trace and message.
6
+ *
7
+ * @param value - The caught value (typically from a `catch` block typed as `unknown`).
8
+ * @returns The original value if already an `Error`, otherwise a new `Error` wrapping `String(value)`.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * try {
13
+ * riskyOperation();
14
+ * } catch (error: unknown) {
15
+ * const normalized = toError(error);
16
+ * console.error(normalized.message);
17
+ * }
18
+ * ```
19
+ *
20
+ * @internal
21
+ */
22
+ export function toError(value: unknown): Error {
23
+ if (value instanceof Error) return value;
24
+
25
+ try {
26
+ return new Error(String(value));
27
+ } catch (conversionError: unknown) {
28
+ const detail =
29
+ conversionError instanceof Error ? conversionError.message : 'unknown conversion failure';
30
+
31
+ return new Error(`Unserializable value (${detail})`);
32
+ }
33
+ }
@@ -5,3 +5,4 @@ export {
5
5
  DEFAULT_MAX_BACKOFF_DELAY,
6
6
  } from './backoff.js';
7
7
  export { getNextCronDate, validateCronExpression } from './cron.js';
8
+ export { toError } from './error.js';