@monque/core 1.3.0 → 1.5.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 +31 -0
- package/dist/index.cjs +589 -325
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -34
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +109 -34
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +590 -327
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/events/types.ts +2 -2
- package/src/index.ts +1 -0
- package/src/jobs/document-to-persisted-job.ts +52 -0
- package/src/jobs/index.ts +2 -0
- package/src/scheduler/monque.ts +124 -179
- package/src/scheduler/services/change-stream-handler.ts +2 -1
- package/src/scheduler/services/index.ts +1 -0
- package/src/scheduler/services/job-manager.ts +112 -140
- package/src/scheduler/services/job-processor.ts +94 -62
- package/src/scheduler/services/job-query.ts +81 -36
- package/src/scheduler/services/job-scheduler.ts +42 -2
- package/src/scheduler/services/lifecycle-manager.ts +154 -0
- package/src/scheduler/services/types.ts +5 -1
- package/src/scheduler/types.ts +34 -0
- package/src/shared/errors.ts +31 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/utils/error.ts +33 -0
- package/src/shared/utils/index.ts +1 -0
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,42 @@
|
|
|
1
|
-
import { ObjectId } from "mongodb";
|
|
1
|
+
import { BSON, ObjectId } from "mongodb";
|
|
2
2
|
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.
|
|
@@ -445,6 +479,32 @@ var AggregationTimeoutError = class AggregationTimeoutError extends MonqueError
|
|
|
445
479
|
if (Error.captureStackTrace) Error.captureStackTrace(this, AggregationTimeoutError);
|
|
446
480
|
}
|
|
447
481
|
};
|
|
482
|
+
/**
|
|
483
|
+
* Error thrown when a job payload exceeds the configured maximum BSON byte size.
|
|
484
|
+
*
|
|
485
|
+
* @example
|
|
486
|
+
* ```typescript
|
|
487
|
+
* const monque = new Monque(db, { maxPayloadSize: 1_000_000 }); // 1 MB
|
|
488
|
+
*
|
|
489
|
+
* try {
|
|
490
|
+
* await monque.enqueue('job', hugePayload);
|
|
491
|
+
* } catch (error) {
|
|
492
|
+
* if (error instanceof PayloadTooLargeError) {
|
|
493
|
+
* console.error(`Payload ${error.actualSize} bytes exceeds limit ${error.maxSize} bytes`);
|
|
494
|
+
* }
|
|
495
|
+
* }
|
|
496
|
+
* ```
|
|
497
|
+
*/
|
|
498
|
+
var PayloadTooLargeError = class PayloadTooLargeError extends MonqueError {
|
|
499
|
+
constructor(message, actualSize, maxSize) {
|
|
500
|
+
super(message);
|
|
501
|
+
this.actualSize = actualSize;
|
|
502
|
+
this.maxSize = maxSize;
|
|
503
|
+
this.name = "PayloadTooLargeError";
|
|
504
|
+
/* istanbul ignore next -- @preserve captureStackTrace is always available in Node.js */
|
|
505
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, PayloadTooLargeError);
|
|
506
|
+
}
|
|
507
|
+
};
|
|
448
508
|
|
|
449
509
|
//#endregion
|
|
450
510
|
//#region src/shared/utils/backoff.ts
|
|
@@ -561,6 +621,39 @@ function handleCronParseError(expression, error) {
|
|
|
561
621
|
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
622
|
}
|
|
563
623
|
|
|
624
|
+
//#endregion
|
|
625
|
+
//#region src/shared/utils/error.ts
|
|
626
|
+
/**
|
|
627
|
+
* Normalize an unknown caught value into a proper `Error` instance.
|
|
628
|
+
*
|
|
629
|
+
* In JavaScript, any value can be thrown — strings, numbers, objects, `undefined`, etc.
|
|
630
|
+
* This function ensures we always have a real `Error` with a proper stack trace and message.
|
|
631
|
+
*
|
|
632
|
+
* @param value - The caught value (typically from a `catch` block typed as `unknown`).
|
|
633
|
+
* @returns The original value if already an `Error`, otherwise a new `Error` wrapping `String(value)`.
|
|
634
|
+
*
|
|
635
|
+
* @example
|
|
636
|
+
* ```ts
|
|
637
|
+
* try {
|
|
638
|
+
* riskyOperation();
|
|
639
|
+
* } catch (error: unknown) {
|
|
640
|
+
* const normalized = toError(error);
|
|
641
|
+
* console.error(normalized.message);
|
|
642
|
+
* }
|
|
643
|
+
* ```
|
|
644
|
+
*
|
|
645
|
+
* @internal
|
|
646
|
+
*/
|
|
647
|
+
function toError(value) {
|
|
648
|
+
if (value instanceof Error) return value;
|
|
649
|
+
try {
|
|
650
|
+
return new Error(String(value));
|
|
651
|
+
} catch (conversionError) {
|
|
652
|
+
const detail = conversionError instanceof Error ? conversionError.message : "unknown conversion failure";
|
|
653
|
+
return /* @__PURE__ */ new Error(`Unserializable value (${detail})`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
564
657
|
//#endregion
|
|
565
658
|
//#region src/scheduler/helpers.ts
|
|
566
659
|
/**
|
|
@@ -710,7 +803,7 @@ var ChangeStreamHandler = class {
|
|
|
710
803
|
this.debounceTimer = setTimeout(() => {
|
|
711
804
|
this.debounceTimer = null;
|
|
712
805
|
this.onPoll().catch((error) => {
|
|
713
|
-
this.ctx.emit("job:error", { error });
|
|
806
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
714
807
|
});
|
|
715
808
|
}, 100);
|
|
716
809
|
}
|
|
@@ -819,9 +912,8 @@ var JobManager = class {
|
|
|
819
912
|
const _id = new ObjectId(jobId);
|
|
820
913
|
const jobDoc = await this.ctx.collection.findOne({ _id });
|
|
821
914
|
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");
|
|
915
|
+
if (jobDoc["status"] === JobStatus.CANCELLED) return this.ctx.documentToPersistedJob(jobDoc);
|
|
916
|
+
if (jobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot cancel job in status '${jobDoc["status"]}'`, jobId, jobDoc["status"], "cancel");
|
|
825
917
|
const result = await this.ctx.collection.findOneAndUpdate({
|
|
826
918
|
_id,
|
|
827
919
|
status: JobStatus.PENDING
|
|
@@ -906,8 +998,7 @@ var JobManager = class {
|
|
|
906
998
|
const _id = new ObjectId(jobId);
|
|
907
999
|
const currentJobDoc = await this.ctx.collection.findOne({ _id });
|
|
908
1000
|
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");
|
|
1001
|
+
if (currentJobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot reschedule job in status '${currentJobDoc["status"]}'`, jobId, currentJobDoc["status"], "reschedule");
|
|
911
1002
|
const result = await this.ctx.collection.findOneAndUpdate({
|
|
912
1003
|
_id,
|
|
913
1004
|
status: JobStatus.PENDING
|
|
@@ -945,14 +1036,15 @@ var JobManager = class {
|
|
|
945
1036
|
return false;
|
|
946
1037
|
}
|
|
947
1038
|
/**
|
|
948
|
-
* Cancel multiple jobs matching the given filter.
|
|
1039
|
+
* Cancel multiple jobs matching the given filter via a single updateMany call.
|
|
949
1040
|
*
|
|
950
|
-
* Only cancels jobs in 'pending' status
|
|
951
|
-
*
|
|
1041
|
+
* Only cancels jobs in 'pending' status — the status guard is applied regardless
|
|
1042
|
+
* of what the filter specifies. Jobs in other states are silently skipped (not
|
|
1043
|
+
* matched by the query). Emits a 'jobs:cancelled' event with the count of
|
|
952
1044
|
* successfully cancelled jobs.
|
|
953
1045
|
*
|
|
954
1046
|
* @param filter - Selector for which jobs to cancel (name, status, date range)
|
|
955
|
-
* @returns Result with count of cancelled jobs
|
|
1047
|
+
* @returns Result with count of cancelled jobs (errors array always empty for bulk ops)
|
|
956
1048
|
*
|
|
957
1049
|
* @example Cancel all pending jobs for a queue
|
|
958
1050
|
* ```typescript
|
|
@@ -964,54 +1056,39 @@ var JobManager = class {
|
|
|
964
1056
|
* ```
|
|
965
1057
|
*/
|
|
966
1058
|
async cancelJobs(filter) {
|
|
967
|
-
const
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
error: `Cannot cancel job in status '${job.status}'`
|
|
978
|
-
});
|
|
979
|
-
continue;
|
|
980
|
-
}
|
|
981
|
-
if (job.status === JobStatus.CANCELLED) {
|
|
982
|
-
cancelledIds.push(jobId);
|
|
983
|
-
continue;
|
|
984
|
-
}
|
|
985
|
-
if (await this.ctx.collection.findOneAndUpdate({
|
|
986
|
-
_id: job._id,
|
|
987
|
-
status: JobStatus.PENDING
|
|
988
|
-
}, { $set: {
|
|
1059
|
+
const query = buildSelectorQuery(filter);
|
|
1060
|
+
if (filter.status !== void 0) {
|
|
1061
|
+
if (!(Array.isArray(filter.status) ? filter.status : [filter.status]).includes(JobStatus.PENDING)) return {
|
|
1062
|
+
count: 0,
|
|
1063
|
+
errors: []
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
query["status"] = JobStatus.PENDING;
|
|
1067
|
+
try {
|
|
1068
|
+
const count = (await this.ctx.collection.updateMany(query, { $set: {
|
|
989
1069
|
status: JobStatus.CANCELLED,
|
|
990
1070
|
updatedAt: /* @__PURE__ */ new Date()
|
|
991
|
-
} }
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1071
|
+
} })).modifiedCount;
|
|
1072
|
+
if (count > 0) this.ctx.emit("jobs:cancelled", { count });
|
|
1073
|
+
return {
|
|
1074
|
+
count,
|
|
1075
|
+
errors: []
|
|
1076
|
+
};
|
|
1077
|
+
} catch (error) {
|
|
1078
|
+
if (error instanceof MonqueError) throw error;
|
|
1079
|
+
throw new ConnectionError(`Failed to cancel jobs: ${error instanceof Error ? error.message : "Unknown error during cancelJobs"}`, error instanceof Error ? { cause: error } : void 0);
|
|
996
1080
|
}
|
|
997
|
-
if (cancelledIds.length > 0) this.ctx.emit("jobs:cancelled", {
|
|
998
|
-
jobIds: cancelledIds,
|
|
999
|
-
count: cancelledIds.length
|
|
1000
|
-
});
|
|
1001
|
-
return {
|
|
1002
|
-
count: cancelledIds.length,
|
|
1003
|
-
errors
|
|
1004
|
-
};
|
|
1005
1081
|
}
|
|
1006
1082
|
/**
|
|
1007
|
-
* Retry multiple jobs matching the given filter.
|
|
1083
|
+
* Retry multiple jobs matching the given filter via a single pipeline-style updateMany call.
|
|
1008
1084
|
*
|
|
1009
|
-
* Only retries jobs in 'failed' or 'cancelled' status
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1085
|
+
* Only retries jobs in 'failed' or 'cancelled' status — the status guard is applied
|
|
1086
|
+
* regardless of what the filter specifies. Jobs in other states are silently skipped.
|
|
1087
|
+
* Uses `$rand` for per-document staggered `nextRunAt` to avoid thundering herd on retry.
|
|
1088
|
+
* Emits a 'jobs:retried' event with the count of successfully retried jobs.
|
|
1012
1089
|
*
|
|
1013
1090
|
* @param filter - Selector for which jobs to retry (name, status, date range)
|
|
1014
|
-
* @returns Result with count of retried jobs
|
|
1091
|
+
* @returns Result with count of retried jobs (errors array always empty for bulk ops)
|
|
1015
1092
|
*
|
|
1016
1093
|
* @example Retry all failed jobs
|
|
1017
1094
|
* ```typescript
|
|
@@ -1022,51 +1099,39 @@ var JobManager = class {
|
|
|
1022
1099
|
* ```
|
|
1023
1100
|
*/
|
|
1024
1101
|
async retryJobs(filter) {
|
|
1025
|
-
const
|
|
1026
|
-
const
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
}, {
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
else errors.push({
|
|
1058
|
-
jobId,
|
|
1059
|
-
error: "Job status changed during retry attempt"
|
|
1060
|
-
});
|
|
1102
|
+
const query = buildSelectorQuery(filter);
|
|
1103
|
+
const retryable = [JobStatus.FAILED, JobStatus.CANCELLED];
|
|
1104
|
+
if (filter.status !== void 0) {
|
|
1105
|
+
const allowed = (Array.isArray(filter.status) ? filter.status : [filter.status]).filter((status) => status === JobStatus.FAILED || status === JobStatus.CANCELLED);
|
|
1106
|
+
if (allowed.length === 0) return {
|
|
1107
|
+
count: 0,
|
|
1108
|
+
errors: []
|
|
1109
|
+
};
|
|
1110
|
+
query["status"] = allowed.length === 1 ? allowed[0] : { $in: allowed };
|
|
1111
|
+
} else query["status"] = { $in: retryable };
|
|
1112
|
+
const spreadWindowMs = 3e4;
|
|
1113
|
+
try {
|
|
1114
|
+
const count = (await this.ctx.collection.updateMany(query, [{ $set: {
|
|
1115
|
+
status: JobStatus.PENDING,
|
|
1116
|
+
failCount: 0,
|
|
1117
|
+
nextRunAt: { $add: [/* @__PURE__ */ new Date(), { $multiply: [{ $rand: {} }, spreadWindowMs] }] },
|
|
1118
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1119
|
+
} }, { $unset: [
|
|
1120
|
+
"failReason",
|
|
1121
|
+
"lockedAt",
|
|
1122
|
+
"claimedBy",
|
|
1123
|
+
"lastHeartbeat",
|
|
1124
|
+
"heartbeatInterval"
|
|
1125
|
+
] }])).modifiedCount;
|
|
1126
|
+
if (count > 0) this.ctx.emit("jobs:retried", { count });
|
|
1127
|
+
return {
|
|
1128
|
+
count,
|
|
1129
|
+
errors: []
|
|
1130
|
+
};
|
|
1131
|
+
} catch (error) {
|
|
1132
|
+
if (error instanceof MonqueError) throw error;
|
|
1133
|
+
throw new ConnectionError(`Failed to retry jobs: ${error instanceof Error ? error.message : "Unknown error during retryJobs"}`, error instanceof Error ? { cause: error } : void 0);
|
|
1061
1134
|
}
|
|
1062
|
-
if (retriedIds.length > 0) this.ctx.emit("jobs:retried", {
|
|
1063
|
-
jobIds: retriedIds,
|
|
1064
|
-
count: retriedIds.length
|
|
1065
|
-
});
|
|
1066
|
-
return {
|
|
1067
|
-
count: retriedIds.length,
|
|
1068
|
-
errors
|
|
1069
|
-
};
|
|
1070
1135
|
}
|
|
1071
1136
|
/**
|
|
1072
1137
|
* Delete multiple jobs matching the given filter.
|
|
@@ -1090,12 +1155,17 @@ var JobManager = class {
|
|
|
1090
1155
|
*/
|
|
1091
1156
|
async deleteJobs(filter) {
|
|
1092
1157
|
const query = buildSelectorQuery(filter);
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1158
|
+
try {
|
|
1159
|
+
const result = await this.ctx.collection.deleteMany(query);
|
|
1160
|
+
if (result.deletedCount > 0) this.ctx.emit("jobs:deleted", { count: result.deletedCount });
|
|
1161
|
+
return {
|
|
1162
|
+
count: result.deletedCount,
|
|
1163
|
+
errors: []
|
|
1164
|
+
};
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
if (error instanceof MonqueError) throw error;
|
|
1167
|
+
throw new ConnectionError(`Failed to delete jobs: ${error instanceof Error ? error.message : "Unknown error during deleteJobs"}`, error instanceof Error ? { cause: error } : void 0);
|
|
1168
|
+
}
|
|
1099
1169
|
}
|
|
1100
1170
|
};
|
|
1101
1171
|
|
|
@@ -1173,7 +1243,7 @@ var JobProcessor = class {
|
|
|
1173
1243
|
worker.activeJobs.set(job._id.toString(), job);
|
|
1174
1244
|
this.processJob(job, worker).catch((error) => {
|
|
1175
1245
|
this.ctx.emit("job:error", {
|
|
1176
|
-
error,
|
|
1246
|
+
error: toError(error),
|
|
1177
1247
|
job
|
|
1178
1248
|
});
|
|
1179
1249
|
});
|
|
@@ -1225,6 +1295,10 @@ var JobProcessor = class {
|
|
|
1225
1295
|
* both success and failure cases. On success, calls `completeJob()`. On failure,
|
|
1226
1296
|
* calls `failJob()` which implements exponential backoff retry logic.
|
|
1227
1297
|
*
|
|
1298
|
+
* Events are only emitted when the underlying atomic status transition succeeds,
|
|
1299
|
+
* ensuring event consumers receive reliable, consistent data backed by the actual
|
|
1300
|
+
* database state.
|
|
1301
|
+
*
|
|
1228
1302
|
* @param job - The job to process
|
|
1229
1303
|
* @param worker - The worker registration containing the handler and active job tracking
|
|
1230
1304
|
*/
|
|
@@ -1235,38 +1309,50 @@ var JobProcessor = class {
|
|
|
1235
1309
|
try {
|
|
1236
1310
|
await worker.handler(job);
|
|
1237
1311
|
const duration = Date.now() - startTime;
|
|
1238
|
-
await this.completeJob(job);
|
|
1239
|
-
this.ctx.emit("job:complete", {
|
|
1240
|
-
job,
|
|
1312
|
+
const updatedJob = await this.completeJob(job);
|
|
1313
|
+
if (updatedJob) this.ctx.emit("job:complete", {
|
|
1314
|
+
job: updatedJob,
|
|
1241
1315
|
duration
|
|
1242
1316
|
});
|
|
1243
1317
|
} catch (error) {
|
|
1244
1318
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
1245
|
-
await this.failJob(job, err);
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
job,
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1319
|
+
const updatedJob = await this.failJob(job, err);
|
|
1320
|
+
if (updatedJob) {
|
|
1321
|
+
const willRetry = updatedJob.status === JobStatus.PENDING;
|
|
1322
|
+
this.ctx.emit("job:fail", {
|
|
1323
|
+
job: updatedJob,
|
|
1324
|
+
error: err,
|
|
1325
|
+
willRetry
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1252
1328
|
} finally {
|
|
1253
1329
|
worker.activeJobs.delete(jobId);
|
|
1254
1330
|
}
|
|
1255
1331
|
}
|
|
1256
1332
|
/**
|
|
1257
|
-
* Mark a job as completed successfully.
|
|
1333
|
+
* Mark a job as completed successfully using an atomic status transition.
|
|
1334
|
+
*
|
|
1335
|
+
* Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
|
|
1336
|
+
* preconditions to ensure the transition only occurs if the job is still owned by this
|
|
1337
|
+
* scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
|
|
1338
|
+
* by another instance after stale recovery).
|
|
1258
1339
|
*
|
|
1259
1340
|
* For recurring jobs (with `repeatInterval`), schedules the next run based on the cron
|
|
1260
1341
|
* expression and resets `failCount` to 0. For one-time jobs, sets status to `completed`.
|
|
1261
1342
|
* Clears `lockedAt` and `failReason` fields in both cases.
|
|
1262
1343
|
*
|
|
1263
1344
|
* @param job - The job that completed successfully
|
|
1345
|
+
* @returns The updated job document, or `null` if the transition could not be applied
|
|
1264
1346
|
*/
|
|
1265
1347
|
async completeJob(job) {
|
|
1266
|
-
if (!isPersistedJob(job)) return;
|
|
1348
|
+
if (!isPersistedJob(job)) return null;
|
|
1267
1349
|
if (job.repeatInterval) {
|
|
1268
1350
|
const nextRunAt = getNextCronDate(job.repeatInterval);
|
|
1269
|
-
await this.ctx.collection.
|
|
1351
|
+
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1352
|
+
_id: job._id,
|
|
1353
|
+
status: JobStatus.PROCESSING,
|
|
1354
|
+
claimedBy: this.ctx.instanceId
|
|
1355
|
+
}, {
|
|
1270
1356
|
$set: {
|
|
1271
1357
|
status: JobStatus.PENDING,
|
|
1272
1358
|
nextRunAt,
|
|
@@ -1280,61 +1366,59 @@ var JobProcessor = class {
|
|
|
1280
1366
|
heartbeatInterval: "",
|
|
1281
1367
|
failReason: ""
|
|
1282
1368
|
}
|
|
1283
|
-
});
|
|
1284
|
-
|
|
1285
|
-
await this.ctx.collection.updateOne({ _id: job._id }, {
|
|
1286
|
-
$set: {
|
|
1287
|
-
status: JobStatus.COMPLETED,
|
|
1288
|
-
updatedAt: /* @__PURE__ */ new Date()
|
|
1289
|
-
},
|
|
1290
|
-
$unset: {
|
|
1291
|
-
lockedAt: "",
|
|
1292
|
-
claimedBy: "",
|
|
1293
|
-
lastHeartbeat: "",
|
|
1294
|
-
heartbeatInterval: "",
|
|
1295
|
-
failReason: ""
|
|
1296
|
-
}
|
|
1297
|
-
});
|
|
1298
|
-
job.status = JobStatus.COMPLETED;
|
|
1369
|
+
}, { returnDocument: "after" });
|
|
1370
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
1299
1371
|
}
|
|
1372
|
+
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1373
|
+
_id: job._id,
|
|
1374
|
+
status: JobStatus.PROCESSING,
|
|
1375
|
+
claimedBy: this.ctx.instanceId
|
|
1376
|
+
}, {
|
|
1377
|
+
$set: {
|
|
1378
|
+
status: JobStatus.COMPLETED,
|
|
1379
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1380
|
+
},
|
|
1381
|
+
$unset: {
|
|
1382
|
+
lockedAt: "",
|
|
1383
|
+
claimedBy: "",
|
|
1384
|
+
lastHeartbeat: "",
|
|
1385
|
+
heartbeatInterval: "",
|
|
1386
|
+
failReason: ""
|
|
1387
|
+
}
|
|
1388
|
+
}, { returnDocument: "after" });
|
|
1389
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
1300
1390
|
}
|
|
1301
1391
|
/**
|
|
1302
|
-
* Handle job failure with exponential backoff retry logic.
|
|
1392
|
+
* Handle job failure with exponential backoff retry logic using an atomic status transition.
|
|
1393
|
+
*
|
|
1394
|
+
* Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
|
|
1395
|
+
* preconditions to ensure the transition only occurs if the job is still owned by this
|
|
1396
|
+
* scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
|
|
1397
|
+
* by another instance after stale recovery).
|
|
1303
1398
|
*
|
|
1304
1399
|
* Increments `failCount` and calculates next retry time using exponential backoff:
|
|
1305
|
-
* `nextRunAt = 2^failCount
|
|
1400
|
+
* `nextRunAt = 2^failCount * baseRetryInterval` (capped by optional `maxBackoffDelay`).
|
|
1306
1401
|
*
|
|
1307
1402
|
* If `failCount >= maxRetries`, marks job as permanently `failed`. Otherwise, resets
|
|
1308
1403
|
* to `pending` status for retry. Stores error message in `failReason` field.
|
|
1309
1404
|
*
|
|
1310
1405
|
* @param job - The job that failed
|
|
1311
1406
|
* @param error - The error that caused the failure
|
|
1407
|
+
* @returns The updated job document, or `null` if the transition could not be applied
|
|
1312
1408
|
*/
|
|
1313
1409
|
async failJob(job, error) {
|
|
1314
|
-
if (!isPersistedJob(job)) return;
|
|
1410
|
+
if (!isPersistedJob(job)) return null;
|
|
1315
1411
|
const newFailCount = job.failCount + 1;
|
|
1316
|
-
if (newFailCount >= this.ctx.options.maxRetries)
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
},
|
|
1323
|
-
$unset: {
|
|
1324
|
-
lockedAt: "",
|
|
1325
|
-
claimedBy: "",
|
|
1326
|
-
lastHeartbeat: "",
|
|
1327
|
-
heartbeatInterval: ""
|
|
1328
|
-
}
|
|
1329
|
-
});
|
|
1330
|
-
else {
|
|
1331
|
-
const nextRunAt = calculateBackoff(newFailCount, this.ctx.options.baseRetryInterval, this.ctx.options.maxBackoffDelay);
|
|
1332
|
-
await this.ctx.collection.updateOne({ _id: job._id }, {
|
|
1412
|
+
if (newFailCount >= this.ctx.options.maxRetries) {
|
|
1413
|
+
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1414
|
+
_id: job._id,
|
|
1415
|
+
status: JobStatus.PROCESSING,
|
|
1416
|
+
claimedBy: this.ctx.instanceId
|
|
1417
|
+
}, {
|
|
1333
1418
|
$set: {
|
|
1334
|
-
status: JobStatus.
|
|
1419
|
+
status: JobStatus.FAILED,
|
|
1335
1420
|
failCount: newFailCount,
|
|
1336
1421
|
failReason: error.message,
|
|
1337
|
-
nextRunAt,
|
|
1338
1422
|
updatedAt: /* @__PURE__ */ new Date()
|
|
1339
1423
|
},
|
|
1340
1424
|
$unset: {
|
|
@@ -1343,8 +1427,30 @@ var JobProcessor = class {
|
|
|
1343
1427
|
lastHeartbeat: "",
|
|
1344
1428
|
heartbeatInterval: ""
|
|
1345
1429
|
}
|
|
1346
|
-
});
|
|
1430
|
+
}, { returnDocument: "after" });
|
|
1431
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
1347
1432
|
}
|
|
1433
|
+
const nextRunAt = calculateBackoff(newFailCount, this.ctx.options.baseRetryInterval, this.ctx.options.maxBackoffDelay);
|
|
1434
|
+
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1435
|
+
_id: job._id,
|
|
1436
|
+
status: JobStatus.PROCESSING,
|
|
1437
|
+
claimedBy: this.ctx.instanceId
|
|
1438
|
+
}, {
|
|
1439
|
+
$set: {
|
|
1440
|
+
status: JobStatus.PENDING,
|
|
1441
|
+
failCount: newFailCount,
|
|
1442
|
+
failReason: error.message,
|
|
1443
|
+
nextRunAt,
|
|
1444
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1445
|
+
},
|
|
1446
|
+
$unset: {
|
|
1447
|
+
lockedAt: "",
|
|
1448
|
+
claimedBy: "",
|
|
1449
|
+
lastHeartbeat: "",
|
|
1450
|
+
heartbeatInterval: ""
|
|
1451
|
+
}
|
|
1452
|
+
}, { returnDocument: "after" });
|
|
1453
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
1348
1454
|
}
|
|
1349
1455
|
/**
|
|
1350
1456
|
* Update heartbeats for all jobs claimed by this scheduler instance.
|
|
@@ -1378,7 +1484,9 @@ var JobProcessor = class {
|
|
|
1378
1484
|
*
|
|
1379
1485
|
* @internal Not part of public API - use Monque class methods instead.
|
|
1380
1486
|
*/
|
|
1381
|
-
var JobQueryService = class {
|
|
1487
|
+
var JobQueryService = class JobQueryService {
|
|
1488
|
+
statsCache = /* @__PURE__ */ new Map();
|
|
1489
|
+
static MAX_CACHE_SIZE = 100;
|
|
1382
1490
|
constructor(ctx) {
|
|
1383
1491
|
this.ctx = ctx;
|
|
1384
1492
|
}
|
|
@@ -1557,11 +1665,22 @@ var JobQueryService = class {
|
|
|
1557
1665
|
};
|
|
1558
1666
|
}
|
|
1559
1667
|
/**
|
|
1668
|
+
* Clear all cached getQueueStats() results.
|
|
1669
|
+
* Called on scheduler stop() for clean state on restart.
|
|
1670
|
+
* @internal
|
|
1671
|
+
*/
|
|
1672
|
+
clearStatsCache() {
|
|
1673
|
+
this.statsCache.clear();
|
|
1674
|
+
}
|
|
1675
|
+
/**
|
|
1560
1676
|
* Get aggregate statistics for the job queue.
|
|
1561
1677
|
*
|
|
1562
1678
|
* Uses MongoDB aggregation pipeline for efficient server-side calculation.
|
|
1563
1679
|
* Returns counts per status and optional average processing duration for completed jobs.
|
|
1564
1680
|
*
|
|
1681
|
+
* Results are cached per unique filter with a configurable TTL (default 5s).
|
|
1682
|
+
* Set `statsCacheTtlMs: 0` to disable caching.
|
|
1683
|
+
*
|
|
1565
1684
|
* @param filter - Optional filter to scope statistics by job name
|
|
1566
1685
|
* @returns Promise resolving to queue statistics
|
|
1567
1686
|
* @throws {AggregationTimeoutError} If aggregation exceeds 30 second timeout
|
|
@@ -1580,6 +1699,12 @@ var JobQueryService = class {
|
|
|
1580
1699
|
* ```
|
|
1581
1700
|
*/
|
|
1582
1701
|
async getQueueStats(filter) {
|
|
1702
|
+
const ttl = this.ctx.options.statsCacheTtlMs;
|
|
1703
|
+
const cacheKey = filter?.name ?? "";
|
|
1704
|
+
if (ttl > 0) {
|
|
1705
|
+
const cached = this.statsCache.get(cacheKey);
|
|
1706
|
+
if (cached && cached.expiresAt > Date.now()) return { ...cached.data };
|
|
1707
|
+
}
|
|
1583
1708
|
const matchStage = {};
|
|
1584
1709
|
if (filter?.name) matchStage["name"] = filter.name;
|
|
1585
1710
|
const pipeline = [...Object.keys(matchStage).length > 0 ? [{ $match: matchStage }] : [], { $facet: {
|
|
@@ -1603,35 +1728,47 @@ var JobQueryService = class {
|
|
|
1603
1728
|
cancelled: 0,
|
|
1604
1729
|
total: 0
|
|
1605
1730
|
};
|
|
1606
|
-
if (
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1731
|
+
if (result) {
|
|
1732
|
+
const statusCounts = result["statusCounts"];
|
|
1733
|
+
for (const entry of statusCounts) {
|
|
1734
|
+
const status = entry._id;
|
|
1735
|
+
const count = entry.count;
|
|
1736
|
+
switch (status) {
|
|
1737
|
+
case JobStatus.PENDING:
|
|
1738
|
+
stats.pending = count;
|
|
1739
|
+
break;
|
|
1740
|
+
case JobStatus.PROCESSING:
|
|
1741
|
+
stats.processing = count;
|
|
1742
|
+
break;
|
|
1743
|
+
case JobStatus.COMPLETED:
|
|
1744
|
+
stats.completed = count;
|
|
1745
|
+
break;
|
|
1746
|
+
case JobStatus.FAILED:
|
|
1747
|
+
stats.failed = count;
|
|
1748
|
+
break;
|
|
1749
|
+
case JobStatus.CANCELLED:
|
|
1750
|
+
stats.cancelled = count;
|
|
1751
|
+
break;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
const totalResult = result["total"];
|
|
1755
|
+
if (totalResult.length > 0 && totalResult[0]) stats.total = totalResult[0].count;
|
|
1756
|
+
const avgDurationResult = result["avgDuration"];
|
|
1757
|
+
if (avgDurationResult.length > 0 && avgDurationResult[0]) {
|
|
1758
|
+
const avgMs = avgDurationResult[0].avgMs;
|
|
1759
|
+
if (typeof avgMs === "number" && !Number.isNaN(avgMs)) stats.avgProcessingDurationMs = Math.round(avgMs);
|
|
1627
1760
|
}
|
|
1628
1761
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1762
|
+
if (ttl > 0) {
|
|
1763
|
+
this.statsCache.delete(cacheKey);
|
|
1764
|
+
if (this.statsCache.size >= JobQueryService.MAX_CACHE_SIZE) {
|
|
1765
|
+
const oldestKey = this.statsCache.keys().next().value;
|
|
1766
|
+
if (oldestKey !== void 0) this.statsCache.delete(oldestKey);
|
|
1767
|
+
}
|
|
1768
|
+
this.statsCache.set(cacheKey, {
|
|
1769
|
+
data: { ...stats },
|
|
1770
|
+
expiresAt: Date.now() + ttl
|
|
1771
|
+
});
|
|
1635
1772
|
}
|
|
1636
1773
|
return stats;
|
|
1637
1774
|
} catch (error) {
|
|
@@ -1656,6 +1793,26 @@ var JobScheduler = class {
|
|
|
1656
1793
|
this.ctx = ctx;
|
|
1657
1794
|
}
|
|
1658
1795
|
/**
|
|
1796
|
+
* Validate that the job data payload does not exceed the configured maximum BSON byte size.
|
|
1797
|
+
*
|
|
1798
|
+
* @param data - The job data payload to validate
|
|
1799
|
+
* @throws {PayloadTooLargeError} If the payload exceeds `maxPayloadSize`
|
|
1800
|
+
*/
|
|
1801
|
+
validatePayloadSize(data) {
|
|
1802
|
+
const maxSize = this.ctx.options.maxPayloadSize;
|
|
1803
|
+
if (maxSize === void 0) return;
|
|
1804
|
+
let size;
|
|
1805
|
+
try {
|
|
1806
|
+
size = BSON.calculateObjectSize({ data });
|
|
1807
|
+
} catch (error) {
|
|
1808
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
1809
|
+
const sizeError = new PayloadTooLargeError(`Failed to calculate job payload size: ${cause.message}`, -1, maxSize);
|
|
1810
|
+
sizeError.cause = cause;
|
|
1811
|
+
throw sizeError;
|
|
1812
|
+
}
|
|
1813
|
+
if (size > maxSize) throw new PayloadTooLargeError(`Job payload exceeds maximum size: ${size} bytes > ${maxSize} bytes`, size, maxSize);
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1659
1816
|
* Enqueue a job for processing.
|
|
1660
1817
|
*
|
|
1661
1818
|
* Jobs are stored in MongoDB and processed by registered workers. Supports
|
|
@@ -1673,6 +1830,7 @@ var JobScheduler = class {
|
|
|
1673
1830
|
* @param options - Scheduling and deduplication options
|
|
1674
1831
|
* @returns Promise resolving to the created or existing job document
|
|
1675
1832
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
1833
|
+
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
1676
1834
|
*
|
|
1677
1835
|
* @example Basic job enqueueing
|
|
1678
1836
|
* ```typescript
|
|
@@ -1700,6 +1858,7 @@ var JobScheduler = class {
|
|
|
1700
1858
|
* ```
|
|
1701
1859
|
*/
|
|
1702
1860
|
async enqueue(name, data, options = {}) {
|
|
1861
|
+
this.validatePayloadSize(data);
|
|
1703
1862
|
const now = /* @__PURE__ */ new Date();
|
|
1704
1863
|
const job = {
|
|
1705
1864
|
name,
|
|
@@ -1785,6 +1944,7 @@ var JobScheduler = class {
|
|
|
1785
1944
|
* @returns Promise resolving to the created job document with `repeatInterval` set
|
|
1786
1945
|
* @throws {InvalidCronError} If cron expression is invalid
|
|
1787
1946
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
1947
|
+
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
1788
1948
|
*
|
|
1789
1949
|
* @example Hourly cleanup job
|
|
1790
1950
|
* ```typescript
|
|
@@ -1810,6 +1970,7 @@ var JobScheduler = class {
|
|
|
1810
1970
|
* ```
|
|
1811
1971
|
*/
|
|
1812
1972
|
async schedule(cron, name, data, options = {}) {
|
|
1973
|
+
this.validatePayloadSize(data);
|
|
1813
1974
|
const nextRunAt = getNextCronDate(cron);
|
|
1814
1975
|
const now = /* @__PURE__ */ new Date();
|
|
1815
1976
|
const job = {
|
|
@@ -1848,6 +2009,114 @@ var JobScheduler = class {
|
|
|
1848
2009
|
}
|
|
1849
2010
|
};
|
|
1850
2011
|
|
|
2012
|
+
//#endregion
|
|
2013
|
+
//#region src/scheduler/services/lifecycle-manager.ts
|
|
2014
|
+
/**
|
|
2015
|
+
* Default retention check interval (1 hour).
|
|
2016
|
+
*/
|
|
2017
|
+
const DEFAULT_RETENTION_INTERVAL = 36e5;
|
|
2018
|
+
/**
|
|
2019
|
+
* Manages scheduler lifecycle timers and job cleanup.
|
|
2020
|
+
*
|
|
2021
|
+
* Owns poll interval, heartbeat interval, cleanup interval, and the
|
|
2022
|
+
* cleanupJobs logic. Extracted from Monque to keep the facade thin.
|
|
2023
|
+
*
|
|
2024
|
+
* @internal Not part of public API.
|
|
2025
|
+
*/
|
|
2026
|
+
var LifecycleManager = class {
|
|
2027
|
+
ctx;
|
|
2028
|
+
pollIntervalId = null;
|
|
2029
|
+
heartbeatIntervalId = null;
|
|
2030
|
+
cleanupIntervalId = null;
|
|
2031
|
+
constructor(ctx) {
|
|
2032
|
+
this.ctx = ctx;
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Start all lifecycle timers.
|
|
2036
|
+
*
|
|
2037
|
+
* Sets up poll interval, heartbeat interval, and (if configured)
|
|
2038
|
+
* cleanup interval. Runs an initial poll immediately.
|
|
2039
|
+
*
|
|
2040
|
+
* @param callbacks - Functions to invoke on each timer tick
|
|
2041
|
+
*/
|
|
2042
|
+
startTimers(callbacks) {
|
|
2043
|
+
this.pollIntervalId = setInterval(() => {
|
|
2044
|
+
callbacks.poll().catch((error) => {
|
|
2045
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
2046
|
+
});
|
|
2047
|
+
}, this.ctx.options.pollInterval);
|
|
2048
|
+
this.heartbeatIntervalId = setInterval(() => {
|
|
2049
|
+
callbacks.updateHeartbeats().catch((error) => {
|
|
2050
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
2051
|
+
});
|
|
2052
|
+
}, this.ctx.options.heartbeatInterval);
|
|
2053
|
+
if (this.ctx.options.jobRetention) {
|
|
2054
|
+
const interval = this.ctx.options.jobRetention.interval ?? DEFAULT_RETENTION_INTERVAL;
|
|
2055
|
+
this.cleanupJobs().catch((error) => {
|
|
2056
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
2057
|
+
});
|
|
2058
|
+
this.cleanupIntervalId = setInterval(() => {
|
|
2059
|
+
this.cleanupJobs().catch((error) => {
|
|
2060
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
2061
|
+
});
|
|
2062
|
+
}, interval);
|
|
2063
|
+
}
|
|
2064
|
+
callbacks.poll().catch((error) => {
|
|
2065
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Stop all lifecycle timers.
|
|
2070
|
+
*
|
|
2071
|
+
* Clears poll, heartbeat, and cleanup intervals.
|
|
2072
|
+
*/
|
|
2073
|
+
stopTimers() {
|
|
2074
|
+
if (this.cleanupIntervalId) {
|
|
2075
|
+
clearInterval(this.cleanupIntervalId);
|
|
2076
|
+
this.cleanupIntervalId = null;
|
|
2077
|
+
}
|
|
2078
|
+
if (this.pollIntervalId) {
|
|
2079
|
+
clearInterval(this.pollIntervalId);
|
|
2080
|
+
this.pollIntervalId = null;
|
|
2081
|
+
}
|
|
2082
|
+
if (this.heartbeatIntervalId) {
|
|
2083
|
+
clearInterval(this.heartbeatIntervalId);
|
|
2084
|
+
this.heartbeatIntervalId = null;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Clean up old completed and failed jobs based on retention policy.
|
|
2089
|
+
*
|
|
2090
|
+
* - Removes completed jobs older than `jobRetention.completed`
|
|
2091
|
+
* - Removes failed jobs older than `jobRetention.failed`
|
|
2092
|
+
*
|
|
2093
|
+
* The cleanup runs concurrently for both statuses if configured.
|
|
2094
|
+
*
|
|
2095
|
+
* @returns Promise resolving when all deletion operations complete
|
|
2096
|
+
*/
|
|
2097
|
+
async cleanupJobs() {
|
|
2098
|
+
if (!this.ctx.options.jobRetention) return;
|
|
2099
|
+
const { completed, failed } = this.ctx.options.jobRetention;
|
|
2100
|
+
const now = Date.now();
|
|
2101
|
+
const deletions = [];
|
|
2102
|
+
if (completed != null) {
|
|
2103
|
+
const cutoff = new Date(now - completed);
|
|
2104
|
+
deletions.push(this.ctx.collection.deleteMany({
|
|
2105
|
+
status: JobStatus.COMPLETED,
|
|
2106
|
+
updatedAt: { $lt: cutoff }
|
|
2107
|
+
}));
|
|
2108
|
+
}
|
|
2109
|
+
if (failed != null) {
|
|
2110
|
+
const cutoff = new Date(now - failed);
|
|
2111
|
+
deletions.push(this.ctx.collection.deleteMany({
|
|
2112
|
+
status: JobStatus.FAILED,
|
|
2113
|
+
updatedAt: { $lt: cutoff }
|
|
2114
|
+
}));
|
|
2115
|
+
}
|
|
2116
|
+
if (deletions.length > 0) await Promise.all(deletions);
|
|
2117
|
+
}
|
|
2118
|
+
};
|
|
2119
|
+
|
|
1851
2120
|
//#endregion
|
|
1852
2121
|
//#region src/scheduler/monque.ts
|
|
1853
2122
|
/**
|
|
@@ -1934,9 +2203,6 @@ var Monque = class extends EventEmitter {
|
|
|
1934
2203
|
options;
|
|
1935
2204
|
collection = null;
|
|
1936
2205
|
workers = /* @__PURE__ */ new Map();
|
|
1937
|
-
pollIntervalId = null;
|
|
1938
|
-
heartbeatIntervalId = null;
|
|
1939
|
-
cleanupIntervalId = null;
|
|
1940
2206
|
isRunning = false;
|
|
1941
2207
|
isInitialized = false;
|
|
1942
2208
|
_scheduler = null;
|
|
@@ -1944,6 +2210,7 @@ var Monque = class extends EventEmitter {
|
|
|
1944
2210
|
_query = null;
|
|
1945
2211
|
_processor = null;
|
|
1946
2212
|
_changeStreamHandler = null;
|
|
2213
|
+
_lifecycleManager = null;
|
|
1947
2214
|
constructor(db, options = {}) {
|
|
1948
2215
|
super();
|
|
1949
2216
|
this.db = db;
|
|
@@ -1960,7 +2227,10 @@ var Monque = class extends EventEmitter {
|
|
|
1960
2227
|
instanceConcurrency: options.instanceConcurrency ?? options.maxConcurrency,
|
|
1961
2228
|
schedulerInstanceId: options.schedulerInstanceId ?? randomUUID(),
|
|
1962
2229
|
heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
|
|
1963
|
-
jobRetention: options.jobRetention
|
|
2230
|
+
jobRetention: options.jobRetention,
|
|
2231
|
+
skipIndexCreation: options.skipIndexCreation ?? false,
|
|
2232
|
+
maxPayloadSize: options.maxPayloadSize,
|
|
2233
|
+
statsCacheTtlMs: options.statsCacheTtlMs ?? 5e3
|
|
1964
2234
|
};
|
|
1965
2235
|
}
|
|
1966
2236
|
/**
|
|
@@ -1973,14 +2243,16 @@ var Monque = class extends EventEmitter {
|
|
|
1973
2243
|
if (this.isInitialized) return;
|
|
1974
2244
|
try {
|
|
1975
2245
|
this.collection = this.db.collection(this.options.collectionName);
|
|
1976
|
-
await this.createIndexes();
|
|
2246
|
+
if (!this.options.skipIndexCreation) await this.createIndexes();
|
|
1977
2247
|
if (this.options.recoverStaleJobs) await this.recoverStaleJobs();
|
|
2248
|
+
await this.checkInstanceCollision();
|
|
1978
2249
|
const ctx = this.buildContext();
|
|
1979
2250
|
this._scheduler = new JobScheduler(ctx);
|
|
1980
2251
|
this._manager = new JobManager(ctx);
|
|
1981
2252
|
this._query = new JobQueryService(ctx);
|
|
1982
2253
|
this._processor = new JobProcessor(ctx);
|
|
1983
2254
|
this._changeStreamHandler = new ChangeStreamHandler(ctx, () => this.processor.poll());
|
|
2255
|
+
this._lifecycleManager = new LifecycleManager(ctx);
|
|
1984
2256
|
this.isInitialized = true;
|
|
1985
2257
|
} catch (error) {
|
|
1986
2258
|
throw new ConnectionError(`Failed to initialize Monque: ${error instanceof Error ? error.message : "Unknown error during initialization"}`);
|
|
@@ -2011,6 +2283,11 @@ var Monque = class extends EventEmitter {
|
|
|
2011
2283
|
if (!this._changeStreamHandler) throw new ConnectionError("Monque not initialized. Call initialize() first.");
|
|
2012
2284
|
return this._changeStreamHandler;
|
|
2013
2285
|
}
|
|
2286
|
+
/** @throws {ConnectionError} if not initialized */
|
|
2287
|
+
get lifecycleManager() {
|
|
2288
|
+
if (!this._lifecycleManager) throw new ConnectionError("Monque not initialized. Call initialize() first.");
|
|
2289
|
+
return this._lifecycleManager;
|
|
2290
|
+
}
|
|
2014
2291
|
/**
|
|
2015
2292
|
* Build the shared context for internal services.
|
|
2016
2293
|
*/
|
|
@@ -2023,7 +2300,7 @@ var Monque = class extends EventEmitter {
|
|
|
2023
2300
|
workers: this.workers,
|
|
2024
2301
|
isRunning: () => this.isRunning,
|
|
2025
2302
|
emit: (event, payload) => this.emit(event, payload),
|
|
2026
|
-
documentToPersistedJob: (doc) =>
|
|
2303
|
+
documentToPersistedJob: (doc) => documentToPersistedJob(doc)
|
|
2027
2304
|
};
|
|
2028
2305
|
}
|
|
2029
2306
|
/**
|
|
@@ -2040,43 +2317,64 @@ var Monque = class extends EventEmitter {
|
|
|
2040
2317
|
*/
|
|
2041
2318
|
async createIndexes() {
|
|
2042
2319
|
if (!this.collection) throw new ConnectionError("Collection not initialized");
|
|
2043
|
-
await this.collection.
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
}, {
|
|
2051
|
-
unique: true,
|
|
2052
|
-
partialFilterExpression: {
|
|
2053
|
-
uniqueKey: { $exists: true },
|
|
2054
|
-
status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] }
|
|
2320
|
+
await this.collection.createIndexes([
|
|
2321
|
+
{
|
|
2322
|
+
key: {
|
|
2323
|
+
status: 1,
|
|
2324
|
+
nextRunAt: 1
|
|
2325
|
+
},
|
|
2326
|
+
background: true
|
|
2055
2327
|
},
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2328
|
+
{
|
|
2329
|
+
key: {
|
|
2330
|
+
name: 1,
|
|
2331
|
+
uniqueKey: 1
|
|
2332
|
+
},
|
|
2333
|
+
unique: true,
|
|
2334
|
+
partialFilterExpression: {
|
|
2335
|
+
uniqueKey: { $exists: true },
|
|
2336
|
+
status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] }
|
|
2337
|
+
},
|
|
2338
|
+
background: true
|
|
2339
|
+
},
|
|
2340
|
+
{
|
|
2341
|
+
key: {
|
|
2342
|
+
name: 1,
|
|
2343
|
+
status: 1
|
|
2344
|
+
},
|
|
2345
|
+
background: true
|
|
2346
|
+
},
|
|
2347
|
+
{
|
|
2348
|
+
key: {
|
|
2349
|
+
claimedBy: 1,
|
|
2350
|
+
status: 1
|
|
2351
|
+
},
|
|
2352
|
+
background: true
|
|
2353
|
+
},
|
|
2354
|
+
{
|
|
2355
|
+
key: {
|
|
2356
|
+
lastHeartbeat: 1,
|
|
2357
|
+
status: 1
|
|
2358
|
+
},
|
|
2359
|
+
background: true
|
|
2360
|
+
},
|
|
2361
|
+
{
|
|
2362
|
+
key: {
|
|
2363
|
+
status: 1,
|
|
2364
|
+
nextRunAt: 1,
|
|
2365
|
+
claimedBy: 1
|
|
2366
|
+
},
|
|
2367
|
+
background: true
|
|
2368
|
+
},
|
|
2369
|
+
{
|
|
2370
|
+
key: {
|
|
2371
|
+
status: 1,
|
|
2372
|
+
lockedAt: 1,
|
|
2373
|
+
lastHeartbeat: 1
|
|
2374
|
+
},
|
|
2375
|
+
background: true
|
|
2376
|
+
}
|
|
2377
|
+
]);
|
|
2080
2378
|
}
|
|
2081
2379
|
/**
|
|
2082
2380
|
* Recover stale jobs that were left in 'processing' status.
|
|
@@ -2104,35 +2402,23 @@ var Monque = class extends EventEmitter {
|
|
|
2104
2402
|
if (result.modifiedCount > 0) this.emit("stale:recovered", { count: result.modifiedCount });
|
|
2105
2403
|
}
|
|
2106
2404
|
/**
|
|
2107
|
-
*
|
|
2108
|
-
*
|
|
2109
|
-
* - Removes completed jobs older than `jobRetention.completed`
|
|
2110
|
-
* - Removes failed jobs older than `jobRetention.failed`
|
|
2405
|
+
* Check if another active instance is using the same schedulerInstanceId.
|
|
2406
|
+
* Uses heartbeat staleness to distinguish active instances from crashed ones.
|
|
2111
2407
|
*
|
|
2112
|
-
*
|
|
2408
|
+
* Called after stale recovery to avoid false positives: stale recovery resets
|
|
2409
|
+
* jobs with old `lockedAt`, so only jobs with recent heartbeats remain.
|
|
2113
2410
|
*
|
|
2114
|
-
* @
|
|
2411
|
+
* @throws {ConnectionError} If an active instance with the same ID is detected
|
|
2115
2412
|
*/
|
|
2116
|
-
async
|
|
2117
|
-
if (!this.collection
|
|
2118
|
-
const
|
|
2119
|
-
const
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
updatedAt: { $lt: cutoff }
|
|
2126
|
-
}));
|
|
2127
|
-
}
|
|
2128
|
-
if (failed) {
|
|
2129
|
-
const cutoff = new Date(now - failed);
|
|
2130
|
-
deletions.push(this.collection.deleteMany({
|
|
2131
|
-
status: JobStatus.FAILED,
|
|
2132
|
-
updatedAt: { $lt: cutoff }
|
|
2133
|
-
}));
|
|
2134
|
-
}
|
|
2135
|
-
if (deletions.length > 0) await Promise.all(deletions);
|
|
2413
|
+
async checkInstanceCollision() {
|
|
2414
|
+
if (!this.collection) return;
|
|
2415
|
+
const aliveThreshold = /* @__PURE__ */ new Date(Date.now() - this.options.heartbeatInterval * 2);
|
|
2416
|
+
const activeJob = await this.collection.findOne({
|
|
2417
|
+
claimedBy: this.options.schedulerInstanceId,
|
|
2418
|
+
status: JobStatus.PROCESSING,
|
|
2419
|
+
lastHeartbeat: { $gte: aliveThreshold }
|
|
2420
|
+
});
|
|
2421
|
+
if (activeJob) throw new ConnectionError(`Another active Monque instance is using schedulerInstanceId "${this.options.schedulerInstanceId}". Found processing job "${activeJob["name"]}" with recent heartbeat. Use a unique schedulerInstanceId or wait for the other instance to stop.`);
|
|
2136
2422
|
}
|
|
2137
2423
|
/**
|
|
2138
2424
|
* Enqueue a job for processing.
|
|
@@ -2152,6 +2438,7 @@ var Monque = class extends EventEmitter {
|
|
|
2152
2438
|
* @param options - Scheduling and deduplication options
|
|
2153
2439
|
* @returns Promise resolving to the created or existing job document
|
|
2154
2440
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
2441
|
+
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
2155
2442
|
*
|
|
2156
2443
|
* @example Basic job enqueueing
|
|
2157
2444
|
* ```typescript
|
|
@@ -2177,6 +2464,8 @@ var Monque = class extends EventEmitter {
|
|
|
2177
2464
|
* });
|
|
2178
2465
|
* // Subsequent enqueues with same uniqueKey return existing pending/processing job
|
|
2179
2466
|
* ```
|
|
2467
|
+
*
|
|
2468
|
+
* @see {@link JobScheduler.enqueue}
|
|
2180
2469
|
*/
|
|
2181
2470
|
async enqueue(name, data, options = {}) {
|
|
2182
2471
|
this.ensureInitialized();
|
|
@@ -2209,6 +2498,8 @@ var Monque = class extends EventEmitter {
|
|
|
2209
2498
|
* await monque.now('process-order', { orderId: order.id });
|
|
2210
2499
|
* return order; // Return immediately, processing happens async
|
|
2211
2500
|
* ```
|
|
2501
|
+
*
|
|
2502
|
+
* @see {@link JobScheduler.now}
|
|
2212
2503
|
*/
|
|
2213
2504
|
async now(name, data) {
|
|
2214
2505
|
this.ensureInitialized();
|
|
@@ -2234,6 +2525,7 @@ var Monque = class extends EventEmitter {
|
|
|
2234
2525
|
* @returns Promise resolving to the created job document with `repeatInterval` set
|
|
2235
2526
|
* @throws {InvalidCronError} If cron expression is invalid
|
|
2236
2527
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
2528
|
+
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
2237
2529
|
*
|
|
2238
2530
|
* @example Hourly cleanup job
|
|
2239
2531
|
* ```typescript
|
|
@@ -2257,6 +2549,8 @@ var Monque = class extends EventEmitter {
|
|
|
2257
2549
|
* recipients: ['analytics@example.com']
|
|
2258
2550
|
* });
|
|
2259
2551
|
* ```
|
|
2552
|
+
*
|
|
2553
|
+
* @see {@link JobScheduler.schedule}
|
|
2260
2554
|
*/
|
|
2261
2555
|
async schedule(cron, name, data, options = {}) {
|
|
2262
2556
|
this.ensureInitialized();
|
|
@@ -2278,6 +2572,8 @@ var Monque = class extends EventEmitter {
|
|
|
2278
2572
|
* const job = await monque.enqueue('report', { type: 'daily' });
|
|
2279
2573
|
* await monque.cancelJob(job._id.toString());
|
|
2280
2574
|
* ```
|
|
2575
|
+
*
|
|
2576
|
+
* @see {@link JobManager.cancelJob}
|
|
2281
2577
|
*/
|
|
2282
2578
|
async cancelJob(jobId) {
|
|
2283
2579
|
this.ensureInitialized();
|
|
@@ -2300,6 +2596,8 @@ var Monque = class extends EventEmitter {
|
|
|
2300
2596
|
* await monque.retryJob(job._id.toString());
|
|
2301
2597
|
* });
|
|
2302
2598
|
* ```
|
|
2599
|
+
*
|
|
2600
|
+
* @see {@link JobManager.retryJob}
|
|
2303
2601
|
*/
|
|
2304
2602
|
async retryJob(jobId) {
|
|
2305
2603
|
this.ensureInitialized();
|
|
@@ -2320,6 +2618,8 @@ var Monque = class extends EventEmitter {
|
|
|
2320
2618
|
* const nextHour = new Date(Date.now() + 60 * 60 * 1000);
|
|
2321
2619
|
* await monque.rescheduleJob(jobId, nextHour);
|
|
2322
2620
|
* ```
|
|
2621
|
+
*
|
|
2622
|
+
* @see {@link JobManager.rescheduleJob}
|
|
2323
2623
|
*/
|
|
2324
2624
|
async rescheduleJob(jobId, runAt) {
|
|
2325
2625
|
this.ensureInitialized();
|
|
@@ -2341,20 +2641,23 @@ var Monque = class extends EventEmitter {
|
|
|
2341
2641
|
* console.log('Job permanently removed');
|
|
2342
2642
|
* }
|
|
2343
2643
|
* ```
|
|
2644
|
+
*
|
|
2645
|
+
* @see {@link JobManager.deleteJob}
|
|
2344
2646
|
*/
|
|
2345
2647
|
async deleteJob(jobId) {
|
|
2346
2648
|
this.ensureInitialized();
|
|
2347
2649
|
return this.manager.deleteJob(jobId);
|
|
2348
2650
|
}
|
|
2349
2651
|
/**
|
|
2350
|
-
* Cancel multiple jobs matching the given filter.
|
|
2652
|
+
* Cancel multiple jobs matching the given filter via a single updateMany call.
|
|
2351
2653
|
*
|
|
2352
|
-
* Only cancels jobs in 'pending' status
|
|
2353
|
-
*
|
|
2654
|
+
* Only cancels jobs in 'pending' status — the status guard is applied regardless
|
|
2655
|
+
* of what the filter specifies. Jobs in other states are silently skipped (not
|
|
2656
|
+
* matched by the query). Emits a 'jobs:cancelled' event with the count of
|
|
2354
2657
|
* successfully cancelled jobs.
|
|
2355
2658
|
*
|
|
2356
2659
|
* @param filter - Selector for which jobs to cancel (name, status, date range)
|
|
2357
|
-
* @returns Result with count of cancelled jobs
|
|
2660
|
+
* @returns Result with count of cancelled jobs (errors array always empty for bulk ops)
|
|
2358
2661
|
*
|
|
2359
2662
|
* @example Cancel all pending jobs for a queue
|
|
2360
2663
|
* ```typescript
|
|
@@ -2364,20 +2667,23 @@ var Monque = class extends EventEmitter {
|
|
|
2364
2667
|
* });
|
|
2365
2668
|
* console.log(`Cancelled ${result.count} jobs`);
|
|
2366
2669
|
* ```
|
|
2670
|
+
*
|
|
2671
|
+
* @see {@link JobManager.cancelJobs}
|
|
2367
2672
|
*/
|
|
2368
2673
|
async cancelJobs(filter) {
|
|
2369
2674
|
this.ensureInitialized();
|
|
2370
2675
|
return this.manager.cancelJobs(filter);
|
|
2371
2676
|
}
|
|
2372
2677
|
/**
|
|
2373
|
-
* Retry multiple jobs matching the given filter.
|
|
2678
|
+
* Retry multiple jobs matching the given filter via a single pipeline-style updateMany call.
|
|
2374
2679
|
*
|
|
2375
|
-
* Only retries jobs in 'failed' or 'cancelled' status
|
|
2376
|
-
*
|
|
2377
|
-
*
|
|
2680
|
+
* Only retries jobs in 'failed' or 'cancelled' status — the status guard is applied
|
|
2681
|
+
* regardless of what the filter specifies. Jobs in other states are silently skipped.
|
|
2682
|
+
* Uses `$rand` for per-document staggered `nextRunAt` to avoid thundering herd on retry.
|
|
2683
|
+
* Emits a 'jobs:retried' event with the count of successfully retried jobs.
|
|
2378
2684
|
*
|
|
2379
2685
|
* @param filter - Selector for which jobs to retry (name, status, date range)
|
|
2380
|
-
* @returns Result with count of retried jobs
|
|
2686
|
+
* @returns Result with count of retried jobs (errors array always empty for bulk ops)
|
|
2381
2687
|
*
|
|
2382
2688
|
* @example Retry all failed jobs
|
|
2383
2689
|
* ```typescript
|
|
@@ -2386,6 +2692,8 @@ var Monque = class extends EventEmitter {
|
|
|
2386
2692
|
* });
|
|
2387
2693
|
* console.log(`Retried ${result.count} jobs`);
|
|
2388
2694
|
* ```
|
|
2695
|
+
*
|
|
2696
|
+
* @see {@link JobManager.retryJobs}
|
|
2389
2697
|
*/
|
|
2390
2698
|
async retryJobs(filter) {
|
|
2391
2699
|
this.ensureInitialized();
|
|
@@ -2395,6 +2703,7 @@ var Monque = class extends EventEmitter {
|
|
|
2395
2703
|
* Delete multiple jobs matching the given filter.
|
|
2396
2704
|
*
|
|
2397
2705
|
* Deletes jobs in any status. Uses a batch delete for efficiency.
|
|
2706
|
+
* Emits a 'jobs:deleted' event with the count of deleted jobs.
|
|
2398
2707
|
* Does not emit individual 'job:deleted' events to avoid noise.
|
|
2399
2708
|
*
|
|
2400
2709
|
* @param filter - Selector for which jobs to delete (name, status, date range)
|
|
@@ -2409,6 +2718,8 @@ var Monque = class extends EventEmitter {
|
|
|
2409
2718
|
* });
|
|
2410
2719
|
* console.log(`Deleted ${result.count} jobs`);
|
|
2411
2720
|
* ```
|
|
2721
|
+
*
|
|
2722
|
+
* @see {@link JobManager.deleteJobs}
|
|
2412
2723
|
*/
|
|
2413
2724
|
async deleteJobs(filter) {
|
|
2414
2725
|
this.ensureInitialized();
|
|
@@ -2444,6 +2755,8 @@ var Monque = class extends EventEmitter {
|
|
|
2444
2755
|
* res.json(job);
|
|
2445
2756
|
* });
|
|
2446
2757
|
* ```
|
|
2758
|
+
*
|
|
2759
|
+
* @see {@link JobQueryService.getJob}
|
|
2447
2760
|
*/
|
|
2448
2761
|
async getJob(id) {
|
|
2449
2762
|
this.ensureInitialized();
|
|
@@ -2490,6 +2803,8 @@ var Monque = class extends EventEmitter {
|
|
|
2490
2803
|
* const jobs = await monque.getJobs();
|
|
2491
2804
|
* const pendingRecurring = jobs.filter(job => isPendingJob(job) && isRecurringJob(job));
|
|
2492
2805
|
* ```
|
|
2806
|
+
*
|
|
2807
|
+
* @see {@link JobQueryService.getJobs}
|
|
2493
2808
|
*/
|
|
2494
2809
|
async getJobs(filter = {}) {
|
|
2495
2810
|
this.ensureInitialized();
|
|
@@ -2523,6 +2838,8 @@ var Monque = class extends EventEmitter {
|
|
|
2523
2838
|
* });
|
|
2524
2839
|
* }
|
|
2525
2840
|
* ```
|
|
2841
|
+
*
|
|
2842
|
+
* @see {@link JobQueryService.getJobsWithCursor}
|
|
2526
2843
|
*/
|
|
2527
2844
|
async getJobsWithCursor(options = {}) {
|
|
2528
2845
|
this.ensureInitialized();
|
|
@@ -2534,6 +2851,9 @@ var Monque = class extends EventEmitter {
|
|
|
2534
2851
|
* Uses MongoDB aggregation pipeline for efficient server-side calculation.
|
|
2535
2852
|
* Returns counts per status and optional average processing duration for completed jobs.
|
|
2536
2853
|
*
|
|
2854
|
+
* Results are cached per unique filter with a configurable TTL (default 5s).
|
|
2855
|
+
* Set `statsCacheTtlMs: 0` to disable caching.
|
|
2856
|
+
*
|
|
2537
2857
|
* @param filter - Optional filter to scope statistics by job name
|
|
2538
2858
|
* @returns Promise resolving to queue statistics
|
|
2539
2859
|
* @throws {AggregationTimeoutError} If aggregation exceeds 30 second timeout
|
|
@@ -2550,6 +2870,8 @@ var Monque = class extends EventEmitter {
|
|
|
2550
2870
|
* const emailStats = await monque.getQueueStats({ name: 'send-email' });
|
|
2551
2871
|
* console.log(`${emailStats.total} email jobs in queue`);
|
|
2552
2872
|
* ```
|
|
2873
|
+
*
|
|
2874
|
+
* @see {@link JobQueryService.getQueueStats}
|
|
2553
2875
|
*/
|
|
2554
2876
|
async getQueueStats(filter) {
|
|
2555
2877
|
this.ensureInitialized();
|
|
@@ -2676,29 +2998,9 @@ var Monque = class extends EventEmitter {
|
|
|
2676
2998
|
if (!this.isInitialized) throw new ConnectionError("Monque not initialized. Call initialize() before start().");
|
|
2677
2999
|
this.isRunning = true;
|
|
2678
3000
|
this.changeStreamHandler.setup();
|
|
2679
|
-
this.
|
|
2680
|
-
this.processor.poll()
|
|
2681
|
-
|
|
2682
|
-
});
|
|
2683
|
-
}, this.options.pollInterval);
|
|
2684
|
-
this.heartbeatIntervalId = setInterval(() => {
|
|
2685
|
-
this.processor.updateHeartbeats().catch((error) => {
|
|
2686
|
-
this.emit("job:error", { error });
|
|
2687
|
-
});
|
|
2688
|
-
}, this.options.heartbeatInterval);
|
|
2689
|
-
if (this.options.jobRetention) {
|
|
2690
|
-
const interval = this.options.jobRetention.interval ?? DEFAULTS.retentionInterval;
|
|
2691
|
-
this.cleanupJobs().catch((error) => {
|
|
2692
|
-
this.emit("job:error", { error });
|
|
2693
|
-
});
|
|
2694
|
-
this.cleanupIntervalId = setInterval(() => {
|
|
2695
|
-
this.cleanupJobs().catch((error) => {
|
|
2696
|
-
this.emit("job:error", { error });
|
|
2697
|
-
});
|
|
2698
|
-
}, interval);
|
|
2699
|
-
}
|
|
2700
|
-
this.processor.poll().catch((error) => {
|
|
2701
|
-
this.emit("job:error", { error });
|
|
3001
|
+
this.lifecycleManager.startTimers({
|
|
3002
|
+
poll: () => this.processor.poll(),
|
|
3003
|
+
updateHeartbeats: () => this.processor.updateHeartbeats()
|
|
2702
3004
|
});
|
|
2703
3005
|
}
|
|
2704
3006
|
/**
|
|
@@ -2737,19 +3039,11 @@ var Monque = class extends EventEmitter {
|
|
|
2737
3039
|
async stop() {
|
|
2738
3040
|
if (!this.isRunning) return;
|
|
2739
3041
|
this.isRunning = false;
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
if (this.pollIntervalId) {
|
|
2746
|
-
clearInterval(this.pollIntervalId);
|
|
2747
|
-
this.pollIntervalId = null;
|
|
2748
|
-
}
|
|
2749
|
-
if (this.heartbeatIntervalId) {
|
|
2750
|
-
clearInterval(this.heartbeatIntervalId);
|
|
2751
|
-
this.heartbeatIntervalId = null;
|
|
2752
|
-
}
|
|
3042
|
+
this._query?.clearStatsCache();
|
|
3043
|
+
try {
|
|
3044
|
+
await this.changeStreamHandler.close();
|
|
3045
|
+
} catch {}
|
|
3046
|
+
this.lifecycleManager.stopTimers();
|
|
2753
3047
|
if (this.getActiveJobs().length === 0) return;
|
|
2754
3048
|
let checkInterval;
|
|
2755
3049
|
const waitForJobs = new Promise((resolve) => {
|
|
@@ -2856,37 +3150,6 @@ var Monque = class extends EventEmitter {
|
|
|
2856
3150
|
return activeJobs;
|
|
2857
3151
|
}
|
|
2858
3152
|
/**
|
|
2859
|
-
* Convert a MongoDB document to a typed PersistedJob object.
|
|
2860
|
-
*
|
|
2861
|
-
* Maps raw MongoDB document fields to the strongly-typed `PersistedJob<T>` interface,
|
|
2862
|
-
* ensuring type safety and handling optional fields (`lockedAt`, `failReason`, etc.).
|
|
2863
|
-
*
|
|
2864
|
-
* @private
|
|
2865
|
-
* @template T - The job data payload type
|
|
2866
|
-
* @param doc - The raw MongoDB document with `_id`
|
|
2867
|
-
* @returns A strongly-typed PersistedJob object with guaranteed `_id`
|
|
2868
|
-
*/
|
|
2869
|
-
documentToPersistedJob(doc) {
|
|
2870
|
-
const job = {
|
|
2871
|
-
_id: doc._id,
|
|
2872
|
-
name: doc["name"],
|
|
2873
|
-
data: doc["data"],
|
|
2874
|
-
status: doc["status"],
|
|
2875
|
-
nextRunAt: doc["nextRunAt"],
|
|
2876
|
-
failCount: doc["failCount"],
|
|
2877
|
-
createdAt: doc["createdAt"],
|
|
2878
|
-
updatedAt: doc["updatedAt"]
|
|
2879
|
-
};
|
|
2880
|
-
if (doc["lockedAt"] !== void 0) job.lockedAt = doc["lockedAt"];
|
|
2881
|
-
if (doc["claimedBy"] !== void 0) job.claimedBy = doc["claimedBy"];
|
|
2882
|
-
if (doc["lastHeartbeat"] !== void 0) job.lastHeartbeat = doc["lastHeartbeat"];
|
|
2883
|
-
if (doc["heartbeatInterval"] !== void 0) job.heartbeatInterval = doc["heartbeatInterval"];
|
|
2884
|
-
if (doc["failReason"] !== void 0) job.failReason = doc["failReason"];
|
|
2885
|
-
if (doc["repeatInterval"] !== void 0) job.repeatInterval = doc["repeatInterval"];
|
|
2886
|
-
if (doc["uniqueKey"] !== void 0) job.uniqueKey = doc["uniqueKey"];
|
|
2887
|
-
return job;
|
|
2888
|
-
}
|
|
2889
|
-
/**
|
|
2890
3153
|
* Type-safe event emitter methods
|
|
2891
3154
|
*/
|
|
2892
3155
|
emit(event, payload) {
|
|
@@ -2904,5 +3167,5 @@ var Monque = class extends EventEmitter {
|
|
|
2904
3167
|
};
|
|
2905
3168
|
|
|
2906
3169
|
//#endregion
|
|
2907
|
-
export { AggregationTimeoutError, ConnectionError, CursorDirection, DEFAULT_BASE_INTERVAL, DEFAULT_MAX_BACKOFF_DELAY, InvalidCronError, InvalidCursorError, JobStateError, JobStatus, Monque, MonqueError, ShutdownTimeoutError, WorkerRegistrationError, calculateBackoff, calculateBackoffDelay, getNextCronDate, isCancelledJob, isCompletedJob, isFailedJob, isPendingJob, isPersistedJob, isProcessingJob, isRecurringJob, isValidJobStatus, validateCronExpression };
|
|
3170
|
+
export { AggregationTimeoutError, ConnectionError, CursorDirection, DEFAULT_BASE_INTERVAL, DEFAULT_MAX_BACKOFF_DELAY, InvalidCronError, InvalidCursorError, JobStateError, JobStatus, Monque, MonqueError, PayloadTooLargeError, ShutdownTimeoutError, WorkerRegistrationError, calculateBackoff, calculateBackoffDelay, getNextCronDate, isCancelledJob, isCompletedJob, isFailedJob, isPendingJob, isPersistedJob, isProcessingJob, isRecurringJob, isValidJobStatus, validateCronExpression };
|
|
2908
3171
|
//# sourceMappingURL=index.mjs.map
|