@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monque/core",
3
- "version": "1.5.2",
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.0.0"
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.18",
82
+ "@vitest/coverage-v8": "^4.1.0",
83
83
  "fishery": "^2.4.0",
84
84
  "mongodb": "^7.1.0",
85
- "tsdown": "^0.21.1",
86
- "vitest": "^4.0.18"
85
+ "tsdown": "^0.21.2",
86
+ "vitest": "^4.1.0"
87
87
  }
88
88
  }
@@ -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, () => this.processor.poll());
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
- 'updateDescription.updatedFields.status': { $exists: true },
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 by triggering a debounced poll.
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
- * Events are debounced to prevent "claim storms" when multiple changes arrive
108
- * in rapid succession (e.g., bulk job inserts). A 100ms debounce window
109
- * collects multiple events and triggers a single poll.
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 isPendingStatus = fullDocument?.['status'] === JobStatus.PENDING;
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: trigger if status changed to pending (retry/release scenario)
128
- const shouldTrigger = isInsert || (isUpdate && isPendingStatus);
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
- // Debounce poll triggers to avoid claim storms
132
- if (this.debounceTimer) {
133
- clearTimeout(this.debounceTimer);
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
- this.debounceTimer = setTimeout(() => {
137
- this.debounceTimer = null;
138
- this.onPoll().catch((error: unknown) => {
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
- if (this.reconnectAttempts > this.maxReconnectAttempts) {
162
- // Fall back to polling-only mode
163
- this.usingChangeStreams = false;
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
- // Clear debounce timer
228
- if (this.debounceTimer) {
229
- clearTimeout(this.debounceTimer);
230
- this.debounceTimer = null;
231
- }
383
+ const wasActive = this.usingChangeStreams;
232
384
 
233
- // Clear reconnection timer
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 (this.usingChangeStreams) {
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
- return this.ctx.documentToPersistedJob(result);
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() || this._isPolling) {
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
- await this._doPoll();
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
- return result ? this.ctx.documentToPersistedJob(result) : null;
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
- return result ? this.ctx.documentToPersistedJob(result) : null;
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
- return this.ctx.documentToPersistedJob<T>(result);
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 { ...job, _id: result.insertedId } as PersistedJob<T>;
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
- return this.ctx.documentToPersistedJob<T>(result);
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 { ...job, _id: result.insertedId } as PersistedJob<T>;
309
+ return persistedJob;
296
310
  } catch (error) {
297
311
  if (error instanceof MonqueError) {
298
312
  throw error;