@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,239 @@
|
|
|
1
|
+
import type { ChangeStream, ChangeStreamDocument, Document } from 'mongodb';
|
|
2
|
+
|
|
3
|
+
import { JobStatus } from '@/jobs';
|
|
4
|
+
|
|
5
|
+
import type { SchedulerContext } from './types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Internal service for MongoDB Change Stream lifecycle.
|
|
9
|
+
*
|
|
10
|
+
* Provides real-time job notifications when available, with automatic
|
|
11
|
+
* reconnection and graceful fallback to polling-only mode.
|
|
12
|
+
*
|
|
13
|
+
* @internal Not part of public API.
|
|
14
|
+
*/
|
|
15
|
+
export class ChangeStreamHandler {
|
|
16
|
+
/** MongoDB Change Stream for real-time job notifications */
|
|
17
|
+
private changeStream: ChangeStream | null = null;
|
|
18
|
+
|
|
19
|
+
/** Number of consecutive reconnection attempts */
|
|
20
|
+
private reconnectAttempts = 0;
|
|
21
|
+
|
|
22
|
+
/** Maximum reconnection attempts before falling back to polling-only mode */
|
|
23
|
+
private readonly maxReconnectAttempts = 3;
|
|
24
|
+
|
|
25
|
+
/** Debounce timer for change stream event processing */
|
|
26
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
|
|
28
|
+
/** Timer ID for reconnection with exponential backoff */
|
|
29
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
30
|
+
|
|
31
|
+
/** Whether the scheduler is currently using change streams */
|
|
32
|
+
private usingChangeStreams = false;
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly ctx: SchedulerContext,
|
|
36
|
+
private readonly onPoll: () => Promise<void>,
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Set up MongoDB Change Stream for real-time job notifications.
|
|
41
|
+
*
|
|
42
|
+
* Change streams provide instant notifications when jobs are inserted or when
|
|
43
|
+
* job status changes to pending (e.g., after a retry). This eliminates the
|
|
44
|
+
* polling delay for reactive job processing.
|
|
45
|
+
*
|
|
46
|
+
* The change stream watches for:
|
|
47
|
+
* - Insert operations (new jobs)
|
|
48
|
+
* - Update operations where status field changes
|
|
49
|
+
*
|
|
50
|
+
* If change streams are unavailable (e.g., standalone MongoDB), the system
|
|
51
|
+
* gracefully falls back to polling-only mode.
|
|
52
|
+
*/
|
|
53
|
+
setup(): void {
|
|
54
|
+
if (!this.ctx.isRunning()) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
// Create change stream with pipeline to filter relevant events
|
|
60
|
+
const pipeline = [
|
|
61
|
+
{
|
|
62
|
+
$match: {
|
|
63
|
+
$or: [
|
|
64
|
+
{ operationType: 'insert' },
|
|
65
|
+
{
|
|
66
|
+
operationType: 'update',
|
|
67
|
+
'updateDescription.updatedFields.status': { $exists: true },
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
this.changeStream = this.ctx.collection.watch(pipeline, {
|
|
75
|
+
fullDocument: 'updateLookup',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Handle change events
|
|
79
|
+
this.changeStream.on('change', (change) => {
|
|
80
|
+
this.handleEvent(change);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Handle errors with reconnection
|
|
84
|
+
this.changeStream.on('error', (error: Error) => {
|
|
85
|
+
this.ctx.emit('changestream:error', { error });
|
|
86
|
+
this.handleError(error);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Mark as connected
|
|
90
|
+
this.usingChangeStreams = true;
|
|
91
|
+
this.reconnectAttempts = 0;
|
|
92
|
+
this.ctx.emit('changestream:connected', undefined);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// Change streams not available (e.g., standalone MongoDB)
|
|
95
|
+
this.usingChangeStreams = false;
|
|
96
|
+
const reason = error instanceof Error ? error.message : 'Unknown error';
|
|
97
|
+
this.ctx.emit('changestream:fallback', { reason });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Handle a change stream event by triggering a debounced poll.
|
|
103
|
+
*
|
|
104
|
+
* Events are debounced to prevent "claim storms" when multiple changes arrive
|
|
105
|
+
* in rapid succession (e.g., bulk job inserts). A 100ms debounce window
|
|
106
|
+
* collects multiple events and triggers a single poll.
|
|
107
|
+
*
|
|
108
|
+
* @param change - The change stream event document
|
|
109
|
+
*/
|
|
110
|
+
handleEvent(change: ChangeStreamDocument<Document>): void {
|
|
111
|
+
if (!this.ctx.isRunning()) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Trigger poll on insert (new job) or update where status changes
|
|
116
|
+
const isInsert = change.operationType === 'insert';
|
|
117
|
+
const isUpdate = change.operationType === 'update';
|
|
118
|
+
|
|
119
|
+
// Get fullDocument if available (for insert or with updateLookup option)
|
|
120
|
+
const fullDocument = 'fullDocument' in change ? change.fullDocument : undefined;
|
|
121
|
+
const isPendingStatus = fullDocument?.['status'] === JobStatus.PENDING;
|
|
122
|
+
|
|
123
|
+
// For inserts: always trigger since new pending jobs need processing
|
|
124
|
+
// For updates: trigger if status changed to pending (retry/release scenario)
|
|
125
|
+
const shouldTrigger = isInsert || (isUpdate && isPendingStatus);
|
|
126
|
+
|
|
127
|
+
if (shouldTrigger) {
|
|
128
|
+
// Debounce poll triggers to avoid claim storms
|
|
129
|
+
if (this.debounceTimer) {
|
|
130
|
+
clearTimeout(this.debounceTimer);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this.debounceTimer = setTimeout(() => {
|
|
134
|
+
this.debounceTimer = null;
|
|
135
|
+
this.onPoll().catch((error: unknown) => {
|
|
136
|
+
this.ctx.emit('job:error', { error: error as Error });
|
|
137
|
+
});
|
|
138
|
+
}, 100);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Handle change stream errors with exponential backoff reconnection.
|
|
144
|
+
*
|
|
145
|
+
* Attempts to reconnect up to `maxReconnectAttempts` times with
|
|
146
|
+
* exponential backoff (base 1000ms). After exhausting retries, falls back to
|
|
147
|
+
* polling-only mode.
|
|
148
|
+
*
|
|
149
|
+
* @param error - The error that caused the change stream failure
|
|
150
|
+
*/
|
|
151
|
+
handleError(error: Error): void {
|
|
152
|
+
if (!this.ctx.isRunning()) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.reconnectAttempts++;
|
|
157
|
+
|
|
158
|
+
if (this.reconnectAttempts > this.maxReconnectAttempts) {
|
|
159
|
+
// Fall back to polling-only mode
|
|
160
|
+
this.usingChangeStreams = false;
|
|
161
|
+
|
|
162
|
+
if (this.reconnectTimer) {
|
|
163
|
+
clearTimeout(this.reconnectTimer);
|
|
164
|
+
this.reconnectTimer = null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (this.changeStream) {
|
|
168
|
+
this.changeStream.close().catch(() => {});
|
|
169
|
+
this.changeStream = null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.ctx.emit('changestream:fallback', {
|
|
173
|
+
reason: `Exhausted ${this.maxReconnectAttempts} reconnection attempts: ${error.message}`,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Exponential backoff: 1s, 2s, 4s
|
|
180
|
+
const delay = 2 ** (this.reconnectAttempts - 1) * 1000;
|
|
181
|
+
|
|
182
|
+
// Clear any existing reconnect timer before scheduling a new one
|
|
183
|
+
if (this.reconnectTimer) {
|
|
184
|
+
clearTimeout(this.reconnectTimer);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.reconnectTimer = setTimeout(() => {
|
|
188
|
+
this.reconnectTimer = null;
|
|
189
|
+
if (this.ctx.isRunning()) {
|
|
190
|
+
// Close existing change stream before reconnecting
|
|
191
|
+
if (this.changeStream) {
|
|
192
|
+
this.changeStream.close().catch(() => {});
|
|
193
|
+
this.changeStream = null;
|
|
194
|
+
}
|
|
195
|
+
this.setup();
|
|
196
|
+
}
|
|
197
|
+
}, delay);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Close the change stream cursor and emit closed event.
|
|
202
|
+
*/
|
|
203
|
+
async close(): Promise<void> {
|
|
204
|
+
// Clear debounce timer
|
|
205
|
+
if (this.debounceTimer) {
|
|
206
|
+
clearTimeout(this.debounceTimer);
|
|
207
|
+
this.debounceTimer = null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Clear reconnection timer
|
|
211
|
+
if (this.reconnectTimer) {
|
|
212
|
+
clearTimeout(this.reconnectTimer);
|
|
213
|
+
this.reconnectTimer = null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (this.changeStream) {
|
|
217
|
+
try {
|
|
218
|
+
await this.changeStream.close();
|
|
219
|
+
} catch {
|
|
220
|
+
// Ignore close errors during shutdown
|
|
221
|
+
}
|
|
222
|
+
this.changeStream = null;
|
|
223
|
+
|
|
224
|
+
if (this.usingChangeStreams) {
|
|
225
|
+
this.ctx.emit('changestream:closed', undefined);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.usingChangeStreams = false;
|
|
230
|
+
this.reconnectAttempts = 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Check if change streams are currently active.
|
|
235
|
+
*/
|
|
236
|
+
isActive(): boolean {
|
|
237
|
+
return this.usingChangeStreams;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Services
|
|
2
|
+
export { ChangeStreamHandler } from './change-stream-handler.js';
|
|
3
|
+
export { JobManager } from './job-manager.js';
|
|
4
|
+
export { JobProcessor } from './job-processor.js';
|
|
5
|
+
export { JobQueryService } from './job-query.js';
|
|
6
|
+
export { JobScheduler } from './job-scheduler.js';
|
|
7
|
+
// Types
|
|
8
|
+
export type { ResolvedMonqueOptions, SchedulerContext } from './types.js';
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { ObjectId, type WithId } from 'mongodb';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type BulkOperationResult,
|
|
5
|
+
type Job,
|
|
6
|
+
type JobSelector,
|
|
7
|
+
JobStatus,
|
|
8
|
+
type PersistedJob,
|
|
9
|
+
} from '@/jobs';
|
|
10
|
+
import { buildSelectorQuery } from '@/scheduler';
|
|
11
|
+
import { JobStateError } from '@/shared';
|
|
12
|
+
|
|
13
|
+
import type { SchedulerContext } from './types.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Internal service for job lifecycle management operations.
|
|
17
|
+
*
|
|
18
|
+
* Provides atomic state transitions (cancel, retry, reschedule) and deletion.
|
|
19
|
+
* Emits appropriate events on each operation.
|
|
20
|
+
*
|
|
21
|
+
* @internal Not part of public API - use Monque class methods instead.
|
|
22
|
+
*/
|
|
23
|
+
export class JobManager {
|
|
24
|
+
constructor(private readonly ctx: SchedulerContext) {}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Cancel a pending or scheduled job.
|
|
28
|
+
*
|
|
29
|
+
* Sets the job status to 'cancelled' and emits a 'job:cancelled' event.
|
|
30
|
+
* If the job is already cancelled, this is a no-op and returns the job.
|
|
31
|
+
* Cannot cancel jobs that are currently 'processing', 'completed', or 'failed'.
|
|
32
|
+
*
|
|
33
|
+
* @param jobId - The ID of the job to cancel
|
|
34
|
+
* @returns The cancelled job, or null if not found
|
|
35
|
+
* @throws {JobStateError} If job is in an invalid state for cancellation
|
|
36
|
+
*
|
|
37
|
+
* @example Cancel a pending job
|
|
38
|
+
* ```typescript
|
|
39
|
+
* const job = await monque.enqueue('report', { type: 'daily' });
|
|
40
|
+
* await monque.cancelJob(job._id.toString());
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
async cancelJob(jobId: string): Promise<PersistedJob<unknown> | null> {
|
|
44
|
+
if (!ObjectId.isValid(jobId)) return null;
|
|
45
|
+
|
|
46
|
+
const _id = new ObjectId(jobId);
|
|
47
|
+
|
|
48
|
+
// Fetch job first to allow emitting the full job object in the event
|
|
49
|
+
const jobDoc = await this.ctx.collection.findOne({ _id });
|
|
50
|
+
if (!jobDoc) return null;
|
|
51
|
+
|
|
52
|
+
const currentJob = jobDoc as unknown as WithId<Job>;
|
|
53
|
+
|
|
54
|
+
if (currentJob.status === JobStatus.CANCELLED) {
|
|
55
|
+
return this.ctx.documentToPersistedJob(currentJob);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (currentJob.status !== JobStatus.PENDING) {
|
|
59
|
+
throw new JobStateError(
|
|
60
|
+
`Cannot cancel job in status '${currentJob.status}'`,
|
|
61
|
+
jobId,
|
|
62
|
+
currentJob.status,
|
|
63
|
+
'cancel',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const result = await this.ctx.collection.findOneAndUpdate(
|
|
68
|
+
{ _id, status: JobStatus.PENDING },
|
|
69
|
+
{
|
|
70
|
+
$set: {
|
|
71
|
+
status: JobStatus.CANCELLED,
|
|
72
|
+
updatedAt: new Date(),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{ returnDocument: 'after' },
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (!result) {
|
|
79
|
+
// Race condition: job changed state between check and update
|
|
80
|
+
throw new JobStateError(
|
|
81
|
+
'Job status changed during cancellation attempt',
|
|
82
|
+
jobId,
|
|
83
|
+
'unknown',
|
|
84
|
+
'cancel',
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const job = this.ctx.documentToPersistedJob(result);
|
|
89
|
+
this.ctx.emit('job:cancelled', { job });
|
|
90
|
+
return job;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Retry a failed or cancelled job.
|
|
95
|
+
*
|
|
96
|
+
* Resets the job to 'pending' status, clears failure count/reason, and sets
|
|
97
|
+
* nextRunAt to now (immediate retry). Emits a 'job:retried' event.
|
|
98
|
+
*
|
|
99
|
+
* @param jobId - The ID of the job to retry
|
|
100
|
+
* @returns The updated job, or null if not found
|
|
101
|
+
* @throws {JobStateError} If job is in an invalid state for retry (must be failed or cancelled)
|
|
102
|
+
*
|
|
103
|
+
* @example Retry a failed job
|
|
104
|
+
* ```typescript
|
|
105
|
+
* monque.on('job:fail', async ({ job }) => {
|
|
106
|
+
* console.log(`Job ${job._id} failed, retrying manually...`);
|
|
107
|
+
* await monque.retryJob(job._id.toString());
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
async retryJob(jobId: string): Promise<PersistedJob<unknown> | null> {
|
|
112
|
+
if (!ObjectId.isValid(jobId)) return null;
|
|
113
|
+
|
|
114
|
+
const _id = new ObjectId(jobId);
|
|
115
|
+
const currentJob = await this.ctx.collection.findOne({ _id });
|
|
116
|
+
|
|
117
|
+
if (!currentJob) return null;
|
|
118
|
+
|
|
119
|
+
if (currentJob['status'] !== JobStatus.FAILED && currentJob['status'] !== JobStatus.CANCELLED) {
|
|
120
|
+
throw new JobStateError(
|
|
121
|
+
`Cannot retry job in status '${currentJob['status']}'`,
|
|
122
|
+
jobId,
|
|
123
|
+
currentJob['status'],
|
|
124
|
+
'retry',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const previousStatus = currentJob['status'] as 'failed' | 'cancelled';
|
|
129
|
+
|
|
130
|
+
const result = await this.ctx.collection.findOneAndUpdate(
|
|
131
|
+
{
|
|
132
|
+
_id,
|
|
133
|
+
status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] },
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
$set: {
|
|
137
|
+
status: JobStatus.PENDING,
|
|
138
|
+
failCount: 0,
|
|
139
|
+
nextRunAt: new Date(),
|
|
140
|
+
updatedAt: new Date(),
|
|
141
|
+
},
|
|
142
|
+
$unset: {
|
|
143
|
+
failReason: '',
|
|
144
|
+
lockedAt: '',
|
|
145
|
+
claimedBy: '',
|
|
146
|
+
lastHeartbeat: '',
|
|
147
|
+
heartbeatInterval: '',
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{ returnDocument: 'after' },
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
if (!result) {
|
|
154
|
+
throw new JobStateError('Job status changed during retry attempt', jobId, 'unknown', 'retry');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const job = this.ctx.documentToPersistedJob(result);
|
|
158
|
+
this.ctx.emit('job:retried', { job, previousStatus });
|
|
159
|
+
return job;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reschedule a pending job to run at a different time.
|
|
164
|
+
*
|
|
165
|
+
* Only works for jobs in 'pending' status.
|
|
166
|
+
*
|
|
167
|
+
* @param jobId - The ID of the job to reschedule
|
|
168
|
+
* @param runAt - The new Date when the job should run
|
|
169
|
+
* @returns The updated job, or null if not found
|
|
170
|
+
* @throws {JobStateError} If job is not in pending state
|
|
171
|
+
*
|
|
172
|
+
* @example Delay a job by 1 hour
|
|
173
|
+
* ```typescript
|
|
174
|
+
* const nextHour = new Date(Date.now() + 60 * 60 * 1000);
|
|
175
|
+
* await monque.rescheduleJob(jobId, nextHour);
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
async rescheduleJob(jobId: string, runAt: Date): Promise<PersistedJob<unknown> | null> {
|
|
179
|
+
if (!ObjectId.isValid(jobId)) return null;
|
|
180
|
+
|
|
181
|
+
const _id = new ObjectId(jobId);
|
|
182
|
+
const currentJobDoc = await this.ctx.collection.findOne({ _id });
|
|
183
|
+
|
|
184
|
+
if (!currentJobDoc) return null;
|
|
185
|
+
|
|
186
|
+
const currentJob = currentJobDoc as unknown as WithId<Job>;
|
|
187
|
+
|
|
188
|
+
if (currentJob.status !== JobStatus.PENDING) {
|
|
189
|
+
throw new JobStateError(
|
|
190
|
+
`Cannot reschedule job in status '${currentJob.status}'`,
|
|
191
|
+
jobId,
|
|
192
|
+
currentJob.status,
|
|
193
|
+
'reschedule',
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const result = await this.ctx.collection.findOneAndUpdate(
|
|
198
|
+
{ _id, status: JobStatus.PENDING },
|
|
199
|
+
{
|
|
200
|
+
$set: {
|
|
201
|
+
nextRunAt: runAt,
|
|
202
|
+
updatedAt: new Date(),
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{ returnDocument: 'after' },
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
if (!result) {
|
|
209
|
+
throw new JobStateError(
|
|
210
|
+
'Job status changed during reschedule attempt',
|
|
211
|
+
jobId,
|
|
212
|
+
'unknown',
|
|
213
|
+
'reschedule',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return this.ctx.documentToPersistedJob(result);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Permanently delete a job.
|
|
222
|
+
*
|
|
223
|
+
* This action is irreversible. Emits a 'job:deleted' event upon success.
|
|
224
|
+
* Can delete a job in any state.
|
|
225
|
+
*
|
|
226
|
+
* @param jobId - The ID of the job to delete
|
|
227
|
+
* @returns true if deleted, false if job not found
|
|
228
|
+
*
|
|
229
|
+
* @example Delete a cleanup job
|
|
230
|
+
* ```typescript
|
|
231
|
+
* const deleted = await monque.deleteJob(jobId);
|
|
232
|
+
* if (deleted) {
|
|
233
|
+
* console.log('Job permanently removed');
|
|
234
|
+
* }
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
async deleteJob(jobId: string): Promise<boolean> {
|
|
238
|
+
if (!ObjectId.isValid(jobId)) return false;
|
|
239
|
+
|
|
240
|
+
const _id = new ObjectId(jobId);
|
|
241
|
+
|
|
242
|
+
const result = await this.ctx.collection.deleteOne({ _id });
|
|
243
|
+
|
|
244
|
+
if (result.deletedCount > 0) {
|
|
245
|
+
this.ctx.emit('job:deleted', { jobId });
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
253
|
+
// Bulk Operations
|
|
254
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Cancel multiple jobs matching the given filter.
|
|
258
|
+
*
|
|
259
|
+
* Only cancels jobs in 'pending' status. Jobs in other states are collected
|
|
260
|
+
* as errors in the result. Emits a 'jobs:cancelled' event with the IDs of
|
|
261
|
+
* successfully cancelled jobs.
|
|
262
|
+
*
|
|
263
|
+
* @param filter - Selector for which jobs to cancel (name, status, date range)
|
|
264
|
+
* @returns Result with count of cancelled jobs and any errors encountered
|
|
265
|
+
*
|
|
266
|
+
* @example Cancel all pending jobs for a queue
|
|
267
|
+
* ```typescript
|
|
268
|
+
* const result = await monque.cancelJobs({
|
|
269
|
+
* name: 'email-queue',
|
|
270
|
+
* status: 'pending'
|
|
271
|
+
* });
|
|
272
|
+
* console.log(`Cancelled ${result.count} jobs`);
|
|
273
|
+
* ```
|
|
274
|
+
*/
|
|
275
|
+
async cancelJobs(filter: JobSelector): Promise<BulkOperationResult> {
|
|
276
|
+
const baseQuery = buildSelectorQuery(filter);
|
|
277
|
+
const errors: Array<{ jobId: string; error: string }> = [];
|
|
278
|
+
const cancelledIds: string[] = [];
|
|
279
|
+
|
|
280
|
+
// Find all matching jobs and stream them to avoid memory pressure
|
|
281
|
+
const cursor = this.ctx.collection.find(baseQuery);
|
|
282
|
+
|
|
283
|
+
for await (const doc of cursor) {
|
|
284
|
+
const job = doc as unknown as WithId<Job>;
|
|
285
|
+
const jobId = job._id.toString();
|
|
286
|
+
|
|
287
|
+
if (job.status !== JobStatus.PENDING && job.status !== JobStatus.CANCELLED) {
|
|
288
|
+
errors.push({
|
|
289
|
+
jobId,
|
|
290
|
+
error: `Cannot cancel job in status '${job.status}'`,
|
|
291
|
+
});
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Skip already cancelled jobs (idempotent)
|
|
296
|
+
if (job.status === JobStatus.CANCELLED) {
|
|
297
|
+
cancelledIds.push(jobId);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Atomically update to cancelled
|
|
302
|
+
const result = await this.ctx.collection.findOneAndUpdate(
|
|
303
|
+
{ _id: job._id, status: JobStatus.PENDING },
|
|
304
|
+
{
|
|
305
|
+
$set: {
|
|
306
|
+
status: JobStatus.CANCELLED,
|
|
307
|
+
updatedAt: new Date(),
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
{ returnDocument: 'after' },
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
if (result) {
|
|
314
|
+
cancelledIds.push(jobId);
|
|
315
|
+
} else {
|
|
316
|
+
// Race condition: status changed
|
|
317
|
+
errors.push({
|
|
318
|
+
jobId,
|
|
319
|
+
error: 'Job status changed during cancellation',
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (cancelledIds.length > 0) {
|
|
325
|
+
this.ctx.emit('jobs:cancelled', {
|
|
326
|
+
jobIds: cancelledIds,
|
|
327
|
+
count: cancelledIds.length,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
count: cancelledIds.length,
|
|
333
|
+
errors,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Retry multiple jobs matching the given filter.
|
|
339
|
+
*
|
|
340
|
+
* Only retries jobs in 'failed' or 'cancelled' status. Jobs in other states
|
|
341
|
+
* are collected as errors in the result. Emits a 'jobs:retried' event with
|
|
342
|
+
* the IDs of successfully retried jobs.
|
|
343
|
+
*
|
|
344
|
+
* @param filter - Selector for which jobs to retry (name, status, date range)
|
|
345
|
+
* @returns Result with count of retried jobs and any errors encountered
|
|
346
|
+
*
|
|
347
|
+
* @example Retry all failed jobs
|
|
348
|
+
* ```typescript
|
|
349
|
+
* const result = await monque.retryJobs({
|
|
350
|
+
* status: 'failed'
|
|
351
|
+
* });
|
|
352
|
+
* console.log(`Retried ${result.count} jobs`);
|
|
353
|
+
* ```
|
|
354
|
+
*/
|
|
355
|
+
async retryJobs(filter: JobSelector): Promise<BulkOperationResult> {
|
|
356
|
+
const baseQuery = buildSelectorQuery(filter);
|
|
357
|
+
const errors: Array<{ jobId: string; error: string }> = [];
|
|
358
|
+
const retriedIds: string[] = [];
|
|
359
|
+
|
|
360
|
+
const cursor = this.ctx.collection.find(baseQuery);
|
|
361
|
+
|
|
362
|
+
for await (const doc of cursor) {
|
|
363
|
+
const job = doc as unknown as WithId<Job>;
|
|
364
|
+
const jobId = job._id.toString();
|
|
365
|
+
|
|
366
|
+
if (job.status !== JobStatus.FAILED && job.status !== JobStatus.CANCELLED) {
|
|
367
|
+
errors.push({
|
|
368
|
+
jobId,
|
|
369
|
+
error: `Cannot retry job in status '${job.status}'`,
|
|
370
|
+
});
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const result = await this.ctx.collection.findOneAndUpdate(
|
|
375
|
+
{
|
|
376
|
+
_id: job._id,
|
|
377
|
+
status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] },
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
$set: {
|
|
381
|
+
status: JobStatus.PENDING,
|
|
382
|
+
failCount: 0,
|
|
383
|
+
nextRunAt: new Date(),
|
|
384
|
+
updatedAt: new Date(),
|
|
385
|
+
},
|
|
386
|
+
$unset: {
|
|
387
|
+
failReason: '',
|
|
388
|
+
lockedAt: '',
|
|
389
|
+
claimedBy: '',
|
|
390
|
+
lastHeartbeat: '',
|
|
391
|
+
heartbeatInterval: '',
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
{ returnDocument: 'after' },
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
if (result) {
|
|
398
|
+
retriedIds.push(jobId);
|
|
399
|
+
} else {
|
|
400
|
+
errors.push({
|
|
401
|
+
jobId,
|
|
402
|
+
error: 'Job status changed during retry attempt',
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (retriedIds.length > 0) {
|
|
408
|
+
this.ctx.emit('jobs:retried', {
|
|
409
|
+
jobIds: retriedIds,
|
|
410
|
+
count: retriedIds.length,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
count: retriedIds.length,
|
|
416
|
+
errors,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Delete multiple jobs matching the given filter.
|
|
422
|
+
*
|
|
423
|
+
* Deletes jobs in any status. Uses a batch delete for efficiency.
|
|
424
|
+
* Emits a 'jobs:deleted' event with the count of deleted jobs.
|
|
425
|
+
* Does not emit individual 'job:deleted' events to avoid noise.
|
|
426
|
+
*
|
|
427
|
+
* @param filter - Selector for which jobs to delete (name, status, date range)
|
|
428
|
+
* @returns Result with count of deleted jobs (errors array always empty for delete)
|
|
429
|
+
*
|
|
430
|
+
* @example Delete old completed jobs
|
|
431
|
+
* ```typescript
|
|
432
|
+
* const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
433
|
+
* const result = await monque.deleteJobs({
|
|
434
|
+
* status: 'completed',
|
|
435
|
+
* olderThan: weekAgo
|
|
436
|
+
* });
|
|
437
|
+
* console.log(`Deleted ${result.count} jobs`);
|
|
438
|
+
* ```
|
|
439
|
+
*/
|
|
440
|
+
async deleteJobs(filter: JobSelector): Promise<BulkOperationResult> {
|
|
441
|
+
const query = buildSelectorQuery(filter);
|
|
442
|
+
|
|
443
|
+
// Use deleteMany for efficiency
|
|
444
|
+
const result = await this.ctx.collection.deleteMany(query);
|
|
445
|
+
|
|
446
|
+
if (result.deletedCount > 0) {
|
|
447
|
+
this.ctx.emit('jobs:deleted', { count: result.deletedCount });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
count: result.deletedCount,
|
|
452
|
+
errors: [],
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
}
|