@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/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 by triggering a debounced poll.
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
- * Events are debounced to prevent "claim storms" when multiple changes arrive
784
- * in rapid succession (e.g., bulk job inserts). A 100ms debounce window
785
- * collects multiple events and triggers a single poll.
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 isPendingStatus = ("fullDocument" in change ? change.fullDocument : void 0)?.["status"] === JobStatus.PENDING;
794
- if (isInsert || isUpdate && isPendingStatus) {
795
- if (this.debounceTimer) clearTimeout(this.debounceTimer);
796
- this.debounceTimer = setTimeout(() => {
797
- this.debounceTimer = null;
798
- this.onPoll().catch((error) => {
799
- this.ctx.emit("job:error", { error: toError(error) });
800
- });
801
- }, 100);
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
- if (this.debounceTimer) {
852
- clearTimeout(this.debounceTimer);
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 (this.usingChangeStreams) this.ctx.emit("changestream:closed", void 0);
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
- const jobDoc = await this.ctx.collection.findOne({ _id });
908
- if (!jobDoc) return null;
909
- if (jobDoc["status"] === JobStatus.CANCELLED) return this.ctx.documentToPersistedJob(jobDoc);
910
- if (jobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot cancel job in status '${jobDoc["status"]}'`, jobId, jobDoc["status"], "cancel");
911
- const result = await this.ctx.collection.findOneAndUpdate({
912
- _id,
913
- status: JobStatus.PENDING
914
- }, { $set: {
915
- status: JobStatus.CANCELLED,
916
- updatedAt: /* @__PURE__ */ new Date()
917
- } }, { returnDocument: "after" });
918
- if (!result) throw new JobStateError("Job status changed during cancellation attempt", jobId, "unknown", "cancel");
919
- const job = this.ctx.documentToPersistedJob(result);
920
- this.ctx.emit("job:cancelled", { job });
921
- return job;
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
- const currentJob = await this.ctx.collection.findOne({ _id });
945
- if (!currentJob) return null;
946
- if (currentJob["status"] !== JobStatus.FAILED && currentJob["status"] !== JobStatus.CANCELLED) throw new JobStateError(`Cannot retry job in status '${currentJob["status"]}'`, jobId, currentJob["status"], "retry");
947
- const previousStatus = currentJob["status"];
948
- const result = await this.ctx.collection.findOneAndUpdate({
949
- _id,
950
- status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] }
951
- }, {
952
- $set: {
953
- status: JobStatus.PENDING,
954
- failCount: 0,
955
- nextRunAt: /* @__PURE__ */ new Date(),
956
- updatedAt: /* @__PURE__ */ new Date()
957
- },
958
- $unset: {
959
- failReason: "",
960
- lockedAt: "",
961
- claimedBy: "",
962
- lastHeartbeat: "",
963
- heartbeatInterval: ""
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
- }, { returnDocument: "after" });
966
- if (!result) throw new JobStateError("Job status changed during retry attempt", jobId, "unknown", "retry");
967
- const job = this.ctx.documentToPersistedJob(result);
968
- this.ctx.emit("job:retried", {
969
- job,
970
- previousStatus
971
- });
972
- return job;
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
- const currentJobDoc = await this.ctx.collection.findOne({ _id });
994
- if (!currentJobDoc) return null;
995
- if (currentJobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot reschedule job in status '${currentJobDoc["status"]}'`, jobId, currentJobDoc["status"], "reschedule");
996
- const result = await this.ctx.collection.findOneAndUpdate({
997
- _id,
998
- status: JobStatus.PENDING
999
- }, { $set: {
1000
- nextRunAt: runAt,
1001
- updatedAt: /* @__PURE__ */ new Date()
1002
- } }, { returnDocument: "after" });
1003
- if (!result) throw new JobStateError("Job status changed during reschedule attempt", jobId, "unknown", "reschedule");
1004
- return this.ctx.documentToPersistedJob(result);
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
- if ((await this.ctx.collection.deleteOne({ _id })).deletedCount > 0) {
1027
- this.ctx.emit("job:deleted", { jobId });
1028
- return true;
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: /* @__PURE__ */ new Date()
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: [/* @__PURE__ */ new Date(), { $multiply: [{ $rand: {} }, spreadWindowMs] }] },
1112
- updatedAt: /* @__PURE__ */ new Date()
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
- constructor(ctx) {
1179
- this.ctx = ctx;
1180
- }
1376
+ /** Flag to request a re-poll after the current poll finishes */
1377
+ _repollRequested = false;
1181
1378
  /**
1182
- * Get the total number of active jobs across all workers.
1379
+ * O(1) counter tracking the total number of active jobs across all workers.
1183
1380
  *
1184
- * Used for instance-level throttling when `instanceConcurrency` is configured.
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
- getTotalActiveJobs() {
1187
- let total = 0;
1188
- for (const worker of this.ctx.workers.values()) total += worker.activeJobs.size;
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.getTotalActiveJobs();
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() || this._isPolling) return;
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
- await this._doPoll();
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.getTotalActiveJobs() >= instanceConcurrency) return;
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
- for (let i = 0; i < availableSlots; i++) {
1232
- if (!this.ctx.isRunning()) return;
1233
- if (instanceConcurrency !== void 0 && this.getTotalActiveJobs() >= instanceConcurrency) return;
1234
- const job = await this.acquireJob(name);
1235
- if (job) {
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 break;
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: /* @__PURE__ */ new Date()
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
- return result ? this.ctx.documentToPersistedJob(result) : null;
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: /* @__PURE__ */ new Date()
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
- return result ? this.ctx.documentToPersistedJob(result) : null;
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: /* @__PURE__ */ new Date()
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: /* @__PURE__ */ new Date()
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
- return this.ctx.documentToPersistedJob(result);
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
- return {
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
- return this.ctx.documentToPersistedJob(result);
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
- return {
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 interval, heartbeat interval, cleanup interval, and the
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
- pollIntervalId = null;
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 interval, heartbeat interval, and (if configured)
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.pollIntervalId = setInterval(() => {
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
- callbacks.poll().catch((error) => {
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 intervals.
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.pollIntervalId) {
2069
- clearInterval(this.pollIntervalId);
2070
- this.pollIntervalId = null;
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.processor.poll());
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.getActiveJobs().length === 0) return;
3037
- let checkInterval;
3384
+ if (this.getActiveJobCount() === 0) return;
3038
3385
  const waitForJobs = new Promise((resolve) => {
3039
- checkInterval = setInterval(() => {
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
- let result;
3050
- try {
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 array of active job IDs across all workers.
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 Array of job ID strings currently being processed
3473
+ * @returns Number of jobs currently being processed
3124
3474
  */
3125
- getActiveJobs() {
3126
- const activeJobs = [];
3127
- for (const worker of this.workers.values()) activeJobs.push(...worker.activeJobs.keys());
3128
- return activeJobs;
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