@monque/core 1.5.2 → 1.7.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.7.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"
@@ -76,13 +76,13 @@
76
76
  },
77
77
  "devDependencies": {
78
78
  "@faker-js/faker": "^10.3.0",
79
- "@testcontainers/mongodb": "^11.12.0",
79
+ "@testcontainers/mongodb": "^11.13.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.4",
86
+ "vitest": "^4.1.0"
87
87
  }
88
88
  }
package/src/index.ts CHANGED
@@ -41,11 +41,14 @@ export {
41
41
  getNextCronDate,
42
42
  InvalidCronError,
43
43
  InvalidCursorError,
44
+ InvalidJobIdentifierError,
44
45
  JobStateError,
45
46
  MonqueError,
46
47
  PayloadTooLargeError,
47
48
  ShutdownTimeoutError,
48
49
  validateCronExpression,
50
+ validateJobName,
51
+ validateUniqueKey,
49
52
  WorkerRegistrationError,
50
53
  } from '@/shared';
51
54
  // Types - Workers
@@ -18,11 +18,18 @@ import {
18
18
  type QueueStats,
19
19
  type ScheduleOptions,
20
20
  } from '@/jobs';
21
- import { ConnectionError, ShutdownTimeoutError, WorkerRegistrationError } from '@/shared';
21
+ import {
22
+ ConnectionError,
23
+ ShutdownTimeoutError,
24
+ validateJobName,
25
+ validateUniqueKey,
26
+ WorkerRegistrationError,
27
+ } from '@/shared';
22
28
  import type { WorkerOptions, WorkerRegistration } from '@/workers';
23
29
 
24
30
  import {
25
31
  ChangeStreamHandler,
32
+ CLEANUP_STATUSES,
26
33
  JobManager,
27
34
  JobProcessor,
28
35
  JobQueryService,
@@ -39,6 +46,7 @@ import type { MonqueOptions } from './types.js';
39
46
  const DEFAULTS = {
40
47
  collectionName: 'monque_jobs',
41
48
  pollInterval: 1000,
49
+ safetyPollInterval: 30_000,
42
50
  maxRetries: 10,
43
51
  baseRetryInterval: 1000,
44
52
  shutdownTimeout: 30000,
@@ -121,6 +129,15 @@ export class Monque extends EventEmitter {
121
129
  private isRunning = false;
122
130
  private isInitialized = false;
123
131
 
132
+ /**
133
+ * Resolve function for the reactive shutdown drain promise.
134
+ * Set during stop() when active jobs need to finish; called by
135
+ * onJobFinished() when the last active job completes.
136
+ *
137
+ * @private
138
+ */
139
+ private _drainResolve: (() => void) | null = null;
140
+
124
141
  // Internal services (initialized in initialize())
125
142
  private _scheduler: JobScheduler | null = null;
126
143
  private _manager: JobManager | null = null;
@@ -131,10 +148,12 @@ export class Monque extends EventEmitter {
131
148
 
132
149
  constructor(db: Db, options: MonqueOptions = {}) {
133
150
  super();
151
+ this.setMaxListeners(20);
134
152
  this.db = db;
135
153
  this.options = {
136
154
  collectionName: options.collectionName ?? DEFAULTS.collectionName,
137
155
  pollInterval: options.pollInterval ?? DEFAULTS.pollInterval,
156
+ safetyPollInterval: options.safetyPollInterval ?? DEFAULTS.safetyPollInterval,
138
157
  maxRetries: options.maxRetries ?? DEFAULTS.maxRetries,
139
158
  baseRetryInterval: options.baseRetryInterval ?? DEFAULTS.baseRetryInterval,
140
159
  shutdownTimeout: options.shutdownTimeout ?? DEFAULTS.shutdownTimeout,
@@ -151,6 +170,17 @@ export class Monque extends EventEmitter {
151
170
  maxPayloadSize: options.maxPayloadSize,
152
171
  statsCacheTtlMs: options.statsCacheTtlMs ?? 5000,
153
172
  };
173
+
174
+ if (options.defaultConcurrency !== undefined) {
175
+ console.warn(
176
+ '[@monque/core] "defaultConcurrency" is deprecated and will be removed in a future major version. Use "workerConcurrency" instead.',
177
+ );
178
+ }
179
+ if (options.maxConcurrency !== undefined) {
180
+ console.warn(
181
+ '[@monque/core] "maxConcurrency" is deprecated and will be removed in a future major version. Use "instanceConcurrency" instead.',
182
+ );
183
+ }
154
184
  }
155
185
 
156
186
  /**
@@ -186,7 +216,9 @@ export class Monque extends EventEmitter {
186
216
  this._manager = new JobManager(ctx);
187
217
  this._query = new JobQueryService(ctx);
188
218
  this._processor = new JobProcessor(ctx);
189
- this._changeStreamHandler = new ChangeStreamHandler(ctx, () => this.processor.poll());
219
+ this._changeStreamHandler = new ChangeStreamHandler(ctx, (targetNames) =>
220
+ this.handleChangeStreamPoll(targetNames),
221
+ );
190
222
  this._lifecycleManager = new LifecycleManager(ctx);
191
223
 
192
224
  this.isInitialized = true;
@@ -255,6 +287,26 @@ export class Monque extends EventEmitter {
255
287
  return this._lifecycleManager;
256
288
  }
257
289
 
290
+ private validateSchedulingIdentifiers(name: string, uniqueKey?: string): void {
291
+ validateJobName(name);
292
+
293
+ if (uniqueKey !== undefined) {
294
+ validateUniqueKey(uniqueKey);
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Handle a change-stream-triggered poll and reset the safety poll timer.
300
+ *
301
+ * Used as the `onPoll` callback for {@link ChangeStreamHandler}. Runs a
302
+ * targeted poll for the given worker names, then resets the adaptive safety
303
+ * poll timer so it doesn't fire redundantly.
304
+ */
305
+ private async handleChangeStreamPoll(targetNames?: ReadonlySet<string>): Promise<void> {
306
+ await this.processor.poll(targetNames);
307
+ this.lifecycleManager.resetPollTimer();
308
+ }
309
+
258
310
  /**
259
311
  * Build the shared context for internal services.
260
312
  */
@@ -271,6 +323,14 @@ export class Monque extends EventEmitter {
271
323
  isRunning: () => this.isRunning,
272
324
  emit: <K extends keyof MonqueEventMap>(event: K, payload: MonqueEventMap[K]) =>
273
325
  this.emit(event, payload),
326
+ notifyPendingJob: (name: string, nextRunAt: Date) => {
327
+ if (!this.isRunning || !this._changeStreamHandler) {
328
+ return;
329
+ }
330
+
331
+ this._changeStreamHandler.notifyPendingJob(name, nextRunAt);
332
+ },
333
+ notifyJobFinished: () => this.onJobFinished(),
274
334
  documentToPersistedJob: <T>(doc: WithId<Document>) => documentToPersistedJob<T>(doc),
275
335
  };
276
336
  }
@@ -318,6 +378,20 @@ export class Monque extends EventEmitter {
318
378
  { key: { status: 1, nextRunAt: 1, claimedBy: 1 }, background: true },
319
379
  // Expanded index that supports recovery scans (status + lockedAt) plus heartbeat monitoring patterns.
320
380
  { key: { status: 1, lockedAt: 1, lastHeartbeat: 1 }, background: true },
381
+ // Index for efficient lifecycle manager cleanup when jobRetention is configured.
382
+ // Allows fast queries for deleteMany({ status, updatedAt: { $lt: cutoff } }).
383
+ ...(this.options.jobRetention
384
+ ? [
385
+ {
386
+ key: { status: 1, updatedAt: 1 } as const,
387
+ background: true,
388
+ partialFilterExpression: {
389
+ status: { $in: CLEANUP_STATUSES },
390
+ updatedAt: { $exists: true },
391
+ },
392
+ },
393
+ ]
394
+ : []),
321
395
  ]);
322
396
  }
323
397
 
@@ -347,7 +421,6 @@ export class Monque extends EventEmitter {
347
421
  lockedAt: '',
348
422
  claimedBy: '',
349
423
  lastHeartbeat: '',
350
- heartbeatInterval: '',
351
424
  },
352
425
  },
353
426
  );
@@ -414,6 +487,7 @@ export class Monque extends EventEmitter {
414
487
  * @param data - Job payload, will be passed to the worker handler
415
488
  * @param options - Scheduling and deduplication options
416
489
  * @returns Promise resolving to the created or existing job document
490
+ * @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
417
491
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
418
492
  * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
419
493
  *
@@ -446,6 +520,7 @@ export class Monque extends EventEmitter {
446
520
  */
447
521
  async enqueue<T>(name: string, data: T, options: EnqueueOptions = {}): Promise<PersistedJob<T>> {
448
522
  this.ensureInitialized();
523
+ this.validateSchedulingIdentifiers(name, options.uniqueKey);
449
524
  return this.scheduler.enqueue(name, data, options);
450
525
  }
451
526
 
@@ -459,6 +534,7 @@ export class Monque extends EventEmitter {
459
534
  * @param name - Job type identifier, must match a registered worker
460
535
  * @param data - Job payload, will be passed to the worker handler
461
536
  * @returns Promise resolving to the created job document
537
+ * @throws {InvalidJobIdentifierError} If `name` fails public identifier validation
462
538
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
463
539
  *
464
540
  * @example Send email immediately
@@ -481,6 +557,7 @@ export class Monque extends EventEmitter {
481
557
  */
482
558
  async now<T>(name: string, data: T): Promise<PersistedJob<T>> {
483
559
  this.ensureInitialized();
560
+ validateJobName(name);
484
561
  return this.scheduler.now(name, data);
485
562
  }
486
563
 
@@ -502,6 +579,7 @@ export class Monque extends EventEmitter {
502
579
  * @param data - Job payload, will be passed to the worker handler on each run
503
580
  * @param options - Scheduling options (uniqueKey for deduplication)
504
581
  * @returns Promise resolving to the created job document with `repeatInterval` set
582
+ * @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
505
583
  * @throws {InvalidCronError} If cron expression is invalid
506
584
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
507
585
  * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
@@ -538,6 +616,7 @@ export class Monque extends EventEmitter {
538
616
  options: ScheduleOptions = {},
539
617
  ): Promise<PersistedJob<T>> {
540
618
  this.ensureInitialized();
619
+ this.validateSchedulingIdentifiers(name, options.uniqueKey);
541
620
  return this.scheduler.schedule(cron, name, data, options);
542
621
  }
543
622
 
@@ -907,6 +986,7 @@ export class Monque extends EventEmitter {
907
986
  * @param options - Worker configuration
908
987
  * @param options.concurrency - Maximum concurrent jobs for this worker (default: `defaultConcurrency`)
909
988
  * @param options.replace - When `true`, replace existing worker instead of throwing error
989
+ * @throws {InvalidJobIdentifierError} If `name` fails public identifier validation
910
990
  * @throws {WorkerRegistrationError} When a worker is already registered for `name` and `replace` is not `true`
911
991
  *
912
992
  * @example Basic email worker
@@ -950,6 +1030,7 @@ export class Monque extends EventEmitter {
950
1030
  * ```
951
1031
  */
952
1032
  register<T>(name: string, handler: JobHandler<T>, options: WorkerOptions = {}): void {
1033
+ validateJobName(name);
953
1034
  const concurrency = options.concurrency ?? this.options.workerConcurrency;
954
1035
 
955
1036
  // Check for existing worker and throw unless replace is explicitly true
@@ -1032,6 +1113,7 @@ export class Monque extends EventEmitter {
1032
1113
  this.lifecycleManager.startTimers({
1033
1114
  poll: () => this.processor.poll(),
1034
1115
  updateHeartbeats: () => this.processor.updateHeartbeats(),
1116
+ isChangeStreamActive: () => this.changeStreamHandler.isActive(),
1035
1117
  });
1036
1118
  }
1037
1119
 
@@ -1092,20 +1174,14 @@ export class Monque extends EventEmitter {
1092
1174
  }
1093
1175
 
1094
1176
  // Wait for all active jobs to complete (with timeout)
1095
- const activeJobs = this.getActiveJobs();
1096
- if (activeJobs.length === 0) {
1177
+ if (this.getActiveJobCount() === 0) {
1097
1178
  return;
1098
1179
  }
1099
1180
 
1100
- // Create a promise that resolves when all jobs are done
1101
- let checkInterval: ReturnType<typeof setInterval> | undefined;
1181
+ // Reactive drain: resolve when the last active job finishes.
1182
+ // onJobFinished() is called from processJob's finally block.
1102
1183
  const waitForJobs = new Promise<undefined>((resolve) => {
1103
- checkInterval = setInterval(() => {
1104
- if (this.getActiveJobs().length === 0) {
1105
- clearInterval(checkInterval);
1106
- resolve(undefined);
1107
- }
1108
- }, 100);
1184
+ this._drainResolve = () => resolve(undefined);
1109
1185
  });
1110
1186
 
1111
1187
  // Race between job completion and timeout
@@ -1113,15 +1189,9 @@ export class Monque extends EventEmitter {
1113
1189
  setTimeout(() => resolve('timeout'), this.options.shutdownTimeout);
1114
1190
  });
1115
1191
 
1116
- let result: undefined | 'timeout';
1192
+ const result = await Promise.race([waitForJobs, timeout]);
1117
1193
 
1118
- try {
1119
- result = await Promise.race([waitForJobs, timeout]);
1120
- } finally {
1121
- if (checkInterval) {
1122
- clearInterval(checkInterval);
1123
- }
1124
- }
1194
+ this._drainResolve = null;
1125
1195
 
1126
1196
  if (result === 'timeout') {
1127
1197
  const incompleteJobs = this.getActiveJobsList();
@@ -1188,6 +1258,18 @@ export class Monque extends EventEmitter {
1188
1258
  // Private Helpers
1189
1259
  // ─────────────────────────────────────────────────────────────────────────────
1190
1260
 
1261
+ /**
1262
+ * Called when a job finishes processing. If a shutdown drain is pending
1263
+ * and no active jobs remain, resolves the drain promise.
1264
+ *
1265
+ * @private
1266
+ */
1267
+ private onJobFinished(): void {
1268
+ if (this._drainResolve && this.getActiveJobCount() === 0) {
1269
+ this._drainResolve();
1270
+ }
1271
+ }
1272
+
1191
1273
  /**
1192
1274
  * Ensure the scheduler is initialized before operations.
1193
1275
  *
@@ -1201,17 +1283,20 @@ export class Monque extends EventEmitter {
1201
1283
  }
1202
1284
 
1203
1285
  /**
1204
- * Get array of active job IDs across all workers.
1286
+ * Get total count of active jobs across all workers.
1287
+ *
1288
+ * Returns only the count (O(workers)) instead of allocating
1289
+ * a throw-away array of IDs, since callers only need `.length`.
1205
1290
  *
1206
1291
  * @private
1207
- * @returns Array of job ID strings currently being processed
1292
+ * @returns Number of jobs currently being processed
1208
1293
  */
1209
- private getActiveJobs(): string[] {
1210
- const activeJobs: string[] = [];
1294
+ private getActiveJobCount(): number {
1295
+ let count = 0;
1211
1296
  for (const worker of this.workers.values()) {
1212
- activeJobs.push(...worker.activeJobs.keys());
1297
+ count += worker.activeJobs.size;
1213
1298
  }
1214
- return activeJobs;
1299
+ return count;
1215
1300
  }
1216
1301
 
1217
1302
  /**
@@ -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
 
@@ -4,6 +4,6 @@ export { JobManager } from './job-manager.js';
4
4
  export { JobProcessor } from './job-processor.js';
5
5
  export { JobQueryService } from './job-query.js';
6
6
  export { JobScheduler } from './job-scheduler.js';
7
- export { LifecycleManager } from './lifecycle-manager.js';
7
+ export { CLEANUP_STATUSES, LifecycleManager } from './lifecycle-manager.js';
8
8
  // Types
9
9
  export type { ResolvedMonqueOptions, SchedulerContext } from './types.js';