@monque/core 1.3.0 → 1.5.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.3.0",
3
+ "version": "1.5.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": {
@@ -78,7 +78,7 @@
78
78
  "@faker-js/faker": "^10.3.0",
79
79
  "@testcontainers/mongodb": "^11.12.0",
80
80
  "@total-typescript/ts-reset": "^0.6.1",
81
- "@types/node": "^22.19.11",
81
+ "@types/node": "^22.19.13",
82
82
  "@vitest/coverage-v8": "^4.0.18",
83
83
  "fishery": "^2.4.0",
84
84
  "mongodb": "^7.1.0",
@@ -90,17 +90,17 @@ export interface MonqueEventMap {
90
90
 
91
91
  /**
92
92
  * Emitted when multiple jobs are cancelled in bulk.
93
+ * Contains only the count of affected jobs (no individual IDs for O(1) performance).
93
94
  */
94
95
  'jobs:cancelled': {
95
- jobIds: string[];
96
96
  count: number;
97
97
  };
98
98
 
99
99
  /**
100
100
  * Emitted when multiple jobs are retried in bulk.
101
+ * Contains only the count of affected jobs (no individual IDs for O(1) performance).
101
102
  */
102
103
  'jobs:retried': {
103
- jobIds: string[];
104
104
  count: number;
105
105
  };
106
106
 
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export {
43
43
  InvalidCursorError,
44
44
  JobStateError,
45
45
  MonqueError,
46
+ PayloadTooLargeError,
46
47
  ShutdownTimeoutError,
47
48
  validateCronExpression,
48
49
  WorkerRegistrationError,
@@ -0,0 +1,52 @@
1
+ import type { Document, WithId } from 'mongodb';
2
+
3
+ import type { PersistedJob } from './types.js';
4
+
5
+ /**
6
+ * Convert a raw MongoDB document to a strongly-typed {@link PersistedJob}.
7
+ *
8
+ * Maps required fields directly and conditionally includes optional fields
9
+ * only when they are present in the document (`!== undefined`).
10
+ *
11
+ * @internal Not part of the public API.
12
+ * @template T - The job data payload type
13
+ * @param doc - The raw MongoDB document with `_id`
14
+ * @returns A strongly-typed PersistedJob object with guaranteed `_id`
15
+ */
16
+ export function documentToPersistedJob<T = unknown>(doc: WithId<Document>): PersistedJob<T> {
17
+ const job: PersistedJob<T> = {
18
+ _id: doc._id,
19
+ name: doc['name'],
20
+ data: doc['data'],
21
+ status: doc['status'],
22
+ nextRunAt: doc['nextRunAt'],
23
+ failCount: doc['failCount'],
24
+ createdAt: doc['createdAt'],
25
+ updatedAt: doc['updatedAt'],
26
+ };
27
+
28
+ // Only set optional properties if they exist
29
+ if (doc['lockedAt'] !== undefined) {
30
+ job.lockedAt = doc['lockedAt'];
31
+ }
32
+ if (doc['claimedBy'] !== undefined) {
33
+ job.claimedBy = doc['claimedBy'];
34
+ }
35
+ if (doc['lastHeartbeat'] !== undefined) {
36
+ job.lastHeartbeat = doc['lastHeartbeat'];
37
+ }
38
+ if (doc['heartbeatInterval'] !== undefined) {
39
+ job.heartbeatInterval = doc['heartbeatInterval'];
40
+ }
41
+ if (doc['failReason'] !== undefined) {
42
+ job.failReason = doc['failReason'];
43
+ }
44
+ if (doc['repeatInterval'] !== undefined) {
45
+ job.repeatInterval = doc['repeatInterval'];
46
+ }
47
+ if (doc['uniqueKey'] !== undefined) {
48
+ job.uniqueKey = doc['uniqueKey'];
49
+ }
50
+
51
+ return job;
52
+ }
package/src/jobs/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ // Mapper
2
+ export { documentToPersistedJob } from './document-to-persisted-job.js';
1
3
  // Guards
2
4
  export {
3
5
  isCancelledJob,
@@ -1,19 +1,19 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { EventEmitter } from 'node:events';
3
- import type { Collection, Db, DeleteResult, Document, ObjectId, WithId } from 'mongodb';
3
+ import type { Collection, Db, Document, ObjectId, WithId } from 'mongodb';
4
4
 
5
5
  import type { MonqueEventMap } from '@/events';
6
6
  import {
7
7
  type BulkOperationResult,
8
8
  type CursorOptions,
9
9
  type CursorPage,
10
+ documentToPersistedJob,
10
11
  type EnqueueOptions,
11
12
  type GetJobsFilter,
12
13
  type Job,
13
14
  type JobHandler,
14
15
  type JobSelector,
15
16
  JobStatus,
16
- type JobStatusType,
17
17
  type PersistedJob,
18
18
  type QueueStats,
19
19
  type ScheduleOptions,
@@ -27,6 +27,7 @@ import {
27
27
  JobProcessor,
28
28
  JobQueryService,
29
29
  JobScheduler,
30
+ LifecycleManager,
30
31
  type ResolvedMonqueOptions,
31
32
  type SchedulerContext,
32
33
  } from './services/index.js';
@@ -117,9 +118,6 @@ export class Monque extends EventEmitter {
117
118
  private readonly options: ResolvedMonqueOptions;
118
119
  private collection: Collection<Document> | null = null;
119
120
  private workers: Map<string, WorkerRegistration> = new Map();
120
- private pollIntervalId: ReturnType<typeof setInterval> | null = null;
121
- private heartbeatIntervalId: ReturnType<typeof setInterval> | null = null;
122
- private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
123
121
  private isRunning = false;
124
122
  private isInitialized = false;
125
123
 
@@ -129,6 +127,7 @@ export class Monque extends EventEmitter {
129
127
  private _query: JobQueryService | null = null;
130
128
  private _processor: JobProcessor | null = null;
131
129
  private _changeStreamHandler: ChangeStreamHandler | null = null;
130
+ private _lifecycleManager: LifecycleManager | null = null;
132
131
 
133
132
  constructor(db: Db, options: MonqueOptions = {}) {
134
133
  super();
@@ -148,6 +147,9 @@ export class Monque extends EventEmitter {
148
147
  schedulerInstanceId: options.schedulerInstanceId ?? randomUUID(),
149
148
  heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
150
149
  jobRetention: options.jobRetention,
150
+ skipIndexCreation: options.skipIndexCreation ?? false,
151
+ maxPayloadSize: options.maxPayloadSize,
152
+ statsCacheTtlMs: options.statsCacheTtlMs ?? 5000,
151
153
  };
152
154
  }
153
155
 
@@ -165,14 +167,19 @@ export class Monque extends EventEmitter {
165
167
  try {
166
168
  this.collection = this.db.collection(this.options.collectionName);
167
169
 
168
- // Create indexes for efficient queries
169
- await this.createIndexes();
170
+ // Create indexes for efficient queries (unless externally managed)
171
+ if (!this.options.skipIndexCreation) {
172
+ await this.createIndexes();
173
+ }
170
174
 
171
175
  // Recover stale jobs if enabled
172
176
  if (this.options.recoverStaleJobs) {
173
177
  await this.recoverStaleJobs();
174
178
  }
175
179
 
180
+ // Check for instance ID collisions (after stale recovery to avoid false positives)
181
+ await this.checkInstanceCollision();
182
+
176
183
  // Initialize services with shared context
177
184
  const ctx = this.buildContext();
178
185
  this._scheduler = new JobScheduler(ctx);
@@ -180,6 +187,7 @@ export class Monque extends EventEmitter {
180
187
  this._query = new JobQueryService(ctx);
181
188
  this._processor = new JobProcessor(ctx);
182
189
  this._changeStreamHandler = new ChangeStreamHandler(ctx, () => this.processor.poll());
190
+ this._lifecycleManager = new LifecycleManager(ctx);
183
191
 
184
192
  this.isInitialized = true;
185
193
  } catch (error) {
@@ -238,6 +246,15 @@ export class Monque extends EventEmitter {
238
246
  return this._changeStreamHandler;
239
247
  }
240
248
 
249
+ /** @throws {ConnectionError} if not initialized */
250
+ private get lifecycleManager(): LifecycleManager {
251
+ if (!this._lifecycleManager) {
252
+ throw new ConnectionError('Monque not initialized. Call initialize() first.');
253
+ }
254
+
255
+ return this._lifecycleManager;
256
+ }
257
+
241
258
  /**
242
259
  * Build the shared context for internal services.
243
260
  */
@@ -254,7 +271,7 @@ export class Monque extends EventEmitter {
254
271
  isRunning: () => this.isRunning,
255
272
  emit: <K extends keyof MonqueEventMap>(event: K, payload: MonqueEventMap[K]) =>
256
273
  this.emit(event, payload),
257
- documentToPersistedJob: <T>(doc: WithId<Document>) => this.documentToPersistedJob<T>(doc),
274
+ documentToPersistedJob: <T>(doc: WithId<Document>) => documentToPersistedJob<T>(doc),
258
275
  };
259
276
  }
260
277
  /**
@@ -274,14 +291,13 @@ export class Monque extends EventEmitter {
274
291
  throw new ConnectionError('Collection not initialized');
275
292
  }
276
293
 
277
- // Compound index for job polling - status + nextRunAt for efficient queries
278
- await this.collection.createIndex({ status: 1, nextRunAt: 1 }, { background: true });
279
-
280
- // Partial unique index for deduplication - scoped by name + uniqueKey
281
- // Only enforced where uniqueKey exists and status is pending/processing
282
- await this.collection.createIndex(
283
- { name: 1, uniqueKey: 1 },
294
+ await this.collection.createIndexes([
295
+ // Compound index for job polling - status + nextRunAt for efficient queries
296
+ { key: { status: 1, nextRunAt: 1 }, background: true },
297
+ // Partial unique index for deduplication - scoped by name + uniqueKey
298
+ // Only enforced where uniqueKey exists and status is pending/processing
284
299
  {
300
+ key: { name: 1, uniqueKey: 1 },
285
301
  unique: true,
286
302
  partialFilterExpression: {
287
303
  uniqueKey: { $exists: true },
@@ -289,31 +305,20 @@ export class Monque extends EventEmitter {
289
305
  },
290
306
  background: true,
291
307
  },
292
- );
293
-
294
- // Index for job lookup by name
295
- await this.collection.createIndex({ name: 1, status: 1 }, { background: true });
296
-
297
- // Compound index for finding jobs claimed by a specific scheduler instance.
298
- // Used for heartbeat updates and cleanup on shutdown.
299
- await this.collection.createIndex({ claimedBy: 1, status: 1 }, { background: true });
300
-
301
- // Compound index for monitoring/debugging via heartbeat timestamps.
302
- // Note: stale recovery uses lockedAt + lockTimeout as the source of truth.
303
- await this.collection.createIndex({ lastHeartbeat: 1, status: 1 }, { background: true });
304
-
305
- // Compound index for atomic claim queries.
306
- // Optimizes the findOneAndUpdate query that claims unclaimed pending jobs.
307
- await this.collection.createIndex(
308
- { status: 1, nextRunAt: 1, claimedBy: 1 },
309
- { background: true },
310
- );
311
-
312
- // Expanded index that supports recovery scans (status + lockedAt) plus heartbeat monitoring patterns.
313
- await this.collection.createIndex(
314
- { status: 1, lockedAt: 1, lastHeartbeat: 1 },
315
- { background: true },
316
- );
308
+ // Index for job lookup by name
309
+ { key: { name: 1, status: 1 }, background: true },
310
+ // Compound index for finding jobs claimed by a specific scheduler instance.
311
+ // Used for heartbeat updates and cleanup on shutdown.
312
+ { key: { claimedBy: 1, status: 1 }, background: true },
313
+ // Compound index for monitoring/debugging via heartbeat timestamps.
314
+ // Note: stale recovery uses lockedAt + lockTimeout as the source of truth.
315
+ { key: { lastHeartbeat: 1, status: 1 }, background: true },
316
+ // Compound index for atomic claim queries.
317
+ // Optimizes the findOneAndUpdate query that claims unclaimed pending jobs.
318
+ { key: { status: 1, nextRunAt: 1, claimedBy: 1 }, background: true },
319
+ // Expanded index that supports recovery scans (status + lockedAt) plus heartbeat monitoring patterns.
320
+ { key: { status: 1, lockedAt: 1, lastHeartbeat: 1 }, background: true },
321
+ ]);
317
322
  }
318
323
 
319
324
  /**
@@ -356,46 +361,35 @@ export class Monque extends EventEmitter {
356
361
  }
357
362
 
358
363
  /**
359
- * Clean up old completed and failed jobs based on retention policy.
360
- *
361
- * - Removes completed jobs older than `jobRetention.completed`
362
- * - Removes failed jobs older than `jobRetention.failed`
364
+ * Check if another active instance is using the same schedulerInstanceId.
365
+ * Uses heartbeat staleness to distinguish active instances from crashed ones.
363
366
  *
364
- * The cleanup runs concurrently for both statuses if configured.
367
+ * Called after stale recovery to avoid false positives: stale recovery resets
368
+ * jobs with old `lockedAt`, so only jobs with recent heartbeats remain.
365
369
  *
366
- * @returns Promise resolving when all deletion operations complete
370
+ * @throws {ConnectionError} If an active instance with the same ID is detected
367
371
  */
368
- private async cleanupJobs(): Promise<void> {
369
- if (!this.collection || !this.options.jobRetention) {
372
+ private async checkInstanceCollision(): Promise<void> {
373
+ if (!this.collection) {
370
374
  return;
371
375
  }
372
376
 
373
- const { completed, failed } = this.options.jobRetention;
374
- const now = Date.now();
375
- const deletions: Promise<DeleteResult>[] = [];
376
-
377
- if (completed) {
378
- const cutoff = new Date(now - completed);
379
- deletions.push(
380
- this.collection.deleteMany({
381
- status: JobStatus.COMPLETED,
382
- updatedAt: { $lt: cutoff },
383
- }),
384
- );
385
- }
377
+ // Look for any job currently claimed by this instance ID
378
+ // that has a recent heartbeat (within 2× heartbeat interval = "alive" threshold)
379
+ const aliveThreshold = new Date(Date.now() - this.options.heartbeatInterval * 2);
386
380
 
387
- if (failed) {
388
- const cutoff = new Date(now - failed);
389
- deletions.push(
390
- this.collection.deleteMany({
391
- status: JobStatus.FAILED,
392
- updatedAt: { $lt: cutoff },
393
- }),
394
- );
395
- }
381
+ const activeJob = await this.collection.findOne({
382
+ claimedBy: this.options.schedulerInstanceId,
383
+ status: JobStatus.PROCESSING,
384
+ lastHeartbeat: { $gte: aliveThreshold },
385
+ });
396
386
 
397
- if (deletions.length > 0) {
398
- await Promise.all(deletions);
387
+ if (activeJob) {
388
+ throw new ConnectionError(
389
+ `Another active Monque instance is using schedulerInstanceId "${this.options.schedulerInstanceId}". ` +
390
+ `Found processing job "${activeJob['name']}" with recent heartbeat. ` +
391
+ `Use a unique schedulerInstanceId or wait for the other instance to stop.`,
392
+ );
399
393
  }
400
394
  }
401
395
 
@@ -421,6 +415,7 @@ export class Monque extends EventEmitter {
421
415
  * @param options - Scheduling and deduplication options
422
416
  * @returns Promise resolving to the created or existing job document
423
417
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
418
+ * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
424
419
  *
425
420
  * @example Basic job enqueueing
426
421
  * ```typescript
@@ -446,6 +441,8 @@ export class Monque extends EventEmitter {
446
441
  * });
447
442
  * // Subsequent enqueues with same uniqueKey return existing pending/processing job
448
443
  * ```
444
+ *
445
+ * @see {@link JobScheduler.enqueue}
449
446
  */
450
447
  async enqueue<T>(name: string, data: T, options: EnqueueOptions = {}): Promise<PersistedJob<T>> {
451
448
  this.ensureInitialized();
@@ -479,6 +476,8 @@ export class Monque extends EventEmitter {
479
476
  * await monque.now('process-order', { orderId: order.id });
480
477
  * return order; // Return immediately, processing happens async
481
478
  * ```
479
+ *
480
+ * @see {@link JobScheduler.now}
482
481
  */
483
482
  async now<T>(name: string, data: T): Promise<PersistedJob<T>> {
484
483
  this.ensureInitialized();
@@ -505,6 +504,7 @@ export class Monque extends EventEmitter {
505
504
  * @returns Promise resolving to the created job document with `repeatInterval` set
506
505
  * @throws {InvalidCronError} If cron expression is invalid
507
506
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
507
+ * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
508
508
  *
509
509
  * @example Hourly cleanup job
510
510
  * ```typescript
@@ -528,6 +528,8 @@ export class Monque extends EventEmitter {
528
528
  * recipients: ['analytics@example.com']
529
529
  * });
530
530
  * ```
531
+ *
532
+ * @see {@link JobScheduler.schedule}
531
533
  */
532
534
  async schedule<T>(
533
535
  cron: string,
@@ -559,6 +561,8 @@ export class Monque extends EventEmitter {
559
561
  * const job = await monque.enqueue('report', { type: 'daily' });
560
562
  * await monque.cancelJob(job._id.toString());
561
563
  * ```
564
+ *
565
+ * @see {@link JobManager.cancelJob}
562
566
  */
563
567
  async cancelJob(jobId: string): Promise<PersistedJob<unknown> | null> {
564
568
  this.ensureInitialized();
@@ -582,6 +586,8 @@ export class Monque extends EventEmitter {
582
586
  * await monque.retryJob(job._id.toString());
583
587
  * });
584
588
  * ```
589
+ *
590
+ * @see {@link JobManager.retryJob}
585
591
  */
586
592
  async retryJob(jobId: string): Promise<PersistedJob<unknown> | null> {
587
593
  this.ensureInitialized();
@@ -603,6 +609,8 @@ export class Monque extends EventEmitter {
603
609
  * const nextHour = new Date(Date.now() + 60 * 60 * 1000);
604
610
  * await monque.rescheduleJob(jobId, nextHour);
605
611
  * ```
612
+ *
613
+ * @see {@link JobManager.rescheduleJob}
606
614
  */
607
615
  async rescheduleJob(jobId: string, runAt: Date): Promise<PersistedJob<unknown> | null> {
608
616
  this.ensureInitialized();
@@ -625,6 +633,8 @@ export class Monque extends EventEmitter {
625
633
  * console.log('Job permanently removed');
626
634
  * }
627
635
  * ```
636
+ *
637
+ * @see {@link JobManager.deleteJob}
628
638
  */
629
639
  async deleteJob(jobId: string): Promise<boolean> {
630
640
  this.ensureInitialized();
@@ -632,14 +642,15 @@ export class Monque extends EventEmitter {
632
642
  }
633
643
 
634
644
  /**
635
- * Cancel multiple jobs matching the given filter.
645
+ * Cancel multiple jobs matching the given filter via a single updateMany call.
636
646
  *
637
- * Only cancels jobs in 'pending' status. Jobs in other states are collected
638
- * as errors in the result. Emits a 'jobs:cancelled' event with the IDs of
647
+ * Only cancels jobs in 'pending' status the status guard is applied regardless
648
+ * of what the filter specifies. Jobs in other states are silently skipped (not
649
+ * matched by the query). Emits a 'jobs:cancelled' event with the count of
639
650
  * successfully cancelled jobs.
640
651
  *
641
652
  * @param filter - Selector for which jobs to cancel (name, status, date range)
642
- * @returns Result with count of cancelled jobs and any errors encountered
653
+ * @returns Result with count of cancelled jobs (errors array always empty for bulk ops)
643
654
  *
644
655
  * @example Cancel all pending jobs for a queue
645
656
  * ```typescript
@@ -649,6 +660,8 @@ export class Monque extends EventEmitter {
649
660
  * });
650
661
  * console.log(`Cancelled ${result.count} jobs`);
651
662
  * ```
663
+ *
664
+ * @see {@link JobManager.cancelJobs}
652
665
  */
653
666
  async cancelJobs(filter: JobSelector): Promise<BulkOperationResult> {
654
667
  this.ensureInitialized();
@@ -656,14 +669,15 @@ export class Monque extends EventEmitter {
656
669
  }
657
670
 
658
671
  /**
659
- * Retry multiple jobs matching the given filter.
672
+ * Retry multiple jobs matching the given filter via a single pipeline-style updateMany call.
660
673
  *
661
- * Only retries jobs in 'failed' or 'cancelled' status. Jobs in other states
662
- * are collected as errors in the result. Emits a 'jobs:retried' event with
663
- * the IDs of successfully retried jobs.
674
+ * Only retries jobs in 'failed' or 'cancelled' status the status guard is applied
675
+ * regardless of what the filter specifies. Jobs in other states are silently skipped.
676
+ * Uses `$rand` for per-document staggered `nextRunAt` to avoid thundering herd on retry.
677
+ * Emits a 'jobs:retried' event with the count of successfully retried jobs.
664
678
  *
665
679
  * @param filter - Selector for which jobs to retry (name, status, date range)
666
- * @returns Result with count of retried jobs and any errors encountered
680
+ * @returns Result with count of retried jobs (errors array always empty for bulk ops)
667
681
  *
668
682
  * @example Retry all failed jobs
669
683
  * ```typescript
@@ -672,6 +686,8 @@ export class Monque extends EventEmitter {
672
686
  * });
673
687
  * console.log(`Retried ${result.count} jobs`);
674
688
  * ```
689
+ *
690
+ * @see {@link JobManager.retryJobs}
675
691
  */
676
692
  async retryJobs(filter: JobSelector): Promise<BulkOperationResult> {
677
693
  this.ensureInitialized();
@@ -682,6 +698,7 @@ export class Monque extends EventEmitter {
682
698
  * Delete multiple jobs matching the given filter.
683
699
  *
684
700
  * Deletes jobs in any status. Uses a batch delete for efficiency.
701
+ * Emits a 'jobs:deleted' event with the count of deleted jobs.
685
702
  * Does not emit individual 'job:deleted' events to avoid noise.
686
703
  *
687
704
  * @param filter - Selector for which jobs to delete (name, status, date range)
@@ -696,6 +713,8 @@ export class Monque extends EventEmitter {
696
713
  * });
697
714
  * console.log(`Deleted ${result.count} jobs`);
698
715
  * ```
716
+ *
717
+ * @see {@link JobManager.deleteJobs}
699
718
  */
700
719
  async deleteJobs(filter: JobSelector): Promise<BulkOperationResult> {
701
720
  this.ensureInitialized();
@@ -736,6 +755,8 @@ export class Monque extends EventEmitter {
736
755
  * res.json(job);
737
756
  * });
738
757
  * ```
758
+ *
759
+ * @see {@link JobQueryService.getJob}
739
760
  */
740
761
  async getJob<T = unknown>(id: ObjectId): Promise<PersistedJob<T> | null> {
741
762
  this.ensureInitialized();
@@ -783,6 +804,8 @@ export class Monque extends EventEmitter {
783
804
  * const jobs = await monque.getJobs();
784
805
  * const pendingRecurring = jobs.filter(job => isPendingJob(job) && isRecurringJob(job));
785
806
  * ```
807
+ *
808
+ * @see {@link JobQueryService.getJobs}
786
809
  */
787
810
  async getJobs<T = unknown>(filter: GetJobsFilter = {}): Promise<PersistedJob<T>[]> {
788
811
  this.ensureInitialized();
@@ -817,6 +840,8 @@ export class Monque extends EventEmitter {
817
840
  * });
818
841
  * }
819
842
  * ```
843
+ *
844
+ * @see {@link JobQueryService.getJobsWithCursor}
820
845
  */
821
846
  async getJobsWithCursor<T = unknown>(options: CursorOptions = {}): Promise<CursorPage<T>> {
822
847
  this.ensureInitialized();
@@ -829,6 +854,9 @@ export class Monque extends EventEmitter {
829
854
  * Uses MongoDB aggregation pipeline for efficient server-side calculation.
830
855
  * Returns counts per status and optional average processing duration for completed jobs.
831
856
  *
857
+ * Results are cached per unique filter with a configurable TTL (default 5s).
858
+ * Set `statsCacheTtlMs: 0` to disable caching.
859
+ *
832
860
  * @param filter - Optional filter to scope statistics by job name
833
861
  * @returns Promise resolving to queue statistics
834
862
  * @throws {AggregationTimeoutError} If aggregation exceeds 30 second timeout
@@ -845,6 +873,8 @@ export class Monque extends EventEmitter {
845
873
  * const emailStats = await monque.getQueueStats({ name: 'send-email' });
846
874
  * console.log(`${emailStats.total} email jobs in queue`);
847
875
  * ```
876
+ *
877
+ * @see {@link JobQueryService.getQueueStats}
848
878
  */
849
879
  async getQueueStats(filter?: Pick<JobSelector, 'name'>): Promise<QueueStats> {
850
880
  this.ensureInitialized();
@@ -998,39 +1028,10 @@ export class Monque extends EventEmitter {
998
1028
  // Set up change streams as the primary notification mechanism
999
1029
  this.changeStreamHandler.setup();
1000
1030
 
1001
- // Set up polling as backup (runs at configured interval)
1002
- this.pollIntervalId = setInterval(() => {
1003
- this.processor.poll().catch((error: unknown) => {
1004
- this.emit('job:error', { error: error as Error });
1005
- });
1006
- }, this.options.pollInterval);
1007
-
1008
- // Start heartbeat interval for claimed jobs
1009
- this.heartbeatIntervalId = setInterval(() => {
1010
- this.processor.updateHeartbeats().catch((error: unknown) => {
1011
- this.emit('job:error', { error: error as Error });
1012
- });
1013
- }, this.options.heartbeatInterval);
1014
-
1015
- // Start cleanup interval if retention is configured
1016
- if (this.options.jobRetention) {
1017
- const interval = this.options.jobRetention.interval ?? DEFAULTS.retentionInterval;
1018
-
1019
- // Run immediately on start
1020
- this.cleanupJobs().catch((error: unknown) => {
1021
- this.emit('job:error', { error: error as Error });
1022
- });
1023
-
1024
- this.cleanupIntervalId = setInterval(() => {
1025
- this.cleanupJobs().catch((error: unknown) => {
1026
- this.emit('job:error', { error: error as Error });
1027
- });
1028
- }, interval);
1029
- }
1030
-
1031
- // Run initial poll immediately to pick up any existing jobs
1032
- this.processor.poll().catch((error: unknown) => {
1033
- this.emit('job:error', { error: error as Error });
1031
+ // Delegate timer management to LifecycleManager
1032
+ this.lifecycleManager.startTimers({
1033
+ poll: () => this.processor.poll(),
1034
+ updateHeartbeats: () => this.processor.updateHeartbeats(),
1034
1035
  });
1035
1036
  }
1036
1037
 
@@ -1075,25 +1076,18 @@ export class Monque extends EventEmitter {
1075
1076
 
1076
1077
  this.isRunning = false;
1077
1078
 
1078
- // Close change stream
1079
- await this.changeStreamHandler.close();
1079
+ // Clear stats cache for clean state on restart
1080
+ this._query?.clearStatsCache();
1080
1081
 
1081
- if (this.cleanupIntervalId) {
1082
- clearInterval(this.cleanupIntervalId);
1083
- this.cleanupIntervalId = null;
1084
- }
1085
-
1086
- // Clear polling interval
1087
- if (this.pollIntervalId) {
1088
- clearInterval(this.pollIntervalId);
1089
- this.pollIntervalId = null;
1082
+ // Close change stream — catch-and-ignore per shutdown cleanup guideline
1083
+ try {
1084
+ await this.changeStreamHandler.close();
1085
+ } catch {
1086
+ // ignore errors during shutdown cleanup
1090
1087
  }
1091
1088
 
1092
- // Clear heartbeat interval
1093
- if (this.heartbeatIntervalId) {
1094
- clearInterval(this.heartbeatIntervalId);
1095
- this.heartbeatIntervalId = null;
1096
- }
1089
+ // Stop all lifecycle timers
1090
+ this.lifecycleManager.stopTimers();
1097
1091
 
1098
1092
  // Wait for all active jobs to complete (with timeout)
1099
1093
  const activeJobs = this.getActiveJobs();
@@ -1232,55 +1226,6 @@ export class Monque extends EventEmitter {
1232
1226
  return activeJobs;
1233
1227
  }
1234
1228
 
1235
- /**
1236
- * Convert a MongoDB document to a typed PersistedJob object.
1237
- *
1238
- * Maps raw MongoDB document fields to the strongly-typed `PersistedJob<T>` interface,
1239
- * ensuring type safety and handling optional fields (`lockedAt`, `failReason`, etc.).
1240
- *
1241
- * @private
1242
- * @template T - The job data payload type
1243
- * @param doc - The raw MongoDB document with `_id`
1244
- * @returns A strongly-typed PersistedJob object with guaranteed `_id`
1245
- */
1246
- private documentToPersistedJob<T>(doc: WithId<Document>): PersistedJob<T> {
1247
- const job: PersistedJob<T> = {
1248
- _id: doc._id,
1249
- name: doc['name'] as string,
1250
- data: doc['data'] as T,
1251
- status: doc['status'] as JobStatusType,
1252
- nextRunAt: doc['nextRunAt'] as Date,
1253
- failCount: doc['failCount'] as number,
1254
- createdAt: doc['createdAt'] as Date,
1255
- updatedAt: doc['updatedAt'] as Date,
1256
- };
1257
-
1258
- // Only set optional properties if they exist
1259
- if (doc['lockedAt'] !== undefined) {
1260
- job.lockedAt = doc['lockedAt'] as Date | null;
1261
- }
1262
- if (doc['claimedBy'] !== undefined) {
1263
- job.claimedBy = doc['claimedBy'] as string | null;
1264
- }
1265
- if (doc['lastHeartbeat'] !== undefined) {
1266
- job.lastHeartbeat = doc['lastHeartbeat'] as Date | null;
1267
- }
1268
- if (doc['heartbeatInterval'] !== undefined) {
1269
- job.heartbeatInterval = doc['heartbeatInterval'] as number;
1270
- }
1271
- if (doc['failReason'] !== undefined) {
1272
- job.failReason = doc['failReason'] as string;
1273
- }
1274
- if (doc['repeatInterval'] !== undefined) {
1275
- job.repeatInterval = doc['repeatInterval'] as string;
1276
- }
1277
- if (doc['uniqueKey'] !== undefined) {
1278
- job.uniqueKey = doc['uniqueKey'] as string;
1279
- }
1280
-
1281
- return job;
1282
- }
1283
-
1284
1229
  /**
1285
1230
  * Type-safe event emitter methods
1286
1231
  */