@monque/core 1.3.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/dist/CHANGELOG.md +18 -0
- package/dist/index.cjs +235 -145
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -12
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +10 -12
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +235 -145
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/jobs/document-to-persisted-job.ts +52 -0
- package/src/jobs/index.ts +2 -0
- package/src/scheduler/monque.ts +33 -91
- package/src/scheduler/services/change-stream-handler.ts +2 -1
- package/src/scheduler/services/job-manager.ts +20 -32
- package/src/scheduler/services/job-processor.ts +94 -62
- package/src/scheduler/types.ts +11 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/utils/error.ts +33 -0
- package/src/shared/utils/index.ts +1 -0
package/dist/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
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
|
+
|
|
3
21
|
## 1.3.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/dist/index.cjs
CHANGED
|
@@ -4,6 +4,40 @@ let cron_parser = require("cron-parser");
|
|
|
4
4
|
let node_crypto = require("node:crypto");
|
|
5
5
|
let node_events = require("node:events");
|
|
6
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
|
|
7
41
|
//#region src/jobs/types.ts
|
|
8
42
|
/**
|
|
9
43
|
* Represents the lifecycle states of a job in the queue.
|
|
@@ -562,6 +596,39 @@ function handleCronParseError(expression, error) {
|
|
|
562
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)`);
|
|
563
597
|
}
|
|
564
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
|
+
|
|
565
632
|
//#endregion
|
|
566
633
|
//#region src/scheduler/helpers.ts
|
|
567
634
|
/**
|
|
@@ -711,7 +778,7 @@ var ChangeStreamHandler = class {
|
|
|
711
778
|
this.debounceTimer = setTimeout(() => {
|
|
712
779
|
this.debounceTimer = null;
|
|
713
780
|
this.onPoll().catch((error) => {
|
|
714
|
-
this.ctx.emit("job:error", { error });
|
|
781
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
715
782
|
});
|
|
716
783
|
}, 100);
|
|
717
784
|
}
|
|
@@ -820,9 +887,8 @@ var JobManager = class {
|
|
|
820
887
|
const _id = new mongodb.ObjectId(jobId);
|
|
821
888
|
const jobDoc = await this.ctx.collection.findOne({ _id });
|
|
822
889
|
if (!jobDoc) return null;
|
|
823
|
-
|
|
824
|
-
if (
|
|
825
|
-
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");
|
|
826
892
|
const result = await this.ctx.collection.findOneAndUpdate({
|
|
827
893
|
_id,
|
|
828
894
|
status: JobStatus.PENDING
|
|
@@ -907,8 +973,7 @@ var JobManager = class {
|
|
|
907
973
|
const _id = new mongodb.ObjectId(jobId);
|
|
908
974
|
const currentJobDoc = await this.ctx.collection.findOne({ _id });
|
|
909
975
|
if (!currentJobDoc) return null;
|
|
910
|
-
|
|
911
|
-
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");
|
|
912
977
|
const result = await this.ctx.collection.findOneAndUpdate({
|
|
913
978
|
_id,
|
|
914
979
|
status: JobStatus.PENDING
|
|
@@ -970,21 +1035,20 @@ var JobManager = class {
|
|
|
970
1035
|
const cancelledIds = [];
|
|
971
1036
|
const cursor = this.ctx.collection.find(baseQuery);
|
|
972
1037
|
for await (const doc of cursor) {
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
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) {
|
|
976
1040
|
errors.push({
|
|
977
1041
|
jobId,
|
|
978
|
-
error: `Cannot cancel job in status '${
|
|
1042
|
+
error: `Cannot cancel job in status '${doc["status"]}'`
|
|
979
1043
|
});
|
|
980
1044
|
continue;
|
|
981
1045
|
}
|
|
982
|
-
if (
|
|
1046
|
+
if (doc["status"] === JobStatus.CANCELLED) {
|
|
983
1047
|
cancelledIds.push(jobId);
|
|
984
1048
|
continue;
|
|
985
1049
|
}
|
|
986
1050
|
if (await this.ctx.collection.findOneAndUpdate({
|
|
987
|
-
_id:
|
|
1051
|
+
_id: doc._id,
|
|
988
1052
|
status: JobStatus.PENDING
|
|
989
1053
|
}, { $set: {
|
|
990
1054
|
status: JobStatus.CANCELLED,
|
|
@@ -1028,17 +1092,16 @@ var JobManager = class {
|
|
|
1028
1092
|
const retriedIds = [];
|
|
1029
1093
|
const cursor = this.ctx.collection.find(baseQuery);
|
|
1030
1094
|
for await (const doc of cursor) {
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1033
|
-
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) {
|
|
1034
1097
|
errors.push({
|
|
1035
1098
|
jobId,
|
|
1036
|
-
error: `Cannot retry job in status '${
|
|
1099
|
+
error: `Cannot retry job in status '${doc["status"]}'`
|
|
1037
1100
|
});
|
|
1038
1101
|
continue;
|
|
1039
1102
|
}
|
|
1040
1103
|
if (await this.ctx.collection.findOneAndUpdate({
|
|
1041
|
-
_id:
|
|
1104
|
+
_id: doc._id,
|
|
1042
1105
|
status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] }
|
|
1043
1106
|
}, {
|
|
1044
1107
|
$set: {
|
|
@@ -1174,7 +1237,7 @@ var JobProcessor = class {
|
|
|
1174
1237
|
worker.activeJobs.set(job._id.toString(), job);
|
|
1175
1238
|
this.processJob(job, worker).catch((error) => {
|
|
1176
1239
|
this.ctx.emit("job:error", {
|
|
1177
|
-
error,
|
|
1240
|
+
error: toError(error),
|
|
1178
1241
|
job
|
|
1179
1242
|
});
|
|
1180
1243
|
});
|
|
@@ -1226,6 +1289,10 @@ var JobProcessor = class {
|
|
|
1226
1289
|
* both success and failure cases. On success, calls `completeJob()`. On failure,
|
|
1227
1290
|
* calls `failJob()` which implements exponential backoff retry logic.
|
|
1228
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
|
+
*
|
|
1229
1296
|
* @param job - The job to process
|
|
1230
1297
|
* @param worker - The worker registration containing the handler and active job tracking
|
|
1231
1298
|
*/
|
|
@@ -1236,38 +1303,50 @@ var JobProcessor = class {
|
|
|
1236
1303
|
try {
|
|
1237
1304
|
await worker.handler(job);
|
|
1238
1305
|
const duration = Date.now() - startTime;
|
|
1239
|
-
await this.completeJob(job);
|
|
1240
|
-
this.ctx.emit("job:complete", {
|
|
1241
|
-
job,
|
|
1306
|
+
const updatedJob = await this.completeJob(job);
|
|
1307
|
+
if (updatedJob) this.ctx.emit("job:complete", {
|
|
1308
|
+
job: updatedJob,
|
|
1242
1309
|
duration
|
|
1243
1310
|
});
|
|
1244
1311
|
} catch (error) {
|
|
1245
1312
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
1246
|
-
await this.failJob(job, err);
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
job,
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
+
}
|
|
1253
1322
|
} finally {
|
|
1254
1323
|
worker.activeJobs.delete(jobId);
|
|
1255
1324
|
}
|
|
1256
1325
|
}
|
|
1257
1326
|
/**
|
|
1258
|
-
* 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).
|
|
1259
1333
|
*
|
|
1260
1334
|
* For recurring jobs (with `repeatInterval`), schedules the next run based on the cron
|
|
1261
1335
|
* expression and resets `failCount` to 0. For one-time jobs, sets status to `completed`.
|
|
1262
1336
|
* Clears `lockedAt` and `failReason` fields in both cases.
|
|
1263
1337
|
*
|
|
1264
1338
|
* @param job - The job that completed successfully
|
|
1339
|
+
* @returns The updated job document, or `null` if the transition could not be applied
|
|
1265
1340
|
*/
|
|
1266
1341
|
async completeJob(job) {
|
|
1267
|
-
if (!isPersistedJob(job)) return;
|
|
1342
|
+
if (!isPersistedJob(job)) return null;
|
|
1268
1343
|
if (job.repeatInterval) {
|
|
1269
1344
|
const nextRunAt = getNextCronDate(job.repeatInterval);
|
|
1270
|
-
await this.ctx.collection.
|
|
1345
|
+
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1346
|
+
_id: job._id,
|
|
1347
|
+
status: JobStatus.PROCESSING,
|
|
1348
|
+
claimedBy: this.ctx.instanceId
|
|
1349
|
+
}, {
|
|
1271
1350
|
$set: {
|
|
1272
1351
|
status: JobStatus.PENDING,
|
|
1273
1352
|
nextRunAt,
|
|
@@ -1281,61 +1360,59 @@ var JobProcessor = class {
|
|
|
1281
1360
|
heartbeatInterval: "",
|
|
1282
1361
|
failReason: ""
|
|
1283
1362
|
}
|
|
1284
|
-
});
|
|
1285
|
-
|
|
1286
|
-
await this.ctx.collection.updateOne({ _id: job._id }, {
|
|
1287
|
-
$set: {
|
|
1288
|
-
status: JobStatus.COMPLETED,
|
|
1289
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1290
|
-
},
|
|
1291
|
-
$unset: {
|
|
1292
|
-
lockedAt: "",
|
|
1293
|
-
claimedBy: "",
|
|
1294
|
-
lastHeartbeat: "",
|
|
1295
|
-
heartbeatInterval: "",
|
|
1296
|
-
failReason: ""
|
|
1297
|
-
}
|
|
1298
|
-
});
|
|
1299
|
-
job.status = JobStatus.COMPLETED;
|
|
1363
|
+
}, { returnDocument: "after" });
|
|
1364
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
1300
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;
|
|
1301
1384
|
}
|
|
1302
1385
|
/**
|
|
1303
|
-
* 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).
|
|
1304
1392
|
*
|
|
1305
1393
|
* Increments `failCount` and calculates next retry time using exponential backoff:
|
|
1306
|
-
* `nextRunAt = 2^failCount
|
|
1394
|
+
* `nextRunAt = 2^failCount * baseRetryInterval` (capped by optional `maxBackoffDelay`).
|
|
1307
1395
|
*
|
|
1308
1396
|
* If `failCount >= maxRetries`, marks job as permanently `failed`. Otherwise, resets
|
|
1309
1397
|
* to `pending` status for retry. Stores error message in `failReason` field.
|
|
1310
1398
|
*
|
|
1311
1399
|
* @param job - The job that failed
|
|
1312
1400
|
* @param error - The error that caused the failure
|
|
1401
|
+
* @returns The updated job document, or `null` if the transition could not be applied
|
|
1313
1402
|
*/
|
|
1314
1403
|
async failJob(job, error) {
|
|
1315
|
-
if (!isPersistedJob(job)) return;
|
|
1404
|
+
if (!isPersistedJob(job)) return null;
|
|
1316
1405
|
const newFailCount = job.failCount + 1;
|
|
1317
|
-
if (newFailCount >= this.ctx.options.maxRetries)
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
},
|
|
1324
|
-
$unset: {
|
|
1325
|
-
lockedAt: "",
|
|
1326
|
-
claimedBy: "",
|
|
1327
|
-
lastHeartbeat: "",
|
|
1328
|
-
heartbeatInterval: ""
|
|
1329
|
-
}
|
|
1330
|
-
});
|
|
1331
|
-
else {
|
|
1332
|
-
const nextRunAt = calculateBackoff(newFailCount, this.ctx.options.baseRetryInterval, this.ctx.options.maxBackoffDelay);
|
|
1333
|
-
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
|
+
}, {
|
|
1334
1412
|
$set: {
|
|
1335
|
-
status: JobStatus.
|
|
1413
|
+
status: JobStatus.FAILED,
|
|
1336
1414
|
failCount: newFailCount,
|
|
1337
1415
|
failReason: error.message,
|
|
1338
|
-
nextRunAt,
|
|
1339
1416
|
updatedAt: /* @__PURE__ */ new Date()
|
|
1340
1417
|
},
|
|
1341
1418
|
$unset: {
|
|
@@ -1344,8 +1421,30 @@ var JobProcessor = class {
|
|
|
1344
1421
|
lastHeartbeat: "",
|
|
1345
1422
|
heartbeatInterval: ""
|
|
1346
1423
|
}
|
|
1347
|
-
});
|
|
1424
|
+
}, { returnDocument: "after" });
|
|
1425
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
1348
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;
|
|
1349
1448
|
}
|
|
1350
1449
|
/**
|
|
1351
1450
|
* Update heartbeats for all jobs claimed by this scheduler instance.
|
|
@@ -1961,7 +2060,8 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
1961
2060
|
instanceConcurrency: options.instanceConcurrency ?? options.maxConcurrency,
|
|
1962
2061
|
schedulerInstanceId: options.schedulerInstanceId ?? (0, node_crypto.randomUUID)(),
|
|
1963
2062
|
heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
|
|
1964
|
-
jobRetention: options.jobRetention
|
|
2063
|
+
jobRetention: options.jobRetention,
|
|
2064
|
+
skipIndexCreation: options.skipIndexCreation ?? false
|
|
1965
2065
|
};
|
|
1966
2066
|
}
|
|
1967
2067
|
/**
|
|
@@ -1974,7 +2074,7 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
1974
2074
|
if (this.isInitialized) return;
|
|
1975
2075
|
try {
|
|
1976
2076
|
this.collection = this.db.collection(this.options.collectionName);
|
|
1977
|
-
await this.createIndexes();
|
|
2077
|
+
if (!this.options.skipIndexCreation) await this.createIndexes();
|
|
1978
2078
|
if (this.options.recoverStaleJobs) await this.recoverStaleJobs();
|
|
1979
2079
|
const ctx = this.buildContext();
|
|
1980
2080
|
this._scheduler = new JobScheduler(ctx);
|
|
@@ -2024,7 +2124,7 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2024
2124
|
workers: this.workers,
|
|
2025
2125
|
isRunning: () => this.isRunning,
|
|
2026
2126
|
emit: (event, payload) => this.emit(event, payload),
|
|
2027
|
-
documentToPersistedJob: (doc) =>
|
|
2127
|
+
documentToPersistedJob: (doc) => documentToPersistedJob(doc)
|
|
2028
2128
|
};
|
|
2029
2129
|
}
|
|
2030
2130
|
/**
|
|
@@ -2041,43 +2141,64 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2041
2141
|
*/
|
|
2042
2142
|
async createIndexes() {
|
|
2043
2143
|
if (!this.collection) throw new ConnectionError("Collection not initialized");
|
|
2044
|
-
await this.collection.
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
}, {
|
|
2052
|
-
unique: true,
|
|
2053
|
-
partialFilterExpression: {
|
|
2054
|
-
uniqueKey: { $exists: true },
|
|
2055
|
-
status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] }
|
|
2144
|
+
await this.collection.createIndexes([
|
|
2145
|
+
{
|
|
2146
|
+
key: {
|
|
2147
|
+
status: 1,
|
|
2148
|
+
nextRunAt: 1
|
|
2149
|
+
},
|
|
2150
|
+
background: true
|
|
2056
2151
|
},
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
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
|
+
]);
|
|
2081
2202
|
}
|
|
2082
2203
|
/**
|
|
2083
2204
|
* Recover stale jobs that were left in 'processing' status.
|
|
@@ -2679,27 +2800,27 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2679
2800
|
this.changeStreamHandler.setup();
|
|
2680
2801
|
this.pollIntervalId = setInterval(() => {
|
|
2681
2802
|
this.processor.poll().catch((error) => {
|
|
2682
|
-
this.emit("job:error", { error });
|
|
2803
|
+
this.emit("job:error", { error: toError(error) });
|
|
2683
2804
|
});
|
|
2684
2805
|
}, this.options.pollInterval);
|
|
2685
2806
|
this.heartbeatIntervalId = setInterval(() => {
|
|
2686
2807
|
this.processor.updateHeartbeats().catch((error) => {
|
|
2687
|
-
this.emit("job:error", { error });
|
|
2808
|
+
this.emit("job:error", { error: toError(error) });
|
|
2688
2809
|
});
|
|
2689
2810
|
}, this.options.heartbeatInterval);
|
|
2690
2811
|
if (this.options.jobRetention) {
|
|
2691
2812
|
const interval = this.options.jobRetention.interval ?? DEFAULTS.retentionInterval;
|
|
2692
2813
|
this.cleanupJobs().catch((error) => {
|
|
2693
|
-
this.emit("job:error", { error });
|
|
2814
|
+
this.emit("job:error", { error: toError(error) });
|
|
2694
2815
|
});
|
|
2695
2816
|
this.cleanupIntervalId = setInterval(() => {
|
|
2696
2817
|
this.cleanupJobs().catch((error) => {
|
|
2697
|
-
this.emit("job:error", { error });
|
|
2818
|
+
this.emit("job:error", { error: toError(error) });
|
|
2698
2819
|
});
|
|
2699
2820
|
}, interval);
|
|
2700
2821
|
}
|
|
2701
2822
|
this.processor.poll().catch((error) => {
|
|
2702
|
-
this.emit("job:error", { error });
|
|
2823
|
+
this.emit("job:error", { error: toError(error) });
|
|
2703
2824
|
});
|
|
2704
2825
|
}
|
|
2705
2826
|
/**
|
|
@@ -2857,37 +2978,6 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2857
2978
|
return activeJobs;
|
|
2858
2979
|
}
|
|
2859
2980
|
/**
|
|
2860
|
-
* Convert a MongoDB document to a typed PersistedJob object.
|
|
2861
|
-
*
|
|
2862
|
-
* Maps raw MongoDB document fields to the strongly-typed `PersistedJob<T>` interface,
|
|
2863
|
-
* ensuring type safety and handling optional fields (`lockedAt`, `failReason`, etc.).
|
|
2864
|
-
*
|
|
2865
|
-
* @private
|
|
2866
|
-
* @template T - The job data payload type
|
|
2867
|
-
* @param doc - The raw MongoDB document with `_id`
|
|
2868
|
-
* @returns A strongly-typed PersistedJob object with guaranteed `_id`
|
|
2869
|
-
*/
|
|
2870
|
-
documentToPersistedJob(doc) {
|
|
2871
|
-
const job = {
|
|
2872
|
-
_id: doc._id,
|
|
2873
|
-
name: doc["name"],
|
|
2874
|
-
data: doc["data"],
|
|
2875
|
-
status: doc["status"],
|
|
2876
|
-
nextRunAt: doc["nextRunAt"],
|
|
2877
|
-
failCount: doc["failCount"],
|
|
2878
|
-
createdAt: doc["createdAt"],
|
|
2879
|
-
updatedAt: doc["updatedAt"]
|
|
2880
|
-
};
|
|
2881
|
-
if (doc["lockedAt"] !== void 0) job.lockedAt = doc["lockedAt"];
|
|
2882
|
-
if (doc["claimedBy"] !== void 0) job.claimedBy = doc["claimedBy"];
|
|
2883
|
-
if (doc["lastHeartbeat"] !== void 0) job.lastHeartbeat = doc["lastHeartbeat"];
|
|
2884
|
-
if (doc["heartbeatInterval"] !== void 0) job.heartbeatInterval = doc["heartbeatInterval"];
|
|
2885
|
-
if (doc["failReason"] !== void 0) job.failReason = doc["failReason"];
|
|
2886
|
-
if (doc["repeatInterval"] !== void 0) job.repeatInterval = doc["repeatInterval"];
|
|
2887
|
-
if (doc["uniqueKey"] !== void 0) job.uniqueKey = doc["uniqueKey"];
|
|
2888
|
-
return job;
|
|
2889
|
-
}
|
|
2890
|
-
/**
|
|
2891
2981
|
* Type-safe event emitter methods
|
|
2892
2982
|
*/
|
|
2893
2983
|
emit(event, payload) {
|