@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,335 @@
|
|
|
1
|
+
import type { ObjectId } from 'mongodb';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Represents the lifecycle states of a job in the queue.
|
|
5
|
+
*
|
|
6
|
+
* Jobs transition through states as follows:
|
|
7
|
+
* - PENDING → PROCESSING (when picked up by a worker)
|
|
8
|
+
* - PROCESSING → COMPLETED (on success)
|
|
9
|
+
* - PROCESSING → PENDING (on failure, if retries remain)
|
|
10
|
+
* - PROCESSING → FAILED (on failure, after max retries exhausted)
|
|
11
|
+
* - PENDING → CANCELLED (on manual cancellation)
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* if (job.status === JobStatus.PENDING) {
|
|
16
|
+
* // job is waiting to be picked up
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export const JobStatus = {
|
|
21
|
+
/** Job is waiting to be picked up by a worker */
|
|
22
|
+
PENDING: 'pending',
|
|
23
|
+
/** Job is currently being executed by a worker */
|
|
24
|
+
PROCESSING: 'processing',
|
|
25
|
+
/** Job completed successfully */
|
|
26
|
+
COMPLETED: 'completed',
|
|
27
|
+
/** Job permanently failed after exhausting all retry attempts */
|
|
28
|
+
FAILED: 'failed',
|
|
29
|
+
/** Job was manually cancelled */
|
|
30
|
+
CANCELLED: 'cancelled',
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Union type of all possible job status values: `'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'`
|
|
35
|
+
*/
|
|
36
|
+
export type JobStatusType = (typeof JobStatus)[keyof typeof JobStatus];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Represents a job in the Monque queue.
|
|
40
|
+
*
|
|
41
|
+
* @template T - The type of the job's data payload
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* interface EmailJobData {
|
|
46
|
+
* to: string;
|
|
47
|
+
* subject: string;
|
|
48
|
+
* template: string;
|
|
49
|
+
* }
|
|
50
|
+
*
|
|
51
|
+
* const job: Job<EmailJobData> = {
|
|
52
|
+
* name: 'send-email',
|
|
53
|
+
* data: { to: 'user@example.com', subject: 'Welcome!', template: 'welcome' },
|
|
54
|
+
* status: JobStatus.PENDING,
|
|
55
|
+
* nextRunAt: new Date(),
|
|
56
|
+
* failCount: 0,
|
|
57
|
+
* createdAt: new Date(),
|
|
58
|
+
* updatedAt: new Date(),
|
|
59
|
+
* };
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export interface Job<T = unknown> {
|
|
63
|
+
/** MongoDB document identifier */
|
|
64
|
+
_id?: ObjectId;
|
|
65
|
+
|
|
66
|
+
/** Job type identifier, matches worker registration */
|
|
67
|
+
name: string;
|
|
68
|
+
|
|
69
|
+
/** Job payload - must be JSON-serializable */
|
|
70
|
+
data: T;
|
|
71
|
+
|
|
72
|
+
/** Current lifecycle state */
|
|
73
|
+
status: JobStatusType;
|
|
74
|
+
|
|
75
|
+
/** When the job should be processed */
|
|
76
|
+
nextRunAt: Date;
|
|
77
|
+
|
|
78
|
+
/** Timestamp when job was locked for processing */
|
|
79
|
+
lockedAt?: Date | null;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Unique identifier of the scheduler instance that claimed this job.
|
|
83
|
+
* Used for atomic claim pattern - ensures only one instance processes each job.
|
|
84
|
+
* Set when a job is claimed, cleared when job completes or fails.
|
|
85
|
+
*/
|
|
86
|
+
claimedBy?: string | null;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Timestamp of the last heartbeat update for this job.
|
|
90
|
+
* Used to detect stale jobs when a scheduler instance crashes without releasing.
|
|
91
|
+
* Updated periodically while job is being processed.
|
|
92
|
+
*/
|
|
93
|
+
lastHeartbeat?: Date | null;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Heartbeat interval in milliseconds for this job.
|
|
97
|
+
* Stored on the job to allow recovery logic to use the correct timeout.
|
|
98
|
+
*/
|
|
99
|
+
heartbeatInterval?: number;
|
|
100
|
+
|
|
101
|
+
/** Number of failed attempts */
|
|
102
|
+
failCount: number;
|
|
103
|
+
|
|
104
|
+
/** Last failure error message */
|
|
105
|
+
failReason?: string;
|
|
106
|
+
|
|
107
|
+
/** Cron expression for recurring jobs */
|
|
108
|
+
repeatInterval?: string;
|
|
109
|
+
|
|
110
|
+
/** Deduplication key to prevent duplicate jobs */
|
|
111
|
+
uniqueKey?: string;
|
|
112
|
+
|
|
113
|
+
/** Job creation timestamp */
|
|
114
|
+
createdAt: Date;
|
|
115
|
+
|
|
116
|
+
/** Last modification timestamp */
|
|
117
|
+
updatedAt: Date;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A job that has been persisted to MongoDB and has a guaranteed `_id`.
|
|
122
|
+
* This is returned by `enqueue()`, `now()`, and `schedule()` methods.
|
|
123
|
+
*
|
|
124
|
+
* @template T - The type of the job's data payload
|
|
125
|
+
*/
|
|
126
|
+
export type PersistedJob<T = unknown> = Job<T> & { _id: ObjectId };
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Options for enqueueing a job.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* ```typescript
|
|
133
|
+
* await monque.enqueue('sync-user', { userId: '123' }, {
|
|
134
|
+
* uniqueKey: 'sync-user-123',
|
|
135
|
+
* runAt: new Date(Date.now() + 5000), // Run in 5 seconds
|
|
136
|
+
* });
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
export interface EnqueueOptions {
|
|
140
|
+
/**
|
|
141
|
+
* Deduplication key. If a job with this key is already pending or processing,
|
|
142
|
+
* the enqueue operation will not create a duplicate.
|
|
143
|
+
*/
|
|
144
|
+
uniqueKey?: string;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* When the job should be processed. Defaults to immediately (new Date()).
|
|
148
|
+
*/
|
|
149
|
+
runAt?: Date;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Options for scheduling a recurring job.
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```typescript
|
|
157
|
+
* await monque. schedule('0 * * * *', 'hourly-cleanup', { dir: '/tmp' }, {
|
|
158
|
+
* uniqueKey: 'hourly-cleanup-job',
|
|
159
|
+
* });
|
|
160
|
+
* ```
|
|
161
|
+
*/
|
|
162
|
+
export interface ScheduleOptions {
|
|
163
|
+
/**
|
|
164
|
+
* Deduplication key. If a job with this key is already pending or processing,
|
|
165
|
+
* the schedule operation will not create a duplicate.
|
|
166
|
+
*/
|
|
167
|
+
uniqueKey?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Filter options for querying jobs.
|
|
172
|
+
*
|
|
173
|
+
* Use with `monque.getJobs()` to filter jobs by name, status, or limit results.
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```typescript
|
|
177
|
+
* // Get all pending email jobs
|
|
178
|
+
* const pendingEmails = await monque.getJobs({
|
|
179
|
+
* name: 'send-email',
|
|
180
|
+
* status: JobStatus.PENDING,
|
|
181
|
+
* });
|
|
182
|
+
*
|
|
183
|
+
* // Get all failed or completed jobs (paginated)
|
|
184
|
+
* const finishedJobs = await monque.getJobs({
|
|
185
|
+
* status: [JobStatus.COMPLETED, JobStatus.FAILED],
|
|
186
|
+
* limit: 50,
|
|
187
|
+
* skip: 100,
|
|
188
|
+
* });
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
export interface GetJobsFilter {
|
|
192
|
+
/** Filter by job type name */
|
|
193
|
+
name?: string;
|
|
194
|
+
|
|
195
|
+
/** Filter by status (single or multiple) */
|
|
196
|
+
status?: JobStatusType | JobStatusType[];
|
|
197
|
+
|
|
198
|
+
/** Maximum number of jobs to return (default: 100) */
|
|
199
|
+
limit?: number;
|
|
200
|
+
|
|
201
|
+
/** Number of jobs to skip for pagination */
|
|
202
|
+
skip?: number;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Handler function signature for processing jobs.
|
|
207
|
+
*
|
|
208
|
+
* @template T - The type of the job's data payload
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```typescript
|
|
212
|
+
* const emailHandler: JobHandler<EmailJobData> = async (job) => {
|
|
213
|
+
* await sendEmail(job.data.to, job.data.subject);
|
|
214
|
+
* };
|
|
215
|
+
* ```
|
|
216
|
+
*/
|
|
217
|
+
export type JobHandler<T = unknown> = (job: Job<T>) => Promise<void> | void;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Valid cursor directions for pagination.
|
|
221
|
+
*
|
|
222
|
+
* @example
|
|
223
|
+
* ```typescript
|
|
224
|
+
* const direction = CursorDirection.FORWARD;
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
export const CursorDirection = {
|
|
228
|
+
FORWARD: 'forward',
|
|
229
|
+
BACKWARD: 'backward',
|
|
230
|
+
} as const;
|
|
231
|
+
|
|
232
|
+
export type CursorDirectionType = (typeof CursorDirection)[keyof typeof CursorDirection];
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Selector options for bulk operations.
|
|
236
|
+
*
|
|
237
|
+
* Used to select multiple jobs for operations like cancellation or deletion.
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* ```typescript
|
|
241
|
+
* // Select all failed jobs older than 7 days
|
|
242
|
+
* const selector: JobSelector = {
|
|
243
|
+
* status: JobStatus.FAILED,
|
|
244
|
+
* olderThan: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
|
245
|
+
* };
|
|
246
|
+
* ```
|
|
247
|
+
*/
|
|
248
|
+
export interface JobSelector {
|
|
249
|
+
name?: string;
|
|
250
|
+
status?: JobStatusType | JobStatusType[];
|
|
251
|
+
olderThan?: Date;
|
|
252
|
+
newerThan?: Date;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Options for cursor-based pagination.
|
|
257
|
+
*
|
|
258
|
+
* @example
|
|
259
|
+
* ```typescript
|
|
260
|
+
* const options: CursorOptions = {
|
|
261
|
+
* limit: 50,
|
|
262
|
+
* direction: CursorDirection.FORWARD,
|
|
263
|
+
* filter: { status: JobStatus.PENDING },
|
|
264
|
+
* };
|
|
265
|
+
* ```
|
|
266
|
+
*/
|
|
267
|
+
export interface CursorOptions {
|
|
268
|
+
cursor?: string;
|
|
269
|
+
limit?: number;
|
|
270
|
+
direction?: CursorDirectionType;
|
|
271
|
+
filter?: Pick<GetJobsFilter, 'name' | 'status'>;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Response structure for cursor-based pagination.
|
|
276
|
+
*
|
|
277
|
+
* @template T - The type of the job's data payload
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* ```typescript
|
|
281
|
+
* const page = await monque.listJobs({ limit: 10 });
|
|
282
|
+
* console.log(`Got ${page.jobs.length} jobs`);
|
|
283
|
+
*
|
|
284
|
+
* if (page.hasNextPage) {
|
|
285
|
+
* console.log(`Next cursor: ${page.cursor}`);
|
|
286
|
+
* }
|
|
287
|
+
* ```
|
|
288
|
+
*/
|
|
289
|
+
export interface CursorPage<T = unknown> {
|
|
290
|
+
jobs: PersistedJob<T>[];
|
|
291
|
+
cursor: string | null;
|
|
292
|
+
hasNextPage: boolean;
|
|
293
|
+
hasPreviousPage: boolean;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Aggregated statistics for the job queue.
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* ```typescript
|
|
301
|
+
* const stats = await monque.getQueueStats();
|
|
302
|
+
* console.log(`Total jobs: ${stats.total}`);
|
|
303
|
+
* console.log(`Pending: ${stats.pending}`);
|
|
304
|
+
* console.log(`Processing: ${stats.processing}`);
|
|
305
|
+
* console.log(`Failed: ${stats.failed}`);
|
|
306
|
+
* console.log(`Start to finish avg: ${stats.avgProcessingDurationMs}ms`);
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
309
|
+
export interface QueueStats {
|
|
310
|
+
pending: number;
|
|
311
|
+
processing: number;
|
|
312
|
+
completed: number;
|
|
313
|
+
failed: number;
|
|
314
|
+
cancelled: number;
|
|
315
|
+
total: number;
|
|
316
|
+
avgProcessingDurationMs?: number;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Result of a bulk operation.
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* ```typescript
|
|
324
|
+
* const result = await monque.cancelJobs(selector);
|
|
325
|
+
* console.log(`Cancelled ${result.count} jobs`);
|
|
326
|
+
*
|
|
327
|
+
* if (result.errors.length > 0) {
|
|
328
|
+
* console.warn('Some jobs could not be cancelled:', result.errors);
|
|
329
|
+
* }
|
|
330
|
+
* ```
|
|
331
|
+
*/
|
|
332
|
+
export interface BulkOperationResult {
|
|
333
|
+
count: number;
|
|
334
|
+
errors: Array<{ jobId: string; error: string }>;
|
|
335
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { type Document, type Filter, ObjectId } from 'mongodb';
|
|
2
|
+
|
|
3
|
+
import { CursorDirection, type CursorDirectionType, type JobSelector } from '@/jobs';
|
|
4
|
+
import { InvalidCursorError } from '@/shared';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a MongoDB query filter from a JobSelector.
|
|
8
|
+
*
|
|
9
|
+
* Translates the high-level `JobSelector` interface into a MongoDB `Filter<Document>`.
|
|
10
|
+
* Handles array values for status (using `$in`) and date range filtering.
|
|
11
|
+
*
|
|
12
|
+
* @param filter - The user-provided job selector
|
|
13
|
+
* @returns A standard MongoDB filter object
|
|
14
|
+
*/
|
|
15
|
+
export function buildSelectorQuery(filter: JobSelector): Filter<Document> {
|
|
16
|
+
const query: Filter<Document> = {};
|
|
17
|
+
|
|
18
|
+
if (filter.name) {
|
|
19
|
+
query['name'] = filter.name;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (filter.status) {
|
|
23
|
+
if (Array.isArray(filter.status)) {
|
|
24
|
+
query['status'] = { $in: filter.status };
|
|
25
|
+
} else {
|
|
26
|
+
query['status'] = filter.status;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (filter.olderThan || filter.newerThan) {
|
|
31
|
+
query['createdAt'] = {};
|
|
32
|
+
if (filter.olderThan) {
|
|
33
|
+
query['createdAt'].$lt = filter.olderThan;
|
|
34
|
+
}
|
|
35
|
+
if (filter.newerThan) {
|
|
36
|
+
query['createdAt'].$gt = filter.newerThan;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return query;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Encode an ObjectId and direction into an opaque cursor string.
|
|
45
|
+
*
|
|
46
|
+
* Format: `prefix` + `base64url(objectId)`
|
|
47
|
+
* Prefix: 'F' (forward) or 'B' (backward)
|
|
48
|
+
*
|
|
49
|
+
* @param id - The job ID to use as the cursor anchor (exclusive)
|
|
50
|
+
* @param direction - 'forward' or 'backward'
|
|
51
|
+
* @returns Base64url-encoded cursor string
|
|
52
|
+
*/
|
|
53
|
+
export function encodeCursor(id: ObjectId, direction: CursorDirectionType): string {
|
|
54
|
+
const prefix = direction === 'forward' ? 'F' : 'B';
|
|
55
|
+
const buffer = Buffer.from(id.toHexString(), 'hex');
|
|
56
|
+
|
|
57
|
+
return prefix + buffer.toString('base64url');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Decode an opaque cursor string into an ObjectId and direction.
|
|
62
|
+
*
|
|
63
|
+
* Validates format and returns the components.
|
|
64
|
+
*
|
|
65
|
+
* @param cursor - The opaque cursor string
|
|
66
|
+
* @returns The decoded ID and direction
|
|
67
|
+
* @throws {InvalidCursorError} If the cursor format is invalid or ID is malformed
|
|
68
|
+
*/
|
|
69
|
+
export function decodeCursor(cursor: string): {
|
|
70
|
+
id: ObjectId;
|
|
71
|
+
direction: CursorDirectionType;
|
|
72
|
+
} {
|
|
73
|
+
if (!cursor || cursor.length < 2) {
|
|
74
|
+
throw new InvalidCursorError('Cursor is empty or too short');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const prefix = cursor.charAt(0);
|
|
78
|
+
const payload = cursor.slice(1);
|
|
79
|
+
|
|
80
|
+
let direction: CursorDirectionType;
|
|
81
|
+
|
|
82
|
+
if (prefix === 'F') {
|
|
83
|
+
direction = CursorDirection.FORWARD;
|
|
84
|
+
} else if (prefix === 'B') {
|
|
85
|
+
direction = CursorDirection.BACKWARD;
|
|
86
|
+
} else {
|
|
87
|
+
throw new InvalidCursorError(`Invalid cursor prefix: ${prefix}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const buffer = Buffer.from(payload, 'base64url');
|
|
92
|
+
const hex = buffer.toString('hex');
|
|
93
|
+
// standard ObjectID is 12 bytes = 24 hex chars
|
|
94
|
+
if (hex.length !== 24) {
|
|
95
|
+
throw new InvalidCursorError('Invalid length');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const id = new ObjectId(hex);
|
|
99
|
+
|
|
100
|
+
return { id, direction };
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (error instanceof InvalidCursorError) {
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
throw new InvalidCursorError('Invalid cursor payload');
|
|
106
|
+
}
|
|
107
|
+
}
|