@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/dist/CHANGELOG.md +31 -0
- package/dist/index.cjs +589 -325
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -34
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +109 -34
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +590 -327
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/events/types.ts +2 -2
- package/src/index.ts +1 -0
- package/src/jobs/document-to-persisted-job.ts +52 -0
- package/src/jobs/index.ts +2 -0
- package/src/scheduler/monque.ts +124 -179
- package/src/scheduler/services/change-stream-handler.ts +2 -1
- package/src/scheduler/services/index.ts +1 -0
- package/src/scheduler/services/job-manager.ts +112 -140
- package/src/scheduler/services/job-processor.ts +94 -62
- 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 +34 -0
- package/src/shared/errors.ts +31 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/utils/error.ts +33 -0
- package/src/shared/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monque/core",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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",
|
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
|
@@ -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
package/src/scheduler/monque.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
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 {
|
|
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
|
-
|
|
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>) =>
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
370
|
+
* @throws {ConnectionError} If an active instance with the same ID is detected
|
|
367
371
|
*/
|
|
368
|
-
private async
|
|
369
|
-
if (!this.collection
|
|
372
|
+
private async checkInstanceCollision(): Promise<void> {
|
|
373
|
+
if (!this.collection) {
|
|
370
374
|
return;
|
|
371
375
|
}
|
|
372
376
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 (
|
|
398
|
-
|
|
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
|
|
638
|
-
*
|
|
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
|
|
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
|
|
662
|
-
*
|
|
663
|
-
*
|
|
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
|
|
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
|
-
//
|
|
1002
|
-
this.
|
|
1003
|
-
this.processor.poll()
|
|
1004
|
-
|
|
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
|
-
//
|
|
1079
|
-
|
|
1079
|
+
// Clear stats cache for clean state on restart
|
|
1080
|
+
this._query?.clearStatsCache();
|
|
1080
1081
|
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
this.
|
|
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
|
-
//
|
|
1093
|
-
|
|
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
|
*/
|