@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/dist/CHANGELOG.md +13 -0
- package/dist/index.cjs +369 -195
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +99 -22
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +99 -22
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +370 -197
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/events/types.ts +2 -2
- package/src/index.ts +1 -0
- package/src/jobs/document-to-persisted-job.ts +16 -16
- package/src/scheduler/monque.ts +98 -95
- package/src/scheduler/services/index.ts +1 -0
- package/src/scheduler/services/job-manager.ts +100 -116
- package/src/scheduler/services/job-query.ts +81 -36
- package/src/scheduler/services/job-scheduler.ts +42 -2
- package/src/scheduler/services/lifecycle-manager.ts +154 -0
- package/src/scheduler/services/types.ts +5 -1
- package/src/scheduler/types.ts +23 -0
- package/src/shared/errors.ts +31 -0
- package/src/shared/index.ts +1 -0
package/package.json
CHANGED
package/src/events/types.ts
CHANGED
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Document, WithId } from 'mongodb';
|
|
2
2
|
|
|
3
|
-
import type {
|
|
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']
|
|
20
|
-
data: doc['data']
|
|
21
|
-
status: doc['status']
|
|
22
|
-
nextRunAt: doc['nextRunAt']
|
|
23
|
-
failCount: doc['failCount']
|
|
24
|
-
createdAt: doc['createdAt']
|
|
25
|
-
updatedAt: doc['updatedAt']
|
|
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']
|
|
30
|
+
job.lockedAt = doc['lockedAt'];
|
|
31
31
|
}
|
|
32
32
|
if (doc['claimedBy'] !== undefined) {
|
|
33
|
-
job.claimedBy = doc['claimedBy']
|
|
33
|
+
job.claimedBy = doc['claimedBy'];
|
|
34
34
|
}
|
|
35
35
|
if (doc['lastHeartbeat'] !== undefined) {
|
|
36
|
-
job.lastHeartbeat = doc['lastHeartbeat']
|
|
36
|
+
job.lastHeartbeat = doc['lastHeartbeat'];
|
|
37
37
|
}
|
|
38
38
|
if (doc['heartbeatInterval'] !== undefined) {
|
|
39
|
-
job.heartbeatInterval = doc['heartbeatInterval']
|
|
39
|
+
job.heartbeatInterval = doc['heartbeatInterval'];
|
|
40
40
|
}
|
|
41
41
|
if (doc['failReason'] !== undefined) {
|
|
42
|
-
job.failReason = doc['failReason']
|
|
42
|
+
job.failReason = doc['failReason'];
|
|
43
43
|
}
|
|
44
44
|
if (doc['repeatInterval'] !== undefined) {
|
|
45
|
-
job.repeatInterval = doc['repeatInterval']
|
|
45
|
+
job.repeatInterval = doc['repeatInterval'];
|
|
46
46
|
}
|
|
47
47
|
if (doc['uniqueKey'] !== undefined) {
|
|
48
|
-
job.uniqueKey = doc['uniqueKey']
|
|
48
|
+
job.uniqueKey = doc['uniqueKey'];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
return job;
|
package/src/scheduler/monque.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { EventEmitter } from 'node:events';
|
|
3
|
-
import type { Collection, Db,
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
370
|
+
* @throws {ConnectionError} If an active instance with the same ID is detected
|
|
358
371
|
*/
|
|
359
|
-
private async
|
|
360
|
-
if (!this.collection
|
|
372
|
+
private async checkInstanceCollision(): Promise<void> {
|
|
373
|
+
if (!this.collection) {
|
|
361
374
|
return;
|
|
362
375
|
}
|
|
363
376
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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 (
|
|
389
|
-
|
|
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
|
|
629
|
-
*
|
|
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
|
|
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
|
|
653
|
-
*
|
|
654
|
-
*
|
|
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
|
|
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
|
-
//
|
|
993
|
-
this.
|
|
994
|
-
this.processor.poll()
|
|
995
|
-
|
|
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
|
-
//
|
|
1070
|
-
|
|
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
|
-
//
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
//
|
|
1084
|
-
|
|
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';
|