@monque/core 1.5.2 → 1.7.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 +44 -0
- package/dist/index.cjs +522 -169
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +83 -4
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +83 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +520 -170
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/index.ts +3 -0
- package/src/scheduler/monque.ts +112 -27
- package/src/scheduler/services/change-stream-handler.ts +183 -31
- package/src/scheduler/services/index.ts +1 -1
- package/src/scheduler/services/job-manager.ts +151 -114
- package/src/scheduler/services/job-processor.ts +109 -54
- package/src/scheduler/services/job-scheduler.ts +42 -9
- package/src/scheduler/services/lifecycle-manager.ts +77 -17
- package/src/scheduler/services/types.ts +7 -0
- package/src/scheduler/types.ts +14 -0
- package/src/shared/errors.ts +29 -0
- package/src/shared/index.ts +3 -0
- package/src/shared/utils/index.ts +1 -0
- package/src/shared/utils/job-identifiers.ts +71 -0
package/dist/index.cjs
CHANGED
|
@@ -455,6 +455,30 @@ var InvalidCursorError = class InvalidCursorError extends MonqueError {
|
|
|
455
455
|
}
|
|
456
456
|
};
|
|
457
457
|
/**
|
|
458
|
+
* Error thrown when a public job identifier fails validation.
|
|
459
|
+
*
|
|
460
|
+
* @example
|
|
461
|
+
* ```typescript
|
|
462
|
+
* try {
|
|
463
|
+
* await monque.enqueue('invalid job name', {});
|
|
464
|
+
* } catch (error) {
|
|
465
|
+
* if (error instanceof InvalidJobIdentifierError) {
|
|
466
|
+
* console.error(`Invalid ${error.field}: ${error.message}`);
|
|
467
|
+
* }
|
|
468
|
+
* }
|
|
469
|
+
* ```
|
|
470
|
+
*/
|
|
471
|
+
var InvalidJobIdentifierError = class InvalidJobIdentifierError extends MonqueError {
|
|
472
|
+
constructor(field, value, message) {
|
|
473
|
+
super(message);
|
|
474
|
+
this.field = field;
|
|
475
|
+
this.value = value;
|
|
476
|
+
this.name = "InvalidJobIdentifierError";
|
|
477
|
+
/* istanbul ignore next -- @preserve captureStackTrace is always available in Node.js */
|
|
478
|
+
if (Error.captureStackTrace) Error.captureStackTrace(this, InvalidJobIdentifierError);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
/**
|
|
458
482
|
* Error thrown when a statistics aggregation times out.
|
|
459
483
|
*
|
|
460
484
|
* @example
|
|
@@ -648,6 +672,34 @@ function toError(value) {
|
|
|
648
672
|
}
|
|
649
673
|
}
|
|
650
674
|
//#endregion
|
|
675
|
+
//#region src/shared/utils/job-identifiers.ts
|
|
676
|
+
const JOB_NAME_PATTERN = /^[^\s\p{Cc}]+$/u;
|
|
677
|
+
const CONTROL_CHARACTER_PATTERN = /\p{Cc}/u;
|
|
678
|
+
const MAX_JOB_NAME_LENGTH = 255;
|
|
679
|
+
const MAX_UNIQUE_KEY_LENGTH = 1024;
|
|
680
|
+
/**
|
|
681
|
+
* Validate a public job name before it is registered or scheduled.
|
|
682
|
+
*
|
|
683
|
+
* @param name - The job name to validate
|
|
684
|
+
* @throws {InvalidJobIdentifierError} If the job name is empty, too long, or contains unsupported characters
|
|
685
|
+
*/
|
|
686
|
+
function validateJobName(name) {
|
|
687
|
+
if (name.length === 0 || name.trim().length === 0) throw new InvalidJobIdentifierError("name", name, "Job name cannot be empty or whitespace only.");
|
|
688
|
+
if (name.length > MAX_JOB_NAME_LENGTH) throw new InvalidJobIdentifierError("name", name, `Job name cannot exceed ${MAX_JOB_NAME_LENGTH} characters.`);
|
|
689
|
+
if (!JOB_NAME_PATTERN.test(name)) throw new InvalidJobIdentifierError("name", name, "Job name cannot contain whitespace or control characters.");
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Validate a deduplication key before it is stored or used in a unique query.
|
|
693
|
+
*
|
|
694
|
+
* @param uniqueKey - The unique key to validate
|
|
695
|
+
* @throws {InvalidJobIdentifierError} If the key is empty, too long, or contains control characters
|
|
696
|
+
*/
|
|
697
|
+
function validateUniqueKey(uniqueKey) {
|
|
698
|
+
if (uniqueKey.length === 0 || uniqueKey.trim().length === 0) throw new InvalidJobIdentifierError("uniqueKey", uniqueKey, "Unique key cannot be empty or whitespace only.");
|
|
699
|
+
if (uniqueKey.length > MAX_UNIQUE_KEY_LENGTH) throw new InvalidJobIdentifierError("uniqueKey", uniqueKey, `Unique key cannot exceed ${MAX_UNIQUE_KEY_LENGTH} characters.`);
|
|
700
|
+
if (CONTROL_CHARACTER_PATTERN.test(uniqueKey)) throw new InvalidJobIdentifierError("uniqueKey", uniqueKey, "Unique key cannot contain control characters.");
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
651
703
|
//#region src/scheduler/helpers.ts
|
|
652
704
|
/**
|
|
653
705
|
* Build a MongoDB query filter from a JobSelector.
|
|
@@ -714,12 +766,20 @@ function decodeCursor(cursor) {
|
|
|
714
766
|
}
|
|
715
767
|
//#endregion
|
|
716
768
|
//#region src/scheduler/services/change-stream-handler.ts
|
|
769
|
+
/** Minimum poll interval floor to prevent tight loops (ms) */
|
|
770
|
+
const MIN_POLL_INTERVAL = 100;
|
|
771
|
+
/** Grace period after nextRunAt before scheduling a wakeup poll (ms) */
|
|
772
|
+
const POLL_GRACE_PERIOD = 200;
|
|
717
773
|
/**
|
|
718
774
|
* Internal service for MongoDB Change Stream lifecycle.
|
|
719
775
|
*
|
|
720
776
|
* Provides real-time job notifications when available, with automatic
|
|
721
777
|
* reconnection and graceful fallback to polling-only mode.
|
|
722
778
|
*
|
|
779
|
+
* Leverages the full document from change stream events to:
|
|
780
|
+
* - Trigger **targeted polls** for specific workers (using the job `name`)
|
|
781
|
+
* - Schedule **precise wakeup timers** for future-dated jobs (using `nextRunAt`)
|
|
782
|
+
*
|
|
723
783
|
* @internal Not part of public API.
|
|
724
784
|
*/
|
|
725
785
|
var ChangeStreamHandler = class {
|
|
@@ -735,6 +795,12 @@ var ChangeStreamHandler = class {
|
|
|
735
795
|
reconnectTimer = null;
|
|
736
796
|
/** Whether the scheduler is currently using change streams */
|
|
737
797
|
usingChangeStreams = false;
|
|
798
|
+
/** Job names collected during the current debounce window for targeted polling */
|
|
799
|
+
pendingTargetNames = /* @__PURE__ */ new Set();
|
|
800
|
+
/** Wakeup timer for the earliest known future job */
|
|
801
|
+
wakeupTimer = null;
|
|
802
|
+
/** Time of the currently scheduled wakeup */
|
|
803
|
+
wakeupTime = null;
|
|
738
804
|
constructor(ctx, onPoll) {
|
|
739
805
|
this.ctx = ctx;
|
|
740
806
|
this.onPoll = onPoll;
|
|
@@ -759,7 +825,7 @@ var ChangeStreamHandler = class {
|
|
|
759
825
|
try {
|
|
760
826
|
this.changeStream = this.ctx.collection.watch([{ $match: { $or: [{ operationType: "insert" }, {
|
|
761
827
|
operationType: "update",
|
|
762
|
-
"updateDescription.updatedFields.status": { $exists: true }
|
|
828
|
+
$or: [{ "updateDescription.updatedFields.status": { $exists: true } }, { "updateDescription.updatedFields.nextRunAt": { $exists: true } }]
|
|
763
829
|
}] } }], { fullDocument: "updateLookup" });
|
|
764
830
|
this.changeStream.on("change", (change) => {
|
|
765
831
|
this.handleEvent(change);
|
|
@@ -778,11 +844,20 @@ var ChangeStreamHandler = class {
|
|
|
778
844
|
}
|
|
779
845
|
}
|
|
780
846
|
/**
|
|
781
|
-
* Handle a change stream event
|
|
847
|
+
* Handle a change stream event using the full document for intelligent routing.
|
|
848
|
+
*
|
|
849
|
+
* For **immediate jobs** (`nextRunAt <= now`): collects the job name and triggers
|
|
850
|
+
* a debounced targeted poll for only the relevant workers.
|
|
851
|
+
*
|
|
852
|
+
* For **future jobs** (`nextRunAt > now`): schedules a precise wakeup timer so
|
|
853
|
+
* the job is picked up near its scheduled time without blind polling.
|
|
782
854
|
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
785
|
-
*
|
|
855
|
+
* For **completed/failed jobs** (slot freed): triggers a targeted re-poll for that
|
|
856
|
+
* worker so the next pending job is picked up immediately, maintaining continuous
|
|
857
|
+
* throughput without waiting for the safety poll interval.
|
|
858
|
+
*
|
|
859
|
+
* Falls back to a full poll (no target names) if the document is missing
|
|
860
|
+
* required fields.
|
|
786
861
|
*
|
|
787
862
|
* @param change - The change stream event document
|
|
788
863
|
*/
|
|
@@ -790,16 +865,81 @@ var ChangeStreamHandler = class {
|
|
|
790
865
|
if (!this.ctx.isRunning()) return;
|
|
791
866
|
const isInsert = change.operationType === "insert";
|
|
792
867
|
const isUpdate = change.operationType === "update";
|
|
793
|
-
const
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
868
|
+
const fullDocument = "fullDocument" in change ? change.fullDocument : void 0;
|
|
869
|
+
const currentStatus = fullDocument?.["status"];
|
|
870
|
+
const isPendingStatus = currentStatus === JobStatus.PENDING;
|
|
871
|
+
const isSlotFreed = isUpdate && (currentStatus === JobStatus.COMPLETED || currentStatus === JobStatus.FAILED);
|
|
872
|
+
if (!(isInsert || isUpdate && isPendingStatus || isSlotFreed)) return;
|
|
873
|
+
if (isSlotFreed) {
|
|
874
|
+
const jobName = fullDocument?.["name"];
|
|
875
|
+
if (jobName) this.pendingTargetNames.add(jobName);
|
|
876
|
+
this.debouncedPoll();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
const jobName = fullDocument?.["name"];
|
|
880
|
+
const nextRunAt = fullDocument?.["nextRunAt"];
|
|
881
|
+
if (jobName && nextRunAt) {
|
|
882
|
+
this.notifyPendingJob(jobName, nextRunAt);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (jobName) this.pendingTargetNames.add(jobName);
|
|
886
|
+
this.debouncedPoll();
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Notify the handler about a pending job created or updated by this process.
|
|
890
|
+
*
|
|
891
|
+
* Reuses the same routing logic as change stream events so local writes don't
|
|
892
|
+
* depend on the MongoDB change stream cursor already being fully ready.
|
|
893
|
+
*
|
|
894
|
+
* @param jobName - Worker name for targeted polling
|
|
895
|
+
* @param nextRunAt - When the job becomes eligible for processing
|
|
896
|
+
*/
|
|
897
|
+
notifyPendingJob(jobName, nextRunAt) {
|
|
898
|
+
if (!this.ctx.isRunning()) return;
|
|
899
|
+
if (nextRunAt.getTime() > Date.now()) {
|
|
900
|
+
this.scheduleWakeup(nextRunAt);
|
|
901
|
+
return;
|
|
802
902
|
}
|
|
903
|
+
this.pendingTargetNames.add(jobName);
|
|
904
|
+
this.debouncedPoll();
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Schedule a debounced poll with collected target names.
|
|
908
|
+
*
|
|
909
|
+
* Collects job names from multiple change stream events during the debounce
|
|
910
|
+
* window, then triggers a single targeted poll for only those workers.
|
|
911
|
+
*/
|
|
912
|
+
debouncedPoll() {
|
|
913
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
914
|
+
this.debounceTimer = setTimeout(() => {
|
|
915
|
+
this.debounceTimer = null;
|
|
916
|
+
const names = this.pendingTargetNames.size > 0 ? new Set(this.pendingTargetNames) : void 0;
|
|
917
|
+
this.pendingTargetNames.clear();
|
|
918
|
+
this.onPoll(names).catch((error) => {
|
|
919
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
920
|
+
});
|
|
921
|
+
}, 100);
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Schedule a wakeup timer for a future-dated job.
|
|
925
|
+
*
|
|
926
|
+
* Maintains a single timer set to the earliest known future job's `nextRunAt`.
|
|
927
|
+
* When the timer fires, triggers a full poll to pick up all due jobs.
|
|
928
|
+
*
|
|
929
|
+
* @param nextRunAt - When the future job should become ready
|
|
930
|
+
*/
|
|
931
|
+
scheduleWakeup(nextRunAt) {
|
|
932
|
+
if (this.wakeupTime && nextRunAt >= this.wakeupTime) return;
|
|
933
|
+
this.clearWakeupTimer();
|
|
934
|
+
this.wakeupTime = nextRunAt;
|
|
935
|
+
const delay = Math.max(nextRunAt.getTime() - Date.now() + POLL_GRACE_PERIOD, MIN_POLL_INTERVAL);
|
|
936
|
+
this.wakeupTimer = setTimeout(() => {
|
|
937
|
+
this.wakeupTime = null;
|
|
938
|
+
this.wakeupTimer = null;
|
|
939
|
+
this.onPoll().catch((error) => {
|
|
940
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
941
|
+
});
|
|
942
|
+
}, delay);
|
|
803
943
|
}
|
|
804
944
|
/**
|
|
805
945
|
* Handle change stream errors with exponential backoff reconnection.
|
|
@@ -813,10 +953,10 @@ var ChangeStreamHandler = class {
|
|
|
813
953
|
handleError(error) {
|
|
814
954
|
if (!this.ctx.isRunning()) return;
|
|
815
955
|
this.reconnectAttempts++;
|
|
956
|
+
this.resetActiveState();
|
|
957
|
+
this.closeChangeStream();
|
|
816
958
|
if (this.reconnectAttempts > this.maxReconnectAttempts) {
|
|
817
|
-
this.usingChangeStreams = false;
|
|
818
959
|
this.clearReconnectTimer();
|
|
819
|
-
this.closeChangeStream();
|
|
820
960
|
this.ctx.emit("changestream:fallback", { reason: `Exhausted ${this.maxReconnectAttempts} reconnection attempts: ${error.message}` });
|
|
821
961
|
return;
|
|
822
962
|
}
|
|
@@ -839,6 +979,29 @@ var ChangeStreamHandler = class {
|
|
|
839
979
|
clearTimeout(this.reconnectTimer);
|
|
840
980
|
this.reconnectTimer = null;
|
|
841
981
|
}
|
|
982
|
+
/**
|
|
983
|
+
* Reset all active change stream state: clear debounce timer, wakeup timer,
|
|
984
|
+
* pending target names, and mark as inactive.
|
|
985
|
+
*
|
|
986
|
+
* Does NOT close the cursor (callers handle sync vs async close) or clear
|
|
987
|
+
* the reconnect timer/attempts (callers manage reconnection lifecycle).
|
|
988
|
+
*/
|
|
989
|
+
resetActiveState() {
|
|
990
|
+
if (this.debounceTimer) {
|
|
991
|
+
clearTimeout(this.debounceTimer);
|
|
992
|
+
this.debounceTimer = null;
|
|
993
|
+
}
|
|
994
|
+
this.pendingTargetNames.clear();
|
|
995
|
+
this.clearWakeupTimer();
|
|
996
|
+
this.usingChangeStreams = false;
|
|
997
|
+
}
|
|
998
|
+
clearWakeupTimer() {
|
|
999
|
+
if (this.wakeupTimer) {
|
|
1000
|
+
clearTimeout(this.wakeupTimer);
|
|
1001
|
+
this.wakeupTimer = null;
|
|
1002
|
+
}
|
|
1003
|
+
this.wakeupTime = null;
|
|
1004
|
+
}
|
|
842
1005
|
closeChangeStream() {
|
|
843
1006
|
if (!this.changeStream) return;
|
|
844
1007
|
this.changeStream.close().catch(() => {});
|
|
@@ -848,19 +1011,16 @@ var ChangeStreamHandler = class {
|
|
|
848
1011
|
* Close the change stream cursor and emit closed event.
|
|
849
1012
|
*/
|
|
850
1013
|
async close() {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
this.debounceTimer = null;
|
|
854
|
-
}
|
|
1014
|
+
const wasActive = this.usingChangeStreams;
|
|
1015
|
+
this.resetActiveState();
|
|
855
1016
|
this.clearReconnectTimer();
|
|
856
1017
|
if (this.changeStream) {
|
|
857
1018
|
try {
|
|
858
1019
|
await this.changeStream.close();
|
|
859
1020
|
} catch {}
|
|
860
1021
|
this.changeStream = null;
|
|
861
|
-
if (
|
|
1022
|
+
if (wasActive) this.ctx.emit("changestream:closed", void 0);
|
|
862
1023
|
}
|
|
863
|
-
this.usingChangeStreams = false;
|
|
864
1024
|
this.reconnectAttempts = 0;
|
|
865
1025
|
}
|
|
866
1026
|
/**
|
|
@@ -904,21 +1064,28 @@ var JobManager = class {
|
|
|
904
1064
|
async cancelJob(jobId) {
|
|
905
1065
|
if (!mongodb.ObjectId.isValid(jobId)) return null;
|
|
906
1066
|
const _id = new mongodb.ObjectId(jobId);
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1067
|
+
try {
|
|
1068
|
+
const now = /* @__PURE__ */ new Date();
|
|
1069
|
+
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1070
|
+
_id,
|
|
1071
|
+
status: JobStatus.PENDING
|
|
1072
|
+
}, { $set: {
|
|
1073
|
+
status: JobStatus.CANCELLED,
|
|
1074
|
+
updatedAt: now
|
|
1075
|
+
} }, { returnDocument: "after" });
|
|
1076
|
+
if (!result) {
|
|
1077
|
+
const jobDoc = await this.ctx.collection.findOne({ _id });
|
|
1078
|
+
if (!jobDoc) return null;
|
|
1079
|
+
if (jobDoc["status"] === JobStatus.CANCELLED) return this.ctx.documentToPersistedJob(jobDoc);
|
|
1080
|
+
throw new JobStateError(`Cannot cancel job in status '${jobDoc["status"]}'`, jobId, jobDoc["status"], "cancel");
|
|
1081
|
+
}
|
|
1082
|
+
const job = this.ctx.documentToPersistedJob(result);
|
|
1083
|
+
this.ctx.emit("job:cancelled", { job });
|
|
1084
|
+
return job;
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
if (error instanceof MonqueError) throw error;
|
|
1087
|
+
throw new ConnectionError(`Failed to cancel job: ${error instanceof Error ? error.message : "Unknown error during cancelJob"}`, error instanceof Error ? { cause: error } : void 0);
|
|
1088
|
+
}
|
|
922
1089
|
}
|
|
923
1090
|
/**
|
|
924
1091
|
* Retry a failed or cancelled job.
|
|
@@ -941,35 +1108,51 @@ var JobManager = class {
|
|
|
941
1108
|
async retryJob(jobId) {
|
|
942
1109
|
if (!mongodb.ObjectId.isValid(jobId)) return null;
|
|
943
1110
|
const _id = new mongodb.ObjectId(jobId);
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1111
|
+
try {
|
|
1112
|
+
const now = /* @__PURE__ */ new Date();
|
|
1113
|
+
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1114
|
+
_id,
|
|
1115
|
+
status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] }
|
|
1116
|
+
}, {
|
|
1117
|
+
$set: {
|
|
1118
|
+
status: JobStatus.PENDING,
|
|
1119
|
+
failCount: 0,
|
|
1120
|
+
nextRunAt: now,
|
|
1121
|
+
updatedAt: now
|
|
1122
|
+
},
|
|
1123
|
+
$unset: {
|
|
1124
|
+
failReason: "",
|
|
1125
|
+
lockedAt: "",
|
|
1126
|
+
claimedBy: "",
|
|
1127
|
+
lastHeartbeat: ""
|
|
1128
|
+
}
|
|
1129
|
+
}, { returnDocument: "before" });
|
|
1130
|
+
if (!result) {
|
|
1131
|
+
const currentJob = await this.ctx.collection.findOne({ _id });
|
|
1132
|
+
if (!currentJob) return null;
|
|
1133
|
+
throw new JobStateError(`Cannot retry job in status '${currentJob["status"]}'`, jobId, currentJob["status"], "retry");
|
|
964
1134
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1135
|
+
const previousStatus = result["status"];
|
|
1136
|
+
const updatedDoc = { ...result };
|
|
1137
|
+
updatedDoc["status"] = JobStatus.PENDING;
|
|
1138
|
+
updatedDoc["failCount"] = 0;
|
|
1139
|
+
updatedDoc["nextRunAt"] = now;
|
|
1140
|
+
updatedDoc["updatedAt"] = now;
|
|
1141
|
+
delete updatedDoc["failReason"];
|
|
1142
|
+
delete updatedDoc["lockedAt"];
|
|
1143
|
+
delete updatedDoc["claimedBy"];
|
|
1144
|
+
delete updatedDoc["lastHeartbeat"];
|
|
1145
|
+
const job = this.ctx.documentToPersistedJob(updatedDoc);
|
|
1146
|
+
this.ctx.notifyPendingJob(job.name, job.nextRunAt);
|
|
1147
|
+
this.ctx.emit("job:retried", {
|
|
1148
|
+
job,
|
|
1149
|
+
previousStatus
|
|
1150
|
+
});
|
|
1151
|
+
return job;
|
|
1152
|
+
} catch (error) {
|
|
1153
|
+
if (error instanceof MonqueError) throw error;
|
|
1154
|
+
throw new ConnectionError(`Failed to retry job: ${error instanceof Error ? error.message : "Unknown error during retryJob"}`, error instanceof Error ? { cause: error } : void 0);
|
|
1155
|
+
}
|
|
973
1156
|
}
|
|
974
1157
|
/**
|
|
975
1158
|
* Reschedule a pending job to run at a different time.
|
|
@@ -990,18 +1173,27 @@ var JobManager = class {
|
|
|
990
1173
|
async rescheduleJob(jobId, runAt) {
|
|
991
1174
|
if (!mongodb.ObjectId.isValid(jobId)) return null;
|
|
992
1175
|
const _id = new mongodb.ObjectId(jobId);
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1176
|
+
try {
|
|
1177
|
+
const now = /* @__PURE__ */ new Date();
|
|
1178
|
+
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1179
|
+
_id,
|
|
1180
|
+
status: JobStatus.PENDING
|
|
1181
|
+
}, { $set: {
|
|
1182
|
+
nextRunAt: runAt,
|
|
1183
|
+
updatedAt: now
|
|
1184
|
+
} }, { returnDocument: "after" });
|
|
1185
|
+
if (!result) {
|
|
1186
|
+
const currentJobDoc = await this.ctx.collection.findOne({ _id });
|
|
1187
|
+
if (!currentJobDoc) return null;
|
|
1188
|
+
throw new JobStateError(`Cannot reschedule job in status '${currentJobDoc["status"]}'`, jobId, currentJobDoc["status"], "reschedule");
|
|
1189
|
+
}
|
|
1190
|
+
const job = this.ctx.documentToPersistedJob(result);
|
|
1191
|
+
this.ctx.notifyPendingJob(job.name, job.nextRunAt);
|
|
1192
|
+
return job;
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
if (error instanceof MonqueError) throw error;
|
|
1195
|
+
throw new ConnectionError(`Failed to reschedule job: ${error instanceof Error ? error.message : "Unknown error during rescheduleJob"}`, error instanceof Error ? { cause: error } : void 0);
|
|
1196
|
+
}
|
|
1005
1197
|
}
|
|
1006
1198
|
/**
|
|
1007
1199
|
* Permanently delete a job.
|
|
@@ -1023,11 +1215,16 @@ var JobManager = class {
|
|
|
1023
1215
|
async deleteJob(jobId) {
|
|
1024
1216
|
if (!mongodb.ObjectId.isValid(jobId)) return false;
|
|
1025
1217
|
const _id = new mongodb.ObjectId(jobId);
|
|
1026
|
-
|
|
1027
|
-
this.ctx.
|
|
1028
|
-
|
|
1218
|
+
try {
|
|
1219
|
+
if ((await this.ctx.collection.deleteOne({ _id })).deletedCount > 0) {
|
|
1220
|
+
this.ctx.emit("job:deleted", { jobId });
|
|
1221
|
+
return true;
|
|
1222
|
+
}
|
|
1223
|
+
return false;
|
|
1224
|
+
} catch (error) {
|
|
1225
|
+
if (error instanceof MonqueError) throw error;
|
|
1226
|
+
throw new ConnectionError(`Failed to delete job: ${error instanceof Error ? error.message : "Unknown error during deleteJob"}`, error instanceof Error ? { cause: error } : void 0);
|
|
1029
1227
|
}
|
|
1030
|
-
return false;
|
|
1031
1228
|
}
|
|
1032
1229
|
/**
|
|
1033
1230
|
* Cancel multiple jobs matching the given filter via a single updateMany call.
|
|
@@ -1059,9 +1256,10 @@ var JobManager = class {
|
|
|
1059
1256
|
}
|
|
1060
1257
|
query["status"] = JobStatus.PENDING;
|
|
1061
1258
|
try {
|
|
1259
|
+
const now = /* @__PURE__ */ new Date();
|
|
1062
1260
|
const count = (await this.ctx.collection.updateMany(query, { $set: {
|
|
1063
1261
|
status: JobStatus.CANCELLED,
|
|
1064
|
-
updatedAt:
|
|
1262
|
+
updatedAt: now
|
|
1065
1263
|
} })).modifiedCount;
|
|
1066
1264
|
if (count > 0) this.ctx.emit("jobs:cancelled", { count });
|
|
1067
1265
|
return {
|
|
@@ -1105,17 +1303,17 @@ var JobManager = class {
|
|
|
1105
1303
|
} else query["status"] = { $in: retryable };
|
|
1106
1304
|
const spreadWindowMs = 3e4;
|
|
1107
1305
|
try {
|
|
1306
|
+
const now = /* @__PURE__ */ new Date();
|
|
1108
1307
|
const count = (await this.ctx.collection.updateMany(query, [{ $set: {
|
|
1109
1308
|
status: JobStatus.PENDING,
|
|
1110
1309
|
failCount: 0,
|
|
1111
|
-
nextRunAt: { $add: [
|
|
1112
|
-
updatedAt:
|
|
1310
|
+
nextRunAt: { $add: [now, { $multiply: [{ $rand: {} }, spreadWindowMs] }] },
|
|
1311
|
+
updatedAt: now
|
|
1113
1312
|
} }, { $unset: [
|
|
1114
1313
|
"failReason",
|
|
1115
1314
|
"lockedAt",
|
|
1116
1315
|
"claimedBy",
|
|
1117
|
-
"lastHeartbeat"
|
|
1118
|
-
"heartbeatInterval"
|
|
1316
|
+
"lastHeartbeat"
|
|
1119
1317
|
] }])).modifiedCount;
|
|
1120
1318
|
if (count > 0) this.ctx.emit("jobs:retried", { count });
|
|
1121
1319
|
return {
|
|
@@ -1175,18 +1373,18 @@ var JobManager = class {
|
|
|
1175
1373
|
var JobProcessor = class {
|
|
1176
1374
|
/** Guard flag to prevent concurrent poll() execution */
|
|
1177
1375
|
_isPolling = false;
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
}
|
|
1376
|
+
/** Flag to request a re-poll after the current poll finishes */
|
|
1377
|
+
_repollRequested = false;
|
|
1181
1378
|
/**
|
|
1182
|
-
*
|
|
1379
|
+
* O(1) counter tracking the total number of active jobs across all workers.
|
|
1183
1380
|
*
|
|
1184
|
-
*
|
|
1381
|
+
* Incremented when a job is added to `worker.activeJobs` in `_doPoll`,
|
|
1382
|
+
* decremented in the `processJob` finally block. Replaces the previous
|
|
1383
|
+
* O(workers) loop in `getTotalActiveJobs()` for instance-level throttling.
|
|
1185
1384
|
*/
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
return total;
|
|
1385
|
+
_totalActiveJobs = 0;
|
|
1386
|
+
constructor(ctx) {
|
|
1387
|
+
this.ctx = ctx;
|
|
1190
1388
|
}
|
|
1191
1389
|
/**
|
|
1192
1390
|
* Get the number of available slots considering the global instanceConcurrency limit.
|
|
@@ -1197,7 +1395,7 @@ var JobProcessor = class {
|
|
|
1197
1395
|
getGloballyAvailableSlots(workerAvailableSlots) {
|
|
1198
1396
|
const { instanceConcurrency } = this.ctx.options;
|
|
1199
1397
|
if (instanceConcurrency === void 0) return workerAvailableSlots;
|
|
1200
|
-
const globalAvailable = instanceConcurrency - this.
|
|
1398
|
+
const globalAvailable = instanceConcurrency - this._totalActiveJobs;
|
|
1201
1399
|
return Math.min(workerAvailableSlots, globalAvailable);
|
|
1202
1400
|
}
|
|
1203
1401
|
/**
|
|
@@ -1207,12 +1405,27 @@ var JobProcessor = class {
|
|
|
1207
1405
|
* attempts to acquire jobs up to the worker's available concurrency slots.
|
|
1208
1406
|
* Aborts early if the scheduler is stopping (`isRunning` is false) or if
|
|
1209
1407
|
* the instance-level `instanceConcurrency` limit is reached.
|
|
1408
|
+
*
|
|
1409
|
+
* If a poll is requested while one is already running, it is queued and
|
|
1410
|
+
* executed as a full poll after the current one finishes. This prevents
|
|
1411
|
+
* change-stream-triggered polls from being silently dropped.
|
|
1412
|
+
*
|
|
1413
|
+
* @param targetNames - Optional set of worker names to poll. When provided, only the
|
|
1414
|
+
* specified workers are checked. Used by change stream handler for targeted polling.
|
|
1210
1415
|
*/
|
|
1211
|
-
async poll() {
|
|
1212
|
-
if (!this.ctx.isRunning()
|
|
1416
|
+
async poll(targetNames) {
|
|
1417
|
+
if (!this.ctx.isRunning()) return;
|
|
1418
|
+
if (this._isPolling) {
|
|
1419
|
+
this._repollRequested = true;
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1213
1422
|
this._isPolling = true;
|
|
1214
1423
|
try {
|
|
1215
|
-
|
|
1424
|
+
do {
|
|
1425
|
+
this._repollRequested = false;
|
|
1426
|
+
await this._doPoll(targetNames);
|
|
1427
|
+
targetNames = void 0;
|
|
1428
|
+
} while (this._repollRequested && this.ctx.isRunning());
|
|
1216
1429
|
} finally {
|
|
1217
1430
|
this._isPolling = false;
|
|
1218
1431
|
}
|
|
@@ -1220,28 +1433,51 @@ var JobProcessor = class {
|
|
|
1220
1433
|
/**
|
|
1221
1434
|
* Internal poll implementation.
|
|
1222
1435
|
*/
|
|
1223
|
-
async _doPoll() {
|
|
1436
|
+
async _doPoll(targetNames) {
|
|
1224
1437
|
const { instanceConcurrency } = this.ctx.options;
|
|
1225
|
-
if (instanceConcurrency !== void 0 && this.
|
|
1438
|
+
if (instanceConcurrency !== void 0 && this._totalActiveJobs >= instanceConcurrency) return;
|
|
1226
1439
|
for (const [name, worker] of this.ctx.workers) {
|
|
1440
|
+
if (targetNames && !targetNames.has(name)) continue;
|
|
1227
1441
|
const workerAvailableSlots = worker.concurrency - worker.activeJobs.size;
|
|
1228
1442
|
if (workerAvailableSlots <= 0) continue;
|
|
1229
1443
|
const availableSlots = this.getGloballyAvailableSlots(workerAvailableSlots);
|
|
1230
1444
|
if (availableSlots <= 0) return;
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
if (
|
|
1445
|
+
if (!this.ctx.isRunning()) return;
|
|
1446
|
+
const acquisitionPromises = [];
|
|
1447
|
+
for (let i = 0; i < availableSlots; i++) acquisitionPromises.push(this.acquireJob(name).then(async (job) => {
|
|
1448
|
+
if (!job) return;
|
|
1449
|
+
if (this.ctx.isRunning()) {
|
|
1236
1450
|
worker.activeJobs.set(job._id.toString(), job);
|
|
1451
|
+
this._totalActiveJobs++;
|
|
1237
1452
|
this.processJob(job, worker).catch((error) => {
|
|
1238
1453
|
this.ctx.emit("job:error", {
|
|
1239
1454
|
error: toError(error),
|
|
1240
1455
|
job
|
|
1241
1456
|
});
|
|
1242
1457
|
});
|
|
1243
|
-
} else
|
|
1244
|
-
|
|
1458
|
+
} else try {
|
|
1459
|
+
await this.ctx.collection.updateOne({
|
|
1460
|
+
_id: job._id,
|
|
1461
|
+
status: JobStatus.PROCESSING,
|
|
1462
|
+
claimedBy: this.ctx.instanceId
|
|
1463
|
+
}, {
|
|
1464
|
+
$set: {
|
|
1465
|
+
status: JobStatus.PENDING,
|
|
1466
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1467
|
+
},
|
|
1468
|
+
$unset: {
|
|
1469
|
+
lockedAt: "",
|
|
1470
|
+
claimedBy: "",
|
|
1471
|
+
lastHeartbeat: ""
|
|
1472
|
+
}
|
|
1473
|
+
});
|
|
1474
|
+
} catch (error) {
|
|
1475
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
1476
|
+
}
|
|
1477
|
+
}).catch((error) => {
|
|
1478
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
1479
|
+
}));
|
|
1480
|
+
await Promise.allSettled(acquisitionPromises);
|
|
1245
1481
|
}
|
|
1246
1482
|
}
|
|
1247
1483
|
/**
|
|
@@ -1277,7 +1513,6 @@ var JobProcessor = class {
|
|
|
1277
1513
|
sort: { nextRunAt: 1 },
|
|
1278
1514
|
returnDocument: "after"
|
|
1279
1515
|
});
|
|
1280
|
-
if (!this.ctx.isRunning()) return null;
|
|
1281
1516
|
if (!result) return null;
|
|
1282
1517
|
return this.ctx.documentToPersistedJob(result);
|
|
1283
1518
|
}
|
|
@@ -1320,6 +1555,8 @@ var JobProcessor = class {
|
|
|
1320
1555
|
}
|
|
1321
1556
|
} finally {
|
|
1322
1557
|
worker.activeJobs.delete(jobId);
|
|
1558
|
+
this._totalActiveJobs--;
|
|
1559
|
+
this.ctx.notifyJobFinished();
|
|
1323
1560
|
}
|
|
1324
1561
|
}
|
|
1325
1562
|
/**
|
|
@@ -1339,6 +1576,7 @@ var JobProcessor = class {
|
|
|
1339
1576
|
*/
|
|
1340
1577
|
async completeJob(job) {
|
|
1341
1578
|
if (!isPersistedJob(job)) return null;
|
|
1579
|
+
const now = /* @__PURE__ */ new Date();
|
|
1342
1580
|
if (job.repeatInterval) {
|
|
1343
1581
|
const nextRunAt = getNextCronDate(job.repeatInterval);
|
|
1344
1582
|
const result = await this.ctx.collection.findOneAndUpdate({
|
|
@@ -1350,17 +1588,19 @@ var JobProcessor = class {
|
|
|
1350
1588
|
status: JobStatus.PENDING,
|
|
1351
1589
|
nextRunAt,
|
|
1352
1590
|
failCount: 0,
|
|
1353
|
-
updatedAt:
|
|
1591
|
+
updatedAt: now
|
|
1354
1592
|
},
|
|
1355
1593
|
$unset: {
|
|
1356
1594
|
lockedAt: "",
|
|
1357
1595
|
claimedBy: "",
|
|
1358
1596
|
lastHeartbeat: "",
|
|
1359
|
-
heartbeatInterval: "",
|
|
1360
1597
|
failReason: ""
|
|
1361
1598
|
}
|
|
1362
1599
|
}, { returnDocument: "after" });
|
|
1363
|
-
|
|
1600
|
+
if (!result) return null;
|
|
1601
|
+
const persistedJob = this.ctx.documentToPersistedJob(result);
|
|
1602
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
1603
|
+
return persistedJob;
|
|
1364
1604
|
}
|
|
1365
1605
|
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1366
1606
|
_id: job._id,
|
|
@@ -1369,17 +1609,17 @@ var JobProcessor = class {
|
|
|
1369
1609
|
}, {
|
|
1370
1610
|
$set: {
|
|
1371
1611
|
status: JobStatus.COMPLETED,
|
|
1372
|
-
updatedAt:
|
|
1612
|
+
updatedAt: now
|
|
1373
1613
|
},
|
|
1374
1614
|
$unset: {
|
|
1375
1615
|
lockedAt: "",
|
|
1376
1616
|
claimedBy: "",
|
|
1377
1617
|
lastHeartbeat: "",
|
|
1378
|
-
heartbeatInterval: "",
|
|
1379
1618
|
failReason: ""
|
|
1380
1619
|
}
|
|
1381
1620
|
}, { returnDocument: "after" });
|
|
1382
|
-
|
|
1621
|
+
if (!result) return null;
|
|
1622
|
+
return this.ctx.documentToPersistedJob(result);
|
|
1383
1623
|
}
|
|
1384
1624
|
/**
|
|
1385
1625
|
* Handle job failure with exponential backoff retry logic using an atomic status transition.
|
|
@@ -1401,6 +1641,7 @@ var JobProcessor = class {
|
|
|
1401
1641
|
*/
|
|
1402
1642
|
async failJob(job, error) {
|
|
1403
1643
|
if (!isPersistedJob(job)) return null;
|
|
1644
|
+
const now = /* @__PURE__ */ new Date();
|
|
1404
1645
|
const newFailCount = job.failCount + 1;
|
|
1405
1646
|
if (newFailCount >= this.ctx.options.maxRetries) {
|
|
1406
1647
|
const result = await this.ctx.collection.findOneAndUpdate({
|
|
@@ -1412,13 +1653,12 @@ var JobProcessor = class {
|
|
|
1412
1653
|
status: JobStatus.FAILED,
|
|
1413
1654
|
failCount: newFailCount,
|
|
1414
1655
|
failReason: error.message,
|
|
1415
|
-
updatedAt:
|
|
1656
|
+
updatedAt: now
|
|
1416
1657
|
},
|
|
1417
1658
|
$unset: {
|
|
1418
1659
|
lockedAt: "",
|
|
1419
1660
|
claimedBy: "",
|
|
1420
|
-
lastHeartbeat: ""
|
|
1421
|
-
heartbeatInterval: ""
|
|
1661
|
+
lastHeartbeat: ""
|
|
1422
1662
|
}
|
|
1423
1663
|
}, { returnDocument: "after" });
|
|
1424
1664
|
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
@@ -1434,13 +1674,12 @@ var JobProcessor = class {
|
|
|
1434
1674
|
failCount: newFailCount,
|
|
1435
1675
|
failReason: error.message,
|
|
1436
1676
|
nextRunAt,
|
|
1437
|
-
updatedAt:
|
|
1677
|
+
updatedAt: now
|
|
1438
1678
|
},
|
|
1439
1679
|
$unset: {
|
|
1440
1680
|
lockedAt: "",
|
|
1441
1681
|
claimedBy: "",
|
|
1442
|
-
lastHeartbeat: ""
|
|
1443
|
-
heartbeatInterval: ""
|
|
1682
|
+
lastHeartbeat: ""
|
|
1444
1683
|
}
|
|
1445
1684
|
}, { returnDocument: "after" });
|
|
1446
1685
|
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
@@ -1783,6 +2022,10 @@ var JobScheduler = class {
|
|
|
1783
2022
|
constructor(ctx) {
|
|
1784
2023
|
this.ctx = ctx;
|
|
1785
2024
|
}
|
|
2025
|
+
validateJobIdentifiers(name, uniqueKey) {
|
|
2026
|
+
validateJobName(name);
|
|
2027
|
+
if (uniqueKey !== void 0) validateUniqueKey(uniqueKey);
|
|
2028
|
+
}
|
|
1786
2029
|
/**
|
|
1787
2030
|
* Validate that the job data payload does not exceed the configured maximum BSON byte size.
|
|
1788
2031
|
*
|
|
@@ -1820,6 +2063,7 @@ var JobScheduler = class {
|
|
|
1820
2063
|
* @param data - Job payload, will be passed to the worker handler
|
|
1821
2064
|
* @param options - Scheduling and deduplication options
|
|
1822
2065
|
* @returns Promise resolving to the created or existing job document
|
|
2066
|
+
* @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
|
|
1823
2067
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
1824
2068
|
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
1825
2069
|
*
|
|
@@ -1849,6 +2093,7 @@ var JobScheduler = class {
|
|
|
1849
2093
|
* ```
|
|
1850
2094
|
*/
|
|
1851
2095
|
async enqueue(name, data, options = {}) {
|
|
2096
|
+
this.validateJobIdentifiers(name, options.uniqueKey);
|
|
1852
2097
|
this.validatePayloadSize(data);
|
|
1853
2098
|
const now = /* @__PURE__ */ new Date();
|
|
1854
2099
|
const job = {
|
|
@@ -1860,9 +2105,9 @@ var JobScheduler = class {
|
|
|
1860
2105
|
createdAt: now,
|
|
1861
2106
|
updatedAt: now
|
|
1862
2107
|
};
|
|
1863
|
-
if (options.uniqueKey) job.uniqueKey = options.uniqueKey;
|
|
2108
|
+
if (options.uniqueKey !== void 0) job.uniqueKey = options.uniqueKey;
|
|
1864
2109
|
try {
|
|
1865
|
-
if (options.uniqueKey) {
|
|
2110
|
+
if (options.uniqueKey !== void 0) {
|
|
1866
2111
|
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1867
2112
|
name,
|
|
1868
2113
|
uniqueKey: options.uniqueKey,
|
|
@@ -1872,13 +2117,17 @@ var JobScheduler = class {
|
|
|
1872
2117
|
returnDocument: "after"
|
|
1873
2118
|
});
|
|
1874
2119
|
if (!result) throw new ConnectionError("Failed to enqueue job: findOneAndUpdate returned no document");
|
|
1875
|
-
|
|
2120
|
+
const persistedJob = this.ctx.documentToPersistedJob(result);
|
|
2121
|
+
if (persistedJob.status === JobStatus.PENDING) this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
2122
|
+
return persistedJob;
|
|
1876
2123
|
}
|
|
1877
2124
|
const result = await this.ctx.collection.insertOne(job);
|
|
1878
|
-
|
|
2125
|
+
const persistedJob = {
|
|
1879
2126
|
...job,
|
|
1880
2127
|
_id: result.insertedId
|
|
1881
2128
|
};
|
|
2129
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
2130
|
+
return persistedJob;
|
|
1882
2131
|
} catch (error) {
|
|
1883
2132
|
if (error instanceof ConnectionError) throw error;
|
|
1884
2133
|
throw new ConnectionError(`Failed to enqueue job: ${error instanceof Error ? error.message : "Unknown error during enqueue"}`, error instanceof Error ? { cause: error } : void 0);
|
|
@@ -1933,6 +2182,7 @@ var JobScheduler = class {
|
|
|
1933
2182
|
* @param data - Job payload, will be passed to the worker handler on each run
|
|
1934
2183
|
* @param options - Scheduling options (uniqueKey for deduplication)
|
|
1935
2184
|
* @returns Promise resolving to the created job document with `repeatInterval` set
|
|
2185
|
+
* @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
|
|
1936
2186
|
* @throws {InvalidCronError} If cron expression is invalid
|
|
1937
2187
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
1938
2188
|
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
@@ -1961,6 +2211,7 @@ var JobScheduler = class {
|
|
|
1961
2211
|
* ```
|
|
1962
2212
|
*/
|
|
1963
2213
|
async schedule(cron, name, data, options = {}) {
|
|
2214
|
+
this.validateJobIdentifiers(name, options.uniqueKey);
|
|
1964
2215
|
this.validatePayloadSize(data);
|
|
1965
2216
|
const nextRunAt = getNextCronDate(cron);
|
|
1966
2217
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -1974,9 +2225,9 @@ var JobScheduler = class {
|
|
|
1974
2225
|
createdAt: now,
|
|
1975
2226
|
updatedAt: now
|
|
1976
2227
|
};
|
|
1977
|
-
if (options.uniqueKey) job.uniqueKey = options.uniqueKey;
|
|
2228
|
+
if (options.uniqueKey !== void 0) job.uniqueKey = options.uniqueKey;
|
|
1978
2229
|
try {
|
|
1979
|
-
if (options.uniqueKey) {
|
|
2230
|
+
if (options.uniqueKey !== void 0) {
|
|
1980
2231
|
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1981
2232
|
name,
|
|
1982
2233
|
uniqueKey: options.uniqueKey,
|
|
@@ -1986,13 +2237,17 @@ var JobScheduler = class {
|
|
|
1986
2237
|
returnDocument: "after"
|
|
1987
2238
|
});
|
|
1988
2239
|
if (!result) throw new ConnectionError("Failed to schedule job: findOneAndUpdate returned no document");
|
|
1989
|
-
|
|
2240
|
+
const persistedJob = this.ctx.documentToPersistedJob(result);
|
|
2241
|
+
if (persistedJob.status === JobStatus.PENDING) this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
2242
|
+
return persistedJob;
|
|
1990
2243
|
}
|
|
1991
2244
|
const result = await this.ctx.collection.insertOne(job);
|
|
1992
|
-
|
|
2245
|
+
const persistedJob = {
|
|
1993
2246
|
...job,
|
|
1994
2247
|
_id: result.insertedId
|
|
1995
2248
|
};
|
|
2249
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
2250
|
+
return persistedJob;
|
|
1996
2251
|
} catch (error) {
|
|
1997
2252
|
if (error instanceof MonqueError) throw error;
|
|
1998
2253
|
throw new ConnectionError(`Failed to schedule job: ${error instanceof Error ? error.message : "Unknown error during schedule"}`, error instanceof Error ? { cause: error } : void 0);
|
|
@@ -2006,16 +2261,25 @@ var JobScheduler = class {
|
|
|
2006
2261
|
*/
|
|
2007
2262
|
const DEFAULT_RETENTION_INTERVAL = 36e5;
|
|
2008
2263
|
/**
|
|
2264
|
+
* Statuses that are eligible for cleanup by the retention policy.
|
|
2265
|
+
*/
|
|
2266
|
+
const CLEANUP_STATUSES = [JobStatus.COMPLETED, JobStatus.FAILED];
|
|
2267
|
+
/**
|
|
2009
2268
|
* Manages scheduler lifecycle timers and job cleanup.
|
|
2010
2269
|
*
|
|
2011
|
-
* Owns poll
|
|
2270
|
+
* Owns poll scheduling, heartbeat interval, cleanup interval, and the
|
|
2012
2271
|
* cleanupJobs logic. Extracted from Monque to keep the facade thin.
|
|
2013
2272
|
*
|
|
2273
|
+
* Uses adaptive poll scheduling: when change streams are active, polls at
|
|
2274
|
+
* `safetyPollInterval` (safety net only). When change streams are inactive,
|
|
2275
|
+
* polls at `pollInterval` (primary discovery mechanism).
|
|
2276
|
+
*
|
|
2014
2277
|
* @internal Not part of public API.
|
|
2015
2278
|
*/
|
|
2016
2279
|
var LifecycleManager = class {
|
|
2017
2280
|
ctx;
|
|
2018
|
-
|
|
2281
|
+
callbacks = null;
|
|
2282
|
+
pollTimeoutId = null;
|
|
2019
2283
|
heartbeatIntervalId = null;
|
|
2020
2284
|
cleanupIntervalId = null;
|
|
2021
2285
|
constructor(ctx) {
|
|
@@ -2024,17 +2288,13 @@ var LifecycleManager = class {
|
|
|
2024
2288
|
/**
|
|
2025
2289
|
* Start all lifecycle timers.
|
|
2026
2290
|
*
|
|
2027
|
-
* Sets up poll
|
|
2291
|
+
* Sets up adaptive poll scheduling, heartbeat interval, and (if configured)
|
|
2028
2292
|
* cleanup interval. Runs an initial poll immediately.
|
|
2029
2293
|
*
|
|
2030
2294
|
* @param callbacks - Functions to invoke on each timer tick
|
|
2031
2295
|
*/
|
|
2032
2296
|
startTimers(callbacks) {
|
|
2033
|
-
this.
|
|
2034
|
-
callbacks.poll().catch((error) => {
|
|
2035
|
-
this.ctx.emit("job:error", { error: toError(error) });
|
|
2036
|
-
});
|
|
2037
|
-
}, this.ctx.options.pollInterval);
|
|
2297
|
+
this.callbacks = callbacks;
|
|
2038
2298
|
this.heartbeatIntervalId = setInterval(() => {
|
|
2039
2299
|
callbacks.updateHeartbeats().catch((error) => {
|
|
2040
2300
|
this.ctx.emit("job:error", { error: toError(error) });
|
|
@@ -2051,23 +2311,22 @@ var LifecycleManager = class {
|
|
|
2051
2311
|
});
|
|
2052
2312
|
}, interval);
|
|
2053
2313
|
}
|
|
2054
|
-
|
|
2055
|
-
this.ctx.emit("job:error", { error: toError(error) });
|
|
2056
|
-
});
|
|
2314
|
+
this.executePollAndScheduleNext();
|
|
2057
2315
|
}
|
|
2058
2316
|
/**
|
|
2059
2317
|
* Stop all lifecycle timers.
|
|
2060
2318
|
*
|
|
2061
|
-
* Clears poll, heartbeat, and cleanup
|
|
2319
|
+
* Clears poll timeout, heartbeat interval, and cleanup interval.
|
|
2062
2320
|
*/
|
|
2063
2321
|
stopTimers() {
|
|
2322
|
+
this.callbacks = null;
|
|
2064
2323
|
if (this.cleanupIntervalId) {
|
|
2065
2324
|
clearInterval(this.cleanupIntervalId);
|
|
2066
2325
|
this.cleanupIntervalId = null;
|
|
2067
2326
|
}
|
|
2068
|
-
if (this.
|
|
2069
|
-
|
|
2070
|
-
this.
|
|
2327
|
+
if (this.pollTimeoutId) {
|
|
2328
|
+
clearTimeout(this.pollTimeoutId);
|
|
2329
|
+
this.pollTimeoutId = null;
|
|
2071
2330
|
}
|
|
2072
2331
|
if (this.heartbeatIntervalId) {
|
|
2073
2332
|
clearInterval(this.heartbeatIntervalId);
|
|
@@ -2075,6 +2334,43 @@ var LifecycleManager = class {
|
|
|
2075
2334
|
}
|
|
2076
2335
|
}
|
|
2077
2336
|
/**
|
|
2337
|
+
* Reset the poll timer to reschedule the next poll.
|
|
2338
|
+
*
|
|
2339
|
+
* Called after change-stream-triggered polls to ensure the safety poll timer
|
|
2340
|
+
* is recalculated (not fired redundantly from an old schedule).
|
|
2341
|
+
*/
|
|
2342
|
+
resetPollTimer() {
|
|
2343
|
+
this.scheduleNextPoll();
|
|
2344
|
+
}
|
|
2345
|
+
/**
|
|
2346
|
+
* Execute a poll and schedule the next one adaptively.
|
|
2347
|
+
*/
|
|
2348
|
+
executePollAndScheduleNext() {
|
|
2349
|
+
if (!this.callbacks) return;
|
|
2350
|
+
this.callbacks.poll().catch((error) => {
|
|
2351
|
+
this.ctx.emit("job:error", { error: toError(error) });
|
|
2352
|
+
}).finally(() => {
|
|
2353
|
+
this.scheduleNextPoll();
|
|
2354
|
+
});
|
|
2355
|
+
}
|
|
2356
|
+
/**
|
|
2357
|
+
* Schedule the next poll using adaptive timing.
|
|
2358
|
+
*
|
|
2359
|
+
* When change streams are active, uses `safetyPollInterval` (longer, safety net only).
|
|
2360
|
+
* When change streams are inactive, uses `pollInterval` (shorter, primary discovery).
|
|
2361
|
+
*/
|
|
2362
|
+
scheduleNextPoll() {
|
|
2363
|
+
if (this.pollTimeoutId) {
|
|
2364
|
+
clearTimeout(this.pollTimeoutId);
|
|
2365
|
+
this.pollTimeoutId = null;
|
|
2366
|
+
}
|
|
2367
|
+
if (!this.ctx.isRunning() || !this.callbacks) return;
|
|
2368
|
+
const delay = this.callbacks.isChangeStreamActive() ? this.ctx.options.safetyPollInterval : this.ctx.options.pollInterval;
|
|
2369
|
+
this.pollTimeoutId = setTimeout(() => {
|
|
2370
|
+
this.executePollAndScheduleNext();
|
|
2371
|
+
}, delay);
|
|
2372
|
+
}
|
|
2373
|
+
/**
|
|
2078
2374
|
* Clean up old completed and failed jobs based on retention policy.
|
|
2079
2375
|
*
|
|
2080
2376
|
* - Removes completed jobs older than `jobRetention.completed`
|
|
@@ -2114,6 +2410,7 @@ var LifecycleManager = class {
|
|
|
2114
2410
|
const DEFAULTS = {
|
|
2115
2411
|
collectionName: "monque_jobs",
|
|
2116
2412
|
pollInterval: 1e3,
|
|
2413
|
+
safetyPollInterval: 3e4,
|
|
2117
2414
|
maxRetries: 10,
|
|
2118
2415
|
baseRetryInterval: 1e3,
|
|
2119
2416
|
shutdownTimeout: 3e4,
|
|
@@ -2194,6 +2491,14 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2194
2491
|
workers = /* @__PURE__ */ new Map();
|
|
2195
2492
|
isRunning = false;
|
|
2196
2493
|
isInitialized = false;
|
|
2494
|
+
/**
|
|
2495
|
+
* Resolve function for the reactive shutdown drain promise.
|
|
2496
|
+
* Set during stop() when active jobs need to finish; called by
|
|
2497
|
+
* onJobFinished() when the last active job completes.
|
|
2498
|
+
*
|
|
2499
|
+
* @private
|
|
2500
|
+
*/
|
|
2501
|
+
_drainResolve = null;
|
|
2197
2502
|
_scheduler = null;
|
|
2198
2503
|
_manager = null;
|
|
2199
2504
|
_query = null;
|
|
@@ -2202,10 +2507,12 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2202
2507
|
_lifecycleManager = null;
|
|
2203
2508
|
constructor(db, options = {}) {
|
|
2204
2509
|
super();
|
|
2510
|
+
this.setMaxListeners(20);
|
|
2205
2511
|
this.db = db;
|
|
2206
2512
|
this.options = {
|
|
2207
2513
|
collectionName: options.collectionName ?? DEFAULTS.collectionName,
|
|
2208
2514
|
pollInterval: options.pollInterval ?? DEFAULTS.pollInterval,
|
|
2515
|
+
safetyPollInterval: options.safetyPollInterval ?? DEFAULTS.safetyPollInterval,
|
|
2209
2516
|
maxRetries: options.maxRetries ?? DEFAULTS.maxRetries,
|
|
2210
2517
|
baseRetryInterval: options.baseRetryInterval ?? DEFAULTS.baseRetryInterval,
|
|
2211
2518
|
shutdownTimeout: options.shutdownTimeout ?? DEFAULTS.shutdownTimeout,
|
|
@@ -2221,6 +2528,8 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2221
2528
|
maxPayloadSize: options.maxPayloadSize,
|
|
2222
2529
|
statsCacheTtlMs: options.statsCacheTtlMs ?? 5e3
|
|
2223
2530
|
};
|
|
2531
|
+
if (options.defaultConcurrency !== void 0) console.warn("[@monque/core] \"defaultConcurrency\" is deprecated and will be removed in a future major version. Use \"workerConcurrency\" instead.");
|
|
2532
|
+
if (options.maxConcurrency !== void 0) console.warn("[@monque/core] \"maxConcurrency\" is deprecated and will be removed in a future major version. Use \"instanceConcurrency\" instead.");
|
|
2224
2533
|
}
|
|
2225
2534
|
/**
|
|
2226
2535
|
* Initialize the scheduler by setting up the MongoDB collection and indexes.
|
|
@@ -2240,7 +2549,7 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2240
2549
|
this._manager = new JobManager(ctx);
|
|
2241
2550
|
this._query = new JobQueryService(ctx);
|
|
2242
2551
|
this._processor = new JobProcessor(ctx);
|
|
2243
|
-
this._changeStreamHandler = new ChangeStreamHandler(ctx, () => this.
|
|
2552
|
+
this._changeStreamHandler = new ChangeStreamHandler(ctx, (targetNames) => this.handleChangeStreamPoll(targetNames));
|
|
2244
2553
|
this._lifecycleManager = new LifecycleManager(ctx);
|
|
2245
2554
|
this.isInitialized = true;
|
|
2246
2555
|
} catch (error) {
|
|
@@ -2277,6 +2586,21 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2277
2586
|
if (!this._lifecycleManager) throw new ConnectionError("Monque not initialized. Call initialize() first.");
|
|
2278
2587
|
return this._lifecycleManager;
|
|
2279
2588
|
}
|
|
2589
|
+
validateSchedulingIdentifiers(name, uniqueKey) {
|
|
2590
|
+
validateJobName(name);
|
|
2591
|
+
if (uniqueKey !== void 0) validateUniqueKey(uniqueKey);
|
|
2592
|
+
}
|
|
2593
|
+
/**
|
|
2594
|
+
* Handle a change-stream-triggered poll and reset the safety poll timer.
|
|
2595
|
+
*
|
|
2596
|
+
* Used as the `onPoll` callback for {@link ChangeStreamHandler}. Runs a
|
|
2597
|
+
* targeted poll for the given worker names, then resets the adaptive safety
|
|
2598
|
+
* poll timer so it doesn't fire redundantly.
|
|
2599
|
+
*/
|
|
2600
|
+
async handleChangeStreamPoll(targetNames) {
|
|
2601
|
+
await this.processor.poll(targetNames);
|
|
2602
|
+
this.lifecycleManager.resetPollTimer();
|
|
2603
|
+
}
|
|
2280
2604
|
/**
|
|
2281
2605
|
* Build the shared context for internal services.
|
|
2282
2606
|
*/
|
|
@@ -2289,6 +2613,11 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2289
2613
|
workers: this.workers,
|
|
2290
2614
|
isRunning: () => this.isRunning,
|
|
2291
2615
|
emit: (event, payload) => this.emit(event, payload),
|
|
2616
|
+
notifyPendingJob: (name, nextRunAt) => {
|
|
2617
|
+
if (!this.isRunning || !this._changeStreamHandler) return;
|
|
2618
|
+
this._changeStreamHandler.notifyPendingJob(name, nextRunAt);
|
|
2619
|
+
},
|
|
2620
|
+
notifyJobFinished: () => this.onJobFinished(),
|
|
2292
2621
|
documentToPersistedJob: (doc) => documentToPersistedJob(doc)
|
|
2293
2622
|
};
|
|
2294
2623
|
}
|
|
@@ -2362,7 +2691,18 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2362
2691
|
lastHeartbeat: 1
|
|
2363
2692
|
},
|
|
2364
2693
|
background: true
|
|
2365
|
-
}
|
|
2694
|
+
},
|
|
2695
|
+
...this.options.jobRetention ? [{
|
|
2696
|
+
key: {
|
|
2697
|
+
status: 1,
|
|
2698
|
+
updatedAt: 1
|
|
2699
|
+
},
|
|
2700
|
+
background: true,
|
|
2701
|
+
partialFilterExpression: {
|
|
2702
|
+
status: { $in: CLEANUP_STATUSES },
|
|
2703
|
+
updatedAt: { $exists: true }
|
|
2704
|
+
}
|
|
2705
|
+
}] : []
|
|
2366
2706
|
]);
|
|
2367
2707
|
}
|
|
2368
2708
|
/**
|
|
@@ -2384,8 +2724,7 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2384
2724
|
$unset: {
|
|
2385
2725
|
lockedAt: "",
|
|
2386
2726
|
claimedBy: "",
|
|
2387
|
-
lastHeartbeat: ""
|
|
2388
|
-
heartbeatInterval: ""
|
|
2727
|
+
lastHeartbeat: ""
|
|
2389
2728
|
}
|
|
2390
2729
|
});
|
|
2391
2730
|
if (result.modifiedCount > 0) this.emit("stale:recovered", { count: result.modifiedCount });
|
|
@@ -2426,6 +2765,7 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2426
2765
|
* @param data - Job payload, will be passed to the worker handler
|
|
2427
2766
|
* @param options - Scheduling and deduplication options
|
|
2428
2767
|
* @returns Promise resolving to the created or existing job document
|
|
2768
|
+
* @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
|
|
2429
2769
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
2430
2770
|
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
2431
2771
|
*
|
|
@@ -2458,6 +2798,7 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2458
2798
|
*/
|
|
2459
2799
|
async enqueue(name, data, options = {}) {
|
|
2460
2800
|
this.ensureInitialized();
|
|
2801
|
+
this.validateSchedulingIdentifiers(name, options.uniqueKey);
|
|
2461
2802
|
return this.scheduler.enqueue(name, data, options);
|
|
2462
2803
|
}
|
|
2463
2804
|
/**
|
|
@@ -2470,6 +2811,7 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2470
2811
|
* @param name - Job type identifier, must match a registered worker
|
|
2471
2812
|
* @param data - Job payload, will be passed to the worker handler
|
|
2472
2813
|
* @returns Promise resolving to the created job document
|
|
2814
|
+
* @throws {InvalidJobIdentifierError} If `name` fails public identifier validation
|
|
2473
2815
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
2474
2816
|
*
|
|
2475
2817
|
* @example Send email immediately
|
|
@@ -2492,6 +2834,7 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2492
2834
|
*/
|
|
2493
2835
|
async now(name, data) {
|
|
2494
2836
|
this.ensureInitialized();
|
|
2837
|
+
validateJobName(name);
|
|
2495
2838
|
return this.scheduler.now(name, data);
|
|
2496
2839
|
}
|
|
2497
2840
|
/**
|
|
@@ -2512,6 +2855,7 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2512
2855
|
* @param data - Job payload, will be passed to the worker handler on each run
|
|
2513
2856
|
* @param options - Scheduling options (uniqueKey for deduplication)
|
|
2514
2857
|
* @returns Promise resolving to the created job document with `repeatInterval` set
|
|
2858
|
+
* @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
|
|
2515
2859
|
* @throws {InvalidCronError} If cron expression is invalid
|
|
2516
2860
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
2517
2861
|
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
@@ -2543,6 +2887,7 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2543
2887
|
*/
|
|
2544
2888
|
async schedule(cron, name, data, options = {}) {
|
|
2545
2889
|
this.ensureInitialized();
|
|
2890
|
+
this.validateSchedulingIdentifiers(name, options.uniqueKey);
|
|
2546
2891
|
return this.scheduler.schedule(cron, name, data, options);
|
|
2547
2892
|
}
|
|
2548
2893
|
/**
|
|
@@ -2888,6 +3233,7 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2888
3233
|
* @param options - Worker configuration
|
|
2889
3234
|
* @param options.concurrency - Maximum concurrent jobs for this worker (default: `defaultConcurrency`)
|
|
2890
3235
|
* @param options.replace - When `true`, replace existing worker instead of throwing error
|
|
3236
|
+
* @throws {InvalidJobIdentifierError} If `name` fails public identifier validation
|
|
2891
3237
|
* @throws {WorkerRegistrationError} When a worker is already registered for `name` and `replace` is not `true`
|
|
2892
3238
|
*
|
|
2893
3239
|
* @example Basic email worker
|
|
@@ -2931,6 +3277,7 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2931
3277
|
* ```
|
|
2932
3278
|
*/
|
|
2933
3279
|
register(name, handler, options = {}) {
|
|
3280
|
+
validateJobName(name);
|
|
2934
3281
|
const concurrency = options.concurrency ?? this.options.workerConcurrency;
|
|
2935
3282
|
if (this.workers.has(name) && options.replace !== true) throw new WorkerRegistrationError(`Worker already registered for job name "${name}". Use { replace: true } to replace.`, name);
|
|
2936
3283
|
this.workers.set(name, {
|
|
@@ -2989,7 +3336,8 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
2989
3336
|
this.changeStreamHandler.setup();
|
|
2990
3337
|
this.lifecycleManager.startTimers({
|
|
2991
3338
|
poll: () => this.processor.poll(),
|
|
2992
|
-
updateHeartbeats: () => this.processor.updateHeartbeats()
|
|
3339
|
+
updateHeartbeats: () => this.processor.updateHeartbeats(),
|
|
3340
|
+
isChangeStreamActive: () => this.changeStreamHandler.isActive()
|
|
2993
3341
|
});
|
|
2994
3342
|
}
|
|
2995
3343
|
/**
|
|
@@ -3033,25 +3381,15 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
3033
3381
|
try {
|
|
3034
3382
|
await this.changeStreamHandler.close();
|
|
3035
3383
|
} catch {}
|
|
3036
|
-
if (this.
|
|
3037
|
-
let checkInterval;
|
|
3384
|
+
if (this.getActiveJobCount() === 0) return;
|
|
3038
3385
|
const waitForJobs = new Promise((resolve) => {
|
|
3039
|
-
|
|
3040
|
-
if (this.getActiveJobs().length === 0) {
|
|
3041
|
-
clearInterval(checkInterval);
|
|
3042
|
-
resolve(void 0);
|
|
3043
|
-
}
|
|
3044
|
-
}, 100);
|
|
3386
|
+
this._drainResolve = () => resolve(void 0);
|
|
3045
3387
|
});
|
|
3046
3388
|
const timeout = new Promise((resolve) => {
|
|
3047
3389
|
setTimeout(() => resolve("timeout"), this.options.shutdownTimeout);
|
|
3048
3390
|
});
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
result = await Promise.race([waitForJobs, timeout]);
|
|
3052
|
-
} finally {
|
|
3053
|
-
if (checkInterval) clearInterval(checkInterval);
|
|
3054
|
-
}
|
|
3391
|
+
const result = await Promise.race([waitForJobs, timeout]);
|
|
3392
|
+
this._drainResolve = null;
|
|
3055
3393
|
if (result === "timeout") {
|
|
3056
3394
|
const incompleteJobs = this.getActiveJobsList();
|
|
3057
3395
|
const error = new ShutdownTimeoutError(`Shutdown timed out after ${this.options.shutdownTimeout}ms with ${incompleteJobs.length} incomplete jobs`, incompleteJobs);
|
|
@@ -3108,6 +3446,15 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
3108
3446
|
return this.isRunning && this.isInitialized && this.collection !== null;
|
|
3109
3447
|
}
|
|
3110
3448
|
/**
|
|
3449
|
+
* Called when a job finishes processing. If a shutdown drain is pending
|
|
3450
|
+
* and no active jobs remain, resolves the drain promise.
|
|
3451
|
+
*
|
|
3452
|
+
* @private
|
|
3453
|
+
*/
|
|
3454
|
+
onJobFinished() {
|
|
3455
|
+
if (this._drainResolve && this.getActiveJobCount() === 0) this._drainResolve();
|
|
3456
|
+
}
|
|
3457
|
+
/**
|
|
3111
3458
|
* Ensure the scheduler is initialized before operations.
|
|
3112
3459
|
*
|
|
3113
3460
|
* @private
|
|
@@ -3117,15 +3464,18 @@ var Monque = class extends node_events.EventEmitter {
|
|
|
3117
3464
|
if (!this.isInitialized || !this.collection) throw new ConnectionError("Monque not initialized. Call initialize() first.");
|
|
3118
3465
|
}
|
|
3119
3466
|
/**
|
|
3120
|
-
* Get
|
|
3467
|
+
* Get total count of active jobs across all workers.
|
|
3468
|
+
*
|
|
3469
|
+
* Returns only the count (O(workers)) instead of allocating
|
|
3470
|
+
* a throw-away array of IDs, since callers only need `.length`.
|
|
3121
3471
|
*
|
|
3122
3472
|
* @private
|
|
3123
|
-
* @returns
|
|
3473
|
+
* @returns Number of jobs currently being processed
|
|
3124
3474
|
*/
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
for (const worker of this.workers.values())
|
|
3128
|
-
return
|
|
3475
|
+
getActiveJobCount() {
|
|
3476
|
+
let count = 0;
|
|
3477
|
+
for (const worker of this.workers.values()) count += worker.activeJobs.size;
|
|
3478
|
+
return count;
|
|
3129
3479
|
}
|
|
3130
3480
|
/**
|
|
3131
3481
|
* Get list of active job documents (for shutdown timeout error).
|
|
@@ -3162,6 +3512,7 @@ exports.DEFAULT_BASE_INTERVAL = DEFAULT_BASE_INTERVAL;
|
|
|
3162
3512
|
exports.DEFAULT_MAX_BACKOFF_DELAY = DEFAULT_MAX_BACKOFF_DELAY;
|
|
3163
3513
|
exports.InvalidCronError = InvalidCronError;
|
|
3164
3514
|
exports.InvalidCursorError = InvalidCursorError;
|
|
3515
|
+
exports.InvalidJobIdentifierError = InvalidJobIdentifierError;
|
|
3165
3516
|
exports.JobStateError = JobStateError;
|
|
3166
3517
|
exports.JobStatus = JobStatus;
|
|
3167
3518
|
exports.Monque = Monque;
|
|
@@ -3181,5 +3532,7 @@ exports.isProcessingJob = isProcessingJob;
|
|
|
3181
3532
|
exports.isRecurringJob = isRecurringJob;
|
|
3182
3533
|
exports.isValidJobStatus = isValidJobStatus;
|
|
3183
3534
|
exports.validateCronExpression = validateCronExpression;
|
|
3535
|
+
exports.validateJobName = validateJobName;
|
|
3536
|
+
exports.validateUniqueKey = validateUniqueKey;
|
|
3184
3537
|
|
|
3185
3538
|
//# sourceMappingURL=index.cjs.map
|