@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.
package/README.md CHANGED
@@ -26,13 +26,13 @@ A **robust, type-safe MongoDB job queue** for TypeScript with atomic locking, ex
26
26
 
27
27
  ## Features
28
28
 
29
- - 🔒 **Atomic Locking**: Mandatory `findOneAndUpdate` for safe job acquisition in distributed environments.
30
- - 📈 **Exponential Backoff**: Built-in retry logic with configurable backoff strategies.
31
- - 📅 **Cron Scheduling**: Native support for recurring jobs using standard cron syntax.
32
- - 🔍 **Type Safety**: Fully typed job payloads and worker definitions.
33
- - **Event-Driven**: Comprehensive event system for monitoring and logging.
34
- - 🛠️ **Native Driver**: Uses the native MongoDB driver for maximum performance and compatibility.
35
- - 🛑 **Graceful Shutdown**: Ensures all in-progress jobs finish or are safely released before stopping.
29
+ - **Atomic Locking**: Mandatory `findOneAndUpdate` for safe job acquisition in distributed environments.
30
+ - **Exponential Backoff**: Built-in retry logic with configurable backoff strategies.
31
+ - **Cron Scheduling**: Native support for recurring jobs using standard cron syntax.
32
+ - **Type Safety**: Fully typed job payloads and worker definitions.
33
+ - **Event-Driven**: Comprehensive event system for monitoring and logging.
34
+ - **Native Driver**: Uses the native MongoDB driver for maximum performance and compatibility.
35
+ - **Graceful Shutdown**: Ensures all in-progress jobs finish or are safely released before stopping.
36
36
 
37
37
  ## Installation
38
38
 
package/dist/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # @monque/core
2
2
 
3
+ ## 1.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#188](https://github.com/ueberBrot/monque/pull/188) [`9ce0ade`](https://github.com/ueberBrot/monque/commit/9ce0adebda2d47841a4420e94a440b62d10396a0) Thanks [@ueberBrot](https://github.com/ueberBrot)! - Optimize MongoDB index creation by batching 7 sequential `createIndex()` calls into a single `createIndexes()` call. This reduces index creation from 7 round-trips to 1, significantly speeding up `initialize()` on first run.
8
+
9
+ Also adds `skipIndexCreation` option to `MonqueOptions` for production deployments where indexes are managed externally (e.g., via migrations or DBA tooling).
10
+
11
+ ### Patch Changes
12
+
13
+ - [#187](https://github.com/ueberBrot/monque/pull/187) [`67c9d9a`](https://github.com/ueberBrot/monque/commit/67c9d9a2a5df6c493d5f51c15a33cd38db49cbe0) Thanks [@ueberBrot](https://github.com/ueberBrot)! - Use atomic `findOneAndUpdate` with status preconditions in `completeJob` and `failJob` to prevent phantom events when the DB write is a no-op. Events are now only emitted when the transition actually occurred, and `willRetry` is derived from the actual DB document state.
14
+
15
+ - [#173](https://github.com/ueberBrot/monque/pull/173) [`df0630a`](https://github.com/ueberBrot/monque/commit/df0630a5e5c84e3216f5b65a999ce618502f89ab) Thanks [@ueberBrot](https://github.com/ueberBrot)! - Replace unsafe `as unknown as WithId<Job>` type casts in `job-manager` with bracket notation, consistent with the existing pattern in `retryJob`.
16
+
17
+ - [#186](https://github.com/ueberBrot/monque/pull/186) [`5697370`](https://github.com/ueberBrot/monque/commit/5697370cf278302ffa6dcaaf0a4fb34ed9c3bc00) Thanks [@ueberBrot](https://github.com/ueberBrot)! - Replace 7 unsafe `error as Error` casts with a new `toError()` utility that safely normalizes unknown caught values into proper `Error` instances, preventing silent type lies when non-Error values are thrown.
18
+
19
+ - [#189](https://github.com/ueberBrot/monque/pull/189) [`1e32439`](https://github.com/ueberBrot/monque/commit/1e324395caf25309dfb7c40bc35edac60b8d80ad) Thanks [@ueberBrot](https://github.com/ueberBrot)! - Extract `documentToPersistedJob` mapper into a standalone function for testability and add round-trip unit tests to guard against silent field-dropping when new Job fields are added.
20
+
21
+ ## 1.3.0
22
+
23
+ ### Minor Changes
24
+
25
+ - [#158](https://github.com/ueberBrot/monque/pull/158) [`2f83396`](https://github.com/ueberBrot/monque/commit/2f833966d7798307deaa7a1e655e0623cfb42a3e) Thanks [@renovate](https://github.com/apps/renovate)! - mongodb (^7.0.0 → ^7.1.0)
26
+
27
+ ### Patch Changes
28
+
29
+ - [#160](https://github.com/ueberBrot/monque/pull/160) [`b5fcaf8`](https://github.com/ueberBrot/monque/commit/b5fcaf8be2a49fb1ba97b8d3d9f28f00850f77a1) Thanks [@ueberBrot](https://github.com/ueberBrot)! - Fix race condition where concurrent poll cycles could exceed workerConcurrency limit. Added a guard to prevent overlapping poll() execution from setInterval and change stream triggers.
30
+
3
31
  ## 1.2.0
4
32
 
5
33
  ### Minor Changes
package/dist/README.md CHANGED
@@ -26,13 +26,13 @@ A **robust, type-safe MongoDB job queue** for TypeScript with atomic locking, ex
26
26
 
27
27
  ## Features
28
28
 
29
- - 🔒 **Atomic Locking**: Mandatory `findOneAndUpdate` for safe job acquisition in distributed environments.
30
- - 📈 **Exponential Backoff**: Built-in retry logic with configurable backoff strategies.
31
- - 📅 **Cron Scheduling**: Native support for recurring jobs using standard cron syntax.
32
- - 🔍 **Type Safety**: Fully typed job payloads and worker definitions.
33
- - **Event-Driven**: Comprehensive event system for monitoring and logging.
34
- - 🛠️ **Native Driver**: Uses the native MongoDB driver for maximum performance and compatibility.
35
- - 🛑 **Graceful Shutdown**: Ensures all in-progress jobs finish or are safely released before stopping.
29
+ - **Atomic Locking**: Mandatory `findOneAndUpdate` for safe job acquisition in distributed environments.
30
+ - **Exponential Backoff**: Built-in retry logic with configurable backoff strategies.
31
+ - **Cron Scheduling**: Native support for recurring jobs using standard cron syntax.
32
+ - **Type Safety**: Fully typed job payloads and worker definitions.
33
+ - **Event-Driven**: Comprehensive event system for monitoring and logging.
34
+ - **Native Driver**: Uses the native MongoDB driver for maximum performance and compatibility.
35
+ - **Graceful Shutdown**: Ensures all in-progress jobs finish or are safely released before stopping.
36
36
 
37
37
  ## Installation
38
38
 
package/dist/index.cjs CHANGED
@@ -1,8 +1,43 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
1
2
  let mongodb = require("mongodb");
2
3
  let cron_parser = require("cron-parser");
3
4
  let node_crypto = require("node:crypto");
4
5
  let node_events = require("node:events");
5
6
 
7
+ //#region src/jobs/document-to-persisted-job.ts
8
+ /**
9
+ * Convert a raw MongoDB document to a strongly-typed {@link PersistedJob}.
10
+ *
11
+ * Maps required fields directly and conditionally includes optional fields
12
+ * only when they are present in the document (`!== undefined`).
13
+ *
14
+ * @internal Not part of the public API.
15
+ * @template T - The job data payload type
16
+ * @param doc - The raw MongoDB document with `_id`
17
+ * @returns A strongly-typed PersistedJob object with guaranteed `_id`
18
+ */
19
+ function documentToPersistedJob(doc) {
20
+ const job = {
21
+ _id: doc._id,
22
+ name: doc["name"],
23
+ data: doc["data"],
24
+ status: doc["status"],
25
+ nextRunAt: doc["nextRunAt"],
26
+ failCount: doc["failCount"],
27
+ createdAt: doc["createdAt"],
28
+ updatedAt: doc["updatedAt"]
29
+ };
30
+ if (doc["lockedAt"] !== void 0) job.lockedAt = doc["lockedAt"];
31
+ if (doc["claimedBy"] !== void 0) job.claimedBy = doc["claimedBy"];
32
+ if (doc["lastHeartbeat"] !== void 0) job.lastHeartbeat = doc["lastHeartbeat"];
33
+ if (doc["heartbeatInterval"] !== void 0) job.heartbeatInterval = doc["heartbeatInterval"];
34
+ if (doc["failReason"] !== void 0) job.failReason = doc["failReason"];
35
+ if (doc["repeatInterval"] !== void 0) job.repeatInterval = doc["repeatInterval"];
36
+ if (doc["uniqueKey"] !== void 0) job.uniqueKey = doc["uniqueKey"];
37
+ return job;
38
+ }
39
+
40
+ //#endregion
6
41
  //#region src/jobs/types.ts
7
42
  /**
8
43
  * Represents the lifecycle states of a job in the queue.
@@ -561,6 +596,39 @@ function handleCronParseError(expression, error) {
561
596
  throw new InvalidCronError(expression, `Invalid cron expression "${expression}": ${error instanceof Error ? error.message : "Unknown parsing error"}. Expected 5-field format: "minute hour day-of-month month day-of-week" or predefined expression (e.g. @daily). Example: "0 9 * * 1" (every Monday at 9am)`);
562
597
  }
563
598
 
599
+ //#endregion
600
+ //#region src/shared/utils/error.ts
601
+ /**
602
+ * Normalize an unknown caught value into a proper `Error` instance.
603
+ *
604
+ * In JavaScript, any value can be thrown — strings, numbers, objects, `undefined`, etc.
605
+ * This function ensures we always have a real `Error` with a proper stack trace and message.
606
+ *
607
+ * @param value - The caught value (typically from a `catch` block typed as `unknown`).
608
+ * @returns The original value if already an `Error`, otherwise a new `Error` wrapping `String(value)`.
609
+ *
610
+ * @example
611
+ * ```ts
612
+ * try {
613
+ * riskyOperation();
614
+ * } catch (error: unknown) {
615
+ * const normalized = toError(error);
616
+ * console.error(normalized.message);
617
+ * }
618
+ * ```
619
+ *
620
+ * @internal
621
+ */
622
+ function toError(value) {
623
+ if (value instanceof Error) return value;
624
+ try {
625
+ return new Error(String(value));
626
+ } catch (conversionError) {
627
+ const detail = conversionError instanceof Error ? conversionError.message : "unknown conversion failure";
628
+ return /* @__PURE__ */ new Error(`Unserializable value (${detail})`);
629
+ }
630
+ }
631
+
564
632
  //#endregion
565
633
  //#region src/scheduler/helpers.ts
566
634
  /**
@@ -710,7 +778,7 @@ var ChangeStreamHandler = class {
710
778
  this.debounceTimer = setTimeout(() => {
711
779
  this.debounceTimer = null;
712
780
  this.onPoll().catch((error) => {
713
- this.ctx.emit("job:error", { error });
781
+ this.ctx.emit("job:error", { error: toError(error) });
714
782
  });
715
783
  }, 100);
716
784
  }
@@ -819,9 +887,8 @@ var JobManager = class {
819
887
  const _id = new mongodb.ObjectId(jobId);
820
888
  const jobDoc = await this.ctx.collection.findOne({ _id });
821
889
  if (!jobDoc) return null;
822
- const currentJob = jobDoc;
823
- if (currentJob.status === JobStatus.CANCELLED) return this.ctx.documentToPersistedJob(currentJob);
824
- if (currentJob.status !== JobStatus.PENDING) throw new JobStateError(`Cannot cancel job in status '${currentJob.status}'`, jobId, currentJob.status, "cancel");
890
+ if (jobDoc["status"] === JobStatus.CANCELLED) return this.ctx.documentToPersistedJob(jobDoc);
891
+ if (jobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot cancel job in status '${jobDoc["status"]}'`, jobId, jobDoc["status"], "cancel");
825
892
  const result = await this.ctx.collection.findOneAndUpdate({
826
893
  _id,
827
894
  status: JobStatus.PENDING
@@ -906,8 +973,7 @@ var JobManager = class {
906
973
  const _id = new mongodb.ObjectId(jobId);
907
974
  const currentJobDoc = await this.ctx.collection.findOne({ _id });
908
975
  if (!currentJobDoc) return null;
909
- const currentJob = currentJobDoc;
910
- if (currentJob.status !== JobStatus.PENDING) throw new JobStateError(`Cannot reschedule job in status '${currentJob.status}'`, jobId, currentJob.status, "reschedule");
976
+ if (currentJobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot reschedule job in status '${currentJobDoc["status"]}'`, jobId, currentJobDoc["status"], "reschedule");
911
977
  const result = await this.ctx.collection.findOneAndUpdate({
912
978
  _id,
913
979
  status: JobStatus.PENDING
@@ -969,21 +1035,20 @@ var JobManager = class {
969
1035
  const cancelledIds = [];
970
1036
  const cursor = this.ctx.collection.find(baseQuery);
971
1037
  for await (const doc of cursor) {
972
- const job = doc;
973
- const jobId = job._id.toString();
974
- if (job.status !== JobStatus.PENDING && job.status !== JobStatus.CANCELLED) {
1038
+ const jobId = doc._id.toString();
1039
+ if (doc["status"] !== JobStatus.PENDING && doc["status"] !== JobStatus.CANCELLED) {
975
1040
  errors.push({
976
1041
  jobId,
977
- error: `Cannot cancel job in status '${job.status}'`
1042
+ error: `Cannot cancel job in status '${doc["status"]}'`
978
1043
  });
979
1044
  continue;
980
1045
  }
981
- if (job.status === JobStatus.CANCELLED) {
1046
+ if (doc["status"] === JobStatus.CANCELLED) {
982
1047
  cancelledIds.push(jobId);
983
1048
  continue;
984
1049
  }
985
1050
  if (await this.ctx.collection.findOneAndUpdate({
986
- _id: job._id,
1051
+ _id: doc._id,
987
1052
  status: JobStatus.PENDING
988
1053
  }, { $set: {
989
1054
  status: JobStatus.CANCELLED,
@@ -1027,17 +1092,16 @@ var JobManager = class {
1027
1092
  const retriedIds = [];
1028
1093
  const cursor = this.ctx.collection.find(baseQuery);
1029
1094
  for await (const doc of cursor) {
1030
- const job = doc;
1031
- const jobId = job._id.toString();
1032
- if (job.status !== JobStatus.FAILED && job.status !== JobStatus.CANCELLED) {
1095
+ const jobId = doc._id.toString();
1096
+ if (doc["status"] !== JobStatus.FAILED && doc["status"] !== JobStatus.CANCELLED) {
1033
1097
  errors.push({
1034
1098
  jobId,
1035
- error: `Cannot retry job in status '${job.status}'`
1099
+ error: `Cannot retry job in status '${doc["status"]}'`
1036
1100
  });
1037
1101
  continue;
1038
1102
  }
1039
1103
  if (await this.ctx.collection.findOneAndUpdate({
1040
- _id: job._id,
1104
+ _id: doc._id,
1041
1105
  status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] }
1042
1106
  }, {
1043
1107
  $set: {
@@ -1110,6 +1174,8 @@ var JobManager = class {
1110
1174
  * @internal Not part of public API.
1111
1175
  */
1112
1176
  var JobProcessor = class {
1177
+ /** Guard flag to prevent concurrent poll() execution */
1178
+ _isPolling = false;
1113
1179
  constructor(ctx) {
1114
1180
  this.ctx = ctx;
1115
1181
  }
@@ -1144,7 +1210,18 @@ var JobProcessor = class {
1144
1210
  * the instance-level `instanceConcurrency` limit is reached.
1145
1211
  */
1146
1212
  async poll() {
1147
- if (!this.ctx.isRunning()) return;
1213
+ if (!this.ctx.isRunning() || this._isPolling) return;
1214
+ this._isPolling = true;
1215
+ try {
1216
+ await this._doPoll();
1217
+ } finally {
1218
+ this._isPolling = false;
1219
+ }
1220
+ }
1221
+ /**
1222
+ * Internal poll implementation.
1223
+ */
1224
+ async _doPoll() {
1148
1225
  const { instanceConcurrency } = this.ctx.options;
1149
1226
  if (instanceConcurrency !== void 0 && this.getTotalActiveJobs() >= instanceConcurrency) return;
1150
1227
  for (const [name, worker] of this.ctx.workers) {
@@ -1160,7 +1237,7 @@ var JobProcessor = class {
1160
1237
  worker.activeJobs.set(job._id.toString(), job);
1161
1238
  this.processJob(job, worker).catch((error) => {
1162
1239
  this.ctx.emit("job:error", {
1163
- error,
1240
+ error: toError(error),
1164
1241
  job
1165
1242
  });
1166
1243
  });
@@ -1212,6 +1289,10 @@ var JobProcessor = class {
1212
1289
  * both success and failure cases. On success, calls `completeJob()`. On failure,
1213
1290
  * calls `failJob()` which implements exponential backoff retry logic.
1214
1291
  *
1292
+ * Events are only emitted when the underlying atomic status transition succeeds,
1293
+ * ensuring event consumers receive reliable, consistent data backed by the actual
1294
+ * database state.
1295
+ *
1215
1296
  * @param job - The job to process
1216
1297
  * @param worker - The worker registration containing the handler and active job tracking
1217
1298
  */
@@ -1222,38 +1303,50 @@ var JobProcessor = class {
1222
1303
  try {
1223
1304
  await worker.handler(job);
1224
1305
  const duration = Date.now() - startTime;
1225
- await this.completeJob(job);
1226
- this.ctx.emit("job:complete", {
1227
- job,
1306
+ const updatedJob = await this.completeJob(job);
1307
+ if (updatedJob) this.ctx.emit("job:complete", {
1308
+ job: updatedJob,
1228
1309
  duration
1229
1310
  });
1230
1311
  } catch (error) {
1231
1312
  const err = error instanceof Error ? error : new Error(String(error));
1232
- await this.failJob(job, err);
1233
- const willRetry = job.failCount + 1 < this.ctx.options.maxRetries;
1234
- this.ctx.emit("job:fail", {
1235
- job,
1236
- error: err,
1237
- willRetry
1238
- });
1313
+ const updatedJob = await this.failJob(job, err);
1314
+ if (updatedJob) {
1315
+ const willRetry = updatedJob.status === JobStatus.PENDING;
1316
+ this.ctx.emit("job:fail", {
1317
+ job: updatedJob,
1318
+ error: err,
1319
+ willRetry
1320
+ });
1321
+ }
1239
1322
  } finally {
1240
1323
  worker.activeJobs.delete(jobId);
1241
1324
  }
1242
1325
  }
1243
1326
  /**
1244
- * Mark a job as completed successfully.
1327
+ * Mark a job as completed successfully using an atomic status transition.
1328
+ *
1329
+ * Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
1330
+ * preconditions to ensure the transition only occurs if the job is still owned by this
1331
+ * scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
1332
+ * by another instance after stale recovery).
1245
1333
  *
1246
1334
  * For recurring jobs (with `repeatInterval`), schedules the next run based on the cron
1247
1335
  * expression and resets `failCount` to 0. For one-time jobs, sets status to `completed`.
1248
1336
  * Clears `lockedAt` and `failReason` fields in both cases.
1249
1337
  *
1250
1338
  * @param job - The job that completed successfully
1339
+ * @returns The updated job document, or `null` if the transition could not be applied
1251
1340
  */
1252
1341
  async completeJob(job) {
1253
- if (!isPersistedJob(job)) return;
1342
+ if (!isPersistedJob(job)) return null;
1254
1343
  if (job.repeatInterval) {
1255
1344
  const nextRunAt = getNextCronDate(job.repeatInterval);
1256
- await this.ctx.collection.updateOne({ _id: job._id }, {
1345
+ const result = await this.ctx.collection.findOneAndUpdate({
1346
+ _id: job._id,
1347
+ status: JobStatus.PROCESSING,
1348
+ claimedBy: this.ctx.instanceId
1349
+ }, {
1257
1350
  $set: {
1258
1351
  status: JobStatus.PENDING,
1259
1352
  nextRunAt,
@@ -1267,61 +1360,59 @@ var JobProcessor = class {
1267
1360
  heartbeatInterval: "",
1268
1361
  failReason: ""
1269
1362
  }
1270
- });
1271
- } else {
1272
- await this.ctx.collection.updateOne({ _id: job._id }, {
1273
- $set: {
1274
- status: JobStatus.COMPLETED,
1275
- updatedAt: /* @__PURE__ */ new Date()
1276
- },
1277
- $unset: {
1278
- lockedAt: "",
1279
- claimedBy: "",
1280
- lastHeartbeat: "",
1281
- heartbeatInterval: "",
1282
- failReason: ""
1283
- }
1284
- });
1285
- job.status = JobStatus.COMPLETED;
1363
+ }, { returnDocument: "after" });
1364
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1286
1365
  }
1366
+ const result = await this.ctx.collection.findOneAndUpdate({
1367
+ _id: job._id,
1368
+ status: JobStatus.PROCESSING,
1369
+ claimedBy: this.ctx.instanceId
1370
+ }, {
1371
+ $set: {
1372
+ status: JobStatus.COMPLETED,
1373
+ updatedAt: /* @__PURE__ */ new Date()
1374
+ },
1375
+ $unset: {
1376
+ lockedAt: "",
1377
+ claimedBy: "",
1378
+ lastHeartbeat: "",
1379
+ heartbeatInterval: "",
1380
+ failReason: ""
1381
+ }
1382
+ }, { returnDocument: "after" });
1383
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1287
1384
  }
1288
1385
  /**
1289
- * Handle job failure with exponential backoff retry logic.
1386
+ * Handle job failure with exponential backoff retry logic using an atomic status transition.
1387
+ *
1388
+ * Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
1389
+ * preconditions to ensure the transition only occurs if the job is still owned by this
1390
+ * scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
1391
+ * by another instance after stale recovery).
1290
1392
  *
1291
1393
  * Increments `failCount` and calculates next retry time using exponential backoff:
1292
- * `nextRunAt = 2^failCount × baseRetryInterval` (capped by optional `maxBackoffDelay`).
1394
+ * `nextRunAt = 2^failCount * baseRetryInterval` (capped by optional `maxBackoffDelay`).
1293
1395
  *
1294
1396
  * If `failCount >= maxRetries`, marks job as permanently `failed`. Otherwise, resets
1295
1397
  * to `pending` status for retry. Stores error message in `failReason` field.
1296
1398
  *
1297
1399
  * @param job - The job that failed
1298
1400
  * @param error - The error that caused the failure
1401
+ * @returns The updated job document, or `null` if the transition could not be applied
1299
1402
  */
1300
1403
  async failJob(job, error) {
1301
- if (!isPersistedJob(job)) return;
1404
+ if (!isPersistedJob(job)) return null;
1302
1405
  const newFailCount = job.failCount + 1;
1303
- if (newFailCount >= this.ctx.options.maxRetries) await this.ctx.collection.updateOne({ _id: job._id }, {
1304
- $set: {
1305
- status: JobStatus.FAILED,
1306
- failCount: newFailCount,
1307
- failReason: error.message,
1308
- updatedAt: /* @__PURE__ */ new Date()
1309
- },
1310
- $unset: {
1311
- lockedAt: "",
1312
- claimedBy: "",
1313
- lastHeartbeat: "",
1314
- heartbeatInterval: ""
1315
- }
1316
- });
1317
- else {
1318
- const nextRunAt = calculateBackoff(newFailCount, this.ctx.options.baseRetryInterval, this.ctx.options.maxBackoffDelay);
1319
- await this.ctx.collection.updateOne({ _id: job._id }, {
1406
+ if (newFailCount >= this.ctx.options.maxRetries) {
1407
+ const result = await this.ctx.collection.findOneAndUpdate({
1408
+ _id: job._id,
1409
+ status: JobStatus.PROCESSING,
1410
+ claimedBy: this.ctx.instanceId
1411
+ }, {
1320
1412
  $set: {
1321
- status: JobStatus.PENDING,
1413
+ status: JobStatus.FAILED,
1322
1414
  failCount: newFailCount,
1323
1415
  failReason: error.message,
1324
- nextRunAt,
1325
1416
  updatedAt: /* @__PURE__ */ new Date()
1326
1417
  },
1327
1418
  $unset: {
@@ -1330,8 +1421,30 @@ var JobProcessor = class {
1330
1421
  lastHeartbeat: "",
1331
1422
  heartbeatInterval: ""
1332
1423
  }
1333
- });
1424
+ }, { returnDocument: "after" });
1425
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1334
1426
  }
1427
+ const nextRunAt = calculateBackoff(newFailCount, this.ctx.options.baseRetryInterval, this.ctx.options.maxBackoffDelay);
1428
+ const result = await this.ctx.collection.findOneAndUpdate({
1429
+ _id: job._id,
1430
+ status: JobStatus.PROCESSING,
1431
+ claimedBy: this.ctx.instanceId
1432
+ }, {
1433
+ $set: {
1434
+ status: JobStatus.PENDING,
1435
+ failCount: newFailCount,
1436
+ failReason: error.message,
1437
+ nextRunAt,
1438
+ updatedAt: /* @__PURE__ */ new Date()
1439
+ },
1440
+ $unset: {
1441
+ lockedAt: "",
1442
+ claimedBy: "",
1443
+ lastHeartbeat: "",
1444
+ heartbeatInterval: ""
1445
+ }
1446
+ }, { returnDocument: "after" });
1447
+ return result ? this.ctx.documentToPersistedJob(result) : null;
1335
1448
  }
1336
1449
  /**
1337
1450
  * Update heartbeats for all jobs claimed by this scheduler instance.
@@ -1947,7 +2060,8 @@ var Monque = class extends node_events.EventEmitter {
1947
2060
  instanceConcurrency: options.instanceConcurrency ?? options.maxConcurrency,
1948
2061
  schedulerInstanceId: options.schedulerInstanceId ?? (0, node_crypto.randomUUID)(),
1949
2062
  heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
1950
- jobRetention: options.jobRetention
2063
+ jobRetention: options.jobRetention,
2064
+ skipIndexCreation: options.skipIndexCreation ?? false
1951
2065
  };
1952
2066
  }
1953
2067
  /**
@@ -1960,7 +2074,7 @@ var Monque = class extends node_events.EventEmitter {
1960
2074
  if (this.isInitialized) return;
1961
2075
  try {
1962
2076
  this.collection = this.db.collection(this.options.collectionName);
1963
- await this.createIndexes();
2077
+ if (!this.options.skipIndexCreation) await this.createIndexes();
1964
2078
  if (this.options.recoverStaleJobs) await this.recoverStaleJobs();
1965
2079
  const ctx = this.buildContext();
1966
2080
  this._scheduler = new JobScheduler(ctx);
@@ -2010,7 +2124,7 @@ var Monque = class extends node_events.EventEmitter {
2010
2124
  workers: this.workers,
2011
2125
  isRunning: () => this.isRunning,
2012
2126
  emit: (event, payload) => this.emit(event, payload),
2013
- documentToPersistedJob: (doc) => this.documentToPersistedJob(doc)
2127
+ documentToPersistedJob: (doc) => documentToPersistedJob(doc)
2014
2128
  };
2015
2129
  }
2016
2130
  /**
@@ -2027,43 +2141,64 @@ var Monque = class extends node_events.EventEmitter {
2027
2141
  */
2028
2142
  async createIndexes() {
2029
2143
  if (!this.collection) throw new ConnectionError("Collection not initialized");
2030
- await this.collection.createIndex({
2031
- status: 1,
2032
- nextRunAt: 1
2033
- }, { background: true });
2034
- await this.collection.createIndex({
2035
- name: 1,
2036
- uniqueKey: 1
2037
- }, {
2038
- unique: true,
2039
- partialFilterExpression: {
2040
- uniqueKey: { $exists: true },
2041
- status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] }
2144
+ await this.collection.createIndexes([
2145
+ {
2146
+ key: {
2147
+ status: 1,
2148
+ nextRunAt: 1
2149
+ },
2150
+ background: true
2042
2151
  },
2043
- background: true
2044
- });
2045
- await this.collection.createIndex({
2046
- name: 1,
2047
- status: 1
2048
- }, { background: true });
2049
- await this.collection.createIndex({
2050
- claimedBy: 1,
2051
- status: 1
2052
- }, { background: true });
2053
- await this.collection.createIndex({
2054
- lastHeartbeat: 1,
2055
- status: 1
2056
- }, { background: true });
2057
- await this.collection.createIndex({
2058
- status: 1,
2059
- nextRunAt: 1,
2060
- claimedBy: 1
2061
- }, { background: true });
2062
- await this.collection.createIndex({
2063
- status: 1,
2064
- lockedAt: 1,
2065
- lastHeartbeat: 1
2066
- }, { background: true });
2152
+ {
2153
+ key: {
2154
+ name: 1,
2155
+ uniqueKey: 1
2156
+ },
2157
+ unique: true,
2158
+ partialFilterExpression: {
2159
+ uniqueKey: { $exists: true },
2160
+ status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] }
2161
+ },
2162
+ background: true
2163
+ },
2164
+ {
2165
+ key: {
2166
+ name: 1,
2167
+ status: 1
2168
+ },
2169
+ background: true
2170
+ },
2171
+ {
2172
+ key: {
2173
+ claimedBy: 1,
2174
+ status: 1
2175
+ },
2176
+ background: true
2177
+ },
2178
+ {
2179
+ key: {
2180
+ lastHeartbeat: 1,
2181
+ status: 1
2182
+ },
2183
+ background: true
2184
+ },
2185
+ {
2186
+ key: {
2187
+ status: 1,
2188
+ nextRunAt: 1,
2189
+ claimedBy: 1
2190
+ },
2191
+ background: true
2192
+ },
2193
+ {
2194
+ key: {
2195
+ status: 1,
2196
+ lockedAt: 1,
2197
+ lastHeartbeat: 1
2198
+ },
2199
+ background: true
2200
+ }
2201
+ ]);
2067
2202
  }
2068
2203
  /**
2069
2204
  * Recover stale jobs that were left in 'processing' status.
@@ -2665,27 +2800,27 @@ var Monque = class extends node_events.EventEmitter {
2665
2800
  this.changeStreamHandler.setup();
2666
2801
  this.pollIntervalId = setInterval(() => {
2667
2802
  this.processor.poll().catch((error) => {
2668
- this.emit("job:error", { error });
2803
+ this.emit("job:error", { error: toError(error) });
2669
2804
  });
2670
2805
  }, this.options.pollInterval);
2671
2806
  this.heartbeatIntervalId = setInterval(() => {
2672
2807
  this.processor.updateHeartbeats().catch((error) => {
2673
- this.emit("job:error", { error });
2808
+ this.emit("job:error", { error: toError(error) });
2674
2809
  });
2675
2810
  }, this.options.heartbeatInterval);
2676
2811
  if (this.options.jobRetention) {
2677
2812
  const interval = this.options.jobRetention.interval ?? DEFAULTS.retentionInterval;
2678
2813
  this.cleanupJobs().catch((error) => {
2679
- this.emit("job:error", { error });
2814
+ this.emit("job:error", { error: toError(error) });
2680
2815
  });
2681
2816
  this.cleanupIntervalId = setInterval(() => {
2682
2817
  this.cleanupJobs().catch((error) => {
2683
- this.emit("job:error", { error });
2818
+ this.emit("job:error", { error: toError(error) });
2684
2819
  });
2685
2820
  }, interval);
2686
2821
  }
2687
2822
  this.processor.poll().catch((error) => {
2688
- this.emit("job:error", { error });
2823
+ this.emit("job:error", { error: toError(error) });
2689
2824
  });
2690
2825
  }
2691
2826
  /**
@@ -2843,37 +2978,6 @@ var Monque = class extends node_events.EventEmitter {
2843
2978
  return activeJobs;
2844
2979
  }
2845
2980
  /**
2846
- * Convert a MongoDB document to a typed PersistedJob object.
2847
- *
2848
- * Maps raw MongoDB document fields to the strongly-typed `PersistedJob<T>` interface,
2849
- * ensuring type safety and handling optional fields (`lockedAt`, `failReason`, etc.).
2850
- *
2851
- * @private
2852
- * @template T - The job data payload type
2853
- * @param doc - The raw MongoDB document with `_id`
2854
- * @returns A strongly-typed PersistedJob object with guaranteed `_id`
2855
- */
2856
- documentToPersistedJob(doc) {
2857
- const job = {
2858
- _id: doc._id,
2859
- name: doc["name"],
2860
- data: doc["data"],
2861
- status: doc["status"],
2862
- nextRunAt: doc["nextRunAt"],
2863
- failCount: doc["failCount"],
2864
- createdAt: doc["createdAt"],
2865
- updatedAt: doc["updatedAt"]
2866
- };
2867
- if (doc["lockedAt"] !== void 0) job.lockedAt = doc["lockedAt"];
2868
- if (doc["claimedBy"] !== void 0) job.claimedBy = doc["claimedBy"];
2869
- if (doc["lastHeartbeat"] !== void 0) job.lastHeartbeat = doc["lastHeartbeat"];
2870
- if (doc["heartbeatInterval"] !== void 0) job.heartbeatInterval = doc["heartbeatInterval"];
2871
- if (doc["failReason"] !== void 0) job.failReason = doc["failReason"];
2872
- if (doc["repeatInterval"] !== void 0) job.repeatInterval = doc["repeatInterval"];
2873
- if (doc["uniqueKey"] !== void 0) job.uniqueKey = doc["uniqueKey"];
2874
- return job;
2875
- }
2876
- /**
2877
2981
  * Type-safe event emitter methods
2878
2982
  */
2879
2983
  emit(event, payload) {