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