@monque/core 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CHANGELOG.md +30 -0
- package/dist/index.cjs +281 -124
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +62 -4
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +62 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +279 -125
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +3 -0
- package/src/scheduler/monque.ts +87 -26
- package/src/scheduler/services/index.ts +1 -1
- package/src/scheduler/services/job-manager.ts +151 -117
- package/src/scheduler/services/job-processor.ts +67 -49
- package/src/scheduler/services/job-scheduler.ts +24 -5
- package/src/scheduler/services/lifecycle-manager.ts +6 -1
- package/src/scheduler/services/types.ts +3 -0
- package/src/shared/errors.ts +29 -0
- package/src/shared/index.ts +3 -0
- package/src/shared/utils/index.ts +1 -0
- package/src/shared/utils/job-identifiers.ts +71 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monque/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "MongoDB-backed job scheduler with atomic locking, exponential backoff, and cron scheduling",
|
|
5
5
|
"author": "Maurice de Bruyn <debruyn.maurice@gmail.com>",
|
|
6
6
|
"repository": {
|
|
@@ -76,13 +76,13 @@
|
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
78
|
"@faker-js/faker": "^10.3.0",
|
|
79
|
-
"@testcontainers/mongodb": "^11.
|
|
79
|
+
"@testcontainers/mongodb": "^11.13.0",
|
|
80
80
|
"@total-typescript/ts-reset": "^0.6.1",
|
|
81
81
|
"@types/node": "^22.19.15",
|
|
82
82
|
"@vitest/coverage-v8": "^4.1.0",
|
|
83
83
|
"fishery": "^2.4.0",
|
|
84
84
|
"mongodb": "^7.1.0",
|
|
85
|
-
"tsdown": "^0.21.
|
|
85
|
+
"tsdown": "^0.21.4",
|
|
86
86
|
"vitest": "^4.1.0"
|
|
87
87
|
}
|
|
88
88
|
}
|
package/src/index.ts
CHANGED
|
@@ -41,11 +41,14 @@ export {
|
|
|
41
41
|
getNextCronDate,
|
|
42
42
|
InvalidCronError,
|
|
43
43
|
InvalidCursorError,
|
|
44
|
+
InvalidJobIdentifierError,
|
|
44
45
|
JobStateError,
|
|
45
46
|
MonqueError,
|
|
46
47
|
PayloadTooLargeError,
|
|
47
48
|
ShutdownTimeoutError,
|
|
48
49
|
validateCronExpression,
|
|
50
|
+
validateJobName,
|
|
51
|
+
validateUniqueKey,
|
|
49
52
|
WorkerRegistrationError,
|
|
50
53
|
} from '@/shared';
|
|
51
54
|
// Types - Workers
|
package/src/scheduler/monque.ts
CHANGED
|
@@ -18,11 +18,18 @@ import {
|
|
|
18
18
|
type QueueStats,
|
|
19
19
|
type ScheduleOptions,
|
|
20
20
|
} from '@/jobs';
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
ConnectionError,
|
|
23
|
+
ShutdownTimeoutError,
|
|
24
|
+
validateJobName,
|
|
25
|
+
validateUniqueKey,
|
|
26
|
+
WorkerRegistrationError,
|
|
27
|
+
} from '@/shared';
|
|
22
28
|
import type { WorkerOptions, WorkerRegistration } from '@/workers';
|
|
23
29
|
|
|
24
30
|
import {
|
|
25
31
|
ChangeStreamHandler,
|
|
32
|
+
CLEANUP_STATUSES,
|
|
26
33
|
JobManager,
|
|
27
34
|
JobProcessor,
|
|
28
35
|
JobQueryService,
|
|
@@ -122,6 +129,15 @@ export class Monque extends EventEmitter {
|
|
|
122
129
|
private isRunning = false;
|
|
123
130
|
private isInitialized = false;
|
|
124
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Resolve function for the reactive shutdown drain promise.
|
|
134
|
+
* Set during stop() when active jobs need to finish; called by
|
|
135
|
+
* onJobFinished() when the last active job completes.
|
|
136
|
+
*
|
|
137
|
+
* @private
|
|
138
|
+
*/
|
|
139
|
+
private _drainResolve: (() => void) | null = null;
|
|
140
|
+
|
|
125
141
|
// Internal services (initialized in initialize())
|
|
126
142
|
private _scheduler: JobScheduler | null = null;
|
|
127
143
|
private _manager: JobManager | null = null;
|
|
@@ -132,6 +148,7 @@ export class Monque extends EventEmitter {
|
|
|
132
148
|
|
|
133
149
|
constructor(db: Db, options: MonqueOptions = {}) {
|
|
134
150
|
super();
|
|
151
|
+
this.setMaxListeners(20);
|
|
135
152
|
this.db = db;
|
|
136
153
|
this.options = {
|
|
137
154
|
collectionName: options.collectionName ?? DEFAULTS.collectionName,
|
|
@@ -153,6 +170,17 @@ export class Monque extends EventEmitter {
|
|
|
153
170
|
maxPayloadSize: options.maxPayloadSize,
|
|
154
171
|
statsCacheTtlMs: options.statsCacheTtlMs ?? 5000,
|
|
155
172
|
};
|
|
173
|
+
|
|
174
|
+
if (options.defaultConcurrency !== undefined) {
|
|
175
|
+
console.warn(
|
|
176
|
+
'[@monque/core] "defaultConcurrency" is deprecated and will be removed in a future major version. Use "workerConcurrency" instead.',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (options.maxConcurrency !== undefined) {
|
|
180
|
+
console.warn(
|
|
181
|
+
'[@monque/core] "maxConcurrency" is deprecated and will be removed in a future major version. Use "instanceConcurrency" instead.',
|
|
182
|
+
);
|
|
183
|
+
}
|
|
156
184
|
}
|
|
157
185
|
|
|
158
186
|
/**
|
|
@@ -259,6 +287,14 @@ export class Monque extends EventEmitter {
|
|
|
259
287
|
return this._lifecycleManager;
|
|
260
288
|
}
|
|
261
289
|
|
|
290
|
+
private validateSchedulingIdentifiers(name: string, uniqueKey?: string): void {
|
|
291
|
+
validateJobName(name);
|
|
292
|
+
|
|
293
|
+
if (uniqueKey !== undefined) {
|
|
294
|
+
validateUniqueKey(uniqueKey);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
262
298
|
/**
|
|
263
299
|
* Handle a change-stream-triggered poll and reset the safety poll timer.
|
|
264
300
|
*
|
|
@@ -294,6 +330,7 @@ export class Monque extends EventEmitter {
|
|
|
294
330
|
|
|
295
331
|
this._changeStreamHandler.notifyPendingJob(name, nextRunAt);
|
|
296
332
|
},
|
|
333
|
+
notifyJobFinished: () => this.onJobFinished(),
|
|
297
334
|
documentToPersistedJob: <T>(doc: WithId<Document>) => documentToPersistedJob<T>(doc),
|
|
298
335
|
};
|
|
299
336
|
}
|
|
@@ -341,6 +378,20 @@ export class Monque extends EventEmitter {
|
|
|
341
378
|
{ key: { status: 1, nextRunAt: 1, claimedBy: 1 }, background: true },
|
|
342
379
|
// Expanded index that supports recovery scans (status + lockedAt) plus heartbeat monitoring patterns.
|
|
343
380
|
{ key: { status: 1, lockedAt: 1, lastHeartbeat: 1 }, background: true },
|
|
381
|
+
// Index for efficient lifecycle manager cleanup when jobRetention is configured.
|
|
382
|
+
// Allows fast queries for deleteMany({ status, updatedAt: { $lt: cutoff } }).
|
|
383
|
+
...(this.options.jobRetention
|
|
384
|
+
? [
|
|
385
|
+
{
|
|
386
|
+
key: { status: 1, updatedAt: 1 } as const,
|
|
387
|
+
background: true,
|
|
388
|
+
partialFilterExpression: {
|
|
389
|
+
status: { $in: CLEANUP_STATUSES },
|
|
390
|
+
updatedAt: { $exists: true },
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
]
|
|
394
|
+
: []),
|
|
344
395
|
]);
|
|
345
396
|
}
|
|
346
397
|
|
|
@@ -370,7 +421,6 @@ export class Monque extends EventEmitter {
|
|
|
370
421
|
lockedAt: '',
|
|
371
422
|
claimedBy: '',
|
|
372
423
|
lastHeartbeat: '',
|
|
373
|
-
heartbeatInterval: '',
|
|
374
424
|
},
|
|
375
425
|
},
|
|
376
426
|
);
|
|
@@ -437,6 +487,7 @@ export class Monque extends EventEmitter {
|
|
|
437
487
|
* @param data - Job payload, will be passed to the worker handler
|
|
438
488
|
* @param options - Scheduling and deduplication options
|
|
439
489
|
* @returns Promise resolving to the created or existing job document
|
|
490
|
+
* @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
|
|
440
491
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
441
492
|
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
442
493
|
*
|
|
@@ -469,6 +520,7 @@ export class Monque extends EventEmitter {
|
|
|
469
520
|
*/
|
|
470
521
|
async enqueue<T>(name: string, data: T, options: EnqueueOptions = {}): Promise<PersistedJob<T>> {
|
|
471
522
|
this.ensureInitialized();
|
|
523
|
+
this.validateSchedulingIdentifiers(name, options.uniqueKey);
|
|
472
524
|
return this.scheduler.enqueue(name, data, options);
|
|
473
525
|
}
|
|
474
526
|
|
|
@@ -482,6 +534,7 @@ export class Monque extends EventEmitter {
|
|
|
482
534
|
* @param name - Job type identifier, must match a registered worker
|
|
483
535
|
* @param data - Job payload, will be passed to the worker handler
|
|
484
536
|
* @returns Promise resolving to the created job document
|
|
537
|
+
* @throws {InvalidJobIdentifierError} If `name` fails public identifier validation
|
|
485
538
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
486
539
|
*
|
|
487
540
|
* @example Send email immediately
|
|
@@ -504,6 +557,7 @@ export class Monque extends EventEmitter {
|
|
|
504
557
|
*/
|
|
505
558
|
async now<T>(name: string, data: T): Promise<PersistedJob<T>> {
|
|
506
559
|
this.ensureInitialized();
|
|
560
|
+
validateJobName(name);
|
|
507
561
|
return this.scheduler.now(name, data);
|
|
508
562
|
}
|
|
509
563
|
|
|
@@ -525,6 +579,7 @@ export class Monque extends EventEmitter {
|
|
|
525
579
|
* @param data - Job payload, will be passed to the worker handler on each run
|
|
526
580
|
* @param options - Scheduling options (uniqueKey for deduplication)
|
|
527
581
|
* @returns Promise resolving to the created job document with `repeatInterval` set
|
|
582
|
+
* @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
|
|
528
583
|
* @throws {InvalidCronError} If cron expression is invalid
|
|
529
584
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
530
585
|
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
@@ -561,6 +616,7 @@ export class Monque extends EventEmitter {
|
|
|
561
616
|
options: ScheduleOptions = {},
|
|
562
617
|
): Promise<PersistedJob<T>> {
|
|
563
618
|
this.ensureInitialized();
|
|
619
|
+
this.validateSchedulingIdentifiers(name, options.uniqueKey);
|
|
564
620
|
return this.scheduler.schedule(cron, name, data, options);
|
|
565
621
|
}
|
|
566
622
|
|
|
@@ -930,6 +986,7 @@ export class Monque extends EventEmitter {
|
|
|
930
986
|
* @param options - Worker configuration
|
|
931
987
|
* @param options.concurrency - Maximum concurrent jobs for this worker (default: `defaultConcurrency`)
|
|
932
988
|
* @param options.replace - When `true`, replace existing worker instead of throwing error
|
|
989
|
+
* @throws {InvalidJobIdentifierError} If `name` fails public identifier validation
|
|
933
990
|
* @throws {WorkerRegistrationError} When a worker is already registered for `name` and `replace` is not `true`
|
|
934
991
|
*
|
|
935
992
|
* @example Basic email worker
|
|
@@ -973,6 +1030,7 @@ export class Monque extends EventEmitter {
|
|
|
973
1030
|
* ```
|
|
974
1031
|
*/
|
|
975
1032
|
register<T>(name: string, handler: JobHandler<T>, options: WorkerOptions = {}): void {
|
|
1033
|
+
validateJobName(name);
|
|
976
1034
|
const concurrency = options.concurrency ?? this.options.workerConcurrency;
|
|
977
1035
|
|
|
978
1036
|
// Check for existing worker and throw unless replace is explicitly true
|
|
@@ -1116,20 +1174,14 @@ export class Monque extends EventEmitter {
|
|
|
1116
1174
|
}
|
|
1117
1175
|
|
|
1118
1176
|
// Wait for all active jobs to complete (with timeout)
|
|
1119
|
-
|
|
1120
|
-
if (activeJobs.length === 0) {
|
|
1177
|
+
if (this.getActiveJobCount() === 0) {
|
|
1121
1178
|
return;
|
|
1122
1179
|
}
|
|
1123
1180
|
|
|
1124
|
-
//
|
|
1125
|
-
|
|
1181
|
+
// Reactive drain: resolve when the last active job finishes.
|
|
1182
|
+
// onJobFinished() is called from processJob's finally block.
|
|
1126
1183
|
const waitForJobs = new Promise<undefined>((resolve) => {
|
|
1127
|
-
|
|
1128
|
-
if (this.getActiveJobs().length === 0) {
|
|
1129
|
-
clearInterval(checkInterval);
|
|
1130
|
-
resolve(undefined);
|
|
1131
|
-
}
|
|
1132
|
-
}, 100);
|
|
1184
|
+
this._drainResolve = () => resolve(undefined);
|
|
1133
1185
|
});
|
|
1134
1186
|
|
|
1135
1187
|
// Race between job completion and timeout
|
|
@@ -1137,15 +1189,9 @@ export class Monque extends EventEmitter {
|
|
|
1137
1189
|
setTimeout(() => resolve('timeout'), this.options.shutdownTimeout);
|
|
1138
1190
|
});
|
|
1139
1191
|
|
|
1140
|
-
|
|
1192
|
+
const result = await Promise.race([waitForJobs, timeout]);
|
|
1141
1193
|
|
|
1142
|
-
|
|
1143
|
-
result = await Promise.race([waitForJobs, timeout]);
|
|
1144
|
-
} finally {
|
|
1145
|
-
if (checkInterval) {
|
|
1146
|
-
clearInterval(checkInterval);
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1194
|
+
this._drainResolve = null;
|
|
1149
1195
|
|
|
1150
1196
|
if (result === 'timeout') {
|
|
1151
1197
|
const incompleteJobs = this.getActiveJobsList();
|
|
@@ -1212,6 +1258,18 @@ export class Monque extends EventEmitter {
|
|
|
1212
1258
|
// Private Helpers
|
|
1213
1259
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1214
1260
|
|
|
1261
|
+
/**
|
|
1262
|
+
* Called when a job finishes processing. If a shutdown drain is pending
|
|
1263
|
+
* and no active jobs remain, resolves the drain promise.
|
|
1264
|
+
*
|
|
1265
|
+
* @private
|
|
1266
|
+
*/
|
|
1267
|
+
private onJobFinished(): void {
|
|
1268
|
+
if (this._drainResolve && this.getActiveJobCount() === 0) {
|
|
1269
|
+
this._drainResolve();
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1215
1273
|
/**
|
|
1216
1274
|
* Ensure the scheduler is initialized before operations.
|
|
1217
1275
|
*
|
|
@@ -1225,17 +1283,20 @@ export class Monque extends EventEmitter {
|
|
|
1225
1283
|
}
|
|
1226
1284
|
|
|
1227
1285
|
/**
|
|
1228
|
-
* Get
|
|
1286
|
+
* Get total count of active jobs across all workers.
|
|
1287
|
+
*
|
|
1288
|
+
* Returns only the count (O(workers)) instead of allocating
|
|
1289
|
+
* a throw-away array of IDs, since callers only need `.length`.
|
|
1229
1290
|
*
|
|
1230
1291
|
* @private
|
|
1231
|
-
* @returns
|
|
1292
|
+
* @returns Number of jobs currently being processed
|
|
1232
1293
|
*/
|
|
1233
|
-
private
|
|
1234
|
-
|
|
1294
|
+
private getActiveJobCount(): number {
|
|
1295
|
+
let count = 0;
|
|
1235
1296
|
for (const worker of this.workers.values()) {
|
|
1236
|
-
|
|
1297
|
+
count += worker.activeJobs.size;
|
|
1237
1298
|
}
|
|
1238
|
-
return
|
|
1299
|
+
return count;
|
|
1239
1300
|
}
|
|
1240
1301
|
|
|
1241
1302
|
/**
|
|
@@ -4,6 +4,6 @@ export { JobManager } from './job-manager.js';
|
|
|
4
4
|
export { JobProcessor } from './job-processor.js';
|
|
5
5
|
export { JobQueryService } from './job-query.js';
|
|
6
6
|
export { JobScheduler } from './job-scheduler.js';
|
|
7
|
-
export { LifecycleManager } from './lifecycle-manager.js';
|
|
7
|
+
export { CLEANUP_STATUSES, LifecycleManager } from './lifecycle-manager.js';
|
|
8
8
|
// Types
|
|
9
9
|
export type { ResolvedMonqueOptions, SchedulerContext } from './types.js';
|
|
@@ -39,47 +39,48 @@ export class JobManager {
|
|
|
39
39
|
|
|
40
40
|
const _id = new ObjectId(jobId);
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
try {
|
|
43
|
+
const now = new Date();
|
|
44
|
+
const result = await this.ctx.collection.findOneAndUpdate(
|
|
45
|
+
{ _id, status: JobStatus.PENDING },
|
|
46
|
+
{
|
|
47
|
+
$set: {
|
|
48
|
+
status: JobStatus.CANCELLED,
|
|
49
|
+
updatedAt: now,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{ returnDocument: 'after' },
|
|
53
|
+
);
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
if (!result) {
|
|
56
|
+
const jobDoc = await this.ctx.collection.findOne({ _id });
|
|
57
|
+
if (!jobDoc) return null;
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
jobId,
|
|
54
|
-
jobDoc['status'],
|
|
55
|
-
'cancel',
|
|
56
|
-
);
|
|
57
|
-
}
|
|
59
|
+
if (jobDoc['status'] === JobStatus.CANCELLED) {
|
|
60
|
+
return this.ctx.documentToPersistedJob(jobDoc);
|
|
61
|
+
}
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
63
|
+
throw new JobStateError(
|
|
64
|
+
`Cannot cancel job in status '${jobDoc['status']}'`,
|
|
65
|
+
jobId,
|
|
66
|
+
jobDoc['status'],
|
|
67
|
+
'cancel',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const job = this.ctx.documentToPersistedJob(result);
|
|
72
|
+
this.ctx.emit('job:cancelled', { job });
|
|
73
|
+
return job;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof MonqueError) {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
const message = error instanceof Error ? error.message : 'Unknown error during cancelJob';
|
|
79
|
+
throw new ConnectionError(
|
|
80
|
+
`Failed to cancel job: ${message}`,
|
|
81
|
+
error instanceof Error ? { cause: error } : undefined,
|
|
77
82
|
);
|
|
78
83
|
}
|
|
79
|
-
|
|
80
|
-
const job = this.ctx.documentToPersistedJob(result);
|
|
81
|
-
this.ctx.emit('job:cancelled', { job });
|
|
82
|
-
return job;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
/**
|
|
@@ -104,52 +105,69 @@ export class JobManager {
|
|
|
104
105
|
if (!ObjectId.isValid(jobId)) return null;
|
|
105
106
|
|
|
106
107
|
const _id = new ObjectId(jobId);
|
|
107
|
-
const currentJob = await this.ctx.collection.findOne({ _id });
|
|
108
|
-
|
|
109
|
-
if (!currentJob) return null;
|
|
110
108
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
109
|
+
try {
|
|
110
|
+
const now = new Date();
|
|
111
|
+
const result = await this.ctx.collection.findOneAndUpdate(
|
|
112
|
+
{
|
|
113
|
+
_id,
|
|
114
|
+
status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] },
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
$set: {
|
|
118
|
+
status: JobStatus.PENDING,
|
|
119
|
+
failCount: 0,
|
|
120
|
+
nextRunAt: now,
|
|
121
|
+
updatedAt: now,
|
|
122
|
+
},
|
|
123
|
+
$unset: {
|
|
124
|
+
failReason: '',
|
|
125
|
+
lockedAt: '',
|
|
126
|
+
claimedBy: '',
|
|
127
|
+
lastHeartbeat: '',
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{ returnDocument: 'before' },
|
|
117
131
|
);
|
|
118
|
-
}
|
|
119
132
|
|
|
120
|
-
|
|
133
|
+
if (!result) {
|
|
134
|
+
const currentJob = await this.ctx.collection.findOne({ _id });
|
|
135
|
+
if (!currentJob) return null;
|
|
121
136
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
status: JobStatus.PENDING,
|
|
130
|
-
failCount: 0,
|
|
131
|
-
nextRunAt: new Date(),
|
|
132
|
-
updatedAt: new Date(),
|
|
133
|
-
},
|
|
134
|
-
$unset: {
|
|
135
|
-
failReason: '',
|
|
136
|
-
lockedAt: '',
|
|
137
|
-
claimedBy: '',
|
|
138
|
-
lastHeartbeat: '',
|
|
139
|
-
heartbeatInterval: '',
|
|
140
|
-
},
|
|
141
|
-
},
|
|
142
|
-
{ returnDocument: 'after' },
|
|
143
|
-
);
|
|
137
|
+
throw new JobStateError(
|
|
138
|
+
`Cannot retry job in status '${currentJob['status']}'`,
|
|
139
|
+
jobId,
|
|
140
|
+
currentJob['status'],
|
|
141
|
+
'retry',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
144
|
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
const previousStatus = result['status'] as 'failed' | 'cancelled';
|
|
146
|
+
|
|
147
|
+
const updatedDoc = { ...result };
|
|
148
|
+
updatedDoc['status'] = JobStatus.PENDING;
|
|
149
|
+
updatedDoc['failCount'] = 0;
|
|
150
|
+
updatedDoc['nextRunAt'] = now;
|
|
151
|
+
updatedDoc['updatedAt'] = now;
|
|
152
|
+
delete updatedDoc['failReason'];
|
|
153
|
+
delete updatedDoc['lockedAt'];
|
|
154
|
+
delete updatedDoc['claimedBy'];
|
|
155
|
+
delete updatedDoc['lastHeartbeat'];
|
|
156
|
+
|
|
157
|
+
const job = this.ctx.documentToPersistedJob(updatedDoc);
|
|
158
|
+
this.ctx.notifyPendingJob(job.name, job.nextRunAt);
|
|
159
|
+
this.ctx.emit('job:retried', { job, previousStatus });
|
|
160
|
+
return job;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error instanceof MonqueError) {
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
const message = error instanceof Error ? error.message : 'Unknown error during retryJob';
|
|
166
|
+
throw new ConnectionError(
|
|
167
|
+
`Failed to retry job: ${message}`,
|
|
168
|
+
error instanceof Error ? { cause: error } : undefined,
|
|
169
|
+
);
|
|
147
170
|
}
|
|
148
|
-
|
|
149
|
-
const job = this.ctx.documentToPersistedJob(result);
|
|
150
|
-
this.ctx.notifyPendingJob(job.name, job.nextRunAt);
|
|
151
|
-
this.ctx.emit('job:retried', { job, previousStatus });
|
|
152
|
-
return job;
|
|
153
171
|
}
|
|
154
172
|
|
|
155
173
|
/**
|
|
@@ -172,42 +190,45 @@ export class JobManager {
|
|
|
172
190
|
if (!ObjectId.isValid(jobId)) return null;
|
|
173
191
|
|
|
174
192
|
const _id = new ObjectId(jobId);
|
|
175
|
-
const currentJobDoc = await this.ctx.collection.findOne({ _id });
|
|
176
|
-
|
|
177
|
-
if (!currentJobDoc) return null;
|
|
178
193
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
194
|
+
try {
|
|
195
|
+
const now = new Date();
|
|
196
|
+
const result = await this.ctx.collection.findOneAndUpdate(
|
|
197
|
+
{ _id, status: JobStatus.PENDING },
|
|
198
|
+
{
|
|
199
|
+
$set: {
|
|
200
|
+
nextRunAt: runAt,
|
|
201
|
+
updatedAt: now,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
{ returnDocument: 'after' },
|
|
185
205
|
);
|
|
186
|
-
}
|
|
187
206
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
207
|
+
if (!result) {
|
|
208
|
+
const currentJobDoc = await this.ctx.collection.findOne({ _id });
|
|
209
|
+
if (!currentJobDoc) return null;
|
|
210
|
+
|
|
211
|
+
throw new JobStateError(
|
|
212
|
+
`Cannot reschedule job in status '${currentJobDoc['status']}'`,
|
|
213
|
+
jobId,
|
|
214
|
+
currentJobDoc['status'],
|
|
215
|
+
'reschedule',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const job = this.ctx.documentToPersistedJob(result);
|
|
220
|
+
this.ctx.notifyPendingJob(job.name, job.nextRunAt);
|
|
221
|
+
return job;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error instanceof MonqueError) {
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
const message = error instanceof Error ? error.message : 'Unknown error during rescheduleJob';
|
|
227
|
+
throw new ConnectionError(
|
|
228
|
+
`Failed to reschedule job: ${message}`,
|
|
229
|
+
error instanceof Error ? { cause: error } : undefined,
|
|
205
230
|
);
|
|
206
231
|
}
|
|
207
|
-
|
|
208
|
-
const job = this.ctx.documentToPersistedJob(result);
|
|
209
|
-
this.ctx.notifyPendingJob(job.name, job.nextRunAt);
|
|
210
|
-
return job;
|
|
211
232
|
}
|
|
212
233
|
|
|
213
234
|
/**
|
|
@@ -232,14 +253,25 @@ export class JobManager {
|
|
|
232
253
|
|
|
233
254
|
const _id = new ObjectId(jobId);
|
|
234
255
|
|
|
235
|
-
|
|
256
|
+
try {
|
|
257
|
+
const result = await this.ctx.collection.deleteOne({ _id });
|
|
236
258
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
259
|
+
if (result.deletedCount > 0) {
|
|
260
|
+
this.ctx.emit('job:deleted', { jobId });
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
241
263
|
|
|
242
|
-
|
|
264
|
+
return false;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error instanceof MonqueError) {
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
const message = error instanceof Error ? error.message : 'Unknown error during deleteJob';
|
|
270
|
+
throw new ConnectionError(
|
|
271
|
+
`Failed to delete job: ${message}`,
|
|
272
|
+
error instanceof Error ? { cause: error } : undefined,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
243
275
|
}
|
|
244
276
|
|
|
245
277
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -279,10 +311,11 @@ export class JobManager {
|
|
|
279
311
|
query['status'] = JobStatus.PENDING;
|
|
280
312
|
|
|
281
313
|
try {
|
|
314
|
+
const now = new Date();
|
|
282
315
|
const result = await this.ctx.collection.updateMany(query, {
|
|
283
316
|
$set: {
|
|
284
317
|
status: JobStatus.CANCELLED,
|
|
285
|
-
updatedAt:
|
|
318
|
+
updatedAt: now,
|
|
286
319
|
},
|
|
287
320
|
});
|
|
288
321
|
|
|
@@ -346,19 +379,20 @@ export class JobManager {
|
|
|
346
379
|
const spreadWindowMs = 30_000; // 30s max spread for staggered retry
|
|
347
380
|
|
|
348
381
|
try {
|
|
382
|
+
const now = new Date();
|
|
349
383
|
const result = await this.ctx.collection.updateMany(query, [
|
|
350
384
|
{
|
|
351
385
|
$set: {
|
|
352
386
|
status: JobStatus.PENDING,
|
|
353
387
|
failCount: 0,
|
|
354
388
|
nextRunAt: {
|
|
355
|
-
$add: [
|
|
389
|
+
$add: [now, { $multiply: [{ $rand: {} }, spreadWindowMs] }],
|
|
356
390
|
},
|
|
357
|
-
updatedAt:
|
|
391
|
+
updatedAt: now,
|
|
358
392
|
},
|
|
359
393
|
},
|
|
360
394
|
{
|
|
361
|
-
$unset: ['failReason', 'lockedAt', 'claimedBy', 'lastHeartbeat'
|
|
395
|
+
$unset: ['failReason', 'lockedAt', 'claimedBy', 'lastHeartbeat'],
|
|
362
396
|
},
|
|
363
397
|
]);
|
|
364
398
|
|