@monque/core 1.5.1 → 1.6.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
@@ -713,12 +713,20 @@ function decodeCursor(cursor) {
713
713
  }
714
714
  //#endregion
715
715
  //#region src/scheduler/services/change-stream-handler.ts
716
+ /** Minimum poll interval floor to prevent tight loops (ms) */
717
+ const MIN_POLL_INTERVAL = 100;
718
+ /** Grace period after nextRunAt before scheduling a wakeup poll (ms) */
719
+ const POLL_GRACE_PERIOD = 200;
716
720
  /**
717
721
  * Internal service for MongoDB Change Stream lifecycle.
718
722
  *
719
723
  * Provides real-time job notifications when available, with automatic
720
724
  * reconnection and graceful fallback to polling-only mode.
721
725
  *
726
+ * Leverages the full document from change stream events to:
727
+ * - Trigger **targeted polls** for specific workers (using the job `name`)
728
+ * - Schedule **precise wakeup timers** for future-dated jobs (using `nextRunAt`)
729
+ *
722
730
  * @internal Not part of public API.
723
731
  */
724
732
  var ChangeStreamHandler = class {
@@ -734,6 +742,12 @@ var ChangeStreamHandler = class {
734
742
  reconnectTimer = null;
735
743
  /** Whether the scheduler is currently using change streams */
736
744
  usingChangeStreams = false;
745
+ /** Job names collected during the current debounce window for targeted polling */
746
+ pendingTargetNames = /* @__PURE__ */ new Set();
747
+ /** Wakeup timer for the earliest known future job */
748
+ wakeupTimer = null;
749
+ /** Time of the currently scheduled wakeup */
750
+ wakeupTime = null;
737
751
  constructor(ctx, onPoll) {
738
752
  this.ctx = ctx;
739
753
  this.onPoll = onPoll;
@@ -754,10 +768,11 @@ var ChangeStreamHandler = class {
754
768
  */
755
769
  setup() {
756
770
  if (!this.ctx.isRunning()) return;
771
+ this.clearReconnectTimer();
757
772
  try {
758
773
  this.changeStream = this.ctx.collection.watch([{ $match: { $or: [{ operationType: "insert" }, {
759
774
  operationType: "update",
760
- "updateDescription.updatedFields.status": { $exists: true }
775
+ $or: [{ "updateDescription.updatedFields.status": { $exists: true } }, { "updateDescription.updatedFields.nextRunAt": { $exists: true } }]
761
776
  }] } }], { fullDocument: "updateLookup" });
762
777
  this.changeStream.on("change", (change) => {
763
778
  this.handleEvent(change);
@@ -776,11 +791,20 @@ var ChangeStreamHandler = class {
776
791
  }
777
792
  }
778
793
  /**
779
- * Handle a change stream event by triggering a debounced poll.
794
+ * Handle a change stream event using the full document for intelligent routing.
795
+ *
796
+ * For **immediate jobs** (`nextRunAt <= now`): collects the job name and triggers
797
+ * a debounced targeted poll for only the relevant workers.
798
+ *
799
+ * For **future jobs** (`nextRunAt > now`): schedules a precise wakeup timer so
800
+ * the job is picked up near its scheduled time without blind polling.
780
801
  *
781
- * Events are debounced to prevent "claim storms" when multiple changes arrive
782
- * in rapid succession (e.g., bulk job inserts). A 100ms debounce window
783
- * collects multiple events and triggers a single poll.
802
+ * For **completed/failed jobs** (slot freed): triggers a targeted re-poll for that
803
+ * worker so the next pending job is picked up immediately, maintaining continuous
804
+ * throughput without waiting for the safety poll interval.
805
+ *
806
+ * Falls back to a full poll (no target names) if the document is missing
807
+ * required fields.
784
808
  *
785
809
  * @param change - The change stream event document
786
810
  */
@@ -788,16 +812,81 @@ var ChangeStreamHandler = class {
788
812
  if (!this.ctx.isRunning()) return;
789
813
  const isInsert = change.operationType === "insert";
790
814
  const isUpdate = change.operationType === "update";
791
- const isPendingStatus = ("fullDocument" in change ? change.fullDocument : void 0)?.["status"] === JobStatus.PENDING;
792
- if (isInsert || isUpdate && isPendingStatus) {
793
- if (this.debounceTimer) clearTimeout(this.debounceTimer);
794
- this.debounceTimer = setTimeout(() => {
795
- this.debounceTimer = null;
796
- this.onPoll().catch((error) => {
797
- this.ctx.emit("job:error", { error: toError(error) });
798
- });
799
- }, 100);
815
+ const fullDocument = "fullDocument" in change ? change.fullDocument : void 0;
816
+ const currentStatus = fullDocument?.["status"];
817
+ const isPendingStatus = currentStatus === JobStatus.PENDING;
818
+ const isSlotFreed = isUpdate && (currentStatus === JobStatus.COMPLETED || currentStatus === JobStatus.FAILED);
819
+ if (!(isInsert || isUpdate && isPendingStatus || isSlotFreed)) return;
820
+ if (isSlotFreed) {
821
+ const jobName = fullDocument?.["name"];
822
+ if (jobName) this.pendingTargetNames.add(jobName);
823
+ this.debouncedPoll();
824
+ return;
825
+ }
826
+ const jobName = fullDocument?.["name"];
827
+ const nextRunAt = fullDocument?.["nextRunAt"];
828
+ if (jobName && nextRunAt) {
829
+ this.notifyPendingJob(jobName, nextRunAt);
830
+ return;
831
+ }
832
+ if (jobName) this.pendingTargetNames.add(jobName);
833
+ this.debouncedPoll();
834
+ }
835
+ /**
836
+ * Notify the handler about a pending job created or updated by this process.
837
+ *
838
+ * Reuses the same routing logic as change stream events so local writes don't
839
+ * depend on the MongoDB change stream cursor already being fully ready.
840
+ *
841
+ * @param jobName - Worker name for targeted polling
842
+ * @param nextRunAt - When the job becomes eligible for processing
843
+ */
844
+ notifyPendingJob(jobName, nextRunAt) {
845
+ if (!this.ctx.isRunning()) return;
846
+ if (nextRunAt.getTime() > Date.now()) {
847
+ this.scheduleWakeup(nextRunAt);
848
+ return;
800
849
  }
850
+ this.pendingTargetNames.add(jobName);
851
+ this.debouncedPoll();
852
+ }
853
+ /**
854
+ * Schedule a debounced poll with collected target names.
855
+ *
856
+ * Collects job names from multiple change stream events during the debounce
857
+ * window, then triggers a single targeted poll for only those workers.
858
+ */
859
+ debouncedPoll() {
860
+ if (this.debounceTimer) clearTimeout(this.debounceTimer);
861
+ this.debounceTimer = setTimeout(() => {
862
+ this.debounceTimer = null;
863
+ const names = this.pendingTargetNames.size > 0 ? new Set(this.pendingTargetNames) : void 0;
864
+ this.pendingTargetNames.clear();
865
+ this.onPoll(names).catch((error) => {
866
+ this.ctx.emit("job:error", { error: toError(error) });
867
+ });
868
+ }, 100);
869
+ }
870
+ /**
871
+ * Schedule a wakeup timer for a future-dated job.
872
+ *
873
+ * Maintains a single timer set to the earliest known future job's `nextRunAt`.
874
+ * When the timer fires, triggers a full poll to pick up all due jobs.
875
+ *
876
+ * @param nextRunAt - When the future job should become ready
877
+ */
878
+ scheduleWakeup(nextRunAt) {
879
+ if (this.wakeupTime && nextRunAt >= this.wakeupTime) return;
880
+ this.clearWakeupTimer();
881
+ this.wakeupTime = nextRunAt;
882
+ const delay = Math.max(nextRunAt.getTime() - Date.now() + POLL_GRACE_PERIOD, MIN_POLL_INTERVAL);
883
+ this.wakeupTimer = setTimeout(() => {
884
+ this.wakeupTime = null;
885
+ this.wakeupTimer = null;
886
+ this.onPoll().catch((error) => {
887
+ this.ctx.emit("job:error", { error: toError(error) });
888
+ });
889
+ }, delay);
801
890
  }
802
891
  /**
803
892
  * Handle change stream errors with exponential backoff reconnection.
@@ -811,52 +900,74 @@ var ChangeStreamHandler = class {
811
900
  handleError(error) {
812
901
  if (!this.ctx.isRunning()) return;
813
902
  this.reconnectAttempts++;
903
+ this.resetActiveState();
904
+ this.closeChangeStream();
814
905
  if (this.reconnectAttempts > this.maxReconnectAttempts) {
815
- this.usingChangeStreams = false;
816
- if (this.reconnectTimer) {
817
- clearTimeout(this.reconnectTimer);
818
- this.reconnectTimer = null;
819
- }
820
- if (this.changeStream) {
821
- this.changeStream.close().catch(() => {});
822
- this.changeStream = null;
823
- }
906
+ this.clearReconnectTimer();
824
907
  this.ctx.emit("changestream:fallback", { reason: `Exhausted ${this.maxReconnectAttempts} reconnection attempts: ${error.message}` });
825
908
  return;
826
909
  }
827
910
  const delay = 2 ** (this.reconnectAttempts - 1) * 1e3;
828
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
911
+ this.clearReconnectTimer();
912
+ if (!this.ctx.isRunning()) return;
829
913
  this.reconnectTimer = setTimeout(() => {
830
- this.reconnectTimer = null;
831
- if (this.ctx.isRunning()) {
832
- if (this.changeStream) {
833
- this.changeStream.close().catch(() => {});
834
- this.changeStream = null;
835
- }
836
- this.setup();
837
- }
914
+ this.clearReconnectTimer();
915
+ this.reconnect();
838
916
  }, delay);
839
917
  }
918
+ reconnect() {
919
+ if (!this.ctx.isRunning()) return;
920
+ this.closeChangeStream();
921
+ if (!this.ctx.isRunning()) return;
922
+ this.setup();
923
+ }
924
+ clearReconnectTimer() {
925
+ if (!this.reconnectTimer) return;
926
+ clearTimeout(this.reconnectTimer);
927
+ this.reconnectTimer = null;
928
+ }
840
929
  /**
841
- * Close the change stream cursor and emit closed event.
930
+ * Reset all active change stream state: clear debounce timer, wakeup timer,
931
+ * pending target names, and mark as inactive.
932
+ *
933
+ * Does NOT close the cursor (callers handle sync vs async close) or clear
934
+ * the reconnect timer/attempts (callers manage reconnection lifecycle).
842
935
  */
843
- async close() {
936
+ resetActiveState() {
844
937
  if (this.debounceTimer) {
845
938
  clearTimeout(this.debounceTimer);
846
939
  this.debounceTimer = null;
847
940
  }
848
- if (this.reconnectTimer) {
849
- clearTimeout(this.reconnectTimer);
850
- this.reconnectTimer = null;
941
+ this.pendingTargetNames.clear();
942
+ this.clearWakeupTimer();
943
+ this.usingChangeStreams = false;
944
+ }
945
+ clearWakeupTimer() {
946
+ if (this.wakeupTimer) {
947
+ clearTimeout(this.wakeupTimer);
948
+ this.wakeupTimer = null;
851
949
  }
950
+ this.wakeupTime = null;
951
+ }
952
+ closeChangeStream() {
953
+ if (!this.changeStream) return;
954
+ this.changeStream.close().catch(() => {});
955
+ this.changeStream = null;
956
+ }
957
+ /**
958
+ * Close the change stream cursor and emit closed event.
959
+ */
960
+ async close() {
961
+ const wasActive = this.usingChangeStreams;
962
+ this.resetActiveState();
963
+ this.clearReconnectTimer();
852
964
  if (this.changeStream) {
853
965
  try {
854
966
  await this.changeStream.close();
855
967
  } catch {}
856
968
  this.changeStream = null;
857
- if (this.usingChangeStreams) this.ctx.emit("changestream:closed", void 0);
969
+ if (wasActive) this.ctx.emit("changestream:closed", void 0);
858
970
  }
859
- this.usingChangeStreams = false;
860
971
  this.reconnectAttempts = 0;
861
972
  }
862
973
  /**
@@ -961,6 +1072,7 @@ var JobManager = class {
961
1072
  }, { returnDocument: "after" });
962
1073
  if (!result) throw new JobStateError("Job status changed during retry attempt", jobId, "unknown", "retry");
963
1074
  const job = this.ctx.documentToPersistedJob(result);
1075
+ this.ctx.notifyPendingJob(job.name, job.nextRunAt);
964
1076
  this.ctx.emit("job:retried", {
965
1077
  job,
966
1078
  previousStatus
@@ -997,7 +1109,9 @@ var JobManager = class {
997
1109
  updatedAt: /* @__PURE__ */ new Date()
998
1110
  } }, { returnDocument: "after" });
999
1111
  if (!result) throw new JobStateError("Job status changed during reschedule attempt", jobId, "unknown", "reschedule");
1000
- return this.ctx.documentToPersistedJob(result);
1112
+ const job = this.ctx.documentToPersistedJob(result);
1113
+ this.ctx.notifyPendingJob(job.name, job.nextRunAt);
1114
+ return job;
1001
1115
  }
1002
1116
  /**
1003
1117
  * Permanently delete a job.
@@ -1171,6 +1285,8 @@ var JobManager = class {
1171
1285
  var JobProcessor = class {
1172
1286
  /** Guard flag to prevent concurrent poll() execution */
1173
1287
  _isPolling = false;
1288
+ /** Flag to request a re-poll after the current poll finishes */
1289
+ _repollRequested = false;
1174
1290
  constructor(ctx) {
1175
1291
  this.ctx = ctx;
1176
1292
  }
@@ -1203,12 +1319,27 @@ var JobProcessor = class {
1203
1319
  * attempts to acquire jobs up to the worker's available concurrency slots.
1204
1320
  * Aborts early if the scheduler is stopping (`isRunning` is false) or if
1205
1321
  * the instance-level `instanceConcurrency` limit is reached.
1322
+ *
1323
+ * If a poll is requested while one is already running, it is queued and
1324
+ * executed as a full poll after the current one finishes. This prevents
1325
+ * change-stream-triggered polls from being silently dropped.
1326
+ *
1327
+ * @param targetNames - Optional set of worker names to poll. When provided, only the
1328
+ * specified workers are checked. Used by change stream handler for targeted polling.
1206
1329
  */
1207
- async poll() {
1208
- if (!this.ctx.isRunning() || this._isPolling) return;
1330
+ async poll(targetNames) {
1331
+ if (!this.ctx.isRunning()) return;
1332
+ if (this._isPolling) {
1333
+ this._repollRequested = true;
1334
+ return;
1335
+ }
1209
1336
  this._isPolling = true;
1210
1337
  try {
1211
- await this._doPoll();
1338
+ do {
1339
+ this._repollRequested = false;
1340
+ await this._doPoll(targetNames);
1341
+ targetNames = void 0;
1342
+ } while (this._repollRequested && this.ctx.isRunning());
1212
1343
  } finally {
1213
1344
  this._isPolling = false;
1214
1345
  }
@@ -1216,10 +1347,11 @@ var JobProcessor = class {
1216
1347
  /**
1217
1348
  * Internal poll implementation.
1218
1349
  */
1219
- async _doPoll() {
1350
+ async _doPoll(targetNames) {
1220
1351
  const { instanceConcurrency } = this.ctx.options;
1221
1352
  if (instanceConcurrency !== void 0 && this.getTotalActiveJobs() >= instanceConcurrency) return;
1222
1353
  for (const [name, worker] of this.ctx.workers) {
1354
+ if (targetNames && !targetNames.has(name)) continue;
1223
1355
  const workerAvailableSlots = worker.concurrency - worker.activeJobs.size;
1224
1356
  if (workerAvailableSlots <= 0) continue;
1225
1357
  const availableSlots = this.getGloballyAvailableSlots(workerAvailableSlots);
@@ -1356,7 +1488,10 @@ var JobProcessor = class {
1356
1488
  failReason: ""
1357
1489
  }
1358
1490
  }, { returnDocument: "after" });
1359
- return result ? this.ctx.documentToPersistedJob(result) : null;
1491
+ if (!result) return null;
1492
+ const persistedJob = this.ctx.documentToPersistedJob(result);
1493
+ this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
1494
+ return persistedJob;
1360
1495
  }
1361
1496
  const result = await this.ctx.collection.findOneAndUpdate({
1362
1497
  _id: job._id,
@@ -1375,7 +1510,8 @@ var JobProcessor = class {
1375
1510
  failReason: ""
1376
1511
  }
1377
1512
  }, { returnDocument: "after" });
1378
- return result ? this.ctx.documentToPersistedJob(result) : null;
1513
+ if (!result) return null;
1514
+ return this.ctx.documentToPersistedJob(result);
1379
1515
  }
1380
1516
  /**
1381
1517
  * Handle job failure with exponential backoff retry logic using an atomic status transition.
@@ -1868,13 +2004,17 @@ var JobScheduler = class {
1868
2004
  returnDocument: "after"
1869
2005
  });
1870
2006
  if (!result) throw new ConnectionError("Failed to enqueue job: findOneAndUpdate returned no document");
1871
- return this.ctx.documentToPersistedJob(result);
2007
+ const persistedJob = this.ctx.documentToPersistedJob(result);
2008
+ if (persistedJob.status === JobStatus.PENDING) this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
2009
+ return persistedJob;
1872
2010
  }
1873
2011
  const result = await this.ctx.collection.insertOne(job);
1874
- return {
2012
+ const persistedJob = {
1875
2013
  ...job,
1876
2014
  _id: result.insertedId
1877
2015
  };
2016
+ this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
2017
+ return persistedJob;
1878
2018
  } catch (error) {
1879
2019
  if (error instanceof ConnectionError) throw error;
1880
2020
  throw new ConnectionError(`Failed to enqueue job: ${error instanceof Error ? error.message : "Unknown error during enqueue"}`, error instanceof Error ? { cause: error } : void 0);
@@ -1982,13 +2122,17 @@ var JobScheduler = class {
1982
2122
  returnDocument: "after"
1983
2123
  });
1984
2124
  if (!result) throw new ConnectionError("Failed to schedule job: findOneAndUpdate returned no document");
1985
- return this.ctx.documentToPersistedJob(result);
2125
+ const persistedJob = this.ctx.documentToPersistedJob(result);
2126
+ if (persistedJob.status === JobStatus.PENDING) this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
2127
+ return persistedJob;
1986
2128
  }
1987
2129
  const result = await this.ctx.collection.insertOne(job);
1988
- return {
2130
+ const persistedJob = {
1989
2131
  ...job,
1990
2132
  _id: result.insertedId
1991
2133
  };
2134
+ this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
2135
+ return persistedJob;
1992
2136
  } catch (error) {
1993
2137
  if (error instanceof MonqueError) throw error;
1994
2138
  throw new ConnectionError(`Failed to schedule job: ${error instanceof Error ? error.message : "Unknown error during schedule"}`, error instanceof Error ? { cause: error } : void 0);
@@ -2004,14 +2148,19 @@ const DEFAULT_RETENTION_INTERVAL = 36e5;
2004
2148
  /**
2005
2149
  * Manages scheduler lifecycle timers and job cleanup.
2006
2150
  *
2007
- * Owns poll interval, heartbeat interval, cleanup interval, and the
2151
+ * Owns poll scheduling, heartbeat interval, cleanup interval, and the
2008
2152
  * cleanupJobs logic. Extracted from Monque to keep the facade thin.
2009
2153
  *
2154
+ * Uses adaptive poll scheduling: when change streams are active, polls at
2155
+ * `safetyPollInterval` (safety net only). When change streams are inactive,
2156
+ * polls at `pollInterval` (primary discovery mechanism).
2157
+ *
2010
2158
  * @internal Not part of public API.
2011
2159
  */
2012
2160
  var LifecycleManager = class {
2013
2161
  ctx;
2014
- pollIntervalId = null;
2162
+ callbacks = null;
2163
+ pollTimeoutId = null;
2015
2164
  heartbeatIntervalId = null;
2016
2165
  cleanupIntervalId = null;
2017
2166
  constructor(ctx) {
@@ -2020,17 +2169,13 @@ var LifecycleManager = class {
2020
2169
  /**
2021
2170
  * Start all lifecycle timers.
2022
2171
  *
2023
- * Sets up poll interval, heartbeat interval, and (if configured)
2172
+ * Sets up adaptive poll scheduling, heartbeat interval, and (if configured)
2024
2173
  * cleanup interval. Runs an initial poll immediately.
2025
2174
  *
2026
2175
  * @param callbacks - Functions to invoke on each timer tick
2027
2176
  */
2028
2177
  startTimers(callbacks) {
2029
- this.pollIntervalId = setInterval(() => {
2030
- callbacks.poll().catch((error) => {
2031
- this.ctx.emit("job:error", { error: toError(error) });
2032
- });
2033
- }, this.ctx.options.pollInterval);
2178
+ this.callbacks = callbacks;
2034
2179
  this.heartbeatIntervalId = setInterval(() => {
2035
2180
  callbacks.updateHeartbeats().catch((error) => {
2036
2181
  this.ctx.emit("job:error", { error: toError(error) });
@@ -2047,23 +2192,22 @@ var LifecycleManager = class {
2047
2192
  });
2048
2193
  }, interval);
2049
2194
  }
2050
- callbacks.poll().catch((error) => {
2051
- this.ctx.emit("job:error", { error: toError(error) });
2052
- });
2195
+ this.executePollAndScheduleNext();
2053
2196
  }
2054
2197
  /**
2055
2198
  * Stop all lifecycle timers.
2056
2199
  *
2057
- * Clears poll, heartbeat, and cleanup intervals.
2200
+ * Clears poll timeout, heartbeat interval, and cleanup interval.
2058
2201
  */
2059
2202
  stopTimers() {
2203
+ this.callbacks = null;
2060
2204
  if (this.cleanupIntervalId) {
2061
2205
  clearInterval(this.cleanupIntervalId);
2062
2206
  this.cleanupIntervalId = null;
2063
2207
  }
2064
- if (this.pollIntervalId) {
2065
- clearInterval(this.pollIntervalId);
2066
- this.pollIntervalId = null;
2208
+ if (this.pollTimeoutId) {
2209
+ clearTimeout(this.pollTimeoutId);
2210
+ this.pollTimeoutId = null;
2067
2211
  }
2068
2212
  if (this.heartbeatIntervalId) {
2069
2213
  clearInterval(this.heartbeatIntervalId);
@@ -2071,6 +2215,43 @@ var LifecycleManager = class {
2071
2215
  }
2072
2216
  }
2073
2217
  /**
2218
+ * Reset the poll timer to reschedule the next poll.
2219
+ *
2220
+ * Called after change-stream-triggered polls to ensure the safety poll timer
2221
+ * is recalculated (not fired redundantly from an old schedule).
2222
+ */
2223
+ resetPollTimer() {
2224
+ this.scheduleNextPoll();
2225
+ }
2226
+ /**
2227
+ * Execute a poll and schedule the next one adaptively.
2228
+ */
2229
+ executePollAndScheduleNext() {
2230
+ if (!this.callbacks) return;
2231
+ this.callbacks.poll().catch((error) => {
2232
+ this.ctx.emit("job:error", { error: toError(error) });
2233
+ }).then(() => {
2234
+ this.scheduleNextPoll();
2235
+ });
2236
+ }
2237
+ /**
2238
+ * Schedule the next poll using adaptive timing.
2239
+ *
2240
+ * When change streams are active, uses `safetyPollInterval` (longer, safety net only).
2241
+ * When change streams are inactive, uses `pollInterval` (shorter, primary discovery).
2242
+ */
2243
+ scheduleNextPoll() {
2244
+ if (this.pollTimeoutId) {
2245
+ clearTimeout(this.pollTimeoutId);
2246
+ this.pollTimeoutId = null;
2247
+ }
2248
+ if (!this.ctx.isRunning() || !this.callbacks) return;
2249
+ const delay = this.callbacks.isChangeStreamActive() ? this.ctx.options.safetyPollInterval : this.ctx.options.pollInterval;
2250
+ this.pollTimeoutId = setTimeout(() => {
2251
+ this.executePollAndScheduleNext();
2252
+ }, delay);
2253
+ }
2254
+ /**
2074
2255
  * Clean up old completed and failed jobs based on retention policy.
2075
2256
  *
2076
2257
  * - Removes completed jobs older than `jobRetention.completed`
@@ -2110,6 +2291,7 @@ var LifecycleManager = class {
2110
2291
  const DEFAULTS = {
2111
2292
  collectionName: "monque_jobs",
2112
2293
  pollInterval: 1e3,
2294
+ safetyPollInterval: 3e4,
2113
2295
  maxRetries: 10,
2114
2296
  baseRetryInterval: 1e3,
2115
2297
  shutdownTimeout: 3e4,
@@ -2202,6 +2384,7 @@ var Monque = class extends EventEmitter {
2202
2384
  this.options = {
2203
2385
  collectionName: options.collectionName ?? DEFAULTS.collectionName,
2204
2386
  pollInterval: options.pollInterval ?? DEFAULTS.pollInterval,
2387
+ safetyPollInterval: options.safetyPollInterval ?? DEFAULTS.safetyPollInterval,
2205
2388
  maxRetries: options.maxRetries ?? DEFAULTS.maxRetries,
2206
2389
  baseRetryInterval: options.baseRetryInterval ?? DEFAULTS.baseRetryInterval,
2207
2390
  shutdownTimeout: options.shutdownTimeout ?? DEFAULTS.shutdownTimeout,
@@ -2236,7 +2419,7 @@ var Monque = class extends EventEmitter {
2236
2419
  this._manager = new JobManager(ctx);
2237
2420
  this._query = new JobQueryService(ctx);
2238
2421
  this._processor = new JobProcessor(ctx);
2239
- this._changeStreamHandler = new ChangeStreamHandler(ctx, () => this.processor.poll());
2422
+ this._changeStreamHandler = new ChangeStreamHandler(ctx, (targetNames) => this.handleChangeStreamPoll(targetNames));
2240
2423
  this._lifecycleManager = new LifecycleManager(ctx);
2241
2424
  this.isInitialized = true;
2242
2425
  } catch (error) {
@@ -2274,6 +2457,17 @@ var Monque = class extends EventEmitter {
2274
2457
  return this._lifecycleManager;
2275
2458
  }
2276
2459
  /**
2460
+ * Handle a change-stream-triggered poll and reset the safety poll timer.
2461
+ *
2462
+ * Used as the `onPoll` callback for {@link ChangeStreamHandler}. Runs a
2463
+ * targeted poll for the given worker names, then resets the adaptive safety
2464
+ * poll timer so it doesn't fire redundantly.
2465
+ */
2466
+ async handleChangeStreamPoll(targetNames) {
2467
+ await this.processor.poll(targetNames);
2468
+ this.lifecycleManager.resetPollTimer();
2469
+ }
2470
+ /**
2277
2471
  * Build the shared context for internal services.
2278
2472
  */
2279
2473
  buildContext() {
@@ -2285,6 +2479,10 @@ var Monque = class extends EventEmitter {
2285
2479
  workers: this.workers,
2286
2480
  isRunning: () => this.isRunning,
2287
2481
  emit: (event, payload) => this.emit(event, payload),
2482
+ notifyPendingJob: (name, nextRunAt) => {
2483
+ if (!this.isRunning || !this._changeStreamHandler) return;
2484
+ this._changeStreamHandler.notifyPendingJob(name, nextRunAt);
2485
+ },
2288
2486
  documentToPersistedJob: (doc) => documentToPersistedJob(doc)
2289
2487
  };
2290
2488
  }
@@ -2985,7 +3183,8 @@ var Monque = class extends EventEmitter {
2985
3183
  this.changeStreamHandler.setup();
2986
3184
  this.lifecycleManager.startTimers({
2987
3185
  poll: () => this.processor.poll(),
2988
- updateHeartbeats: () => this.processor.updateHeartbeats()
3186
+ updateHeartbeats: () => this.processor.updateHeartbeats(),
3187
+ isChangeStreamActive: () => this.changeStreamHandler.isActive()
2989
3188
  });
2990
3189
  }
2991
3190
  /**