@monque/core 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monque/core",
3
- "version": "1.5.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.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.0",
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
  /**
@@ -56,6 +75,8 @@ export class ChangeStreamHandler {
56
75
  return;
57
76
  }
58
77
 
78
+ this.clearReconnectTimer();
79
+
59
80
  try {
60
81
  // Create change stream with pipeline to filter relevant events
61
82
  const pipeline = [
@@ -65,7 +86,10 @@ export class ChangeStreamHandler {
65
86
  { operationType: 'insert' },
66
87
  {
67
88
  operationType: 'update',
68
- 'updateDescription.updatedFields.status': { $exists: true },
89
+ $or: [
90
+ { 'updateDescription.updatedFields.status': { $exists: true } },
91
+ { 'updateDescription.updatedFields.nextRunAt': { $exists: true } },
92
+ ],
69
93
  },
70
94
  ],
71
95
  },
@@ -100,11 +124,20 @@ export class ChangeStreamHandler {
100
124
  }
101
125
 
102
126
  /**
103
- * Handle a change stream event by triggering a debounced poll.
127
+ * Handle a change stream event using the full document for intelligent routing.
104
128
  *
105
- * Events are debounced to prevent "claim storms" when multiple changes arrive
106
- * in rapid succession (e.g., bulk job inserts). A 100ms debounce window
107
- * collects multiple events and triggers a single poll.
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.
134
+ *
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.
108
141
  *
109
142
  * @param change - The change stream event document
110
143
  */
@@ -119,25 +152,121 @@ export class ChangeStreamHandler {
119
152
 
120
153
  // Get fullDocument if available (for insert or with updateLookup option)
121
154
  const fullDocument = 'fullDocument' in change ? change.fullDocument : undefined;
122
- 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);
123
163
 
124
164
  // For inserts: always trigger since new pending jobs need processing
125
- // For updates: trigger if status changed to pending (retry/release scenario)
126
- 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;
168
+
169
+ if (!shouldTrigger) {
170
+ return;
171
+ }
127
172
 
128
- if (shouldTrigger) {
129
- // Debounce poll triggers to avoid claim storms
130
- if (this.debounceTimer) {
131
- clearTimeout(this.debounceTimer);
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);
132
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
+ }
133
191
 
134
- this.debounceTimer = setTimeout(() => {
135
- this.debounceTimer = null;
136
- this.onPoll().catch((error: unknown) => {
137
- this.ctx.emit('job:error', { error: toError(error) });
138
- });
139
- }, 100);
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
+ }
212
+
213
+ if (nextRunAt.getTime() > Date.now()) {
214
+ this.scheduleWakeup(nextRunAt);
215
+ return;
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;
140
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);
141
270
  }
142
271
 
143
272
  /**
@@ -156,19 +285,15 @@ export class ChangeStreamHandler {
156
285
 
157
286
  this.reconnectAttempts++;
158
287
 
159
- if (this.reconnectAttempts > this.maxReconnectAttempts) {
160
- // Fall back to polling-only mode
161
- this.usingChangeStreams = false;
162
-
163
- if (this.reconnectTimer) {
164
- clearTimeout(this.reconnectTimer);
165
- this.reconnectTimer = null;
166
- }
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();
167
293
 
168
- if (this.changeStream) {
169
- this.changeStream.close().catch(() => {});
170
- this.changeStream = null;
171
- }
294
+ if (this.reconnectAttempts > this.maxReconnectAttempts) {
295
+ // Permanent fallback to polling-only mode
296
+ this.clearReconnectTimer();
172
297
 
173
298
  this.ctx.emit('changestream:fallback', {
174
299
  reason: `Exhausted ${this.maxReconnectAttempts} reconnection attempts: ${error.message}`,
@@ -181,38 +306,85 @@ export class ChangeStreamHandler {
181
306
  const delay = 2 ** (this.reconnectAttempts - 1) * 1000;
182
307
 
183
308
  // Clear any existing reconnect timer before scheduling a new one
184
- if (this.reconnectTimer) {
185
- clearTimeout(this.reconnectTimer);
309
+ this.clearReconnectTimer();
310
+
311
+ if (!this.ctx.isRunning()) {
312
+ return;
186
313
  }
187
314
 
188
315
  this.reconnectTimer = setTimeout(() => {
189
- this.reconnectTimer = null;
190
- if (this.ctx.isRunning()) {
191
- // Close existing change stream before reconnecting
192
- if (this.changeStream) {
193
- this.changeStream.close().catch(() => {});
194
- this.changeStream = null;
195
- }
196
- this.setup();
197
- }
316
+ this.clearReconnectTimer();
317
+ this.reconnect();
198
318
  }, delay);
199
319
  }
200
320
 
321
+ private reconnect(): void {
322
+ if (!this.ctx.isRunning()) {
323
+ return;
324
+ }
325
+
326
+ this.closeChangeStream();
327
+
328
+ if (!this.ctx.isRunning()) {
329
+ return;
330
+ }
331
+
332
+ this.setup();
333
+ }
334
+
335
+ private clearReconnectTimer(): void {
336
+ if (!this.reconnectTimer) {
337
+ return;
338
+ }
339
+
340
+ clearTimeout(this.reconnectTimer);
341
+ this.reconnectTimer = null;
342
+ }
343
+
201
344
  /**
202
- * Close the change stream cursor and emit closed event.
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).
203
350
  */
204
- async close(): Promise<void> {
205
- // Clear debounce timer
351
+ private resetActiveState(): void {
206
352
  if (this.debounceTimer) {
207
353
  clearTimeout(this.debounceTimer);
208
354
  this.debounceTimer = null;
209
355
  }
210
356
 
211
- // Clear reconnection timer
212
- if (this.reconnectTimer) {
213
- clearTimeout(this.reconnectTimer);
214
- this.reconnectTimer = null;
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;
215
366
  }
367
+ this.wakeupTime = null;
368
+ }
369
+
370
+ private closeChangeStream(): void {
371
+ if (!this.changeStream) {
372
+ return;
373
+ }
374
+
375
+ this.changeStream.close().catch(() => {});
376
+ this.changeStream = null;
377
+ }
378
+
379
+ /**
380
+ * Close the change stream cursor and emit closed event.
381
+ */
382
+ async close(): Promise<void> {
383
+ const wasActive = this.usingChangeStreams;
384
+
385
+ // Clear all active state (debounce, wakeup, pending names, flag)
386
+ this.resetActiveState();
387
+ this.clearReconnectTimer();
216
388
 
217
389
  if (this.changeStream) {
218
390
  try {
@@ -222,12 +394,11 @@ export class ChangeStreamHandler {
222
394
  }
223
395
  this.changeStream = null;
224
396
 
225
- if (this.usingChangeStreams) {
397
+ if (wasActive) {
226
398
  this.ctx.emit('changestream:closed', undefined);
227
399
  }
228
400
  }
229
401
 
230
- this.usingChangeStreams = false;
231
402
  this.reconnectAttempts = 0;
232
403
  }
233
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;