@monque/core 1.4.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.4.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": {
@@ -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,
@@ -1,6 +1,6 @@
1
1
  import type { Document, WithId } from 'mongodb';
2
2
 
3
- import type { JobStatusType, PersistedJob } from './types.js';
3
+ import type { PersistedJob } from './types.js';
4
4
 
5
5
  /**
6
6
  * Convert a raw MongoDB document to a strongly-typed {@link PersistedJob}.
@@ -13,39 +13,39 @@ import type { JobStatusType, PersistedJob } from './types.js';
13
13
  * @param doc - The raw MongoDB document with `_id`
14
14
  * @returns A strongly-typed PersistedJob object with guaranteed `_id`
15
15
  */
16
- export function documentToPersistedJob<T>(doc: WithId<Document>): PersistedJob<T> {
16
+ export function documentToPersistedJob<T = unknown>(doc: WithId<Document>): PersistedJob<T> {
17
17
  const job: PersistedJob<T> = {
18
18
  _id: doc._id,
19
- name: doc['name'] as string,
20
- data: doc['data'] as T,
21
- status: doc['status'] as JobStatusType,
22
- nextRunAt: doc['nextRunAt'] as Date,
23
- failCount: doc['failCount'] as number,
24
- createdAt: doc['createdAt'] as Date,
25
- updatedAt: doc['updatedAt'] as Date,
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
26
  };
27
27
 
28
28
  // Only set optional properties if they exist
29
29
  if (doc['lockedAt'] !== undefined) {
30
- job.lockedAt = doc['lockedAt'] as Date | null;
30
+ job.lockedAt = doc['lockedAt'];
31
31
  }
32
32
  if (doc['claimedBy'] !== undefined) {
33
- job.claimedBy = doc['claimedBy'] as string | null;
33
+ job.claimedBy = doc['claimedBy'];
34
34
  }
35
35
  if (doc['lastHeartbeat'] !== undefined) {
36
- job.lastHeartbeat = doc['lastHeartbeat'] as Date | null;
36
+ job.lastHeartbeat = doc['lastHeartbeat'];
37
37
  }
38
38
  if (doc['heartbeatInterval'] !== undefined) {
39
- job.heartbeatInterval = doc['heartbeatInterval'] as number;
39
+ job.heartbeatInterval = doc['heartbeatInterval'];
40
40
  }
41
41
  if (doc['failReason'] !== undefined) {
42
- job.failReason = doc['failReason'] as string;
42
+ job.failReason = doc['failReason'];
43
43
  }
44
44
  if (doc['repeatInterval'] !== undefined) {
45
- job.repeatInterval = doc['repeatInterval'] as string;
45
+ job.repeatInterval = doc['repeatInterval'];
46
46
  }
47
47
  if (doc['uniqueKey'] !== undefined) {
48
- job.uniqueKey = doc['uniqueKey'] as string;
48
+ job.uniqueKey = doc['uniqueKey'];
49
49
  }
50
50
 
51
51
  return job;
@@ -1,6 +1,6 @@
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 {
@@ -18,7 +18,7 @@ import {
18
18
  type QueueStats,
19
19
  type ScheduleOptions,
20
20
  } from '@/jobs';
21
- import { ConnectionError, ShutdownTimeoutError, toError, WorkerRegistrationError } from '@/shared';
21
+ import { ConnectionError, ShutdownTimeoutError, WorkerRegistrationError } from '@/shared';
22
22
  import type { WorkerOptions, WorkerRegistration } from '@/workers';
23
23
 
24
24
  import {
@@ -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();
@@ -149,6 +148,8 @@ export class Monque extends EventEmitter {
149
148
  heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
150
149
  jobRetention: options.jobRetention,
151
150
  skipIndexCreation: options.skipIndexCreation ?? false,
151
+ maxPayloadSize: options.maxPayloadSize,
152
+ statsCacheTtlMs: options.statsCacheTtlMs ?? 5000,
152
153
  };
153
154
  }
154
155
 
@@ -176,6 +177,9 @@ export class Monque extends EventEmitter {
176
177
  await this.recoverStaleJobs();
177
178
  }
178
179
 
180
+ // Check for instance ID collisions (after stale recovery to avoid false positives)
181
+ await this.checkInstanceCollision();
182
+
179
183
  // Initialize services with shared context
180
184
  const ctx = this.buildContext();
181
185
  this._scheduler = new JobScheduler(ctx);
@@ -183,6 +187,7 @@ export class Monque extends EventEmitter {
183
187
  this._query = new JobQueryService(ctx);
184
188
  this._processor = new JobProcessor(ctx);
185
189
  this._changeStreamHandler = new ChangeStreamHandler(ctx, () => this.processor.poll());
190
+ this._lifecycleManager = new LifecycleManager(ctx);
186
191
 
187
192
  this.isInitialized = true;
188
193
  } catch (error) {
@@ -241,6 +246,15 @@ export class Monque extends EventEmitter {
241
246
  return this._changeStreamHandler;
242
247
  }
243
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
+
244
258
  /**
245
259
  * Build the shared context for internal services.
246
260
  */
@@ -347,46 +361,35 @@ export class Monque extends EventEmitter {
347
361
  }
348
362
 
349
363
  /**
350
- * Clean up old completed and failed jobs based on retention policy.
351
- *
352
- * - Removes completed jobs older than `jobRetention.completed`
353
- * - 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.
354
366
  *
355
- * 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.
356
369
  *
357
- * @returns Promise resolving when all deletion operations complete
370
+ * @throws {ConnectionError} If an active instance with the same ID is detected
358
371
  */
359
- private async cleanupJobs(): Promise<void> {
360
- if (!this.collection || !this.options.jobRetention) {
372
+ private async checkInstanceCollision(): Promise<void> {
373
+ if (!this.collection) {
361
374
  return;
362
375
  }
363
376
 
364
- const { completed, failed } = this.options.jobRetention;
365
- const now = Date.now();
366
- const deletions: Promise<DeleteResult>[] = [];
367
-
368
- if (completed) {
369
- const cutoff = new Date(now - completed);
370
- deletions.push(
371
- this.collection.deleteMany({
372
- status: JobStatus.COMPLETED,
373
- updatedAt: { $lt: cutoff },
374
- }),
375
- );
376
- }
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);
377
380
 
378
- if (failed) {
379
- const cutoff = new Date(now - failed);
380
- deletions.push(
381
- this.collection.deleteMany({
382
- status: JobStatus.FAILED,
383
- updatedAt: { $lt: cutoff },
384
- }),
385
- );
386
- }
381
+ const activeJob = await this.collection.findOne({
382
+ claimedBy: this.options.schedulerInstanceId,
383
+ status: JobStatus.PROCESSING,
384
+ lastHeartbeat: { $gte: aliveThreshold },
385
+ });
387
386
 
388
- if (deletions.length > 0) {
389
- 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
+ );
390
393
  }
391
394
  }
392
395
 
@@ -412,6 +415,7 @@ export class Monque extends EventEmitter {
412
415
  * @param options - Scheduling and deduplication options
413
416
  * @returns Promise resolving to the created or existing job document
414
417
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
418
+ * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
415
419
  *
416
420
  * @example Basic job enqueueing
417
421
  * ```typescript
@@ -437,6 +441,8 @@ export class Monque extends EventEmitter {
437
441
  * });
438
442
  * // Subsequent enqueues with same uniqueKey return existing pending/processing job
439
443
  * ```
444
+ *
445
+ * @see {@link JobScheduler.enqueue}
440
446
  */
441
447
  async enqueue<T>(name: string, data: T, options: EnqueueOptions = {}): Promise<PersistedJob<T>> {
442
448
  this.ensureInitialized();
@@ -470,6 +476,8 @@ export class Monque extends EventEmitter {
470
476
  * await monque.now('process-order', { orderId: order.id });
471
477
  * return order; // Return immediately, processing happens async
472
478
  * ```
479
+ *
480
+ * @see {@link JobScheduler.now}
473
481
  */
474
482
  async now<T>(name: string, data: T): Promise<PersistedJob<T>> {
475
483
  this.ensureInitialized();
@@ -496,6 +504,7 @@ export class Monque extends EventEmitter {
496
504
  * @returns Promise resolving to the created job document with `repeatInterval` set
497
505
  * @throws {InvalidCronError} If cron expression is invalid
498
506
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
507
+ * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
499
508
  *
500
509
  * @example Hourly cleanup job
501
510
  * ```typescript
@@ -519,6 +528,8 @@ export class Monque extends EventEmitter {
519
528
  * recipients: ['analytics@example.com']
520
529
  * });
521
530
  * ```
531
+ *
532
+ * @see {@link JobScheduler.schedule}
522
533
  */
523
534
  async schedule<T>(
524
535
  cron: string,
@@ -550,6 +561,8 @@ export class Monque extends EventEmitter {
550
561
  * const job = await monque.enqueue('report', { type: 'daily' });
551
562
  * await monque.cancelJob(job._id.toString());
552
563
  * ```
564
+ *
565
+ * @see {@link JobManager.cancelJob}
553
566
  */
554
567
  async cancelJob(jobId: string): Promise<PersistedJob<unknown> | null> {
555
568
  this.ensureInitialized();
@@ -573,6 +586,8 @@ export class Monque extends EventEmitter {
573
586
  * await monque.retryJob(job._id.toString());
574
587
  * });
575
588
  * ```
589
+ *
590
+ * @see {@link JobManager.retryJob}
576
591
  */
577
592
  async retryJob(jobId: string): Promise<PersistedJob<unknown> | null> {
578
593
  this.ensureInitialized();
@@ -594,6 +609,8 @@ export class Monque extends EventEmitter {
594
609
  * const nextHour = new Date(Date.now() + 60 * 60 * 1000);
595
610
  * await monque.rescheduleJob(jobId, nextHour);
596
611
  * ```
612
+ *
613
+ * @see {@link JobManager.rescheduleJob}
597
614
  */
598
615
  async rescheduleJob(jobId: string, runAt: Date): Promise<PersistedJob<unknown> | null> {
599
616
  this.ensureInitialized();
@@ -616,6 +633,8 @@ export class Monque extends EventEmitter {
616
633
  * console.log('Job permanently removed');
617
634
  * }
618
635
  * ```
636
+ *
637
+ * @see {@link JobManager.deleteJob}
619
638
  */
620
639
  async deleteJob(jobId: string): Promise<boolean> {
621
640
  this.ensureInitialized();
@@ -623,14 +642,15 @@ export class Monque extends EventEmitter {
623
642
  }
624
643
 
625
644
  /**
626
- * Cancel multiple jobs matching the given filter.
645
+ * Cancel multiple jobs matching the given filter via a single updateMany call.
627
646
  *
628
- * Only cancels jobs in 'pending' status. Jobs in other states are collected
629
- * 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
630
650
  * successfully cancelled jobs.
631
651
  *
632
652
  * @param filter - Selector for which jobs to cancel (name, status, date range)
633
- * @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)
634
654
  *
635
655
  * @example Cancel all pending jobs for a queue
636
656
  * ```typescript
@@ -640,6 +660,8 @@ export class Monque extends EventEmitter {
640
660
  * });
641
661
  * console.log(`Cancelled ${result.count} jobs`);
642
662
  * ```
663
+ *
664
+ * @see {@link JobManager.cancelJobs}
643
665
  */
644
666
  async cancelJobs(filter: JobSelector): Promise<BulkOperationResult> {
645
667
  this.ensureInitialized();
@@ -647,14 +669,15 @@ export class Monque extends EventEmitter {
647
669
  }
648
670
 
649
671
  /**
650
- * Retry multiple jobs matching the given filter.
672
+ * Retry multiple jobs matching the given filter via a single pipeline-style updateMany call.
651
673
  *
652
- * Only retries jobs in 'failed' or 'cancelled' status. Jobs in other states
653
- * are collected as errors in the result. Emits a 'jobs:retried' event with
654
- * 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.
655
678
  *
656
679
  * @param filter - Selector for which jobs to retry (name, status, date range)
657
- * @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)
658
681
  *
659
682
  * @example Retry all failed jobs
660
683
  * ```typescript
@@ -663,6 +686,8 @@ export class Monque extends EventEmitter {
663
686
  * });
664
687
  * console.log(`Retried ${result.count} jobs`);
665
688
  * ```
689
+ *
690
+ * @see {@link JobManager.retryJobs}
666
691
  */
667
692
  async retryJobs(filter: JobSelector): Promise<BulkOperationResult> {
668
693
  this.ensureInitialized();
@@ -673,6 +698,7 @@ export class Monque extends EventEmitter {
673
698
  * Delete multiple jobs matching the given filter.
674
699
  *
675
700
  * Deletes jobs in any status. Uses a batch delete for efficiency.
701
+ * Emits a 'jobs:deleted' event with the count of deleted jobs.
676
702
  * Does not emit individual 'job:deleted' events to avoid noise.
677
703
  *
678
704
  * @param filter - Selector for which jobs to delete (name, status, date range)
@@ -687,6 +713,8 @@ export class Monque extends EventEmitter {
687
713
  * });
688
714
  * console.log(`Deleted ${result.count} jobs`);
689
715
  * ```
716
+ *
717
+ * @see {@link JobManager.deleteJobs}
690
718
  */
691
719
  async deleteJobs(filter: JobSelector): Promise<BulkOperationResult> {
692
720
  this.ensureInitialized();
@@ -727,6 +755,8 @@ export class Monque extends EventEmitter {
727
755
  * res.json(job);
728
756
  * });
729
757
  * ```
758
+ *
759
+ * @see {@link JobQueryService.getJob}
730
760
  */
731
761
  async getJob<T = unknown>(id: ObjectId): Promise<PersistedJob<T> | null> {
732
762
  this.ensureInitialized();
@@ -774,6 +804,8 @@ export class Monque extends EventEmitter {
774
804
  * const jobs = await monque.getJobs();
775
805
  * const pendingRecurring = jobs.filter(job => isPendingJob(job) && isRecurringJob(job));
776
806
  * ```
807
+ *
808
+ * @see {@link JobQueryService.getJobs}
777
809
  */
778
810
  async getJobs<T = unknown>(filter: GetJobsFilter = {}): Promise<PersistedJob<T>[]> {
779
811
  this.ensureInitialized();
@@ -808,6 +840,8 @@ export class Monque extends EventEmitter {
808
840
  * });
809
841
  * }
810
842
  * ```
843
+ *
844
+ * @see {@link JobQueryService.getJobsWithCursor}
811
845
  */
812
846
  async getJobsWithCursor<T = unknown>(options: CursorOptions = {}): Promise<CursorPage<T>> {
813
847
  this.ensureInitialized();
@@ -820,6 +854,9 @@ export class Monque extends EventEmitter {
820
854
  * Uses MongoDB aggregation pipeline for efficient server-side calculation.
821
855
  * Returns counts per status and optional average processing duration for completed jobs.
822
856
  *
857
+ * Results are cached per unique filter with a configurable TTL (default 5s).
858
+ * Set `statsCacheTtlMs: 0` to disable caching.
859
+ *
823
860
  * @param filter - Optional filter to scope statistics by job name
824
861
  * @returns Promise resolving to queue statistics
825
862
  * @throws {AggregationTimeoutError} If aggregation exceeds 30 second timeout
@@ -836,6 +873,8 @@ export class Monque extends EventEmitter {
836
873
  * const emailStats = await monque.getQueueStats({ name: 'send-email' });
837
874
  * console.log(`${emailStats.total} email jobs in queue`);
838
875
  * ```
876
+ *
877
+ * @see {@link JobQueryService.getQueueStats}
839
878
  */
840
879
  async getQueueStats(filter?: Pick<JobSelector, 'name'>): Promise<QueueStats> {
841
880
  this.ensureInitialized();
@@ -989,39 +1028,10 @@ export class Monque extends EventEmitter {
989
1028
  // Set up change streams as the primary notification mechanism
990
1029
  this.changeStreamHandler.setup();
991
1030
 
992
- // Set up polling as backup (runs at configured interval)
993
- this.pollIntervalId = setInterval(() => {
994
- this.processor.poll().catch((error: unknown) => {
995
- this.emit('job:error', { error: toError(error) });
996
- });
997
- }, this.options.pollInterval);
998
-
999
- // Start heartbeat interval for claimed jobs
1000
- this.heartbeatIntervalId = setInterval(() => {
1001
- this.processor.updateHeartbeats().catch((error: unknown) => {
1002
- this.emit('job:error', { error: toError(error) });
1003
- });
1004
- }, this.options.heartbeatInterval);
1005
-
1006
- // Start cleanup interval if retention is configured
1007
- if (this.options.jobRetention) {
1008
- const interval = this.options.jobRetention.interval ?? DEFAULTS.retentionInterval;
1009
-
1010
- // Run immediately on start
1011
- this.cleanupJobs().catch((error: unknown) => {
1012
- this.emit('job:error', { error: toError(error) });
1013
- });
1014
-
1015
- this.cleanupIntervalId = setInterval(() => {
1016
- this.cleanupJobs().catch((error: unknown) => {
1017
- this.emit('job:error', { error: toError(error) });
1018
- });
1019
- }, interval);
1020
- }
1021
-
1022
- // Run initial poll immediately to pick up any existing jobs
1023
- this.processor.poll().catch((error: unknown) => {
1024
- this.emit('job:error', { error: toError(error) });
1031
+ // Delegate timer management to LifecycleManager
1032
+ this.lifecycleManager.startTimers({
1033
+ poll: () => this.processor.poll(),
1034
+ updateHeartbeats: () => this.processor.updateHeartbeats(),
1025
1035
  });
1026
1036
  }
1027
1037
 
@@ -1066,25 +1076,18 @@ export class Monque extends EventEmitter {
1066
1076
 
1067
1077
  this.isRunning = false;
1068
1078
 
1069
- // Close change stream
1070
- await this.changeStreamHandler.close();
1071
-
1072
- if (this.cleanupIntervalId) {
1073
- clearInterval(this.cleanupIntervalId);
1074
- this.cleanupIntervalId = null;
1075
- }
1079
+ // Clear stats cache for clean state on restart
1080
+ this._query?.clearStatsCache();
1076
1081
 
1077
- // Clear polling interval
1078
- if (this.pollIntervalId) {
1079
- clearInterval(this.pollIntervalId);
1080
- 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
1081
1087
  }
1082
1088
 
1083
- // Clear heartbeat interval
1084
- if (this.heartbeatIntervalId) {
1085
- clearInterval(this.heartbeatIntervalId);
1086
- this.heartbeatIntervalId = null;
1087
- }
1089
+ // Stop all lifecycle timers
1090
+ this.lifecycleManager.stopTimers();
1088
1091
 
1089
1092
  // Wait for all active jobs to complete (with timeout)
1090
1093
  const activeJobs = this.getActiveJobs();
@@ -4,5 +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
8
  // Types
8
9
  export type { ResolvedMonqueOptions, SchedulerContext } from './types.js';