@monque/core 1.1.0 → 1.1.2
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/LICENSE +15 -0
- package/dist/CHANGELOG.md +89 -0
- package/dist/LICENSE +15 -0
- package/dist/README.md +150 -0
- package/dist/index.cjs +6 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -14
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +6 -14
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +6 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -7
- package/src/events/index.ts +1 -0
- package/src/events/types.ts +113 -0
- package/src/index.ts +51 -0
- package/src/jobs/guards.ts +220 -0
- package/src/jobs/index.ts +29 -0
- package/src/jobs/types.ts +335 -0
- package/src/scheduler/helpers.ts +107 -0
- package/src/scheduler/index.ts +5 -0
- package/src/scheduler/monque.ts +1309 -0
- package/src/scheduler/services/change-stream-handler.ts +239 -0
- package/src/scheduler/services/index.ts +8 -0
- package/src/scheduler/services/job-manager.ts +455 -0
- package/src/scheduler/services/job-processor.ts +301 -0
- package/src/scheduler/services/job-query.ts +411 -0
- package/src/scheduler/services/job-scheduler.ts +267 -0
- package/src/scheduler/services/types.ts +48 -0
- package/src/scheduler/types.ts +123 -0
- package/src/shared/errors.ts +225 -0
- package/src/shared/index.ts +18 -0
- package/src/shared/utils/backoff.ts +77 -0
- package/src/shared/utils/cron.ts +67 -0
- package/src/shared/utils/index.ts +7 -0
- package/src/workers/index.ts +1 -0
- package/src/workers/types.ts +39 -0
|
@@ -0,0 +1,1309 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import type { Collection, Db, DeleteResult, Document, ObjectId, WithId } from 'mongodb';
|
|
4
|
+
|
|
5
|
+
import type { MonqueEventMap } from '@/events';
|
|
6
|
+
import {
|
|
7
|
+
type BulkOperationResult,
|
|
8
|
+
type CursorOptions,
|
|
9
|
+
type CursorPage,
|
|
10
|
+
type EnqueueOptions,
|
|
11
|
+
type GetJobsFilter,
|
|
12
|
+
type Job,
|
|
13
|
+
type JobHandler,
|
|
14
|
+
type JobSelector,
|
|
15
|
+
JobStatus,
|
|
16
|
+
type JobStatusType,
|
|
17
|
+
type PersistedJob,
|
|
18
|
+
type QueueStats,
|
|
19
|
+
type ScheduleOptions,
|
|
20
|
+
} from '@/jobs';
|
|
21
|
+
import { ConnectionError, ShutdownTimeoutError, WorkerRegistrationError } from '@/shared';
|
|
22
|
+
import type { WorkerOptions, WorkerRegistration } from '@/workers';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
ChangeStreamHandler,
|
|
26
|
+
JobManager,
|
|
27
|
+
JobProcessor,
|
|
28
|
+
JobQueryService,
|
|
29
|
+
JobScheduler,
|
|
30
|
+
type ResolvedMonqueOptions,
|
|
31
|
+
type SchedulerContext,
|
|
32
|
+
} from './services/index.js';
|
|
33
|
+
import type { MonqueOptions } from './types.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Default configuration values
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULTS = {
|
|
39
|
+
collectionName: 'monque_jobs',
|
|
40
|
+
pollInterval: 1000,
|
|
41
|
+
maxRetries: 10,
|
|
42
|
+
baseRetryInterval: 1000,
|
|
43
|
+
shutdownTimeout: 30000,
|
|
44
|
+
defaultConcurrency: 5,
|
|
45
|
+
lockTimeout: 1_800_000, // 30 minutes
|
|
46
|
+
recoverStaleJobs: true,
|
|
47
|
+
heartbeatInterval: 30000, // 30 seconds
|
|
48
|
+
retentionInterval: 3600_000, // 1 hour
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Monque - MongoDB-backed job scheduler
|
|
53
|
+
*
|
|
54
|
+
* A type-safe job scheduler with atomic locking, exponential backoff, cron scheduling,
|
|
55
|
+
* stale job recovery, and event-driven observability. Built on native MongoDB driver.
|
|
56
|
+
*
|
|
57
|
+
* @example Complete lifecycle
|
|
58
|
+
* ```typescript
|
|
59
|
+
* import { Monque } from '@monque/core';
|
|
60
|
+
* import { MongoClient } from 'mongodb';
|
|
61
|
+
*
|
|
62
|
+
* const client = new MongoClient('mongodb://localhost:27017');
|
|
63
|
+
* await client.connect();
|
|
64
|
+
* const db = client.db('myapp');
|
|
65
|
+
*
|
|
66
|
+
* // Create instance with options
|
|
67
|
+
* const monque = new Monque(db, {
|
|
68
|
+
* collectionName: 'jobs',
|
|
69
|
+
* pollInterval: 1000,
|
|
70
|
+
* maxRetries: 10,
|
|
71
|
+
* shutdownTimeout: 30000,
|
|
72
|
+
* });
|
|
73
|
+
*
|
|
74
|
+
* // Initialize (sets up indexes and recovers stale jobs)
|
|
75
|
+
* await monque.initialize();
|
|
76
|
+
*
|
|
77
|
+
* // Register workers with type safety
|
|
78
|
+
* type EmailJob = {
|
|
79
|
+
* to: string;
|
|
80
|
+
* subject: string;
|
|
81
|
+
* body: string;
|
|
82
|
+
* };
|
|
83
|
+
*
|
|
84
|
+
* monque.register<EmailJob>('send-email', async (job) => {
|
|
85
|
+
* await emailService.send(job.data.to, job.data.subject, job.data.body);
|
|
86
|
+
* });
|
|
87
|
+
*
|
|
88
|
+
* // Monitor events for observability
|
|
89
|
+
* monque.on('job:complete', ({ job, duration }) => {
|
|
90
|
+
* logger.info(`Job ${job.name} completed in ${duration}ms`);
|
|
91
|
+
* });
|
|
92
|
+
*
|
|
93
|
+
* monque.on('job:fail', ({ job, error, willRetry }) => {
|
|
94
|
+
* logger.error(`Job ${job.name} failed:`, error);
|
|
95
|
+
* });
|
|
96
|
+
*
|
|
97
|
+
* // Start processing
|
|
98
|
+
* monque.start();
|
|
99
|
+
*
|
|
100
|
+
* // Enqueue jobs
|
|
101
|
+
* await monque.enqueue('send-email', {
|
|
102
|
+
* to: 'user@example.com',
|
|
103
|
+
* subject: 'Welcome!',
|
|
104
|
+
* body: 'Thanks for signing up.'
|
|
105
|
+
* });
|
|
106
|
+
*
|
|
107
|
+
* // Graceful shutdown
|
|
108
|
+
* process.on('SIGTERM', async () => {
|
|
109
|
+
* await monque.stop();
|
|
110
|
+
* await client.close();
|
|
111
|
+
* process.exit(0);
|
|
112
|
+
* });
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export class Monque extends EventEmitter {
|
|
116
|
+
private readonly db: Db;
|
|
117
|
+
private readonly options: ResolvedMonqueOptions;
|
|
118
|
+
private collection: Collection<Document> | null = null;
|
|
119
|
+
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
|
+
private isRunning = false;
|
|
124
|
+
private isInitialized = false;
|
|
125
|
+
|
|
126
|
+
// Internal services (initialized in initialize())
|
|
127
|
+
private _scheduler: JobScheduler | null = null;
|
|
128
|
+
private _manager: JobManager | null = null;
|
|
129
|
+
private _query: JobQueryService | null = null;
|
|
130
|
+
private _processor: JobProcessor | null = null;
|
|
131
|
+
private _changeStreamHandler: ChangeStreamHandler | null = null;
|
|
132
|
+
|
|
133
|
+
constructor(db: Db, options: MonqueOptions = {}) {
|
|
134
|
+
super();
|
|
135
|
+
this.db = db;
|
|
136
|
+
this.options = {
|
|
137
|
+
collectionName: options.collectionName ?? DEFAULTS.collectionName,
|
|
138
|
+
pollInterval: options.pollInterval ?? DEFAULTS.pollInterval,
|
|
139
|
+
maxRetries: options.maxRetries ?? DEFAULTS.maxRetries,
|
|
140
|
+
baseRetryInterval: options.baseRetryInterval ?? DEFAULTS.baseRetryInterval,
|
|
141
|
+
shutdownTimeout: options.shutdownTimeout ?? DEFAULTS.shutdownTimeout,
|
|
142
|
+
defaultConcurrency: options.defaultConcurrency ?? DEFAULTS.defaultConcurrency,
|
|
143
|
+
lockTimeout: options.lockTimeout ?? DEFAULTS.lockTimeout,
|
|
144
|
+
recoverStaleJobs: options.recoverStaleJobs ?? DEFAULTS.recoverStaleJobs,
|
|
145
|
+
maxBackoffDelay: options.maxBackoffDelay,
|
|
146
|
+
schedulerInstanceId: options.schedulerInstanceId ?? randomUUID(),
|
|
147
|
+
heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
|
|
148
|
+
jobRetention: options.jobRetention,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Initialize the scheduler by setting up the MongoDB collection and indexes.
|
|
154
|
+
* Must be called before start().
|
|
155
|
+
*
|
|
156
|
+
* @throws {ConnectionError} If collection or index creation fails
|
|
157
|
+
*/
|
|
158
|
+
async initialize(): Promise<void> {
|
|
159
|
+
if (this.isInitialized) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
this.collection = this.db.collection(this.options.collectionName);
|
|
165
|
+
|
|
166
|
+
// Create indexes for efficient queries
|
|
167
|
+
await this.createIndexes();
|
|
168
|
+
|
|
169
|
+
// Recover stale jobs if enabled
|
|
170
|
+
if (this.options.recoverStaleJobs) {
|
|
171
|
+
await this.recoverStaleJobs();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Initialize services with shared context
|
|
175
|
+
const ctx = this.buildContext();
|
|
176
|
+
this._scheduler = new JobScheduler(ctx);
|
|
177
|
+
this._manager = new JobManager(ctx);
|
|
178
|
+
this._query = new JobQueryService(ctx);
|
|
179
|
+
this._processor = new JobProcessor(ctx);
|
|
180
|
+
this._changeStreamHandler = new ChangeStreamHandler(ctx, () => this.processor.poll());
|
|
181
|
+
|
|
182
|
+
this.isInitialized = true;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const message =
|
|
185
|
+
error instanceof Error ? error.message : 'Unknown error during initialization';
|
|
186
|
+
throw new ConnectionError(`Failed to initialize Monque: ${message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
191
|
+
// Service Accessors (throw if not initialized)
|
|
192
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/** @throws {ConnectionError} if not initialized */
|
|
195
|
+
private get scheduler(): JobScheduler {
|
|
196
|
+
if (!this._scheduler) {
|
|
197
|
+
throw new ConnectionError('Monque not initialized. Call initialize() first.');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return this._scheduler;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** @throws {ConnectionError} if not initialized */
|
|
204
|
+
private get manager(): JobManager {
|
|
205
|
+
if (!this._manager) {
|
|
206
|
+
throw new ConnectionError('Monque not initialized. Call initialize() first.');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return this._manager;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** @throws {ConnectionError} if not initialized */
|
|
213
|
+
private get query(): JobQueryService {
|
|
214
|
+
if (!this._query) {
|
|
215
|
+
throw new ConnectionError('Monque not initialized. Call initialize() first.');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return this._query;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @throws {ConnectionError} if not initialized */
|
|
222
|
+
private get processor(): JobProcessor {
|
|
223
|
+
if (!this._processor) {
|
|
224
|
+
throw new ConnectionError('Monque not initialized. Call initialize() first.');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return this._processor;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @throws {ConnectionError} if not initialized */
|
|
231
|
+
private get changeStreamHandler(): ChangeStreamHandler {
|
|
232
|
+
if (!this._changeStreamHandler) {
|
|
233
|
+
throw new ConnectionError('Monque not initialized. Call initialize() first.');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return this._changeStreamHandler;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Build the shared context for internal services.
|
|
241
|
+
*/
|
|
242
|
+
private buildContext(): SchedulerContext {
|
|
243
|
+
if (!this.collection) {
|
|
244
|
+
throw new ConnectionError('Collection not initialized');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
collection: this.collection,
|
|
249
|
+
options: this.options,
|
|
250
|
+
instanceId: this.options.schedulerInstanceId,
|
|
251
|
+
workers: this.workers,
|
|
252
|
+
isRunning: () => this.isRunning,
|
|
253
|
+
emit: <K extends keyof MonqueEventMap>(event: K, payload: MonqueEventMap[K]) =>
|
|
254
|
+
this.emit(event, payload),
|
|
255
|
+
documentToPersistedJob: <T>(doc: WithId<Document>) => this.documentToPersistedJob<T>(doc),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Create required MongoDB indexes for efficient job processing.
|
|
260
|
+
*
|
|
261
|
+
* The following indexes are created:
|
|
262
|
+
* - `{status, nextRunAt}` - For efficient job polling queries
|
|
263
|
+
* - `{name, uniqueKey}` - Partial unique index for deduplication (pending/processing only)
|
|
264
|
+
* - `{name, status}` - For job lookup by type
|
|
265
|
+
* - `{claimedBy, status}` - For finding jobs owned by a specific scheduler instance
|
|
266
|
+
* - `{lastHeartbeat, status}` - For monitoring/debugging queries (e.g., inspecting heartbeat age)
|
|
267
|
+
* - `{status, nextRunAt, claimedBy}` - For atomic claim queries (find unclaimed pending jobs)
|
|
268
|
+
* - `{lockedAt, lastHeartbeat, status}` - Supports recovery scans and monitoring access patterns
|
|
269
|
+
*/
|
|
270
|
+
private async createIndexes(): Promise<void> {
|
|
271
|
+
if (!this.collection) {
|
|
272
|
+
throw new ConnectionError('Collection not initialized');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Compound index for job polling - status + nextRunAt for efficient queries
|
|
276
|
+
await this.collection.createIndex({ status: 1, nextRunAt: 1 }, { background: true });
|
|
277
|
+
|
|
278
|
+
// Partial unique index for deduplication - scoped by name + uniqueKey
|
|
279
|
+
// Only enforced where uniqueKey exists and status is pending/processing
|
|
280
|
+
await this.collection.createIndex(
|
|
281
|
+
{ name: 1, uniqueKey: 1 },
|
|
282
|
+
{
|
|
283
|
+
unique: true,
|
|
284
|
+
partialFilterExpression: {
|
|
285
|
+
uniqueKey: { $exists: true },
|
|
286
|
+
status: { $in: [JobStatus.PENDING, JobStatus.PROCESSING] },
|
|
287
|
+
},
|
|
288
|
+
background: true,
|
|
289
|
+
},
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
// Index for job lookup by name
|
|
293
|
+
await this.collection.createIndex({ name: 1, status: 1 }, { background: true });
|
|
294
|
+
|
|
295
|
+
// Compound index for finding jobs claimed by a specific scheduler instance.
|
|
296
|
+
// Used for heartbeat updates and cleanup on shutdown.
|
|
297
|
+
await this.collection.createIndex({ claimedBy: 1, status: 1 }, { background: true });
|
|
298
|
+
|
|
299
|
+
// Compound index for monitoring/debugging via heartbeat timestamps.
|
|
300
|
+
// Note: stale recovery uses lockedAt + lockTimeout as the source of truth.
|
|
301
|
+
await this.collection.createIndex({ lastHeartbeat: 1, status: 1 }, { background: true });
|
|
302
|
+
|
|
303
|
+
// Compound index for atomic claim queries.
|
|
304
|
+
// Optimizes the findOneAndUpdate query that claims unclaimed pending jobs.
|
|
305
|
+
await this.collection.createIndex(
|
|
306
|
+
{ status: 1, nextRunAt: 1, claimedBy: 1 },
|
|
307
|
+
{ background: true },
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Expanded index that supports recovery scans (status + lockedAt) plus heartbeat monitoring patterns.
|
|
311
|
+
await this.collection.createIndex(
|
|
312
|
+
{ status: 1, lockedAt: 1, lastHeartbeat: 1 },
|
|
313
|
+
{ background: true },
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Recover stale jobs that were left in 'processing' status.
|
|
319
|
+
* A job is considered stale if its `lockedAt` timestamp exceeds the configured `lockTimeout`.
|
|
320
|
+
* Stale jobs are reset to 'pending' so they can be picked up by workers again.
|
|
321
|
+
*/
|
|
322
|
+
private async recoverStaleJobs(): Promise<void> {
|
|
323
|
+
if (!this.collection) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const staleThreshold = new Date(Date.now() - this.options.lockTimeout);
|
|
328
|
+
|
|
329
|
+
const result = await this.collection.updateMany(
|
|
330
|
+
{
|
|
331
|
+
status: JobStatus.PROCESSING,
|
|
332
|
+
lockedAt: { $lt: staleThreshold },
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
$set: {
|
|
336
|
+
status: JobStatus.PENDING,
|
|
337
|
+
updatedAt: new Date(),
|
|
338
|
+
},
|
|
339
|
+
$unset: {
|
|
340
|
+
lockedAt: '',
|
|
341
|
+
claimedBy: '',
|
|
342
|
+
lastHeartbeat: '',
|
|
343
|
+
heartbeatInterval: '',
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
if (result.modifiedCount > 0) {
|
|
349
|
+
// Emit event for recovered jobs
|
|
350
|
+
this.emit('stale:recovered', {
|
|
351
|
+
count: result.modifiedCount,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Clean up old completed and failed jobs based on retention policy.
|
|
358
|
+
*
|
|
359
|
+
* - Removes completed jobs older than `jobRetention.completed`
|
|
360
|
+
* - Removes failed jobs older than `jobRetention.failed`
|
|
361
|
+
*
|
|
362
|
+
* The cleanup runs concurrently for both statuses if configured.
|
|
363
|
+
*
|
|
364
|
+
* @returns Promise resolving when all deletion operations complete
|
|
365
|
+
*/
|
|
366
|
+
private async cleanupJobs(): Promise<void> {
|
|
367
|
+
if (!this.collection || !this.options.jobRetention) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const { completed, failed } = this.options.jobRetention;
|
|
372
|
+
const now = Date.now();
|
|
373
|
+
const deletions: Promise<DeleteResult>[] = [];
|
|
374
|
+
|
|
375
|
+
if (completed) {
|
|
376
|
+
const cutoff = new Date(now - completed);
|
|
377
|
+
deletions.push(
|
|
378
|
+
this.collection.deleteMany({
|
|
379
|
+
status: JobStatus.COMPLETED,
|
|
380
|
+
updatedAt: { $lt: cutoff },
|
|
381
|
+
}),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (failed) {
|
|
386
|
+
const cutoff = new Date(now - failed);
|
|
387
|
+
deletions.push(
|
|
388
|
+
this.collection.deleteMany({
|
|
389
|
+
status: JobStatus.FAILED,
|
|
390
|
+
updatedAt: { $lt: cutoff },
|
|
391
|
+
}),
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (deletions.length > 0) {
|
|
396
|
+
await Promise.all(deletions);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
401
|
+
// Public API - Job Scheduling (delegates to JobScheduler)
|
|
402
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Enqueue a job for processing.
|
|
406
|
+
*
|
|
407
|
+
* Jobs are stored in MongoDB and processed by registered workers. Supports
|
|
408
|
+
* delayed execution via `runAt` and deduplication via `uniqueKey`.
|
|
409
|
+
*
|
|
410
|
+
* When a `uniqueKey` is provided, only one pending or processing job with that key
|
|
411
|
+
* can exist. Completed or failed jobs don't block new jobs with the same key.
|
|
412
|
+
*
|
|
413
|
+
* Failed jobs are automatically retried with exponential backoff up to `maxRetries`
|
|
414
|
+
* (default: 10 attempts). The delay between retries is calculated as `2^failCount × baseRetryInterval`.
|
|
415
|
+
*
|
|
416
|
+
* @template T - The job data payload type (must be JSON-serializable)
|
|
417
|
+
* @param name - Job type identifier, must match a registered worker
|
|
418
|
+
* @param data - Job payload, will be passed to the worker handler
|
|
419
|
+
* @param options - Scheduling and deduplication options
|
|
420
|
+
* @returns Promise resolving to the created or existing job document
|
|
421
|
+
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
422
|
+
*
|
|
423
|
+
* @example Basic job enqueueing
|
|
424
|
+
* ```typescript
|
|
425
|
+
* await monque.enqueue('send-email', {
|
|
426
|
+
* to: 'user@example.com',
|
|
427
|
+
* subject: 'Welcome!',
|
|
428
|
+
* body: 'Thanks for signing up.'
|
|
429
|
+
* });
|
|
430
|
+
* ```
|
|
431
|
+
*
|
|
432
|
+
* @example Delayed execution
|
|
433
|
+
* ```typescript
|
|
434
|
+
* const oneHourLater = new Date(Date.now() + 3600000);
|
|
435
|
+
* await monque.enqueue('reminder', { message: 'Check in!' }, {
|
|
436
|
+
* runAt: oneHourLater
|
|
437
|
+
* });
|
|
438
|
+
* ```
|
|
439
|
+
*
|
|
440
|
+
* @example Prevent duplicates with unique key
|
|
441
|
+
* ```typescript
|
|
442
|
+
* await monque.enqueue('sync-user', { userId: '123' }, {
|
|
443
|
+
* uniqueKey: 'sync-user-123'
|
|
444
|
+
* });
|
|
445
|
+
* // Subsequent enqueues with same uniqueKey return existing pending/processing job
|
|
446
|
+
* ```
|
|
447
|
+
*/
|
|
448
|
+
async enqueue<T>(name: string, data: T, options: EnqueueOptions = {}): Promise<PersistedJob<T>> {
|
|
449
|
+
this.ensureInitialized();
|
|
450
|
+
return this.scheduler.enqueue(name, data, options);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Enqueue a job for immediate processing.
|
|
455
|
+
*
|
|
456
|
+
* Convenience method equivalent to `enqueue(name, data, { runAt: new Date() })`.
|
|
457
|
+
* Jobs are picked up on the next poll cycle (typically within 1 second based on `pollInterval`).
|
|
458
|
+
*
|
|
459
|
+
* @template T - The job data payload type (must be JSON-serializable)
|
|
460
|
+
* @param name - Job type identifier, must match a registered worker
|
|
461
|
+
* @param data - Job payload, will be passed to the worker handler
|
|
462
|
+
* @returns Promise resolving to the created job document
|
|
463
|
+
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
464
|
+
*
|
|
465
|
+
* @example Send email immediately
|
|
466
|
+
* ```typescript
|
|
467
|
+
* await monque.now('send-email', {
|
|
468
|
+
* to: 'admin@example.com',
|
|
469
|
+
* subject: 'Alert',
|
|
470
|
+
* body: 'Immediate attention required'
|
|
471
|
+
* });
|
|
472
|
+
* ```
|
|
473
|
+
*
|
|
474
|
+
* @example Process order in background
|
|
475
|
+
* ```typescript
|
|
476
|
+
* const order = await createOrder(data);
|
|
477
|
+
* await monque.now('process-order', { orderId: order.id });
|
|
478
|
+
* return order; // Return immediately, processing happens async
|
|
479
|
+
* ```
|
|
480
|
+
*/
|
|
481
|
+
async now<T>(name: string, data: T): Promise<PersistedJob<T>> {
|
|
482
|
+
this.ensureInitialized();
|
|
483
|
+
return this.scheduler.now(name, data);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Schedule a recurring job with a cron expression.
|
|
488
|
+
*
|
|
489
|
+
* Creates a job that automatically re-schedules itself based on the cron pattern.
|
|
490
|
+
* Uses standard 5-field cron format: minute, hour, day of month, month, day of week.
|
|
491
|
+
* Also supports predefined expressions like `@daily`, `@weekly`, `@monthly`, etc.
|
|
492
|
+
* After successful completion, the job is reset to `pending` status and scheduled
|
|
493
|
+
* for its next run based on the cron expression.
|
|
494
|
+
*
|
|
495
|
+
* When a `uniqueKey` is provided, only one pending or processing job with that key
|
|
496
|
+
* can exist. This prevents duplicate scheduled jobs on application restart.
|
|
497
|
+
*
|
|
498
|
+
* @template T - The job data payload type (must be JSON-serializable)
|
|
499
|
+
* @param cron - Cron expression (5 fields or predefined expression)
|
|
500
|
+
* @param name - Job type identifier, must match a registered worker
|
|
501
|
+
* @param data - Job payload, will be passed to the worker handler on each run
|
|
502
|
+
* @param options - Scheduling options (uniqueKey for deduplication)
|
|
503
|
+
* @returns Promise resolving to the created job document with `repeatInterval` set
|
|
504
|
+
* @throws {InvalidCronError} If cron expression is invalid
|
|
505
|
+
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
506
|
+
*
|
|
507
|
+
* @example Hourly cleanup job
|
|
508
|
+
* ```typescript
|
|
509
|
+
* await monque.schedule('0 * * * *', 'cleanup-temp-files', {
|
|
510
|
+
* directory: '/tmp/uploads'
|
|
511
|
+
* });
|
|
512
|
+
* ```
|
|
513
|
+
*
|
|
514
|
+
* @example Prevent duplicate scheduled jobs with unique key
|
|
515
|
+
* ```typescript
|
|
516
|
+
* await monque.schedule('0 * * * *', 'hourly-report', { type: 'sales' }, {
|
|
517
|
+
* uniqueKey: 'hourly-report-sales'
|
|
518
|
+
* });
|
|
519
|
+
* // Subsequent calls with same uniqueKey return existing pending/processing job
|
|
520
|
+
* ```
|
|
521
|
+
*
|
|
522
|
+
* @example Daily report at midnight (using predefined expression)
|
|
523
|
+
* ```typescript
|
|
524
|
+
* await monque.schedule('@daily', 'daily-report', {
|
|
525
|
+
* reportType: 'sales',
|
|
526
|
+
* recipients: ['analytics@example.com']
|
|
527
|
+
* });
|
|
528
|
+
* ```
|
|
529
|
+
*/
|
|
530
|
+
async schedule<T>(
|
|
531
|
+
cron: string,
|
|
532
|
+
name: string,
|
|
533
|
+
data: T,
|
|
534
|
+
options: ScheduleOptions = {},
|
|
535
|
+
): Promise<PersistedJob<T>> {
|
|
536
|
+
this.ensureInitialized();
|
|
537
|
+
return this.scheduler.schedule(cron, name, data, options);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
541
|
+
// Public API - Job Management (delegates to JobManager)
|
|
542
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Cancel a pending or scheduled job.
|
|
546
|
+
*
|
|
547
|
+
* Sets the job status to 'cancelled' and emits a 'job:cancelled' event.
|
|
548
|
+
* If the job is already cancelled, this is a no-op and returns the job.
|
|
549
|
+
* Cannot cancel jobs that are currently 'processing', 'completed', or 'failed'.
|
|
550
|
+
*
|
|
551
|
+
* @param jobId - The ID of the job to cancel
|
|
552
|
+
* @returns The cancelled job, or null if not found
|
|
553
|
+
* @throws {JobStateError} If job is in an invalid state for cancellation
|
|
554
|
+
*
|
|
555
|
+
* @example Cancel a pending job
|
|
556
|
+
* ```typescript
|
|
557
|
+
* const job = await monque.enqueue('report', { type: 'daily' });
|
|
558
|
+
* await monque.cancelJob(job._id.toString());
|
|
559
|
+
* ```
|
|
560
|
+
*/
|
|
561
|
+
async cancelJob(jobId: string): Promise<PersistedJob<unknown> | null> {
|
|
562
|
+
this.ensureInitialized();
|
|
563
|
+
return this.manager.cancelJob(jobId);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Retry a failed or cancelled job.
|
|
568
|
+
*
|
|
569
|
+
* Resets the job to 'pending' status, clears failure count/reason, and sets
|
|
570
|
+
* nextRunAt to now (immediate retry). Emits a 'job:retried' event.
|
|
571
|
+
*
|
|
572
|
+
* @param jobId - The ID of the job to retry
|
|
573
|
+
* @returns The updated job, or null if not found
|
|
574
|
+
* @throws {JobStateError} If job is in an invalid state for retry (must be failed or cancelled)
|
|
575
|
+
*
|
|
576
|
+
* @example Retry a failed job
|
|
577
|
+
* ```typescript
|
|
578
|
+
* monque.on('job:fail', async ({ job }) => {
|
|
579
|
+
* console.log(`Job ${job._id} failed, retrying manually...`);
|
|
580
|
+
* await monque.retryJob(job._id.toString());
|
|
581
|
+
* });
|
|
582
|
+
* ```
|
|
583
|
+
*/
|
|
584
|
+
async retryJob(jobId: string): Promise<PersistedJob<unknown> | null> {
|
|
585
|
+
this.ensureInitialized();
|
|
586
|
+
return this.manager.retryJob(jobId);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Reschedule a pending job to run at a different time.
|
|
591
|
+
*
|
|
592
|
+
* Only works for jobs in 'pending' status.
|
|
593
|
+
*
|
|
594
|
+
* @param jobId - The ID of the job to reschedule
|
|
595
|
+
* @param runAt - The new Date when the job should run
|
|
596
|
+
* @returns The updated job, or null if not found
|
|
597
|
+
* @throws {JobStateError} If job is not in pending state
|
|
598
|
+
*
|
|
599
|
+
* @example Delay a job by 1 hour
|
|
600
|
+
* ```typescript
|
|
601
|
+
* const nextHour = new Date(Date.now() + 60 * 60 * 1000);
|
|
602
|
+
* await monque.rescheduleJob(jobId, nextHour);
|
|
603
|
+
* ```
|
|
604
|
+
*/
|
|
605
|
+
async rescheduleJob(jobId: string, runAt: Date): Promise<PersistedJob<unknown> | null> {
|
|
606
|
+
this.ensureInitialized();
|
|
607
|
+
return this.manager.rescheduleJob(jobId, runAt);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Permanently delete a job.
|
|
612
|
+
*
|
|
613
|
+
* This action is irreversible. Emits a 'job:deleted' event upon success.
|
|
614
|
+
* Can delete a job in any state.
|
|
615
|
+
*
|
|
616
|
+
* @param jobId - The ID of the job to delete
|
|
617
|
+
* @returns true if deleted, false if job not found
|
|
618
|
+
*
|
|
619
|
+
* @example Delete a cleanup job
|
|
620
|
+
* ```typescript
|
|
621
|
+
* const deleted = await monque.deleteJob(jobId);
|
|
622
|
+
* if (deleted) {
|
|
623
|
+
* console.log('Job permanently removed');
|
|
624
|
+
* }
|
|
625
|
+
* ```
|
|
626
|
+
*/
|
|
627
|
+
async deleteJob(jobId: string): Promise<boolean> {
|
|
628
|
+
this.ensureInitialized();
|
|
629
|
+
return this.manager.deleteJob(jobId);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Cancel multiple jobs matching the given filter.
|
|
634
|
+
*
|
|
635
|
+
* Only cancels jobs in 'pending' status. Jobs in other states are collected
|
|
636
|
+
* as errors in the result. Emits a 'jobs:cancelled' event with the IDs of
|
|
637
|
+
* successfully cancelled jobs.
|
|
638
|
+
*
|
|
639
|
+
* @param filter - Selector for which jobs to cancel (name, status, date range)
|
|
640
|
+
* @returns Result with count of cancelled jobs and any errors encountered
|
|
641
|
+
*
|
|
642
|
+
* @example Cancel all pending jobs for a queue
|
|
643
|
+
* ```typescript
|
|
644
|
+
* const result = await monque.cancelJobs({
|
|
645
|
+
* name: 'email-queue',
|
|
646
|
+
* status: 'pending'
|
|
647
|
+
* });
|
|
648
|
+
* console.log(`Cancelled ${result.count} jobs`);
|
|
649
|
+
* ```
|
|
650
|
+
*/
|
|
651
|
+
async cancelJobs(filter: JobSelector): Promise<BulkOperationResult> {
|
|
652
|
+
this.ensureInitialized();
|
|
653
|
+
return this.manager.cancelJobs(filter);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Retry multiple jobs matching the given filter.
|
|
658
|
+
*
|
|
659
|
+
* Only retries jobs in 'failed' or 'cancelled' status. Jobs in other states
|
|
660
|
+
* are collected as errors in the result. Emits a 'jobs:retried' event with
|
|
661
|
+
* the IDs of successfully retried jobs.
|
|
662
|
+
*
|
|
663
|
+
* @param filter - Selector for which jobs to retry (name, status, date range)
|
|
664
|
+
* @returns Result with count of retried jobs and any errors encountered
|
|
665
|
+
*
|
|
666
|
+
* @example Retry all failed jobs
|
|
667
|
+
* ```typescript
|
|
668
|
+
* const result = await monque.retryJobs({
|
|
669
|
+
* status: 'failed'
|
|
670
|
+
* });
|
|
671
|
+
* console.log(`Retried ${result.count} jobs`);
|
|
672
|
+
* ```
|
|
673
|
+
*/
|
|
674
|
+
async retryJobs(filter: JobSelector): Promise<BulkOperationResult> {
|
|
675
|
+
this.ensureInitialized();
|
|
676
|
+
return this.manager.retryJobs(filter);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Delete multiple jobs matching the given filter.
|
|
681
|
+
*
|
|
682
|
+
* Deletes jobs in any status. Uses a batch delete for efficiency.
|
|
683
|
+
* Does not emit individual 'job:deleted' events to avoid noise.
|
|
684
|
+
*
|
|
685
|
+
* @param filter - Selector for which jobs to delete (name, status, date range)
|
|
686
|
+
* @returns Result with count of deleted jobs (errors array always empty for delete)
|
|
687
|
+
*
|
|
688
|
+
* @example Delete old completed jobs
|
|
689
|
+
* ```typescript
|
|
690
|
+
* const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
691
|
+
* const result = await monque.deleteJobs({
|
|
692
|
+
* status: 'completed',
|
|
693
|
+
* olderThan: weekAgo
|
|
694
|
+
* });
|
|
695
|
+
* console.log(`Deleted ${result.count} jobs`);
|
|
696
|
+
* ```
|
|
697
|
+
*/
|
|
698
|
+
async deleteJobs(filter: JobSelector): Promise<BulkOperationResult> {
|
|
699
|
+
this.ensureInitialized();
|
|
700
|
+
return this.manager.deleteJobs(filter);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
704
|
+
// Public API - Job Queries (delegates to JobQueryService)
|
|
705
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Get a single job by its MongoDB ObjectId.
|
|
709
|
+
*
|
|
710
|
+
* Useful for retrieving job details when you have a job ID from events,
|
|
711
|
+
* logs, or stored references.
|
|
712
|
+
*
|
|
713
|
+
* @template T - The expected type of the job data payload
|
|
714
|
+
* @param id - The job's ObjectId
|
|
715
|
+
* @returns Promise resolving to the job if found, null otherwise
|
|
716
|
+
* @throws {ConnectionError} If scheduler not initialized
|
|
717
|
+
*
|
|
718
|
+
* @example Look up job from event
|
|
719
|
+
* ```typescript
|
|
720
|
+
* monque.on('job:fail', async ({ job }) => {
|
|
721
|
+
* // Later, retrieve the job to check its status
|
|
722
|
+
* const currentJob = await monque.getJob(job._id);
|
|
723
|
+
* console.log(`Job status: ${currentJob?.status}`);
|
|
724
|
+
* });
|
|
725
|
+
* ```
|
|
726
|
+
*
|
|
727
|
+
* @example Admin endpoint
|
|
728
|
+
* ```typescript
|
|
729
|
+
* app.get('/jobs/:id', async (req, res) => {
|
|
730
|
+
* const job = await monque.getJob(new ObjectId(req.params.id));
|
|
731
|
+
* if (!job) {
|
|
732
|
+
* return res.status(404).json({ error: 'Job not found' });
|
|
733
|
+
* }
|
|
734
|
+
* res.json(job);
|
|
735
|
+
* });
|
|
736
|
+
* ```
|
|
737
|
+
*/
|
|
738
|
+
async getJob<T = unknown>(id: ObjectId): Promise<PersistedJob<T> | null> {
|
|
739
|
+
this.ensureInitialized();
|
|
740
|
+
return this.query.getJob<T>(id);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* Query jobs from the queue with optional filters.
|
|
745
|
+
*
|
|
746
|
+
* Provides read-only access to job data for monitoring, debugging, and
|
|
747
|
+
* administrative purposes. Results are ordered by `nextRunAt` ascending.
|
|
748
|
+
*
|
|
749
|
+
* @template T - The expected type of the job data payload
|
|
750
|
+
* @param filter - Optional filter criteria
|
|
751
|
+
* @returns Promise resolving to array of matching jobs
|
|
752
|
+
* @throws {ConnectionError} If scheduler not initialized
|
|
753
|
+
*
|
|
754
|
+
* @example Get all pending jobs
|
|
755
|
+
* ```typescript
|
|
756
|
+
* const pendingJobs = await monque.getJobs({ status: JobStatus.PENDING });
|
|
757
|
+
* console.log(`${pendingJobs.length} jobs waiting`);
|
|
758
|
+
* ```
|
|
759
|
+
*
|
|
760
|
+
* @example Get failed email jobs
|
|
761
|
+
* ```typescript
|
|
762
|
+
* const failedEmails = await monque.getJobs({
|
|
763
|
+
* name: 'send-email',
|
|
764
|
+
* status: JobStatus.FAILED,
|
|
765
|
+
* });
|
|
766
|
+
* for (const job of failedEmails) {
|
|
767
|
+
* console.error(`Job ${job._id} failed: ${job.failReason}`);
|
|
768
|
+
* }
|
|
769
|
+
* ```
|
|
770
|
+
*
|
|
771
|
+
* @example Paginated job listing
|
|
772
|
+
* ```typescript
|
|
773
|
+
* const page1 = await monque.getJobs({ limit: 50, skip: 0 });
|
|
774
|
+
* const page2 = await monque.getJobs({ limit: 50, skip: 50 });
|
|
775
|
+
* ```
|
|
776
|
+
*
|
|
777
|
+
* @example Use with type guards from @monque/core
|
|
778
|
+
* ```typescript
|
|
779
|
+
* import { isPendingJob, isRecurringJob } from '@monque/core';
|
|
780
|
+
*
|
|
781
|
+
* const jobs = await monque.getJobs();
|
|
782
|
+
* const pendingRecurring = jobs.filter(job => isPendingJob(job) && isRecurringJob(job));
|
|
783
|
+
* ```
|
|
784
|
+
*/
|
|
785
|
+
async getJobs<T = unknown>(filter: GetJobsFilter = {}): Promise<PersistedJob<T>[]> {
|
|
786
|
+
this.ensureInitialized();
|
|
787
|
+
return this.query.getJobs<T>(filter);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Get a paginated list of jobs using opaque cursors.
|
|
792
|
+
*
|
|
793
|
+
* Provides stable pagination for large job lists. Supports forward and backward
|
|
794
|
+
* navigation, filtering, and efficient database access via index-based cursor queries.
|
|
795
|
+
*
|
|
796
|
+
* @template T - The job data payload type
|
|
797
|
+
* @param options - Pagination options (cursor, limit, direction, filter)
|
|
798
|
+
* @returns Page of jobs with next/prev cursors
|
|
799
|
+
* @throws {InvalidCursorError} If the provided cursor is malformed
|
|
800
|
+
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
801
|
+
*
|
|
802
|
+
* @example List pending jobs
|
|
803
|
+
* ```typescript
|
|
804
|
+
* const page = await monque.getJobsWithCursor({
|
|
805
|
+
* limit: 20,
|
|
806
|
+
* filter: { status: 'pending' }
|
|
807
|
+
* });
|
|
808
|
+
* const jobs = page.jobs;
|
|
809
|
+
*
|
|
810
|
+
* // Get next page
|
|
811
|
+
* if (page.hasNextPage) {
|
|
812
|
+
* const page2 = await monque.getJobsWithCursor({
|
|
813
|
+
* cursor: page.cursor,
|
|
814
|
+
* limit: 20
|
|
815
|
+
* });
|
|
816
|
+
* }
|
|
817
|
+
* ```
|
|
818
|
+
*/
|
|
819
|
+
async getJobsWithCursor<T = unknown>(options: CursorOptions = {}): Promise<CursorPage<T>> {
|
|
820
|
+
this.ensureInitialized();
|
|
821
|
+
return this.query.getJobsWithCursor<T>(options);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Get aggregate statistics for the job queue.
|
|
826
|
+
*
|
|
827
|
+
* Uses MongoDB aggregation pipeline for efficient server-side calculation.
|
|
828
|
+
* Returns counts per status and optional average processing duration for completed jobs.
|
|
829
|
+
*
|
|
830
|
+
* @param filter - Optional filter to scope statistics by job name
|
|
831
|
+
* @returns Promise resolving to queue statistics
|
|
832
|
+
* @throws {AggregationTimeoutError} If aggregation exceeds 30 second timeout
|
|
833
|
+
* @throws {ConnectionError} If database operation fails
|
|
834
|
+
*
|
|
835
|
+
* @example Get overall queue statistics
|
|
836
|
+
* ```typescript
|
|
837
|
+
* const stats = await monque.getQueueStats();
|
|
838
|
+
* console.log(`Pending: ${stats.pending}, Failed: ${stats.failed}`);
|
|
839
|
+
* ```
|
|
840
|
+
*
|
|
841
|
+
* @example Get statistics for a specific job type
|
|
842
|
+
* ```typescript
|
|
843
|
+
* const emailStats = await monque.getQueueStats({ name: 'send-email' });
|
|
844
|
+
* console.log(`${emailStats.total} email jobs in queue`);
|
|
845
|
+
* ```
|
|
846
|
+
*/
|
|
847
|
+
async getQueueStats(filter?: Pick<JobSelector, 'name'>): Promise<QueueStats> {
|
|
848
|
+
this.ensureInitialized();
|
|
849
|
+
return this.query.getQueueStats(filter);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
853
|
+
// Public API - Worker Registration
|
|
854
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Register a worker to process jobs of a specific type.
|
|
858
|
+
*
|
|
859
|
+
* Workers can be registered before or after calling `start()`. Each worker
|
|
860
|
+
* processes jobs concurrently up to its configured concurrency limit (default: 5).
|
|
861
|
+
*
|
|
862
|
+
* The handler function receives the full job object including metadata (`_id`, `status`,
|
|
863
|
+
* `failCount`, etc.). If the handler throws an error, the job is retried with exponential
|
|
864
|
+
* backoff up to `maxRetries` times. After exhausting retries, the job is marked as `failed`.
|
|
865
|
+
*
|
|
866
|
+
* Events are emitted during job processing: `job:start`, `job:complete`, `job:fail`, and `job:error`.
|
|
867
|
+
*
|
|
868
|
+
* **Duplicate Registration**: By default, registering a worker for a job name that already has
|
|
869
|
+
* a worker will throw a `WorkerRegistrationError`. This fail-fast behavior prevents accidental
|
|
870
|
+
* replacement of handlers. To explicitly replace a worker, pass `{ replace: true }`.
|
|
871
|
+
*
|
|
872
|
+
* @template T - The job data payload type for type-safe access to `job.data`
|
|
873
|
+
* @param name - Job type identifier to handle
|
|
874
|
+
* @param handler - Async function to execute for each job
|
|
875
|
+
* @param options - Worker configuration
|
|
876
|
+
* @param options.concurrency - Maximum concurrent jobs for this worker (default: `defaultConcurrency`)
|
|
877
|
+
* @param options.replace - When `true`, replace existing worker instead of throwing error
|
|
878
|
+
* @throws {WorkerRegistrationError} When a worker is already registered for `name` and `replace` is not `true`
|
|
879
|
+
*
|
|
880
|
+
* @example Basic email worker
|
|
881
|
+
* ```typescript
|
|
882
|
+
* interface EmailJob {
|
|
883
|
+
* to: string;
|
|
884
|
+
* subject: string;
|
|
885
|
+
* body: string;
|
|
886
|
+
* }
|
|
887
|
+
*
|
|
888
|
+
* monque.register<EmailJob>('send-email', async (job) => {
|
|
889
|
+
* await emailService.send(job.data.to, job.data.subject, job.data.body);
|
|
890
|
+
* });
|
|
891
|
+
* ```
|
|
892
|
+
*
|
|
893
|
+
* @example Worker with custom concurrency
|
|
894
|
+
* ```typescript
|
|
895
|
+
* // Limit to 2 concurrent video processing jobs (resource-intensive)
|
|
896
|
+
* monque.register('process-video', async (job) => {
|
|
897
|
+
* await videoProcessor.transcode(job.data.videoId);
|
|
898
|
+
* }, { concurrency: 2 });
|
|
899
|
+
* ```
|
|
900
|
+
*
|
|
901
|
+
* @example Replacing an existing worker
|
|
902
|
+
* ```typescript
|
|
903
|
+
* // Replace the existing handler for 'send-email'
|
|
904
|
+
* monque.register('send-email', newEmailHandler, { replace: true });
|
|
905
|
+
* ```
|
|
906
|
+
*
|
|
907
|
+
* @example Worker with error handling
|
|
908
|
+
* ```typescript
|
|
909
|
+
* monque.register('sync-user', async (job) => {
|
|
910
|
+
* try {
|
|
911
|
+
* await externalApi.syncUser(job.data.userId);
|
|
912
|
+
* } catch (error) {
|
|
913
|
+
* // Job will retry with exponential backoff
|
|
914
|
+
* // Delay = 2^failCount × baseRetryInterval (default: 1000ms)
|
|
915
|
+
* throw new Error(`Sync failed: ${error.message}`);
|
|
916
|
+
* }
|
|
917
|
+
* });
|
|
918
|
+
* ```
|
|
919
|
+
*/
|
|
920
|
+
register<T>(name: string, handler: JobHandler<T>, options: WorkerOptions = {}): void {
|
|
921
|
+
const concurrency = options.concurrency ?? this.options.defaultConcurrency;
|
|
922
|
+
|
|
923
|
+
// Check for existing worker and throw unless replace is explicitly true
|
|
924
|
+
if (this.workers.has(name) && options.replace !== true) {
|
|
925
|
+
throw new WorkerRegistrationError(
|
|
926
|
+
`Worker already registered for job name "${name}". Use { replace: true } to replace.`,
|
|
927
|
+
name,
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
this.workers.set(name, {
|
|
932
|
+
handler: handler as JobHandler,
|
|
933
|
+
concurrency,
|
|
934
|
+
activeJobs: new Map(),
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
939
|
+
// Public API - Lifecycle
|
|
940
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Start polling for and processing jobs.
|
|
944
|
+
*
|
|
945
|
+
* Begins polling MongoDB at the configured interval (default: 1 second) to pick up
|
|
946
|
+
* pending jobs and dispatch them to registered workers. Must call `initialize()` first.
|
|
947
|
+
* Workers can be registered before or after calling `start()`.
|
|
948
|
+
*
|
|
949
|
+
* Jobs are processed concurrently up to each worker's configured concurrency limit.
|
|
950
|
+
* The scheduler continues running until `stop()` is called.
|
|
951
|
+
*
|
|
952
|
+
* @example Basic startup
|
|
953
|
+
* ```typescript
|
|
954
|
+
* const monque = new Monque(db);
|
|
955
|
+
* await monque.initialize();
|
|
956
|
+
*
|
|
957
|
+
* monque.register('send-email', emailHandler);
|
|
958
|
+
* monque.register('process-order', orderHandler);
|
|
959
|
+
*
|
|
960
|
+
* monque.start(); // Begin processing jobs
|
|
961
|
+
* ```
|
|
962
|
+
*
|
|
963
|
+
* @example With event monitoring
|
|
964
|
+
* ```typescript
|
|
965
|
+
* monque.on('job:start', (job) => {
|
|
966
|
+
* logger.info(`Starting job ${job.name}`);
|
|
967
|
+
* });
|
|
968
|
+
*
|
|
969
|
+
* monque.on('job:complete', ({ job, duration }) => {
|
|
970
|
+
* metrics.recordJobDuration(job.name, duration);
|
|
971
|
+
* });
|
|
972
|
+
*
|
|
973
|
+
* monque.on('job:fail', ({ job, error, willRetry }) => {
|
|
974
|
+
* logger.error(`Job ${job.name} failed:`, error);
|
|
975
|
+
* if (!willRetry) {
|
|
976
|
+
* alerting.sendAlert(`Job permanently failed: ${job.name}`);
|
|
977
|
+
* }
|
|
978
|
+
* });
|
|
979
|
+
*
|
|
980
|
+
* monque.start();
|
|
981
|
+
* ```
|
|
982
|
+
*
|
|
983
|
+
* @throws {ConnectionError} If scheduler not initialized (call `initialize()` first)
|
|
984
|
+
*/
|
|
985
|
+
start(): void {
|
|
986
|
+
if (this.isRunning) {
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (!this.isInitialized) {
|
|
991
|
+
throw new ConnectionError('Monque not initialized. Call initialize() before start().');
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
this.isRunning = true;
|
|
995
|
+
|
|
996
|
+
// Set up change streams as the primary notification mechanism
|
|
997
|
+
this.changeStreamHandler.setup();
|
|
998
|
+
|
|
999
|
+
// Set up polling as backup (runs at configured interval)
|
|
1000
|
+
this.pollIntervalId = setInterval(() => {
|
|
1001
|
+
this.processor.poll().catch((error: unknown) => {
|
|
1002
|
+
this.emit('job:error', { error: error as Error });
|
|
1003
|
+
});
|
|
1004
|
+
}, this.options.pollInterval);
|
|
1005
|
+
|
|
1006
|
+
// Start heartbeat interval for claimed jobs
|
|
1007
|
+
this.heartbeatIntervalId = setInterval(() => {
|
|
1008
|
+
this.processor.updateHeartbeats().catch((error: unknown) => {
|
|
1009
|
+
this.emit('job:error', { error: error as Error });
|
|
1010
|
+
});
|
|
1011
|
+
}, this.options.heartbeatInterval);
|
|
1012
|
+
|
|
1013
|
+
// Start cleanup interval if retention is configured
|
|
1014
|
+
if (this.options.jobRetention) {
|
|
1015
|
+
const interval = this.options.jobRetention.interval ?? DEFAULTS.retentionInterval;
|
|
1016
|
+
|
|
1017
|
+
// Run immediately on start
|
|
1018
|
+
this.cleanupJobs().catch((error: unknown) => {
|
|
1019
|
+
this.emit('job:error', { error: error as Error });
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
this.cleanupIntervalId = setInterval(() => {
|
|
1023
|
+
this.cleanupJobs().catch((error: unknown) => {
|
|
1024
|
+
this.emit('job:error', { error: error as Error });
|
|
1025
|
+
});
|
|
1026
|
+
}, interval);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Run initial poll immediately to pick up any existing jobs
|
|
1030
|
+
this.processor.poll().catch((error: unknown) => {
|
|
1031
|
+
this.emit('job:error', { error: error as Error });
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Stop the scheduler gracefully, waiting for in-progress jobs to complete.
|
|
1037
|
+
*
|
|
1038
|
+
* Stops polling for new jobs and waits for all active jobs to finish processing.
|
|
1039
|
+
* Times out after the configured `shutdownTimeout` (default: 30 seconds), emitting
|
|
1040
|
+
* a `job:error` event with a `ShutdownTimeoutError` containing incomplete jobs.
|
|
1041
|
+
* On timeout, jobs still in progress are left as `processing` for stale job recovery.
|
|
1042
|
+
*
|
|
1043
|
+
* It's safe to call `stop()` multiple times - subsequent calls are no-ops if already stopped.
|
|
1044
|
+
*
|
|
1045
|
+
* @returns Promise that resolves when all jobs complete or timeout is reached
|
|
1046
|
+
*
|
|
1047
|
+
* @example Graceful application shutdown
|
|
1048
|
+
* ```typescript
|
|
1049
|
+
* process.on('SIGTERM', async () => {
|
|
1050
|
+
* console.log('Shutting down gracefully...');
|
|
1051
|
+
* await monque.stop(); // Wait for jobs to complete
|
|
1052
|
+
* await mongoClient.close();
|
|
1053
|
+
* process.exit(0);
|
|
1054
|
+
* });
|
|
1055
|
+
* ```
|
|
1056
|
+
*
|
|
1057
|
+
* @example With timeout handling
|
|
1058
|
+
* ```typescript
|
|
1059
|
+
* monque.on('job:error', ({ error }) => {
|
|
1060
|
+
* if (error.name === 'ShutdownTimeoutError') {
|
|
1061
|
+
* logger.warn('Forced shutdown after timeout:', error.incompleteJobs);
|
|
1062
|
+
* }
|
|
1063
|
+
* });
|
|
1064
|
+
*
|
|
1065
|
+
* await monque.stop();
|
|
1066
|
+
* ```
|
|
1067
|
+
*/
|
|
1068
|
+
|
|
1069
|
+
async stop(): Promise<void> {
|
|
1070
|
+
if (!this.isRunning) {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
this.isRunning = false;
|
|
1075
|
+
|
|
1076
|
+
// Close change stream
|
|
1077
|
+
await this.changeStreamHandler.close();
|
|
1078
|
+
|
|
1079
|
+
if (this.cleanupIntervalId) {
|
|
1080
|
+
clearInterval(this.cleanupIntervalId);
|
|
1081
|
+
this.cleanupIntervalId = null;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// Clear polling interval
|
|
1085
|
+
if (this.pollIntervalId) {
|
|
1086
|
+
clearInterval(this.pollIntervalId);
|
|
1087
|
+
this.pollIntervalId = null;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Clear heartbeat interval
|
|
1091
|
+
if (this.heartbeatIntervalId) {
|
|
1092
|
+
clearInterval(this.heartbeatIntervalId);
|
|
1093
|
+
this.heartbeatIntervalId = null;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Wait for all active jobs to complete (with timeout)
|
|
1097
|
+
const activeJobs = this.getActiveJobs();
|
|
1098
|
+
if (activeJobs.length === 0) {
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Create a promise that resolves when all jobs are done
|
|
1103
|
+
let checkInterval: ReturnType<typeof setInterval> | undefined;
|
|
1104
|
+
const waitForJobs = new Promise<undefined>((resolve) => {
|
|
1105
|
+
checkInterval = setInterval(() => {
|
|
1106
|
+
if (this.getActiveJobs().length === 0) {
|
|
1107
|
+
clearInterval(checkInterval);
|
|
1108
|
+
resolve(undefined);
|
|
1109
|
+
}
|
|
1110
|
+
}, 100);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
// Race between job completion and timeout
|
|
1114
|
+
const timeout = new Promise<'timeout'>((resolve) => {
|
|
1115
|
+
setTimeout(() => resolve('timeout'), this.options.shutdownTimeout);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
let result: undefined | 'timeout';
|
|
1119
|
+
|
|
1120
|
+
try {
|
|
1121
|
+
result = await Promise.race([waitForJobs, timeout]);
|
|
1122
|
+
} finally {
|
|
1123
|
+
if (checkInterval) {
|
|
1124
|
+
clearInterval(checkInterval);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
if (result === 'timeout') {
|
|
1129
|
+
const incompleteJobs = this.getActiveJobsList();
|
|
1130
|
+
|
|
1131
|
+
const error = new ShutdownTimeoutError(
|
|
1132
|
+
`Shutdown timed out after ${this.options.shutdownTimeout}ms with ${incompleteJobs.length} incomplete jobs`,
|
|
1133
|
+
incompleteJobs,
|
|
1134
|
+
);
|
|
1135
|
+
this.emit('job:error', { error });
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Check if the scheduler is healthy (running and connected).
|
|
1141
|
+
*
|
|
1142
|
+
* Returns `true` when the scheduler is started, initialized, and has an active
|
|
1143
|
+
* MongoDB collection reference. Useful for health check endpoints and monitoring.
|
|
1144
|
+
*
|
|
1145
|
+
* A healthy scheduler:
|
|
1146
|
+
* - Has called `initialize()` successfully
|
|
1147
|
+
* - Has called `start()` and is actively polling
|
|
1148
|
+
* - Has a valid MongoDB collection reference
|
|
1149
|
+
*
|
|
1150
|
+
* @returns `true` if scheduler is running and connected, `false` otherwise
|
|
1151
|
+
*
|
|
1152
|
+
* @example Express health check endpoint
|
|
1153
|
+
* ```typescript
|
|
1154
|
+
* app.get('/health', (req, res) => {
|
|
1155
|
+
* const healthy = monque.isHealthy();
|
|
1156
|
+
* res.status(healthy ? 200 : 503).json({
|
|
1157
|
+
* status: healthy ? 'ok' : 'unavailable',
|
|
1158
|
+
* scheduler: healthy,
|
|
1159
|
+
* timestamp: new Date().toISOString()
|
|
1160
|
+
* });
|
|
1161
|
+
* });
|
|
1162
|
+
* ```
|
|
1163
|
+
*
|
|
1164
|
+
* @example Kubernetes readiness probe
|
|
1165
|
+
* ```typescript
|
|
1166
|
+
* app.get('/readyz', (req, res) => {
|
|
1167
|
+
* if (monque.isHealthy() && dbConnected) {
|
|
1168
|
+
* res.status(200).send('ready');
|
|
1169
|
+
* } else {
|
|
1170
|
+
* res.status(503).send('not ready');
|
|
1171
|
+
* }
|
|
1172
|
+
* });
|
|
1173
|
+
* ```
|
|
1174
|
+
*
|
|
1175
|
+
* @example Periodic health monitoring
|
|
1176
|
+
* ```typescript
|
|
1177
|
+
* setInterval(() => {
|
|
1178
|
+
* if (!monque.isHealthy()) {
|
|
1179
|
+
* logger.error('Scheduler unhealthy');
|
|
1180
|
+
* metrics.increment('scheduler.unhealthy');
|
|
1181
|
+
* }
|
|
1182
|
+
* }, 60000); // Check every minute
|
|
1183
|
+
* ```
|
|
1184
|
+
*/
|
|
1185
|
+
isHealthy(): boolean {
|
|
1186
|
+
return this.isRunning && this.isInitialized && this.collection !== null;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1190
|
+
// Private Helpers
|
|
1191
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Ensure the scheduler is initialized before operations.
|
|
1195
|
+
*
|
|
1196
|
+
* @private
|
|
1197
|
+
* @throws {ConnectionError} If scheduler not initialized or collection unavailable
|
|
1198
|
+
*/
|
|
1199
|
+
private ensureInitialized(): void {
|
|
1200
|
+
if (!this.isInitialized || !this.collection) {
|
|
1201
|
+
throw new ConnectionError('Monque not initialized. Call initialize() first.');
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Get array of active job IDs across all workers.
|
|
1207
|
+
*
|
|
1208
|
+
* @private
|
|
1209
|
+
* @returns Array of job ID strings currently being processed
|
|
1210
|
+
*/
|
|
1211
|
+
private getActiveJobs(): string[] {
|
|
1212
|
+
const activeJobs: string[] = [];
|
|
1213
|
+
for (const worker of this.workers.values()) {
|
|
1214
|
+
activeJobs.push(...worker.activeJobs.keys());
|
|
1215
|
+
}
|
|
1216
|
+
return activeJobs;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Get list of active job documents (for shutdown timeout error).
|
|
1221
|
+
*
|
|
1222
|
+
* @private
|
|
1223
|
+
* @returns Array of active Job objects
|
|
1224
|
+
*/
|
|
1225
|
+
private getActiveJobsList(): Job[] {
|
|
1226
|
+
const activeJobs: Job[] = [];
|
|
1227
|
+
for (const worker of this.workers.values()) {
|
|
1228
|
+
activeJobs.push(...worker.activeJobs.values());
|
|
1229
|
+
}
|
|
1230
|
+
return activeJobs;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Convert a MongoDB document to a typed PersistedJob object.
|
|
1235
|
+
*
|
|
1236
|
+
* Maps raw MongoDB document fields to the strongly-typed `PersistedJob<T>` interface,
|
|
1237
|
+
* ensuring type safety and handling optional fields (`lockedAt`, `failReason`, etc.).
|
|
1238
|
+
*
|
|
1239
|
+
* @private
|
|
1240
|
+
* @template T - The job data payload type
|
|
1241
|
+
* @param doc - The raw MongoDB document with `_id`
|
|
1242
|
+
* @returns A strongly-typed PersistedJob object with guaranteed `_id`
|
|
1243
|
+
*/
|
|
1244
|
+
private documentToPersistedJob<T>(doc: WithId<Document>): PersistedJob<T> {
|
|
1245
|
+
const job: PersistedJob<T> = {
|
|
1246
|
+
_id: doc._id,
|
|
1247
|
+
name: doc['name'] as string,
|
|
1248
|
+
data: doc['data'] as T,
|
|
1249
|
+
status: doc['status'] as JobStatusType,
|
|
1250
|
+
nextRunAt: doc['nextRunAt'] as Date,
|
|
1251
|
+
failCount: doc['failCount'] as number,
|
|
1252
|
+
createdAt: doc['createdAt'] as Date,
|
|
1253
|
+
updatedAt: doc['updatedAt'] as Date,
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
// Only set optional properties if they exist
|
|
1257
|
+
if (doc['lockedAt'] !== undefined) {
|
|
1258
|
+
job.lockedAt = doc['lockedAt'] as Date | null;
|
|
1259
|
+
}
|
|
1260
|
+
if (doc['claimedBy'] !== undefined) {
|
|
1261
|
+
job.claimedBy = doc['claimedBy'] as string | null;
|
|
1262
|
+
}
|
|
1263
|
+
if (doc['lastHeartbeat'] !== undefined) {
|
|
1264
|
+
job.lastHeartbeat = doc['lastHeartbeat'] as Date | null;
|
|
1265
|
+
}
|
|
1266
|
+
if (doc['heartbeatInterval'] !== undefined) {
|
|
1267
|
+
job.heartbeatInterval = doc['heartbeatInterval'] as number;
|
|
1268
|
+
}
|
|
1269
|
+
if (doc['failReason'] !== undefined) {
|
|
1270
|
+
job.failReason = doc['failReason'] as string;
|
|
1271
|
+
}
|
|
1272
|
+
if (doc['repeatInterval'] !== undefined) {
|
|
1273
|
+
job.repeatInterval = doc['repeatInterval'] as string;
|
|
1274
|
+
}
|
|
1275
|
+
if (doc['uniqueKey'] !== undefined) {
|
|
1276
|
+
job.uniqueKey = doc['uniqueKey'] as string;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
return job;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/**
|
|
1283
|
+
* Type-safe event emitter methods
|
|
1284
|
+
*/
|
|
1285
|
+
override emit<K extends keyof MonqueEventMap>(event: K, payload: MonqueEventMap[K]): boolean {
|
|
1286
|
+
return super.emit(event, payload);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
override on<K extends keyof MonqueEventMap>(
|
|
1290
|
+
event: K,
|
|
1291
|
+
listener: (payload: MonqueEventMap[K]) => void,
|
|
1292
|
+
): this {
|
|
1293
|
+
return super.on(event, listener);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
override once<K extends keyof MonqueEventMap>(
|
|
1297
|
+
event: K,
|
|
1298
|
+
listener: (payload: MonqueEventMap[K]) => void,
|
|
1299
|
+
): this {
|
|
1300
|
+
return super.once(event, listener);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
override off<K extends keyof MonqueEventMap>(
|
|
1304
|
+
event: K,
|
|
1305
|
+
listener: (payload: MonqueEventMap[K]) => void,
|
|
1306
|
+
): this {
|
|
1307
|
+
return super.off(event, listener);
|
|
1308
|
+
}
|
|
1309
|
+
}
|