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