@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 +14 -0
- package/dist/index.cjs +246 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +21 -0
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +21 -0
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +246 -50
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/scheduler/monque.ts +25 -1
- package/src/scheduler/services/change-stream-handler.ts +183 -31
- package/src/scheduler/services/job-manager.ts +4 -1
- package/src/scheduler/services/job-processor.ts +43 -6
- package/src/scheduler/services/job-scheduler.ts +18 -4
- package/src/scheduler/services/lifecycle-manager.ts +72 -17
- package/src/scheduler/services/types.ts +4 -0
- package/src/scheduler/types.ts +14 -0
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;
|
|
@@ -758,7 +772,7 @@ var ChangeStreamHandler = class {
|
|
|
758
772
|
try {
|
|
759
773
|
this.changeStream = this.ctx.collection.watch([{ $match: { $or: [{ operationType: "insert" }, {
|
|
760
774
|
operationType: "update",
|
|
761
|
-
"updateDescription.updatedFields.status": { $exists: true }
|
|
775
|
+
$or: [{ "updateDescription.updatedFields.status": { $exists: true } }, { "updateDescription.updatedFields.nextRunAt": { $exists: true } }]
|
|
762
776
|
}] } }], { fullDocument: "updateLookup" });
|
|
763
777
|
this.changeStream.on("change", (change) => {
|
|
764
778
|
this.handleEvent(change);
|
|
@@ -777,11 +791,20 @@ var ChangeStreamHandler = class {
|
|
|
777
791
|
}
|
|
778
792
|
}
|
|
779
793
|
/**
|
|
780
|
-
* Handle a change stream event
|
|
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.
|
|
781
801
|
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
*
|
|
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.
|
|
785
808
|
*
|
|
786
809
|
* @param change - The change stream event document
|
|
787
810
|
*/
|
|
@@ -789,16 +812,81 @@ var ChangeStreamHandler = class {
|
|
|
789
812
|
if (!this.ctx.isRunning()) return;
|
|
790
813
|
const isInsert = change.operationType === "insert";
|
|
791
814
|
const isUpdate = change.operationType === "update";
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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;
|
|
801
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;
|
|
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);
|
|
802
890
|
}
|
|
803
891
|
/**
|
|
804
892
|
* Handle change stream errors with exponential backoff reconnection.
|
|
@@ -812,10 +900,10 @@ var ChangeStreamHandler = class {
|
|
|
812
900
|
handleError(error) {
|
|
813
901
|
if (!this.ctx.isRunning()) return;
|
|
814
902
|
this.reconnectAttempts++;
|
|
903
|
+
this.resetActiveState();
|
|
904
|
+
this.closeChangeStream();
|
|
815
905
|
if (this.reconnectAttempts > this.maxReconnectAttempts) {
|
|
816
|
-
this.usingChangeStreams = false;
|
|
817
906
|
this.clearReconnectTimer();
|
|
818
|
-
this.closeChangeStream();
|
|
819
907
|
this.ctx.emit("changestream:fallback", { reason: `Exhausted ${this.maxReconnectAttempts} reconnection attempts: ${error.message}` });
|
|
820
908
|
return;
|
|
821
909
|
}
|
|
@@ -838,6 +926,29 @@ var ChangeStreamHandler = class {
|
|
|
838
926
|
clearTimeout(this.reconnectTimer);
|
|
839
927
|
this.reconnectTimer = null;
|
|
840
928
|
}
|
|
929
|
+
/**
|
|
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).
|
|
935
|
+
*/
|
|
936
|
+
resetActiveState() {
|
|
937
|
+
if (this.debounceTimer) {
|
|
938
|
+
clearTimeout(this.debounceTimer);
|
|
939
|
+
this.debounceTimer = null;
|
|
940
|
+
}
|
|
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;
|
|
949
|
+
}
|
|
950
|
+
this.wakeupTime = null;
|
|
951
|
+
}
|
|
841
952
|
closeChangeStream() {
|
|
842
953
|
if (!this.changeStream) return;
|
|
843
954
|
this.changeStream.close().catch(() => {});
|
|
@@ -847,19 +958,16 @@ var ChangeStreamHandler = class {
|
|
|
847
958
|
* Close the change stream cursor and emit closed event.
|
|
848
959
|
*/
|
|
849
960
|
async close() {
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
this.debounceTimer = null;
|
|
853
|
-
}
|
|
961
|
+
const wasActive = this.usingChangeStreams;
|
|
962
|
+
this.resetActiveState();
|
|
854
963
|
this.clearReconnectTimer();
|
|
855
964
|
if (this.changeStream) {
|
|
856
965
|
try {
|
|
857
966
|
await this.changeStream.close();
|
|
858
967
|
} catch {}
|
|
859
968
|
this.changeStream = null;
|
|
860
|
-
if (
|
|
969
|
+
if (wasActive) this.ctx.emit("changestream:closed", void 0);
|
|
861
970
|
}
|
|
862
|
-
this.usingChangeStreams = false;
|
|
863
971
|
this.reconnectAttempts = 0;
|
|
864
972
|
}
|
|
865
973
|
/**
|
|
@@ -964,6 +1072,7 @@ var JobManager = class {
|
|
|
964
1072
|
}, { returnDocument: "after" });
|
|
965
1073
|
if (!result) throw new JobStateError("Job status changed during retry attempt", jobId, "unknown", "retry");
|
|
966
1074
|
const job = this.ctx.documentToPersistedJob(result);
|
|
1075
|
+
this.ctx.notifyPendingJob(job.name, job.nextRunAt);
|
|
967
1076
|
this.ctx.emit("job:retried", {
|
|
968
1077
|
job,
|
|
969
1078
|
previousStatus
|
|
@@ -1000,7 +1109,9 @@ var JobManager = class {
|
|
|
1000
1109
|
updatedAt: /* @__PURE__ */ new Date()
|
|
1001
1110
|
} }, { returnDocument: "after" });
|
|
1002
1111
|
if (!result) throw new JobStateError("Job status changed during reschedule attempt", jobId, "unknown", "reschedule");
|
|
1003
|
-
|
|
1112
|
+
const job = this.ctx.documentToPersistedJob(result);
|
|
1113
|
+
this.ctx.notifyPendingJob(job.name, job.nextRunAt);
|
|
1114
|
+
return job;
|
|
1004
1115
|
}
|
|
1005
1116
|
/**
|
|
1006
1117
|
* Permanently delete a job.
|
|
@@ -1174,6 +1285,8 @@ var JobManager = class {
|
|
|
1174
1285
|
var JobProcessor = class {
|
|
1175
1286
|
/** Guard flag to prevent concurrent poll() execution */
|
|
1176
1287
|
_isPolling = false;
|
|
1288
|
+
/** Flag to request a re-poll after the current poll finishes */
|
|
1289
|
+
_repollRequested = false;
|
|
1177
1290
|
constructor(ctx) {
|
|
1178
1291
|
this.ctx = ctx;
|
|
1179
1292
|
}
|
|
@@ -1206,12 +1319,27 @@ var JobProcessor = class {
|
|
|
1206
1319
|
* attempts to acquire jobs up to the worker's available concurrency slots.
|
|
1207
1320
|
* Aborts early if the scheduler is stopping (`isRunning` is false) or if
|
|
1208
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.
|
|
1209
1329
|
*/
|
|
1210
|
-
async poll() {
|
|
1211
|
-
if (!this.ctx.isRunning()
|
|
1330
|
+
async poll(targetNames) {
|
|
1331
|
+
if (!this.ctx.isRunning()) return;
|
|
1332
|
+
if (this._isPolling) {
|
|
1333
|
+
this._repollRequested = true;
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1212
1336
|
this._isPolling = true;
|
|
1213
1337
|
try {
|
|
1214
|
-
|
|
1338
|
+
do {
|
|
1339
|
+
this._repollRequested = false;
|
|
1340
|
+
await this._doPoll(targetNames);
|
|
1341
|
+
targetNames = void 0;
|
|
1342
|
+
} while (this._repollRequested && this.ctx.isRunning());
|
|
1215
1343
|
} finally {
|
|
1216
1344
|
this._isPolling = false;
|
|
1217
1345
|
}
|
|
@@ -1219,10 +1347,11 @@ var JobProcessor = class {
|
|
|
1219
1347
|
/**
|
|
1220
1348
|
* Internal poll implementation.
|
|
1221
1349
|
*/
|
|
1222
|
-
async _doPoll() {
|
|
1350
|
+
async _doPoll(targetNames) {
|
|
1223
1351
|
const { instanceConcurrency } = this.ctx.options;
|
|
1224
1352
|
if (instanceConcurrency !== void 0 && this.getTotalActiveJobs() >= instanceConcurrency) return;
|
|
1225
1353
|
for (const [name, worker] of this.ctx.workers) {
|
|
1354
|
+
if (targetNames && !targetNames.has(name)) continue;
|
|
1226
1355
|
const workerAvailableSlots = worker.concurrency - worker.activeJobs.size;
|
|
1227
1356
|
if (workerAvailableSlots <= 0) continue;
|
|
1228
1357
|
const availableSlots = this.getGloballyAvailableSlots(workerAvailableSlots);
|
|
@@ -1359,7 +1488,10 @@ var JobProcessor = class {
|
|
|
1359
1488
|
failReason: ""
|
|
1360
1489
|
}
|
|
1361
1490
|
}, { returnDocument: "after" });
|
|
1362
|
-
|
|
1491
|
+
if (!result) return null;
|
|
1492
|
+
const persistedJob = this.ctx.documentToPersistedJob(result);
|
|
1493
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
1494
|
+
return persistedJob;
|
|
1363
1495
|
}
|
|
1364
1496
|
const result = await this.ctx.collection.findOneAndUpdate({
|
|
1365
1497
|
_id: job._id,
|
|
@@ -1378,7 +1510,8 @@ var JobProcessor = class {
|
|
|
1378
1510
|
failReason: ""
|
|
1379
1511
|
}
|
|
1380
1512
|
}, { returnDocument: "after" });
|
|
1381
|
-
|
|
1513
|
+
if (!result) return null;
|
|
1514
|
+
return this.ctx.documentToPersistedJob(result);
|
|
1382
1515
|
}
|
|
1383
1516
|
/**
|
|
1384
1517
|
* Handle job failure with exponential backoff retry logic using an atomic status transition.
|
|
@@ -1871,13 +2004,17 @@ var JobScheduler = class {
|
|
|
1871
2004
|
returnDocument: "after"
|
|
1872
2005
|
});
|
|
1873
2006
|
if (!result) throw new ConnectionError("Failed to enqueue job: findOneAndUpdate returned no document");
|
|
1874
|
-
|
|
2007
|
+
const persistedJob = this.ctx.documentToPersistedJob(result);
|
|
2008
|
+
if (persistedJob.status === JobStatus.PENDING) this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
2009
|
+
return persistedJob;
|
|
1875
2010
|
}
|
|
1876
2011
|
const result = await this.ctx.collection.insertOne(job);
|
|
1877
|
-
|
|
2012
|
+
const persistedJob = {
|
|
1878
2013
|
...job,
|
|
1879
2014
|
_id: result.insertedId
|
|
1880
2015
|
};
|
|
2016
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
2017
|
+
return persistedJob;
|
|
1881
2018
|
} catch (error) {
|
|
1882
2019
|
if (error instanceof ConnectionError) throw error;
|
|
1883
2020
|
throw new ConnectionError(`Failed to enqueue job: ${error instanceof Error ? error.message : "Unknown error during enqueue"}`, error instanceof Error ? { cause: error } : void 0);
|
|
@@ -1985,13 +2122,17 @@ var JobScheduler = class {
|
|
|
1985
2122
|
returnDocument: "after"
|
|
1986
2123
|
});
|
|
1987
2124
|
if (!result) throw new ConnectionError("Failed to schedule job: findOneAndUpdate returned no document");
|
|
1988
|
-
|
|
2125
|
+
const persistedJob = this.ctx.documentToPersistedJob(result);
|
|
2126
|
+
if (persistedJob.status === JobStatus.PENDING) this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
2127
|
+
return persistedJob;
|
|
1989
2128
|
}
|
|
1990
2129
|
const result = await this.ctx.collection.insertOne(job);
|
|
1991
|
-
|
|
2130
|
+
const persistedJob = {
|
|
1992
2131
|
...job,
|
|
1993
2132
|
_id: result.insertedId
|
|
1994
2133
|
};
|
|
2134
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
2135
|
+
return persistedJob;
|
|
1995
2136
|
} catch (error) {
|
|
1996
2137
|
if (error instanceof MonqueError) throw error;
|
|
1997
2138
|
throw new ConnectionError(`Failed to schedule job: ${error instanceof Error ? error.message : "Unknown error during schedule"}`, error instanceof Error ? { cause: error } : void 0);
|
|
@@ -2007,14 +2148,19 @@ const DEFAULT_RETENTION_INTERVAL = 36e5;
|
|
|
2007
2148
|
/**
|
|
2008
2149
|
* Manages scheduler lifecycle timers and job cleanup.
|
|
2009
2150
|
*
|
|
2010
|
-
* Owns poll
|
|
2151
|
+
* Owns poll scheduling, heartbeat interval, cleanup interval, and the
|
|
2011
2152
|
* cleanupJobs logic. Extracted from Monque to keep the facade thin.
|
|
2012
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
|
+
*
|
|
2013
2158
|
* @internal Not part of public API.
|
|
2014
2159
|
*/
|
|
2015
2160
|
var LifecycleManager = class {
|
|
2016
2161
|
ctx;
|
|
2017
|
-
|
|
2162
|
+
callbacks = null;
|
|
2163
|
+
pollTimeoutId = null;
|
|
2018
2164
|
heartbeatIntervalId = null;
|
|
2019
2165
|
cleanupIntervalId = null;
|
|
2020
2166
|
constructor(ctx) {
|
|
@@ -2023,17 +2169,13 @@ var LifecycleManager = class {
|
|
|
2023
2169
|
/**
|
|
2024
2170
|
* Start all lifecycle timers.
|
|
2025
2171
|
*
|
|
2026
|
-
* Sets up poll
|
|
2172
|
+
* Sets up adaptive poll scheduling, heartbeat interval, and (if configured)
|
|
2027
2173
|
* cleanup interval. Runs an initial poll immediately.
|
|
2028
2174
|
*
|
|
2029
2175
|
* @param callbacks - Functions to invoke on each timer tick
|
|
2030
2176
|
*/
|
|
2031
2177
|
startTimers(callbacks) {
|
|
2032
|
-
this.
|
|
2033
|
-
callbacks.poll().catch((error) => {
|
|
2034
|
-
this.ctx.emit("job:error", { error: toError(error) });
|
|
2035
|
-
});
|
|
2036
|
-
}, this.ctx.options.pollInterval);
|
|
2178
|
+
this.callbacks = callbacks;
|
|
2037
2179
|
this.heartbeatIntervalId = setInterval(() => {
|
|
2038
2180
|
callbacks.updateHeartbeats().catch((error) => {
|
|
2039
2181
|
this.ctx.emit("job:error", { error: toError(error) });
|
|
@@ -2050,23 +2192,22 @@ var LifecycleManager = class {
|
|
|
2050
2192
|
});
|
|
2051
2193
|
}, interval);
|
|
2052
2194
|
}
|
|
2053
|
-
|
|
2054
|
-
this.ctx.emit("job:error", { error: toError(error) });
|
|
2055
|
-
});
|
|
2195
|
+
this.executePollAndScheduleNext();
|
|
2056
2196
|
}
|
|
2057
2197
|
/**
|
|
2058
2198
|
* Stop all lifecycle timers.
|
|
2059
2199
|
*
|
|
2060
|
-
* Clears poll, heartbeat, and cleanup
|
|
2200
|
+
* Clears poll timeout, heartbeat interval, and cleanup interval.
|
|
2061
2201
|
*/
|
|
2062
2202
|
stopTimers() {
|
|
2203
|
+
this.callbacks = null;
|
|
2063
2204
|
if (this.cleanupIntervalId) {
|
|
2064
2205
|
clearInterval(this.cleanupIntervalId);
|
|
2065
2206
|
this.cleanupIntervalId = null;
|
|
2066
2207
|
}
|
|
2067
|
-
if (this.
|
|
2068
|
-
|
|
2069
|
-
this.
|
|
2208
|
+
if (this.pollTimeoutId) {
|
|
2209
|
+
clearTimeout(this.pollTimeoutId);
|
|
2210
|
+
this.pollTimeoutId = null;
|
|
2070
2211
|
}
|
|
2071
2212
|
if (this.heartbeatIntervalId) {
|
|
2072
2213
|
clearInterval(this.heartbeatIntervalId);
|
|
@@ -2074,6 +2215,43 @@ var LifecycleManager = class {
|
|
|
2074
2215
|
}
|
|
2075
2216
|
}
|
|
2076
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
|
+
/**
|
|
2077
2255
|
* Clean up old completed and failed jobs based on retention policy.
|
|
2078
2256
|
*
|
|
2079
2257
|
* - Removes completed jobs older than `jobRetention.completed`
|
|
@@ -2113,6 +2291,7 @@ var LifecycleManager = class {
|
|
|
2113
2291
|
const DEFAULTS = {
|
|
2114
2292
|
collectionName: "monque_jobs",
|
|
2115
2293
|
pollInterval: 1e3,
|
|
2294
|
+
safetyPollInterval: 3e4,
|
|
2116
2295
|
maxRetries: 10,
|
|
2117
2296
|
baseRetryInterval: 1e3,
|
|
2118
2297
|
shutdownTimeout: 3e4,
|
|
@@ -2205,6 +2384,7 @@ var Monque = class extends EventEmitter {
|
|
|
2205
2384
|
this.options = {
|
|
2206
2385
|
collectionName: options.collectionName ?? DEFAULTS.collectionName,
|
|
2207
2386
|
pollInterval: options.pollInterval ?? DEFAULTS.pollInterval,
|
|
2387
|
+
safetyPollInterval: options.safetyPollInterval ?? DEFAULTS.safetyPollInterval,
|
|
2208
2388
|
maxRetries: options.maxRetries ?? DEFAULTS.maxRetries,
|
|
2209
2389
|
baseRetryInterval: options.baseRetryInterval ?? DEFAULTS.baseRetryInterval,
|
|
2210
2390
|
shutdownTimeout: options.shutdownTimeout ?? DEFAULTS.shutdownTimeout,
|
|
@@ -2239,7 +2419,7 @@ var Monque = class extends EventEmitter {
|
|
|
2239
2419
|
this._manager = new JobManager(ctx);
|
|
2240
2420
|
this._query = new JobQueryService(ctx);
|
|
2241
2421
|
this._processor = new JobProcessor(ctx);
|
|
2242
|
-
this._changeStreamHandler = new ChangeStreamHandler(ctx, () => this.
|
|
2422
|
+
this._changeStreamHandler = new ChangeStreamHandler(ctx, (targetNames) => this.handleChangeStreamPoll(targetNames));
|
|
2243
2423
|
this._lifecycleManager = new LifecycleManager(ctx);
|
|
2244
2424
|
this.isInitialized = true;
|
|
2245
2425
|
} catch (error) {
|
|
@@ -2277,6 +2457,17 @@ var Monque = class extends EventEmitter {
|
|
|
2277
2457
|
return this._lifecycleManager;
|
|
2278
2458
|
}
|
|
2279
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
|
+
/**
|
|
2280
2471
|
* Build the shared context for internal services.
|
|
2281
2472
|
*/
|
|
2282
2473
|
buildContext() {
|
|
@@ -2288,6 +2479,10 @@ var Monque = class extends EventEmitter {
|
|
|
2288
2479
|
workers: this.workers,
|
|
2289
2480
|
isRunning: () => this.isRunning,
|
|
2290
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
|
+
},
|
|
2291
2486
|
documentToPersistedJob: (doc) => documentToPersistedJob(doc)
|
|
2292
2487
|
};
|
|
2293
2488
|
}
|
|
@@ -2988,7 +3183,8 @@ var Monque = class extends EventEmitter {
|
|
|
2988
3183
|
this.changeStreamHandler.setup();
|
|
2989
3184
|
this.lifecycleManager.startTimers({
|
|
2990
3185
|
poll: () => this.processor.poll(),
|
|
2991
|
-
updateHeartbeats: () => this.processor.updateHeartbeats()
|
|
3186
|
+
updateHeartbeats: () => this.processor.updateHeartbeats(),
|
|
3187
|
+
isChangeStreamActive: () => this.changeStreamHandler.isActive()
|
|
2992
3188
|
});
|
|
2993
3189
|
}
|
|
2994
3190
|
/**
|