@monque/core 1.2.0 → 1.4.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/README.md +7 -7
- package/dist/CHANGELOG.md +28 -0
- package/dist/README.md +7 -7
- package/dist/index.cjs +250 -146
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +10 -12
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +10 -12
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +249 -146
- package/dist/index.mjs.map +1 -1
- package/package.json +10 -10
- package/src/jobs/document-to-persisted-job.ts +52 -0
- package/src/jobs/index.ts +2 -0
- package/src/scheduler/monque.ts +33 -91
- package/src/scheduler/services/change-stream-handler.ts +2 -1
- package/src/scheduler/services/job-manager.ts +20 -32
- package/src/scheduler/services/job-processor.ts +111 -63
- package/src/scheduler/types.ts +11 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/utils/error.ts +33 -0
- package/src/shared/utils/index.ts +1 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isPersistedJob, type Job, JobStatus, type PersistedJob } from '@/jobs';
|
|
2
|
-
import { calculateBackoff, getNextCronDate } from '@/shared';
|
|
2
|
+
import { calculateBackoff, getNextCronDate, toError } from '@/shared';
|
|
3
3
|
import type { WorkerRegistration } from '@/workers';
|
|
4
4
|
|
|
5
5
|
import type { SchedulerContext } from './types.js';
|
|
@@ -13,6 +13,9 @@ import type { SchedulerContext } from './types.js';
|
|
|
13
13
|
* @internal Not part of public API.
|
|
14
14
|
*/
|
|
15
15
|
export class JobProcessor {
|
|
16
|
+
/** Guard flag to prevent concurrent poll() execution */
|
|
17
|
+
private _isPolling = false;
|
|
18
|
+
|
|
16
19
|
constructor(private readonly ctx: SchedulerContext) {}
|
|
17
20
|
|
|
18
21
|
/**
|
|
@@ -56,10 +59,23 @@ export class JobProcessor {
|
|
|
56
59
|
* the instance-level `instanceConcurrency` limit is reached.
|
|
57
60
|
*/
|
|
58
61
|
async poll(): Promise<void> {
|
|
59
|
-
if (!this.ctx.isRunning()) {
|
|
62
|
+
if (!this.ctx.isRunning() || this._isPolling) {
|
|
60
63
|
return;
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
this._isPolling = true;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
await this._doPoll();
|
|
70
|
+
} finally {
|
|
71
|
+
this._isPolling = false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Internal poll implementation.
|
|
77
|
+
*/
|
|
78
|
+
private async _doPoll(): Promise<void> {
|
|
63
79
|
// Early exit if global instanceConcurrency is reached
|
|
64
80
|
const { instanceConcurrency } = this.ctx.options;
|
|
65
81
|
|
|
@@ -101,7 +117,7 @@ export class JobProcessor {
|
|
|
101
117
|
worker.activeJobs.set(job._id.toString(), job);
|
|
102
118
|
|
|
103
119
|
this.processJob(job, worker).catch((error: unknown) => {
|
|
104
|
-
this.ctx.emit('job:error', { error: error
|
|
120
|
+
this.ctx.emit('job:error', { error: toError(error), job });
|
|
105
121
|
});
|
|
106
122
|
} else {
|
|
107
123
|
// No more jobs available for this worker
|
|
@@ -173,6 +189,10 @@ export class JobProcessor {
|
|
|
173
189
|
* both success and failure cases. On success, calls `completeJob()`. On failure,
|
|
174
190
|
* calls `failJob()` which implements exponential backoff retry logic.
|
|
175
191
|
*
|
|
192
|
+
* Events are only emitted when the underlying atomic status transition succeeds,
|
|
193
|
+
* ensuring event consumers receive reliable, consistent data backed by the actual
|
|
194
|
+
* database state.
|
|
195
|
+
*
|
|
176
196
|
* @param job - The job to process
|
|
177
197
|
* @param worker - The worker registration containing the handler and active job tracking
|
|
178
198
|
*/
|
|
@@ -186,39 +206,50 @@ export class JobProcessor {
|
|
|
186
206
|
|
|
187
207
|
// Job completed successfully
|
|
188
208
|
const duration = Date.now() - startTime;
|
|
189
|
-
await this.completeJob(job);
|
|
190
|
-
|
|
209
|
+
const updatedJob = await this.completeJob(job);
|
|
210
|
+
|
|
211
|
+
if (updatedJob) {
|
|
212
|
+
this.ctx.emit('job:complete', { job: updatedJob, duration });
|
|
213
|
+
}
|
|
191
214
|
} catch (error) {
|
|
192
215
|
// Job failed
|
|
193
216
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
194
|
-
await this.failJob(job, err);
|
|
217
|
+
const updatedJob = await this.failJob(job, err);
|
|
195
218
|
|
|
196
|
-
|
|
197
|
-
|
|
219
|
+
if (updatedJob) {
|
|
220
|
+
const willRetry = updatedJob.status === JobStatus.PENDING;
|
|
221
|
+
this.ctx.emit('job:fail', { job: updatedJob, error: err, willRetry });
|
|
222
|
+
}
|
|
198
223
|
} finally {
|
|
199
224
|
worker.activeJobs.delete(jobId);
|
|
200
225
|
}
|
|
201
226
|
}
|
|
202
227
|
|
|
203
228
|
/**
|
|
204
|
-
* Mark a job as completed successfully.
|
|
229
|
+
* Mark a job as completed successfully using an atomic status transition.
|
|
230
|
+
*
|
|
231
|
+
* Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
|
|
232
|
+
* preconditions to ensure the transition only occurs if the job is still owned by this
|
|
233
|
+
* scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
|
|
234
|
+
* by another instance after stale recovery).
|
|
205
235
|
*
|
|
206
236
|
* For recurring jobs (with `repeatInterval`), schedules the next run based on the cron
|
|
207
237
|
* expression and resets `failCount` to 0. For one-time jobs, sets status to `completed`.
|
|
208
238
|
* Clears `lockedAt` and `failReason` fields in both cases.
|
|
209
239
|
*
|
|
210
240
|
* @param job - The job that completed successfully
|
|
241
|
+
* @returns The updated job document, or `null` if the transition could not be applied
|
|
211
242
|
*/
|
|
212
|
-
async completeJob(job: Job): Promise<
|
|
243
|
+
async completeJob(job: Job): Promise<PersistedJob | null> {
|
|
213
244
|
if (!isPersistedJob(job)) {
|
|
214
|
-
return;
|
|
245
|
+
return null;
|
|
215
246
|
}
|
|
216
247
|
|
|
217
248
|
if (job.repeatInterval) {
|
|
218
249
|
// Recurring job - schedule next run
|
|
219
250
|
const nextRunAt = getNextCronDate(job.repeatInterval);
|
|
220
|
-
await this.ctx.collection.
|
|
221
|
-
{ _id: job._id },
|
|
251
|
+
const result = await this.ctx.collection.findOneAndUpdate(
|
|
252
|
+
{ _id: job._id, status: JobStatus.PROCESSING, claimedBy: this.ctx.instanceId },
|
|
222
253
|
{
|
|
223
254
|
$set: {
|
|
224
255
|
status: JobStatus.PENDING,
|
|
@@ -234,52 +265,63 @@ export class JobProcessor {
|
|
|
234
265
|
failReason: '',
|
|
235
266
|
},
|
|
236
267
|
},
|
|
268
|
+
{ returnDocument: 'after' },
|
|
237
269
|
);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
await this.ctx.collection.updateOne(
|
|
241
|
-
{ _id: job._id },
|
|
242
|
-
{
|
|
243
|
-
$set: {
|
|
244
|
-
status: JobStatus.COMPLETED,
|
|
245
|
-
updatedAt: new Date(),
|
|
246
|
-
},
|
|
247
|
-
$unset: {
|
|
248
|
-
lockedAt: '',
|
|
249
|
-
claimedBy: '',
|
|
250
|
-
lastHeartbeat: '',
|
|
251
|
-
heartbeatInterval: '',
|
|
252
|
-
failReason: '',
|
|
253
|
-
},
|
|
254
|
-
},
|
|
255
|
-
);
|
|
256
|
-
job.status = JobStatus.COMPLETED;
|
|
270
|
+
|
|
271
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
257
272
|
}
|
|
273
|
+
|
|
274
|
+
// One-time job - mark as completed
|
|
275
|
+
const result = await this.ctx.collection.findOneAndUpdate(
|
|
276
|
+
{ _id: job._id, status: JobStatus.PROCESSING, claimedBy: this.ctx.instanceId },
|
|
277
|
+
{
|
|
278
|
+
$set: {
|
|
279
|
+
status: JobStatus.COMPLETED,
|
|
280
|
+
updatedAt: new Date(),
|
|
281
|
+
},
|
|
282
|
+
$unset: {
|
|
283
|
+
lockedAt: '',
|
|
284
|
+
claimedBy: '',
|
|
285
|
+
lastHeartbeat: '',
|
|
286
|
+
heartbeatInterval: '',
|
|
287
|
+
failReason: '',
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{ returnDocument: 'after' },
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
258
294
|
}
|
|
259
295
|
|
|
260
296
|
/**
|
|
261
|
-
* Handle job failure with exponential backoff retry logic.
|
|
297
|
+
* Handle job failure with exponential backoff retry logic using an atomic status transition.
|
|
298
|
+
*
|
|
299
|
+
* Uses `findOneAndUpdate` with `status: processing` and `claimedBy: instanceId`
|
|
300
|
+
* preconditions to ensure the transition only occurs if the job is still owned by this
|
|
301
|
+
* scheduler instance. Returns `null` if the job was concurrently modified (e.g., reclaimed
|
|
302
|
+
* by another instance after stale recovery).
|
|
262
303
|
*
|
|
263
304
|
* Increments `failCount` and calculates next retry time using exponential backoff:
|
|
264
|
-
* `nextRunAt = 2^failCount
|
|
305
|
+
* `nextRunAt = 2^failCount * baseRetryInterval` (capped by optional `maxBackoffDelay`).
|
|
265
306
|
*
|
|
266
307
|
* If `failCount >= maxRetries`, marks job as permanently `failed`. Otherwise, resets
|
|
267
308
|
* to `pending` status for retry. Stores error message in `failReason` field.
|
|
268
309
|
*
|
|
269
310
|
* @param job - The job that failed
|
|
270
311
|
* @param error - The error that caused the failure
|
|
312
|
+
* @returns The updated job document, or `null` if the transition could not be applied
|
|
271
313
|
*/
|
|
272
|
-
async failJob(job: Job, error: Error): Promise<
|
|
314
|
+
async failJob(job: Job, error: Error): Promise<PersistedJob | null> {
|
|
273
315
|
if (!isPersistedJob(job)) {
|
|
274
|
-
return;
|
|
316
|
+
return null;
|
|
275
317
|
}
|
|
276
318
|
|
|
277
319
|
const newFailCount = job.failCount + 1;
|
|
278
320
|
|
|
279
321
|
if (newFailCount >= this.ctx.options.maxRetries) {
|
|
280
322
|
// Permanent failure
|
|
281
|
-
await this.ctx.collection.
|
|
282
|
-
{ _id: job._id },
|
|
323
|
+
const result = await this.ctx.collection.findOneAndUpdate(
|
|
324
|
+
{ _id: job._id, status: JobStatus.PROCESSING, claimedBy: this.ctx.instanceId },
|
|
283
325
|
{
|
|
284
326
|
$set: {
|
|
285
327
|
status: JobStatus.FAILED,
|
|
@@ -294,34 +336,40 @@ export class JobProcessor {
|
|
|
294
336
|
heartbeatInterval: '',
|
|
295
337
|
},
|
|
296
338
|
},
|
|
297
|
-
|
|
298
|
-
} else {
|
|
299
|
-
// Schedule retry with exponential backoff
|
|
300
|
-
const nextRunAt = calculateBackoff(
|
|
301
|
-
newFailCount,
|
|
302
|
-
this.ctx.options.baseRetryInterval,
|
|
303
|
-
this.ctx.options.maxBackoffDelay,
|
|
339
|
+
{ returnDocument: 'after' },
|
|
304
340
|
);
|
|
305
341
|
|
|
306
|
-
|
|
307
|
-
{ _id: job._id },
|
|
308
|
-
{
|
|
309
|
-
$set: {
|
|
310
|
-
status: JobStatus.PENDING,
|
|
311
|
-
failCount: newFailCount,
|
|
312
|
-
failReason: error.message,
|
|
313
|
-
nextRunAt,
|
|
314
|
-
updatedAt: new Date(),
|
|
315
|
-
},
|
|
316
|
-
$unset: {
|
|
317
|
-
lockedAt: '',
|
|
318
|
-
claimedBy: '',
|
|
319
|
-
lastHeartbeat: '',
|
|
320
|
-
heartbeatInterval: '',
|
|
321
|
-
},
|
|
322
|
-
},
|
|
323
|
-
);
|
|
342
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
324
343
|
}
|
|
344
|
+
|
|
345
|
+
// Schedule retry with exponential backoff
|
|
346
|
+
const nextRunAt = calculateBackoff(
|
|
347
|
+
newFailCount,
|
|
348
|
+
this.ctx.options.baseRetryInterval,
|
|
349
|
+
this.ctx.options.maxBackoffDelay,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const result = await this.ctx.collection.findOneAndUpdate(
|
|
353
|
+
{ _id: job._id, status: JobStatus.PROCESSING, claimedBy: this.ctx.instanceId },
|
|
354
|
+
{
|
|
355
|
+
$set: {
|
|
356
|
+
status: JobStatus.PENDING,
|
|
357
|
+
failCount: newFailCount,
|
|
358
|
+
failReason: error.message,
|
|
359
|
+
nextRunAt,
|
|
360
|
+
updatedAt: new Date(),
|
|
361
|
+
},
|
|
362
|
+
$unset: {
|
|
363
|
+
lockedAt: '',
|
|
364
|
+
claimedBy: '',
|
|
365
|
+
lastHeartbeat: '',
|
|
366
|
+
heartbeatInterval: '',
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
{ returnDocument: 'after' },
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
return result ? this.ctx.documentToPersistedJob(result) : null;
|
|
325
373
|
}
|
|
326
374
|
|
|
327
375
|
/**
|
package/src/scheduler/types.ts
CHANGED
|
@@ -162,4 +162,15 @@ export interface MonqueOptions {
|
|
|
162
162
|
* @deprecated Use `instanceConcurrency` instead. Will be removed in a future major version.
|
|
163
163
|
*/
|
|
164
164
|
maxConcurrency?: number | undefined;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Skip automatic index creation during initialization.
|
|
168
|
+
*
|
|
169
|
+
* When `true`, `initialize()` will not create MongoDB indexes. Use this in production
|
|
170
|
+
* environments where indexes are managed externally (e.g., via migration scripts or DBA
|
|
171
|
+
* tooling). See the production checklist for the full list of required indexes.
|
|
172
|
+
*
|
|
173
|
+
* @default false
|
|
174
|
+
*/
|
|
175
|
+
skipIndexCreation?: boolean;
|
|
165
176
|
}
|
package/src/shared/index.ts
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize an unknown caught value into a proper `Error` instance.
|
|
3
|
+
*
|
|
4
|
+
* In JavaScript, any value can be thrown — strings, numbers, objects, `undefined`, etc.
|
|
5
|
+
* This function ensures we always have a real `Error` with a proper stack trace and message.
|
|
6
|
+
*
|
|
7
|
+
* @param value - The caught value (typically from a `catch` block typed as `unknown`).
|
|
8
|
+
* @returns The original value if already an `Error`, otherwise a new `Error` wrapping `String(value)`.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* try {
|
|
13
|
+
* riskyOperation();
|
|
14
|
+
* } catch (error: unknown) {
|
|
15
|
+
* const normalized = toError(error);
|
|
16
|
+
* console.error(normalized.message);
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export function toError(value: unknown): Error {
|
|
23
|
+
if (value instanceof Error) return value;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
return new Error(String(value));
|
|
27
|
+
} catch (conversionError: unknown) {
|
|
28
|
+
const detail =
|
|
29
|
+
conversionError instanceof Error ? conversionError.message : 'unknown conversion failure';
|
|
30
|
+
|
|
31
|
+
return new Error(`Unserializable value (${detail})`);
|
|
32
|
+
}
|
|
33
|
+
}
|