@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 +7 -7
- package/dist/CHANGELOG.md +28 -0
- package/dist/README.md +7 -7
- package/dist/index.cjs +250 -146
- 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 +249 -146
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -10
- 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 +111 -63
- 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/index.mjs
CHANGED
|
@@ -3,6 +3,40 @@ import { CronExpressionParser } from "cron-parser";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { EventEmitter } from "node:events";
|
|
5
5
|
|
|
6
|
+
//#region src/jobs/document-to-persisted-job.ts
|
|
7
|
+
/**
|
|
8
|
+
* Convert a raw MongoDB document to a strongly-typed {@link PersistedJob}.
|
|
9
|
+
*
|
|
10
|
+
* Maps required fields directly and conditionally includes optional fields
|
|
11
|
+
* only when they are present in the document (`!== undefined`).
|
|
12
|
+
*
|
|
13
|
+
* @internal Not part of the public API.
|
|
14
|
+
* @template T - The job data payload type
|
|
15
|
+
* @param doc - The raw MongoDB document with `_id`
|
|
16
|
+
* @returns A strongly-typed PersistedJob object with guaranteed `_id`
|
|
17
|
+
*/
|
|
18
|
+
function documentToPersistedJob(doc) {
|
|
19
|
+
const job = {
|
|
20
|
+
_id: doc._id,
|
|
21
|
+
name: doc["name"],
|
|
22
|
+
data: doc["data"],
|
|
23
|
+
status: doc["status"],
|
|
24
|
+
nextRunAt: doc["nextRunAt"],
|
|
25
|
+
failCount: doc["failCount"],
|
|
26
|
+
createdAt: doc["createdAt"],
|
|
27
|
+
updatedAt: doc["updatedAt"]
|
|
28
|
+
};
|
|
29
|
+
if (doc["lockedAt"] !== void 0) job.lockedAt = doc["lockedAt"];
|
|
30
|
+
if (doc["claimedBy"] !== void 0) job.claimedBy = doc["claimedBy"];
|
|
31
|
+
if (doc["lastHeartbeat"] !== void 0) job.lastHeartbeat = doc["lastHeartbeat"];
|
|
32
|
+
if (doc["heartbeatInterval"] !== void 0) job.heartbeatInterval = doc["heartbeatInterval"];
|
|
33
|
+
if (doc["failReason"] !== void 0) job.failReason = doc["failReason"];
|
|
34
|
+
if (doc["repeatInterval"] !== void 0) job.repeatInterval = doc["repeatInterval"];
|
|
35
|
+
if (doc["uniqueKey"] !== void 0) job.uniqueKey = doc["uniqueKey"];
|
|
36
|
+
return job;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
//#endregion
|
|
6
40
|
//#region src/jobs/types.ts
|
|
7
41
|
/**
|
|
8
42
|
* Represents the lifecycle states of a job in the queue.
|
|
@@ -561,6 +595,39 @@ function handleCronParseError(expression, error) {
|
|
|
561
595
|
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
596
|
}
|
|
563
597
|
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region src/shared/utils/error.ts
|
|
600
|
+
/**
|
|
601
|
+
* Normalize an unknown caught value into a proper `Error` instance.
|
|
602
|
+
*
|
|
603
|
+
* In JavaScript, any value can be thrown — strings, numbers, objects, `undefined`, etc.
|
|
604
|
+
* This function ensures we always have a real `Error` with a proper stack trace and message.
|
|
605
|
+
*
|
|
606
|
+
* @param value - The caught value (typically from a `catch` block typed as `unknown`).
|
|
607
|
+
* @returns The original value if already an `Error`, otherwise a new `Error` wrapping `String(value)`.
|
|
608
|
+
*
|
|
609
|
+
* @example
|
|
610
|
+
* ```ts
|
|
611
|
+
* try {
|
|
612
|
+
* riskyOperation();
|
|
613
|
+
* } catch (error: unknown) {
|
|
614
|
+
* const normalized = toError(error);
|
|
615
|
+
* console.error(normalized.message);
|
|
616
|
+
* }
|
|
617
|
+
* ```
|
|
618
|
+
*
|
|
619
|
+
* @internal
|
|
620
|
+
*/
|
|
621
|
+
function toError(value) {
|
|
622
|
+
if (value instanceof Error) return value;
|
|
623
|
+
try {
|
|
624
|
+
return new Error(String(value));
|
|
625
|
+
} catch (conversionError) {
|
|
626
|
+
const detail = conversionError instanceof Error ? conversionError.message : "unknown conversion failure";
|
|
627
|
+
return /* @__PURE__ */ new Error(`Unserializable value (${detail})`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
564
631
|
//#endregion
|
|
565
632
|
//#region src/scheduler/helpers.ts
|
|
566
633
|
/**
|
|
@@ -710,7 +777,7 @@ var ChangeStreamHandler = class {
|
|
|
710
777
|
this.debounceTimer = setTimeout(() => {
|
|
711
778
|
this.debounceTimer = null;
|
|
712
779
|
this.onPoll().catch((error) => {
|
|
713
|
-
this.ctx.emit("job:error", { error });
|
|
780
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
714
781
|
});
|
|
715
782
|
}, 100);
|
|
716
783
|
}
|
|
@@ -819,9 +886,8 @@ var JobManager = class {
|
|
|
819
886
|
const _id = new ObjectId(jobId);
|
|
820
887
|
const jobDoc = await this.ctx.collection.findOne({ _id });
|
|
821
888
|
if (!jobDoc) return null;
|
|
822
|
-
|
|
823
|
-
if (
|
|
824
|
-
if (currentJob.status !== JobStatus.PENDING) throw new JobStateError(`Cannot cancel job in status '${currentJob.status}'`, jobId, currentJob.status, "cancel");
|
|
889
|
+
if (jobDoc["status"] === JobStatus.CANCELLED) return this.ctx.documentToPersistedJob(jobDoc);
|
|
890
|
+
if (jobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot cancel job in status '${jobDoc["status"]}'`, jobId, jobDoc["status"], "cancel");
|
|
825
891
|
const result = await this.ctx.collection.findOneAndUpdate({
|
|
826
892
|
_id,
|
|
827
893
|
status: JobStatus.PENDING
|
|
@@ -906,8 +972,7 @@ var JobManager = class {
|
|
|
906
972
|
const _id = new ObjectId(jobId);
|
|
907
973
|
const currentJobDoc = await this.ctx.collection.findOne({ _id });
|
|
908
974
|
if (!currentJobDoc) return null;
|
|
909
|
-
|
|
910
|
-
if (currentJob.status !== JobStatus.PENDING) throw new JobStateError(`Cannot reschedule job in status '${currentJob.status}'`, jobId, currentJob.status, "reschedule");
|
|
975
|
+
if (currentJobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot reschedule job in status '${currentJobDoc["status"]}'`, jobId, currentJobDoc["status"], "reschedule");
|
|
911
976
|
const result = await this.ctx.collection.findOneAndUpdate({
|
|
912
977
|
_id,
|
|
913
978
|
status: JobStatus.PENDING
|
|
@@ -969,21 +1034,20 @@ var JobManager = class {
|
|
|
969
1034
|
const cancelledIds = [];
|
|
970
1035
|
const cursor = this.ctx.collection.find(baseQuery);
|
|
971
1036
|
for await (const doc of cursor) {
|
|
972
|
-
const
|
|
973
|
-
|
|
974
|
-
if (job.status !== JobStatus.PENDING && job.status !== JobStatus.CANCELLED) {
|
|
1037
|
+
const jobId = doc._id.toString();
|
|
1038
|
+
if (doc["status"] !== JobStatus.PENDING && doc["status"] !== JobStatus.CANCELLED) {
|
|
975
1039
|
errors.push({
|
|
976
1040
|
jobId,
|
|
977
|
-
error: `Cannot cancel job in status '${
|
|
1041
|
+
error: `Cannot cancel job in status '${doc["status"]}'`
|
|
978
1042
|
});
|
|
979
1043
|
continue;
|
|
980
1044
|
}
|
|
981
|
-
if (
|
|
1045
|
+
if (doc["status"] === JobStatus.CANCELLED) {
|
|
982
1046
|
cancelledIds.push(jobId);
|
|
983
1047
|
continue;
|
|
984
1048
|
}
|
|
985
1049
|
if (await this.ctx.collection.findOneAndUpdate({
|
|
986
|
-
_id:
|
|
1050
|
+
_id: doc._id,
|
|
987
1051
|
status: JobStatus.PENDING
|
|
988
1052
|
}, { $set: {
|
|
989
1053
|
status: JobStatus.CANCELLED,
|
|
@@ -1027,17 +1091,16 @@ var JobManager = class {
|
|
|
1027
1091
|
const retriedIds = [];
|
|
1028
1092
|
const cursor = this.ctx.collection.find(baseQuery);
|
|
1029
1093
|
for await (const doc of cursor) {
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
if (job.status !== JobStatus.FAILED && job.status !== JobStatus.CANCELLED) {
|
|
1094
|
+
const jobId = doc._id.toString();
|
|
1095
|
+
if (doc["status"] !== JobStatus.FAILED && doc["status"] !== JobStatus.CANCELLED) {
|
|
1033
1096
|
errors.push({
|
|
1034
1097
|
jobId,
|
|
1035
|
-
error: `Cannot retry job in status '${
|
|
1098
|
+
error: `Cannot retry job in status '${doc["status"]}'`
|
|
1036
1099
|
});
|
|
1037
1100
|
continue;
|
|
1038
1101
|
}
|
|
1039
1102
|
if (await this.ctx.collection.findOneAndUpdate({
|
|
1040
|
-
_id:
|
|
1103
|
+
_id: doc._id,
|
|
1041
1104
|
status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] }
|
|
1042
1105
|
}, {
|
|
1043
1106
|
$set: {
|
|
@@ -1110,6 +1173,8 @@ var JobManager = class {
|
|
|
1110
1173
|
* @internal Not part of public API.
|
|
1111
1174
|
*/
|
|
1112
1175
|
var JobProcessor = class {
|
|
1176
|
+
/** Guard flag to prevent concurrent poll() execution */
|
|
1177
|
+
_isPolling = false;
|
|
1113
1178
|
constructor(ctx) {
|
|
1114
1179
|
this.ctx = ctx;
|
|
1115
1180
|
}
|
|
@@ -1144,7 +1209,18 @@ var JobProcessor = class {
|
|
|
1144
1209
|
* the instance-level `instanceConcurrency` limit is reached.
|
|
1145
1210
|
*/
|
|
1146
1211
|
async poll() {
|
|
1147
|
-
if (!this.ctx.isRunning()) return;
|
|
1212
|
+
if (!this.ctx.isRunning() || this._isPolling) return;
|
|
1213
|
+
this._isPolling = true;
|
|
1214
|
+
try {
|
|
1215
|
+
await this._doPoll();
|
|
1216
|
+
} finally {
|
|
1217
|
+
this._isPolling = false;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Internal poll implementation.
|
|
1222
|
+
*/
|
|
1223
|
+
async _doPoll() {
|
|
1148
1224
|
const { instanceConcurrency } = this.ctx.options;
|
|
1149
1225
|
if (instanceConcurrency !== void 0 && this.getTotalActiveJobs() >= instanceConcurrency) return;
|
|
1150
1226
|
for (const [name, worker] of this.ctx.workers) {
|
|
@@ -1160,7 +1236,7 @@ var JobProcessor = class {
|
|
|
1160
1236
|
worker.activeJobs.set(job._id.toString(), job);
|
|
1161
1237
|
this.processJob(job, worker).catch((error) => {
|
|
1162
1238
|
this.ctx.emit("job:error", {
|
|
1163
|
-
error,
|
|
1239
|
+
error: toError(error),
|
|
1164
1240
|
job
|
|
1165
1241
|
});
|
|
1166
1242
|
});
|
|
@@ -1212,6 +1288,10 @@ var JobProcessor = class {
|
|
|
1212
1288
|
* both success and failure cases. On success, calls `completeJob()`. On failure,
|
|
1213
1289
|
* calls `failJob()` which implements exponential backoff retry logic.
|
|
1214
1290
|
*
|
|
1291
|
+
* Events are only emitted when the underlying atomic status transition succeeds,
|
|
1292
|
+
* ensuring event consumers receive reliable, consistent data backed by the actual
|
|
1293
|
+
* database state.
|
|
1294
|
+
*
|
|
1215
1295
|
* @param job - The job to process
|
|
1216
1296
|
* @param worker - The worker registration containing the handler and active job tracking
|
|
1217
1297
|
*/
|
|
@@ -1222,38 +1302,50 @@ var JobProcessor = class {
|
|
|
1222
1302
|
try {
|
|
1223
1303
|
await worker.handler(job);
|
|
1224
1304
|
const duration = Date.now() - startTime;
|
|
1225
|
-
await this.completeJob(job);
|
|
1226
|
-
this.ctx.emit("job:complete", {
|
|
1227
|
-
job,
|
|
1305
|
+
const updatedJob = await this.completeJob(job);
|
|
1306
|
+
if (updatedJob) this.ctx.emit("job:complete", {
|
|
1307
|
+
job: updatedJob,
|
|
1228
1308
|
duration
|
|
1229
1309
|
});
|
|
1230
1310
|
} catch (error) {
|
|
1231
1311
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
1232
|
-
await this.failJob(job, err);
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
job,
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1312
|
+
const updatedJob = await this.failJob(job, err);
|
|
1313
|
+
if (updatedJob) {
|
|
1314
|
+
const willRetry = updatedJob.status === JobStatus.PENDING;
|
|
1315
|
+
this.ctx.emit("job:fail", {
|
|
1316
|
+
job: updatedJob,
|
|
1317
|
+
error: err,
|
|
1318
|
+
willRetry
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1239
1321
|
} finally {
|
|
1240
1322
|
worker.activeJobs.delete(jobId);
|
|
1241
1323
|
}
|
|
1242
1324
|
}
|
|
1243
1325
|
/**
|
|
1244
|
-
* Mark a job as completed successfully.
|
|
1326
|
+
* Mark a job as completed successfully using an atomic status transition.
|
|
1327
|
+
*
|
|
1328
|
+
* Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
|
|
1329
|
+
* preconditions to ensure the transition only occurs if the job is still owned by this
|
|
1330
|
+
* scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
|
|
1331
|
+
* by another instance after stale recovery).
|
|
1245
1332
|
*
|
|
1246
1333
|
* For recurring jobs (with `repeatInterval`), schedules the next run based on the cron
|
|
1247
1334
|
* expression and resets `failCount` to 0. For one-time jobs, sets status to `completed`.
|
|
1248
1335
|
* Clears `lockedAt` and `failReason` fields in both cases.
|
|
1249
1336
|
*
|
|
1250
1337
|
* @param job - The job that completed successfully
|
|
1338
|
+
* @returns The updated job document, or `null` if the transition could not be applied
|
|
1251
1339
|
*/
|
|
1252
1340
|
async completeJob(job) {
|
|
1253
|
-
if (!isPersistedJob(job)) return;
|
|
1341
|
+
if (!isPersistedJob(job)) return null;
|
|
1254
1342
|
if (job.repeatInterval) {
|
|
1255
1343
|
const nextRunAt = getNextCronDate(job.repeatInterval);
|
|
1256
|
-
await this.ctx.collection.
|
|
1344
|
+
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1345
|
+
_id: job._id,
|
|
1346
|
+
status: JobStatus.PROCESSING,
|
|
1347
|
+
claimedBy: this.ctx.instanceId
|
|
1348
|
+
}, {
|
|
1257
1349
|
$set: {
|
|
1258
1350
|
status: JobStatus.PENDING,
|
|
1259
1351
|
nextRunAt,
|
|
@@ -1267,61 +1359,59 @@ var JobProcessor = class {
|
|
|
1267
1359
|
heartbeatInterval: "",
|
|
1268
1360
|
failReason: ""
|
|
1269
1361
|
}
|
|
1270
|
-
});
|
|
1271
|
-
|
|
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;
|
|
1362
|
+
}, { returnDocument: "after" });
|
|
1363
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
1286
1364
|
}
|
|
1365
|
+
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1366
|
+
_id: job._id,
|
|
1367
|
+
status: JobStatus.PROCESSING,
|
|
1368
|
+
claimedBy: this.ctx.instanceId
|
|
1369
|
+
}, {
|
|
1370
|
+
$set: {
|
|
1371
|
+
status: JobStatus.COMPLETED,
|
|
1372
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1373
|
+
},
|
|
1374
|
+
$unset: {
|
|
1375
|
+
lockedAt: "",
|
|
1376
|
+
claimedBy: "",
|
|
1377
|
+
lastHeartbeat: "",
|
|
1378
|
+
heartbeatInterval: "",
|
|
1379
|
+
failReason: ""
|
|
1380
|
+
}
|
|
1381
|
+
}, { returnDocument: "after" });
|
|
1382
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
1287
1383
|
}
|
|
1288
1384
|
/**
|
|
1289
|
-
* Handle job failure with exponential backoff retry logic.
|
|
1385
|
+
* Handle job failure with exponential backoff retry logic using an atomic status transition.
|
|
1386
|
+
*
|
|
1387
|
+
* Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
|
|
1388
|
+
* preconditions to ensure the transition only occurs if the job is still owned by this
|
|
1389
|
+
* scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
|
|
1390
|
+
* by another instance after stale recovery).
|
|
1290
1391
|
*
|
|
1291
1392
|
* Increments `failCount` and calculates next retry time using exponential backoff:
|
|
1292
|
-
* `nextRunAt = 2^failCount
|
|
1393
|
+
* `nextRunAt = 2^failCount * baseRetryInterval` (capped by optional `maxBackoffDelay`).
|
|
1293
1394
|
*
|
|
1294
1395
|
* If `failCount >= maxRetries`, marks job as permanently `failed`. Otherwise, resets
|
|
1295
1396
|
* to `pending` status for retry. Stores error message in `failReason` field.
|
|
1296
1397
|
*
|
|
1297
1398
|
* @param job - The job that failed
|
|
1298
1399
|
* @param error - The error that caused the failure
|
|
1400
|
+
* @returns The updated job document, or `null` if the transition could not be applied
|
|
1299
1401
|
*/
|
|
1300
1402
|
async failJob(job, error) {
|
|
1301
|
-
if (!isPersistedJob(job)) return;
|
|
1403
|
+
if (!isPersistedJob(job)) return null;
|
|
1302
1404
|
const newFailCount = job.failCount + 1;
|
|
1303
|
-
if (newFailCount >= this.ctx.options.maxRetries)
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
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 }, {
|
|
1405
|
+
if (newFailCount >= this.ctx.options.maxRetries) {
|
|
1406
|
+
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1407
|
+
_id: job._id,
|
|
1408
|
+
status: JobStatus.PROCESSING,
|
|
1409
|
+
claimedBy: this.ctx.instanceId
|
|
1410
|
+
}, {
|
|
1320
1411
|
$set: {
|
|
1321
|
-
status: JobStatus.
|
|
1412
|
+
status: JobStatus.FAILED,
|
|
1322
1413
|
failCount: newFailCount,
|
|
1323
1414
|
failReason: error.message,
|
|
1324
|
-
nextRunAt,
|
|
1325
1415
|
updatedAt: /* @__PURE__ */ new Date()
|
|
1326
1416
|
},
|
|
1327
1417
|
$unset: {
|
|
@@ -1330,8 +1420,30 @@ var JobProcessor = class {
|
|
|
1330
1420
|
lastHeartbeat: "",
|
|
1331
1421
|
heartbeatInterval: ""
|
|
1332
1422
|
}
|
|
1333
|
-
});
|
|
1423
|
+
}, { returnDocument: "after" });
|
|
1424
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
1334
1425
|
}
|
|
1426
|
+
const nextRunAt = calculateBackoff(newFailCount, this.ctx.options.baseRetryInterval, this.ctx.options.maxBackoffDelay);
|
|
1427
|
+
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1428
|
+
_id: job._id,
|
|
1429
|
+
status: JobStatus.PROCESSING,
|
|
1430
|
+
claimedBy: this.ctx.instanceId
|
|
1431
|
+
}, {
|
|
1432
|
+
$set: {
|
|
1433
|
+
status: JobStatus.PENDING,
|
|
1434
|
+
failCount: newFailCount,
|
|
1435
|
+
failReason: error.message,
|
|
1436
|
+
nextRunAt,
|
|
1437
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1438
|
+
},
|
|
1439
|
+
$unset: {
|
|
1440
|
+
lockedAt: "",
|
|
1441
|
+
claimedBy: "",
|
|
1442
|
+
lastHeartbeat: "",
|
|
1443
|
+
heartbeatInterval: ""
|
|
1444
|
+
}
|
|
1445
|
+
}, { returnDocument: "after" });
|
|
1446
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
1335
1447
|
}
|
|
1336
1448
|
/**
|
|
1337
1449
|
* Update heartbeats for all jobs claimed by this scheduler instance.
|
|
@@ -1947,7 +2059,8 @@ var Monque = class extends EventEmitter {
|
|
|
1947
2059
|
instanceConcurrency: options.instanceConcurrency ?? options.maxConcurrency,
|
|
1948
2060
|
schedulerInstanceId: options.schedulerInstanceId ?? randomUUID(),
|
|
1949
2061
|
heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
|
|
1950
|
-
jobRetention: options.jobRetention
|
|
2062
|
+
jobRetention: options.jobRetention,
|
|
2063
|
+
skipIndexCreation: options.skipIndexCreation ?? false
|
|
1951
2064
|
};
|
|
1952
2065
|
}
|
|
1953
2066
|
/**
|
|
@@ -1960,7 +2073,7 @@ var Monque = class extends EventEmitter {
|
|
|
1960
2073
|
if (this.isInitialized) return;
|
|
1961
2074
|
try {
|
|
1962
2075
|
this.collection = this.db.collection(this.options.collectionName);
|
|
1963
|
-
await this.createIndexes();
|
|
2076
|
+
if (!this.options.skipIndexCreation) await this.createIndexes();
|
|
1964
2077
|
if (this.options.recoverStaleJobs) await this.recoverStaleJobs();
|
|
1965
2078
|
const ctx = this.buildContext();
|
|
1966
2079
|
this._scheduler = new JobScheduler(ctx);
|
|
@@ -2010,7 +2123,7 @@ var Monque = class extends EventEmitter {
|
|
|
2010
2123
|
workers: this.workers,
|
|
2011
2124
|
isRunning: () => this.isRunning,
|
|
2012
2125
|
emit: (event, payload) => this.emit(event, payload),
|
|
2013
|
-
documentToPersistedJob: (doc) =>
|
|
2126
|
+
documentToPersistedJob: (doc) => documentToPersistedJob(doc)
|
|
2014
2127
|
};
|
|
2015
2128
|
}
|
|
2016
2129
|
/**
|
|
@@ -2027,43 +2140,64 @@ var Monque = class extends EventEmitter {
|
|
|
2027
2140
|
*/
|
|
2028
2141
|
async createIndexes() {
|
|
2029
2142
|
if (!this.collection) throw new ConnectionError("Collection not initialized");
|
|
2030
|
-
await this.collection.
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
}, {
|
|
2038
|
-
unique: true,
|
|
2039
|
-
partialFilterExpression: {
|
|
2040
|
-
uniqueKey: { $exists: true },
|
|
2041
|
-
status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] }
|
|
2143
|
+
await this.collection.createIndexes([
|
|
2144
|
+
{
|
|
2145
|
+
key: {
|
|
2146
|
+
status: 1,
|
|
2147
|
+
nextRunAt: 1
|
|
2148
|
+
},
|
|
2149
|
+
background: true
|
|
2042
2150
|
},
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2151
|
+
{
|
|
2152
|
+
key: {
|
|
2153
|
+
name: 1,
|
|
2154
|
+
uniqueKey: 1
|
|
2155
|
+
},
|
|
2156
|
+
unique: true,
|
|
2157
|
+
partialFilterExpression: {
|
|
2158
|
+
uniqueKey: { $exists: true },
|
|
2159
|
+
status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] }
|
|
2160
|
+
},
|
|
2161
|
+
background: true
|
|
2162
|
+
},
|
|
2163
|
+
{
|
|
2164
|
+
key: {
|
|
2165
|
+
name: 1,
|
|
2166
|
+
status: 1
|
|
2167
|
+
},
|
|
2168
|
+
background: true
|
|
2169
|
+
},
|
|
2170
|
+
{
|
|
2171
|
+
key: {
|
|
2172
|
+
claimedBy: 1,
|
|
2173
|
+
status: 1
|
|
2174
|
+
},
|
|
2175
|
+
background: true
|
|
2176
|
+
},
|
|
2177
|
+
{
|
|
2178
|
+
key: {
|
|
2179
|
+
lastHeartbeat: 1,
|
|
2180
|
+
status: 1
|
|
2181
|
+
},
|
|
2182
|
+
background: true
|
|
2183
|
+
},
|
|
2184
|
+
{
|
|
2185
|
+
key: {
|
|
2186
|
+
status: 1,
|
|
2187
|
+
nextRunAt: 1,
|
|
2188
|
+
claimedBy: 1
|
|
2189
|
+
},
|
|
2190
|
+
background: true
|
|
2191
|
+
},
|
|
2192
|
+
{
|
|
2193
|
+
key: {
|
|
2194
|
+
status: 1,
|
|
2195
|
+
lockedAt: 1,
|
|
2196
|
+
lastHeartbeat: 1
|
|
2197
|
+
},
|
|
2198
|
+
background: true
|
|
2199
|
+
}
|
|
2200
|
+
]);
|
|
2067
2201
|
}
|
|
2068
2202
|
/**
|
|
2069
2203
|
* Recover stale jobs that were left in 'processing' status.
|
|
@@ -2665,27 +2799,27 @@ var Monque = class extends EventEmitter {
|
|
|
2665
2799
|
this.changeStreamHandler.setup();
|
|
2666
2800
|
this.pollIntervalId = setInterval(() => {
|
|
2667
2801
|
this.processor.poll().catch((error) => {
|
|
2668
|
-
this.emit("job:error", { error });
|
|
2802
|
+
this.emit("job:error", { error: toError(error) });
|
|
2669
2803
|
});
|
|
2670
2804
|
}, this.options.pollInterval);
|
|
2671
2805
|
this.heartbeatIntervalId = setInterval(() => {
|
|
2672
2806
|
this.processor.updateHeartbeats().catch((error) => {
|
|
2673
|
-
this.emit("job:error", { error });
|
|
2807
|
+
this.emit("job:error", { error: toError(error) });
|
|
2674
2808
|
});
|
|
2675
2809
|
}, this.options.heartbeatInterval);
|
|
2676
2810
|
if (this.options.jobRetention) {
|
|
2677
2811
|
const interval = this.options.jobRetention.interval ?? DEFAULTS.retentionInterval;
|
|
2678
2812
|
this.cleanupJobs().catch((error) => {
|
|
2679
|
-
this.emit("job:error", { error });
|
|
2813
|
+
this.emit("job:error", { error: toError(error) });
|
|
2680
2814
|
});
|
|
2681
2815
|
this.cleanupIntervalId = setInterval(() => {
|
|
2682
2816
|
this.cleanupJobs().catch((error) => {
|
|
2683
|
-
this.emit("job:error", { error });
|
|
2817
|
+
this.emit("job:error", { error: toError(error) });
|
|
2684
2818
|
});
|
|
2685
2819
|
}, interval);
|
|
2686
2820
|
}
|
|
2687
2821
|
this.processor.poll().catch((error) => {
|
|
2688
|
-
this.emit("job:error", { error });
|
|
2822
|
+
this.emit("job:error", { error: toError(error) });
|
|
2689
2823
|
});
|
|
2690
2824
|
}
|
|
2691
2825
|
/**
|
|
@@ -2843,37 +2977,6 @@ var Monque = class extends EventEmitter {
|
|
|
2843
2977
|
return activeJobs;
|
|
2844
2978
|
}
|
|
2845
2979
|
/**
|
|
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
2980
|
* Type-safe event emitter methods
|
|
2878
2981
|
*/
|
|
2879
2982
|
emit(event, payload) {
|