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