@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monque/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "MongoDB-backed job scheduler with atomic locking, exponential backoff, and cron scheduling",
|
|
5
5
|
"author": "Maurice de Bruyn <debruyn.maurice@gmail.com>",
|
|
6
6
|
"repository": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"sideEffects": false,
|
|
17
17
|
"type": "module",
|
|
18
18
|
"engines": {
|
|
19
|
-
"node": ">=22.
|
|
19
|
+
"node": ">=22.12.0"
|
|
20
20
|
},
|
|
21
21
|
"publishConfig": {
|
|
22
22
|
"access": "public"
|
|
@@ -79,10 +79,10 @@
|
|
|
79
79
|
"@testcontainers/mongodb": "^11.12.0",
|
|
80
80
|
"@total-typescript/ts-reset": "^0.6.1",
|
|
81
81
|
"@types/node": "^22.19.15",
|
|
82
|
-
"@vitest/coverage-v8": "^4.0
|
|
82
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
83
83
|
"fishery": "^2.4.0",
|
|
84
84
|
"mongodb": "^7.1.0",
|
|
85
|
-
"tsdown": "^0.21.
|
|
86
|
-
"vitest": "^4.0
|
|
85
|
+
"tsdown": "^0.21.2",
|
|
86
|
+
"vitest": "^4.1.0"
|
|
87
87
|
}
|
|
88
88
|
}
|
package/src/scheduler/monque.ts
CHANGED
|
@@ -39,6 +39,7 @@ import type { MonqueOptions } from './types.js';
|
|
|
39
39
|
const DEFAULTS = {
|
|
40
40
|
collectionName: 'monque_jobs',
|
|
41
41
|
pollInterval: 1000,
|
|
42
|
+
safetyPollInterval: 30_000,
|
|
42
43
|
maxRetries: 10,
|
|
43
44
|
baseRetryInterval: 1000,
|
|
44
45
|
shutdownTimeout: 30000,
|
|
@@ -135,6 +136,7 @@ export class Monque extends EventEmitter {
|
|
|
135
136
|
this.options = {
|
|
136
137
|
collectionName: options.collectionName ?? DEFAULTS.collectionName,
|
|
137
138
|
pollInterval: options.pollInterval ?? DEFAULTS.pollInterval,
|
|
139
|
+
safetyPollInterval: options.safetyPollInterval ?? DEFAULTS.safetyPollInterval,
|
|
138
140
|
maxRetries: options.maxRetries ?? DEFAULTS.maxRetries,
|
|
139
141
|
baseRetryInterval: options.baseRetryInterval ?? DEFAULTS.baseRetryInterval,
|
|
140
142
|
shutdownTimeout: options.shutdownTimeout ?? DEFAULTS.shutdownTimeout,
|
|
@@ -186,7 +188,9 @@ export class Monque extends EventEmitter {
|
|
|
186
188
|
this._manager = new JobManager(ctx);
|
|
187
189
|
this._query = new JobQueryService(ctx);
|
|
188
190
|
this._processor = new JobProcessor(ctx);
|
|
189
|
-
this._changeStreamHandler = new ChangeStreamHandler(ctx, () =>
|
|
191
|
+
this._changeStreamHandler = new ChangeStreamHandler(ctx, (targetNames) =>
|
|
192
|
+
this.handleChangeStreamPoll(targetNames),
|
|
193
|
+
);
|
|
190
194
|
this._lifecycleManager = new LifecycleManager(ctx);
|
|
191
195
|
|
|
192
196
|
this.isInitialized = true;
|
|
@@ -255,6 +259,18 @@ export class Monque extends EventEmitter {
|
|
|
255
259
|
return this._lifecycleManager;
|
|
256
260
|
}
|
|
257
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Handle a change-stream-triggered poll and reset the safety poll timer.
|
|
264
|
+
*
|
|
265
|
+
* Used as the `onPoll` callback for {@link ChangeStreamHandler}. Runs a
|
|
266
|
+
* targeted poll for the given worker names, then resets the adaptive safety
|
|
267
|
+
* poll timer so it doesn't fire redundantly.
|
|
268
|
+
*/
|
|
269
|
+
private async handleChangeStreamPoll(targetNames?: ReadonlySet<string>): Promise<void> {
|
|
270
|
+
await this.processor.poll(targetNames);
|
|
271
|
+
this.lifecycleManager.resetPollTimer();
|
|
272
|
+
}
|
|
273
|
+
|
|
258
274
|
/**
|
|
259
275
|
* Build the shared context for internal services.
|
|
260
276
|
*/
|
|
@@ -271,6 +287,13 @@ export class Monque extends EventEmitter {
|
|
|
271
287
|
isRunning: () => this.isRunning,
|
|
272
288
|
emit: <K extends keyof MonqueEventMap>(event: K, payload: MonqueEventMap[K]) =>
|
|
273
289
|
this.emit(event, payload),
|
|
290
|
+
notifyPendingJob: (name: string, nextRunAt: Date) => {
|
|
291
|
+
if (!this.isRunning || !this._changeStreamHandler) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this._changeStreamHandler.notifyPendingJob(name, nextRunAt);
|
|
296
|
+
},
|
|
274
297
|
documentToPersistedJob: <T>(doc: WithId<Document>) => documentToPersistedJob<T>(doc),
|
|
275
298
|
};
|
|
276
299
|
}
|
|
@@ -1032,6 +1055,7 @@ export class Monque extends EventEmitter {
|
|
|
1032
1055
|
this.lifecycleManager.startTimers({
|
|
1033
1056
|
poll: () => this.processor.poll(),
|
|
1034
1057
|
updateHeartbeats: () => this.processor.updateHeartbeats(),
|
|
1058
|
+
isChangeStreamActive: () => this.changeStreamHandler.isActive(),
|
|
1035
1059
|
});
|
|
1036
1060
|
}
|
|
1037
1061
|
|
|
@@ -5,12 +5,22 @@ import { toError } from '@/shared';
|
|
|
5
5
|
|
|
6
6
|
import type { SchedulerContext } from './types.js';
|
|
7
7
|
|
|
8
|
+
/** Minimum poll interval floor to prevent tight loops (ms) */
|
|
9
|
+
const MIN_POLL_INTERVAL = 100;
|
|
10
|
+
|
|
11
|
+
/** Grace period after nextRunAt before scheduling a wakeup poll (ms) */
|
|
12
|
+
const POLL_GRACE_PERIOD = 200;
|
|
13
|
+
|
|
8
14
|
/**
|
|
9
15
|
* Internal service for MongoDB Change Stream lifecycle.
|
|
10
16
|
*
|
|
11
17
|
* Provides real-time job notifications when available, with automatic
|
|
12
18
|
* reconnection and graceful fallback to polling-only mode.
|
|
13
19
|
*
|
|
20
|
+
* Leverages the full document from change stream events to:
|
|
21
|
+
* - Trigger **targeted polls** for specific workers (using the job `name`)
|
|
22
|
+
* - Schedule **precise wakeup timers** for future-dated jobs (using `nextRunAt`)
|
|
23
|
+
*
|
|
14
24
|
* @internal Not part of public API.
|
|
15
25
|
*/
|
|
16
26
|
export class ChangeStreamHandler {
|
|
@@ -32,9 +42,18 @@ export class ChangeStreamHandler {
|
|
|
32
42
|
/** Whether the scheduler is currently using change streams */
|
|
33
43
|
private usingChangeStreams = false;
|
|
34
44
|
|
|
45
|
+
/** Job names collected during the current debounce window for targeted polling */
|
|
46
|
+
private pendingTargetNames: Set<string> = new Set();
|
|
47
|
+
|
|
48
|
+
/** Wakeup timer for the earliest known future job */
|
|
49
|
+
private wakeupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
50
|
+
|
|
51
|
+
/** Time of the currently scheduled wakeup */
|
|
52
|
+
private wakeupTime: Date | null = null;
|
|
53
|
+
|
|
35
54
|
constructor(
|
|
36
55
|
private readonly ctx: SchedulerContext,
|
|
37
|
-
private readonly onPoll: () => Promise<void>,
|
|
56
|
+
private readonly onPoll: (targetNames?: ReadonlySet<string>) => Promise<void>,
|
|
38
57
|
) {}
|
|
39
58
|
|
|
40
59
|
/**
|
|
@@ -67,7 +86,10 @@ export class ChangeStreamHandler {
|
|
|
67
86
|
{ operationType: 'insert' },
|
|
68
87
|
{
|
|
69
88
|
operationType: 'update',
|
|
70
|
-
|
|
89
|
+
$or: [
|
|
90
|
+
{ 'updateDescription.updatedFields.status': { $exists: true } },
|
|
91
|
+
{ 'updateDescription.updatedFields.nextRunAt': { $exists: true } },
|
|
92
|
+
],
|
|
71
93
|
},
|
|
72
94
|
],
|
|
73
95
|
},
|
|
@@ -102,11 +124,20 @@ export class ChangeStreamHandler {
|
|
|
102
124
|
}
|
|
103
125
|
|
|
104
126
|
/**
|
|
105
|
-
* Handle a change stream event
|
|
127
|
+
* Handle a change stream event using the full document for intelligent routing.
|
|
128
|
+
*
|
|
129
|
+
* For **immediate jobs** (`nextRunAt <= now`): collects the job name and triggers
|
|
130
|
+
* a debounced targeted poll for only the relevant workers.
|
|
131
|
+
*
|
|
132
|
+
* For **future jobs** (`nextRunAt > now`): schedules a precise wakeup timer so
|
|
133
|
+
* the job is picked up near its scheduled time without blind polling.
|
|
106
134
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
135
|
+
* For **completed/failed jobs** (slot freed): triggers a targeted re-poll for that
|
|
136
|
+
* worker so the next pending job is picked up immediately, maintaining continuous
|
|
137
|
+
* throughput without waiting for the safety poll interval.
|
|
138
|
+
*
|
|
139
|
+
* Falls back to a full poll (no target names) if the document is missing
|
|
140
|
+
* required fields.
|
|
110
141
|
*
|
|
111
142
|
* @param change - The change stream event document
|
|
112
143
|
*/
|
|
@@ -121,25 +152,121 @@ export class ChangeStreamHandler {
|
|
|
121
152
|
|
|
122
153
|
// Get fullDocument if available (for insert or with updateLookup option)
|
|
123
154
|
const fullDocument = 'fullDocument' in change ? change.fullDocument : undefined;
|
|
124
|
-
const
|
|
155
|
+
const currentStatus = fullDocument?.['status'] as string | undefined;
|
|
156
|
+
const isPendingStatus = currentStatus === JobStatus.PENDING;
|
|
157
|
+
|
|
158
|
+
// A completed/failed status change means a concurrency slot was freed.
|
|
159
|
+
// Trigger a re-poll so the next pending job is picked up immediately,
|
|
160
|
+
// rather than waiting for the safety poll interval.
|
|
161
|
+
const isSlotFreed =
|
|
162
|
+
isUpdate && (currentStatus === JobStatus.COMPLETED || currentStatus === JobStatus.FAILED);
|
|
125
163
|
|
|
126
164
|
// For inserts: always trigger since new pending jobs need processing
|
|
127
|
-
// For updates
|
|
128
|
-
|
|
165
|
+
// For updates to pending: trigger (retry/release/recurring reschedule)
|
|
166
|
+
// For updates to completed/failed: trigger (concurrency slot freed)
|
|
167
|
+
const shouldTrigger = isInsert || (isUpdate && isPendingStatus) || isSlotFreed;
|
|
129
168
|
|
|
130
|
-
if (shouldTrigger) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
169
|
+
if (!shouldTrigger) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Slot-freed events: targeted poll for that worker to pick up waiting jobs
|
|
174
|
+
if (isSlotFreed) {
|
|
175
|
+
const jobName = fullDocument?.['name'] as string | undefined;
|
|
176
|
+
if (jobName) {
|
|
177
|
+
this.pendingTargetNames.add(jobName);
|
|
134
178
|
}
|
|
179
|
+
this.debouncedPoll();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Extract job metadata from the full document for smart routing
|
|
184
|
+
const jobName = fullDocument?.['name'] as string | undefined;
|
|
185
|
+
const nextRunAt = fullDocument?.['nextRunAt'] as Date | undefined;
|
|
186
|
+
|
|
187
|
+
if (jobName && nextRunAt) {
|
|
188
|
+
this.notifyPendingJob(jobName, nextRunAt);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Immediate job or missing metadata — collect for targeted/full poll
|
|
193
|
+
if (jobName) {
|
|
194
|
+
this.pendingTargetNames.add(jobName);
|
|
195
|
+
}
|
|
196
|
+
this.debouncedPoll();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Notify the handler about a pending job created or updated by this process.
|
|
201
|
+
*
|
|
202
|
+
* Reuses the same routing logic as change stream events so local writes don't
|
|
203
|
+
* depend on the MongoDB change stream cursor already being fully ready.
|
|
204
|
+
*
|
|
205
|
+
* @param jobName - Worker name for targeted polling
|
|
206
|
+
* @param nextRunAt - When the job becomes eligible for processing
|
|
207
|
+
*/
|
|
208
|
+
notifyPendingJob(jobName: string, nextRunAt: Date): void {
|
|
209
|
+
if (!this.ctx.isRunning()) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
135
212
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
this.ctx.emit('job:error', { error: toError(error) });
|
|
140
|
-
});
|
|
141
|
-
}, 100);
|
|
213
|
+
if (nextRunAt.getTime() > Date.now()) {
|
|
214
|
+
this.scheduleWakeup(nextRunAt);
|
|
215
|
+
return;
|
|
142
216
|
}
|
|
217
|
+
|
|
218
|
+
this.pendingTargetNames.add(jobName);
|
|
219
|
+
this.debouncedPoll();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Schedule a debounced poll with collected target names.
|
|
224
|
+
*
|
|
225
|
+
* Collects job names from multiple change stream events during the debounce
|
|
226
|
+
* window, then triggers a single targeted poll for only those workers.
|
|
227
|
+
*/
|
|
228
|
+
private debouncedPoll(): void {
|
|
229
|
+
if (this.debounceTimer) {
|
|
230
|
+
clearTimeout(this.debounceTimer);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this.debounceTimer = setTimeout(() => {
|
|
234
|
+
this.debounceTimer = null;
|
|
235
|
+
const names = this.pendingTargetNames.size > 0 ? new Set(this.pendingTargetNames) : undefined;
|
|
236
|
+
this.pendingTargetNames.clear();
|
|
237
|
+
this.onPoll(names).catch((error: unknown) => {
|
|
238
|
+
this.ctx.emit('job:error', { error: toError(error) });
|
|
239
|
+
});
|
|
240
|
+
}, 100);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Schedule a wakeup timer for a future-dated job.
|
|
245
|
+
*
|
|
246
|
+
* Maintains a single timer set to the earliest known future job's `nextRunAt`.
|
|
247
|
+
* When the timer fires, triggers a full poll to pick up all due jobs.
|
|
248
|
+
*
|
|
249
|
+
* @param nextRunAt - When the future job should become ready
|
|
250
|
+
*/
|
|
251
|
+
private scheduleWakeup(nextRunAt: Date): void {
|
|
252
|
+
// Only update if this job is earlier than the current wakeup
|
|
253
|
+
if (this.wakeupTime && nextRunAt >= this.wakeupTime) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.clearWakeupTimer();
|
|
258
|
+
this.wakeupTime = nextRunAt;
|
|
259
|
+
|
|
260
|
+
const delay = Math.max(nextRunAt.getTime() - Date.now() + POLL_GRACE_PERIOD, MIN_POLL_INTERVAL);
|
|
261
|
+
|
|
262
|
+
this.wakeupTimer = setTimeout(() => {
|
|
263
|
+
this.wakeupTime = null;
|
|
264
|
+
this.wakeupTimer = null;
|
|
265
|
+
// Full poll — there may be multiple jobs due at this time
|
|
266
|
+
this.onPoll().catch((error: unknown) => {
|
|
267
|
+
this.ctx.emit('job:error', { error: toError(error) });
|
|
268
|
+
});
|
|
269
|
+
}, delay);
|
|
143
270
|
}
|
|
144
271
|
|
|
145
272
|
/**
|
|
@@ -158,12 +285,15 @@ export class ChangeStreamHandler {
|
|
|
158
285
|
|
|
159
286
|
this.reconnectAttempts++;
|
|
160
287
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
288
|
+
// Immediately reset active state: clears stale debounce/wakeup timers,
|
|
289
|
+
// closes the broken cursor, and sets isActive() to false so the lifecycle
|
|
290
|
+
// manager switches to fast polling during backoff.
|
|
291
|
+
this.resetActiveState();
|
|
292
|
+
this.closeChangeStream();
|
|
164
293
|
|
|
294
|
+
if (this.reconnectAttempts > this.maxReconnectAttempts) {
|
|
295
|
+
// Permanent fallback to polling-only mode
|
|
165
296
|
this.clearReconnectTimer();
|
|
166
|
-
this.closeChangeStream();
|
|
167
297
|
|
|
168
298
|
this.ctx.emit('changestream:fallback', {
|
|
169
299
|
reason: `Exhausted ${this.maxReconnectAttempts} reconnection attempts: ${error.message}`,
|
|
@@ -211,6 +341,32 @@ export class ChangeStreamHandler {
|
|
|
211
341
|
this.reconnectTimer = null;
|
|
212
342
|
}
|
|
213
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Reset all active change stream state: clear debounce timer, wakeup timer,
|
|
346
|
+
* pending target names, and mark as inactive.
|
|
347
|
+
*
|
|
348
|
+
* Does NOT close the cursor (callers handle sync vs async close) or clear
|
|
349
|
+
* the reconnect timer/attempts (callers manage reconnection lifecycle).
|
|
350
|
+
*/
|
|
351
|
+
private resetActiveState(): void {
|
|
352
|
+
if (this.debounceTimer) {
|
|
353
|
+
clearTimeout(this.debounceTimer);
|
|
354
|
+
this.debounceTimer = null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
this.pendingTargetNames.clear();
|
|
358
|
+
this.clearWakeupTimer();
|
|
359
|
+
this.usingChangeStreams = false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private clearWakeupTimer(): void {
|
|
363
|
+
if (this.wakeupTimer) {
|
|
364
|
+
clearTimeout(this.wakeupTimer);
|
|
365
|
+
this.wakeupTimer = null;
|
|
366
|
+
}
|
|
367
|
+
this.wakeupTime = null;
|
|
368
|
+
}
|
|
369
|
+
|
|
214
370
|
private closeChangeStream(): void {
|
|
215
371
|
if (!this.changeStream) {
|
|
216
372
|
return;
|
|
@@ -224,13 +380,10 @@ export class ChangeStreamHandler {
|
|
|
224
380
|
* Close the change stream cursor and emit closed event.
|
|
225
381
|
*/
|
|
226
382
|
async close(): Promise<void> {
|
|
227
|
-
|
|
228
|
-
if (this.debounceTimer) {
|
|
229
|
-
clearTimeout(this.debounceTimer);
|
|
230
|
-
this.debounceTimer = null;
|
|
231
|
-
}
|
|
383
|
+
const wasActive = this.usingChangeStreams;
|
|
232
384
|
|
|
233
|
-
// Clear
|
|
385
|
+
// Clear all active state (debounce, wakeup, pending names, flag)
|
|
386
|
+
this.resetActiveState();
|
|
234
387
|
this.clearReconnectTimer();
|
|
235
388
|
|
|
236
389
|
if (this.changeStream) {
|
|
@@ -241,12 +394,11 @@ export class ChangeStreamHandler {
|
|
|
241
394
|
}
|
|
242
395
|
this.changeStream = null;
|
|
243
396
|
|
|
244
|
-
if (
|
|
397
|
+
if (wasActive) {
|
|
245
398
|
this.ctx.emit('changestream:closed', undefined);
|
|
246
399
|
}
|
|
247
400
|
}
|
|
248
401
|
|
|
249
|
-
this.usingChangeStreams = false;
|
|
250
402
|
this.reconnectAttempts = 0;
|
|
251
403
|
}
|
|
252
404
|
|
|
@@ -147,6 +147,7 @@ export class JobManager {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
const job = this.ctx.documentToPersistedJob(result);
|
|
150
|
+
this.ctx.notifyPendingJob(job.name, job.nextRunAt);
|
|
150
151
|
this.ctx.emit('job:retried', { job, previousStatus });
|
|
151
152
|
return job;
|
|
152
153
|
}
|
|
@@ -204,7 +205,9 @@ export class JobManager {
|
|
|
204
205
|
);
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
|
|
208
|
+
const job = this.ctx.documentToPersistedJob(result);
|
|
209
|
+
this.ctx.notifyPendingJob(job.name, job.nextRunAt);
|
|
210
|
+
return job;
|
|
208
211
|
}
|
|
209
212
|
|
|
210
213
|
/**
|
|
@@ -16,6 +16,9 @@ export class JobProcessor {
|
|
|
16
16
|
/** Guard flag to prevent concurrent poll() execution */
|
|
17
17
|
private _isPolling = false;
|
|
18
18
|
|
|
19
|
+
/** Flag to request a re-poll after the current poll finishes */
|
|
20
|
+
private _repollRequested = false;
|
|
21
|
+
|
|
19
22
|
constructor(private readonly ctx: SchedulerContext) {}
|
|
20
23
|
|
|
21
24
|
/**
|
|
@@ -57,16 +60,34 @@ export class JobProcessor {
|
|
|
57
60
|
* attempts to acquire jobs up to the worker's available concurrency slots.
|
|
58
61
|
* Aborts early if the scheduler is stopping (`isRunning` is false) or if
|
|
59
62
|
* the instance-level `instanceConcurrency` limit is reached.
|
|
63
|
+
*
|
|
64
|
+
* If a poll is requested while one is already running, it is queued and
|
|
65
|
+
* executed as a full poll after the current one finishes. This prevents
|
|
66
|
+
* change-stream-triggered polls from being silently dropped.
|
|
67
|
+
*
|
|
68
|
+
* @param targetNames - Optional set of worker names to poll. When provided, only the
|
|
69
|
+
* specified workers are checked. Used by change stream handler for targeted polling.
|
|
60
70
|
*/
|
|
61
|
-
async poll(): Promise<void> {
|
|
62
|
-
if (!this.ctx.isRunning()
|
|
71
|
+
async poll(targetNames?: ReadonlySet<string>): Promise<void> {
|
|
72
|
+
if (!this.ctx.isRunning()) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (this._isPolling) {
|
|
77
|
+
// Queue a re-poll so work discovered during this poll isn't missed
|
|
78
|
+
this._repollRequested = true;
|
|
63
79
|
return;
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
this._isPolling = true;
|
|
67
83
|
|
|
68
84
|
try {
|
|
69
|
-
|
|
85
|
+
do {
|
|
86
|
+
this._repollRequested = false;
|
|
87
|
+
await this._doPoll(targetNames);
|
|
88
|
+
// Re-polls are always full polls to catch all pending work
|
|
89
|
+
targetNames = undefined;
|
|
90
|
+
} while (this._repollRequested && this.ctx.isRunning());
|
|
70
91
|
} finally {
|
|
71
92
|
this._isPolling = false;
|
|
72
93
|
}
|
|
@@ -75,7 +96,7 @@ export class JobProcessor {
|
|
|
75
96
|
/**
|
|
76
97
|
* Internal poll implementation.
|
|
77
98
|
*/
|
|
78
|
-
private async _doPoll(): Promise<void> {
|
|
99
|
+
private async _doPoll(targetNames?: ReadonlySet<string>): Promise<void> {
|
|
79
100
|
// Early exit if global instanceConcurrency is reached
|
|
80
101
|
const { instanceConcurrency } = this.ctx.options;
|
|
81
102
|
|
|
@@ -84,6 +105,11 @@ export class JobProcessor {
|
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
for (const [name, worker] of this.ctx.workers) {
|
|
108
|
+
// Skip workers not in the target set (if provided)
|
|
109
|
+
if (targetNames && !targetNames.has(name)) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
87
113
|
// Check if worker has capacity
|
|
88
114
|
const workerAvailableSlots = worker.concurrency - worker.activeJobs.size;
|
|
89
115
|
|
|
@@ -268,7 +294,13 @@ export class JobProcessor {
|
|
|
268
294
|
{ returnDocument: 'after' },
|
|
269
295
|
);
|
|
270
296
|
|
|
271
|
-
|
|
297
|
+
if (!result) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const persistedJob = this.ctx.documentToPersistedJob(result);
|
|
302
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
303
|
+
return persistedJob;
|
|
272
304
|
}
|
|
273
305
|
|
|
274
306
|
// One-time job - mark as completed
|
|
@@ -290,7 +322,12 @@ export class JobProcessor {
|
|
|
290
322
|
{ returnDocument: 'after' },
|
|
291
323
|
);
|
|
292
324
|
|
|
293
|
-
|
|
325
|
+
if (!result) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const persistedJob = this.ctx.documentToPersistedJob(result);
|
|
330
|
+
return persistedJob;
|
|
294
331
|
}
|
|
295
332
|
|
|
296
333
|
/**
|
|
@@ -141,12 +141,19 @@ export class JobScheduler {
|
|
|
141
141
|
throw new ConnectionError('Failed to enqueue job: findOneAndUpdate returned no document');
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
const persistedJob = this.ctx.documentToPersistedJob<T>(result);
|
|
145
|
+
if (persistedJob.status === JobStatus.PENDING) {
|
|
146
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return persistedJob;
|
|
145
150
|
}
|
|
146
151
|
|
|
147
152
|
const result = await this.ctx.collection.insertOne(job as Document);
|
|
153
|
+
const persistedJob = { ...job, _id: result.insertedId } as PersistedJob<T>;
|
|
154
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
148
155
|
|
|
149
|
-
return
|
|
156
|
+
return persistedJob;
|
|
150
157
|
} catch (error) {
|
|
151
158
|
if (error instanceof ConnectionError) {
|
|
152
159
|
throw error;
|
|
@@ -287,12 +294,19 @@ export class JobScheduler {
|
|
|
287
294
|
);
|
|
288
295
|
}
|
|
289
296
|
|
|
290
|
-
|
|
297
|
+
const persistedJob = this.ctx.documentToPersistedJob<T>(result);
|
|
298
|
+
if (persistedJob.status === JobStatus.PENDING) {
|
|
299
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return persistedJob;
|
|
291
303
|
}
|
|
292
304
|
|
|
293
305
|
const result = await this.ctx.collection.insertOne(job as Document);
|
|
306
|
+
const persistedJob = { ...job, _id: result.insertedId } as PersistedJob<T>;
|
|
307
|
+
this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
|
|
294
308
|
|
|
295
|
-
return
|
|
309
|
+
return persistedJob;
|
|
296
310
|
} catch (error) {
|
|
297
311
|
if (error instanceof MonqueError) {
|
|
298
312
|
throw error;
|