@monque/core 1.5.2 → 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 +44 -0
- package/dist/index.cjs +522 -169
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +83 -4
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +83 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +520 -170
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
- package/src/index.ts +3 -0
- package/src/scheduler/monque.ts +112 -27
- package/src/scheduler/services/change-stream-handler.ts +183 -31
- package/src/scheduler/services/index.ts +1 -1
- package/src/scheduler/services/job-manager.ts +151 -114
- package/src/scheduler/services/job-processor.ts +109 -54
- package/src/scheduler/services/job-scheduler.ts +42 -9
- package/src/scheduler/services/lifecycle-manager.ts +77 -17
- package/src/scheduler/services/types.ts +7 -0
- package/src/scheduler/types.ts +14 -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": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"sideEffects": false,
|
|
17
17
|
"type": "module",
|
|
18
18
|
"engines": {
|
|
19
|
-
"node": ">=22.
|
|
19
|
+
"node": ">=22.12.0"
|
|
20
20
|
},
|
|
21
21
|
"publishConfig": {
|
|
22
22
|
"access": "public"
|
|
@@ -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
|
-
"@vitest/coverage-v8": "^4.0
|
|
82
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
83
83
|
"fishery": "^2.4.0",
|
|
84
84
|
"mongodb": "^7.1.0",
|
|
85
|
-
"tsdown": "^0.21.
|
|
86
|
-
"vitest": "^4.0
|
|
85
|
+
"tsdown": "^0.21.4",
|
|
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,
|
|
@@ -39,6 +46,7 @@ import type { MonqueOptions } from './types.js';
|
|
|
39
46
|
const DEFAULTS = {
|
|
40
47
|
collectionName: 'monque_jobs',
|
|
41
48
|
pollInterval: 1000,
|
|
49
|
+
safetyPollInterval: 30_000,
|
|
42
50
|
maxRetries: 10,
|
|
43
51
|
baseRetryInterval: 1000,
|
|
44
52
|
shutdownTimeout: 30000,
|
|
@@ -121,6 +129,15 @@ export class Monque extends EventEmitter {
|
|
|
121
129
|
private isRunning = false;
|
|
122
130
|
private isInitialized = false;
|
|
123
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
|
+
|
|
124
141
|
// Internal services (initialized in initialize())
|
|
125
142
|
private _scheduler: JobScheduler | null = null;
|
|
126
143
|
private _manager: JobManager | null = null;
|
|
@@ -131,10 +148,12 @@ export class Monque extends EventEmitter {
|
|
|
131
148
|
|
|
132
149
|
constructor(db: Db, options: MonqueOptions = {}) {
|
|
133
150
|
super();
|
|
151
|
+
this.setMaxListeners(20);
|
|
134
152
|
this.db = db;
|
|
135
153
|
this.options = {
|
|
136
154
|
collectionName: options.collectionName ?? DEFAULTS.collectionName,
|
|
137
155
|
pollInterval: options.pollInterval ?? DEFAULTS.pollInterval,
|
|
156
|
+
safetyPollInterval: options.safetyPollInterval ?? DEFAULTS.safetyPollInterval,
|
|
138
157
|
maxRetries: options.maxRetries ?? DEFAULTS.maxRetries,
|
|
139
158
|
baseRetryInterval: options.baseRetryInterval ?? DEFAULTS.baseRetryInterval,
|
|
140
159
|
shutdownTimeout: options.shutdownTimeout ?? DEFAULTS.shutdownTimeout,
|
|
@@ -151,6 +170,17 @@ export class Monque extends EventEmitter {
|
|
|
151
170
|
maxPayloadSize: options.maxPayloadSize,
|
|
152
171
|
statsCacheTtlMs: options.statsCacheTtlMs ?? 5000,
|
|
153
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
|
+
}
|
|
154
184
|
}
|
|
155
185
|
|
|
156
186
|
/**
|
|
@@ -186,7 +216,9 @@ export class Monque extends EventEmitter {
|
|
|
186
216
|
this._manager = new JobManager(ctx);
|
|
187
217
|
this._query = new JobQueryService(ctx);
|
|
188
218
|
this._processor = new JobProcessor(ctx);
|
|
189
|
-
this._changeStreamHandler = new ChangeStreamHandler(ctx, () =>
|
|
219
|
+
this._changeStreamHandler = new ChangeStreamHandler(ctx, (targetNames) =>
|
|
220
|
+
this.handleChangeStreamPoll(targetNames),
|
|
221
|
+
);
|
|
190
222
|
this._lifecycleManager = new LifecycleManager(ctx);
|
|
191
223
|
|
|
192
224
|
this.isInitialized = true;
|
|
@@ -255,6 +287,26 @@ export class Monque extends EventEmitter {
|
|
|
255
287
|
return this._lifecycleManager;
|
|
256
288
|
}
|
|
257
289
|
|
|
290
|
+
private validateSchedulingIdentifiers(name: string, uniqueKey?: string): void {
|
|
291
|
+
validateJobName(name);
|
|
292
|
+
|
|
293
|
+
if (uniqueKey !== undefined) {
|
|
294
|
+
validateUniqueKey(uniqueKey);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Handle a change-stream-triggered poll and reset the safety poll timer.
|
|
300
|
+
*
|
|
301
|
+
* Used as the `onPoll` callback for {@link ChangeStreamHandler}. Runs a
|
|
302
|
+
* targeted poll for the given worker names, then resets the adaptive safety
|
|
303
|
+
* poll timer so it doesn't fire redundantly.
|
|
304
|
+
*/
|
|
305
|
+
private async handleChangeStreamPoll(targetNames?: ReadonlySet<string>): Promise<void> {
|
|
306
|
+
await this.processor.poll(targetNames);
|
|
307
|
+
this.lifecycleManager.resetPollTimer();
|
|
308
|
+
}
|
|
309
|
+
|
|
258
310
|
/**
|
|
259
311
|
* Build the shared context for internal services.
|
|
260
312
|
*/
|
|
@@ -271,6 +323,14 @@ export class Monque extends EventEmitter {
|
|
|
271
323
|
isRunning: () => this.isRunning,
|
|
272
324
|
emit: <K extends keyof MonqueEventMap>(event: K, payload: MonqueEventMap[K]) =>
|
|
273
325
|
this.emit(event, payload),
|
|
326
|
+
notifyPendingJob: (name: string, nextRunAt: Date) => {
|
|
327
|
+
if (!this.isRunning || !this._changeStreamHandler) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
this._changeStreamHandler.notifyPendingJob(name, nextRunAt);
|
|
332
|
+
},
|
|
333
|
+
notifyJobFinished: () => this.onJobFinished(),
|
|
274
334
|
documentToPersistedJob: <T>(doc: WithId<Document>) => documentToPersistedJob<T>(doc),
|
|
275
335
|
};
|
|
276
336
|
}
|
|
@@ -318,6 +378,20 @@ export class Monque extends EventEmitter {
|
|
|
318
378
|
{ key: { status: 1, nextRunAt: 1, claimedBy: 1 }, background: true },
|
|
319
379
|
// Expanded index that supports recovery scans (status + lockedAt) plus heartbeat monitoring patterns.
|
|
320
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
|
+
: []),
|
|
321
395
|
]);
|
|
322
396
|
}
|
|
323
397
|
|
|
@@ -347,7 +421,6 @@ export class Monque extends EventEmitter {
|
|
|
347
421
|
lockedAt: '',
|
|
348
422
|
claimedBy: '',
|
|
349
423
|
lastHeartbeat: '',
|
|
350
|
-
heartbeatInterval: '',
|
|
351
424
|
},
|
|
352
425
|
},
|
|
353
426
|
);
|
|
@@ -414,6 +487,7 @@ export class Monque extends EventEmitter {
|
|
|
414
487
|
* @param data - Job payload, will be passed to the worker handler
|
|
415
488
|
* @param options - Scheduling and deduplication options
|
|
416
489
|
* @returns Promise resolving to the created or existing job document
|
|
490
|
+
* @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
|
|
417
491
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
418
492
|
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
419
493
|
*
|
|
@@ -446,6 +520,7 @@ export class Monque extends EventEmitter {
|
|
|
446
520
|
*/
|
|
447
521
|
async enqueue<T>(name: string, data: T, options: EnqueueOptions = {}): Promise<PersistedJob<T>> {
|
|
448
522
|
this.ensureInitialized();
|
|
523
|
+
this.validateSchedulingIdentifiers(name, options.uniqueKey);
|
|
449
524
|
return this.scheduler.enqueue(name, data, options);
|
|
450
525
|
}
|
|
451
526
|
|
|
@@ -459,6 +534,7 @@ export class Monque extends EventEmitter {
|
|
|
459
534
|
* @param name - Job type identifier, must match a registered worker
|
|
460
535
|
* @param data - Job payload, will be passed to the worker handler
|
|
461
536
|
* @returns Promise resolving to the created job document
|
|
537
|
+
* @throws {InvalidJobIdentifierError} If `name` fails public identifier validation
|
|
462
538
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
463
539
|
*
|
|
464
540
|
* @example Send email immediately
|
|
@@ -481,6 +557,7 @@ export class Monque extends EventEmitter {
|
|
|
481
557
|
*/
|
|
482
558
|
async now<T>(name: string, data: T): Promise<PersistedJob<T>> {
|
|
483
559
|
this.ensureInitialized();
|
|
560
|
+
validateJobName(name);
|
|
484
561
|
return this.scheduler.now(name, data);
|
|
485
562
|
}
|
|
486
563
|
|
|
@@ -502,6 +579,7 @@ export class Monque extends EventEmitter {
|
|
|
502
579
|
* @param data - Job payload, will be passed to the worker handler on each run
|
|
503
580
|
* @param options - Scheduling options (uniqueKey for deduplication)
|
|
504
581
|
* @returns Promise resolving to the created job document with `repeatInterval` set
|
|
582
|
+
* @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
|
|
505
583
|
* @throws {InvalidCronError} If cron expression is invalid
|
|
506
584
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
507
585
|
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
@@ -538,6 +616,7 @@ export class Monque extends EventEmitter {
|
|
|
538
616
|
options: ScheduleOptions = {},
|
|
539
617
|
): Promise<PersistedJob<T>> {
|
|
540
618
|
this.ensureInitialized();
|
|
619
|
+
this.validateSchedulingIdentifiers(name, options.uniqueKey);
|
|
541
620
|
return this.scheduler.schedule(cron, name, data, options);
|
|
542
621
|
}
|
|
543
622
|
|
|
@@ -907,6 +986,7 @@ export class Monque extends EventEmitter {
|
|
|
907
986
|
* @param options - Worker configuration
|
|
908
987
|
* @param options.concurrency - Maximum concurrent jobs for this worker (default: `defaultConcurrency`)
|
|
909
988
|
* @param options.replace - When `true`, replace existing worker instead of throwing error
|
|
989
|
+
* @throws {InvalidJobIdentifierError} If `name` fails public identifier validation
|
|
910
990
|
* @throws {WorkerRegistrationError} When a worker is already registered for `name` and `replace` is not `true`
|
|
911
991
|
*
|
|
912
992
|
* @example Basic email worker
|
|
@@ -950,6 +1030,7 @@ export class Monque extends EventEmitter {
|
|
|
950
1030
|
* ```
|
|
951
1031
|
*/
|
|
952
1032
|
register<T>(name: string, handler: JobHandler<T>, options: WorkerOptions = {}): void {
|
|
1033
|
+
validateJobName(name);
|
|
953
1034
|
const concurrency = options.concurrency ?? this.options.workerConcurrency;
|
|
954
1035
|
|
|
955
1036
|
// Check for existing worker and throw unless replace is explicitly true
|
|
@@ -1032,6 +1113,7 @@ export class Monque extends EventEmitter {
|
|
|
1032
1113
|
this.lifecycleManager.startTimers({
|
|
1033
1114
|
poll: () => this.processor.poll(),
|
|
1034
1115
|
updateHeartbeats: () => this.processor.updateHeartbeats(),
|
|
1116
|
+
isChangeStreamActive: () => this.changeStreamHandler.isActive(),
|
|
1035
1117
|
});
|
|
1036
1118
|
}
|
|
1037
1119
|
|
|
@@ -1092,20 +1174,14 @@ export class Monque extends EventEmitter {
|
|
|
1092
1174
|
}
|
|
1093
1175
|
|
|
1094
1176
|
// Wait for all active jobs to complete (with timeout)
|
|
1095
|
-
|
|
1096
|
-
if (activeJobs.length === 0) {
|
|
1177
|
+
if (this.getActiveJobCount() === 0) {
|
|
1097
1178
|
return;
|
|
1098
1179
|
}
|
|
1099
1180
|
|
|
1100
|
-
//
|
|
1101
|
-
|
|
1181
|
+
// Reactive drain: resolve when the last active job finishes.
|
|
1182
|
+
// onJobFinished() is called from processJob's finally block.
|
|
1102
1183
|
const waitForJobs = new Promise<undefined>((resolve) => {
|
|
1103
|
-
|
|
1104
|
-
if (this.getActiveJobs().length === 0) {
|
|
1105
|
-
clearInterval(checkInterval);
|
|
1106
|
-
resolve(undefined);
|
|
1107
|
-
}
|
|
1108
|
-
}, 100);
|
|
1184
|
+
this._drainResolve = () => resolve(undefined);
|
|
1109
1185
|
});
|
|
1110
1186
|
|
|
1111
1187
|
// Race between job completion and timeout
|
|
@@ -1113,15 +1189,9 @@ export class Monque extends EventEmitter {
|
|
|
1113
1189
|
setTimeout(() => resolve('timeout'), this.options.shutdownTimeout);
|
|
1114
1190
|
});
|
|
1115
1191
|
|
|
1116
|
-
|
|
1192
|
+
const result = await Promise.race([waitForJobs, timeout]);
|
|
1117
1193
|
|
|
1118
|
-
|
|
1119
|
-
result = await Promise.race([waitForJobs, timeout]);
|
|
1120
|
-
} finally {
|
|
1121
|
-
if (checkInterval) {
|
|
1122
|
-
clearInterval(checkInterval);
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1194
|
+
this._drainResolve = null;
|
|
1125
1195
|
|
|
1126
1196
|
if (result === 'timeout') {
|
|
1127
1197
|
const incompleteJobs = this.getActiveJobsList();
|
|
@@ -1188,6 +1258,18 @@ export class Monque extends EventEmitter {
|
|
|
1188
1258
|
// Private Helpers
|
|
1189
1259
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1190
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
|
+
|
|
1191
1273
|
/**
|
|
1192
1274
|
* Ensure the scheduler is initialized before operations.
|
|
1193
1275
|
*
|
|
@@ -1201,17 +1283,20 @@ export class Monque extends EventEmitter {
|
|
|
1201
1283
|
}
|
|
1202
1284
|
|
|
1203
1285
|
/**
|
|
1204
|
-
* 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`.
|
|
1205
1290
|
*
|
|
1206
1291
|
* @private
|
|
1207
|
-
* @returns
|
|
1292
|
+
* @returns Number of jobs currently being processed
|
|
1208
1293
|
*/
|
|
1209
|
-
private
|
|
1210
|
-
|
|
1294
|
+
private getActiveJobCount(): number {
|
|
1295
|
+
let count = 0;
|
|
1211
1296
|
for (const worker of this.workers.values()) {
|
|
1212
|
-
|
|
1297
|
+
count += worker.activeJobs.size;
|
|
1213
1298
|
}
|
|
1214
|
-
return
|
|
1299
|
+
return count;
|
|
1215
1300
|
}
|
|
1216
1301
|
|
|
1217
1302
|
/**
|
|
@@ -5,12 +5,22 @@ import { toError } from '@/shared';
|
|
|
5
5
|
|
|
6
6
|
import type { SchedulerContext } from './types.js';
|
|
7
7
|
|
|
8
|
+
/** Minimum poll interval floor to prevent tight loops (ms) */
|
|
9
|
+
const MIN_POLL_INTERVAL = 100;
|
|
10
|
+
|
|
11
|
+
/** Grace period after nextRunAt before scheduling a wakeup poll (ms) */
|
|
12
|
+
const POLL_GRACE_PERIOD = 200;
|
|
13
|
+
|
|
8
14
|
/**
|
|
9
15
|
* Internal service for MongoDB Change Stream lifecycle.
|
|
10
16
|
*
|
|
11
17
|
* Provides real-time job notifications when available, with automatic
|
|
12
18
|
* reconnection and graceful fallback to polling-only mode.
|
|
13
19
|
*
|
|
20
|
+
* Leverages the full document from change stream events to:
|
|
21
|
+
* - Trigger **targeted polls** for specific workers (using the job `name`)
|
|
22
|
+
* - Schedule **precise wakeup timers** for future-dated jobs (using `nextRunAt`)
|
|
23
|
+
*
|
|
14
24
|
* @internal Not part of public API.
|
|
15
25
|
*/
|
|
16
26
|
export class ChangeStreamHandler {
|
|
@@ -32,9 +42,18 @@ export class ChangeStreamHandler {
|
|
|
32
42
|
/** Whether the scheduler is currently using change streams */
|
|
33
43
|
private usingChangeStreams = false;
|
|
34
44
|
|
|
45
|
+
/** Job names collected during the current debounce window for targeted polling */
|
|
46
|
+
private pendingTargetNames: Set<string> = new Set();
|
|
47
|
+
|
|
48
|
+
/** Wakeup timer for the earliest known future job */
|
|
49
|
+
private wakeupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
50
|
+
|
|
51
|
+
/** Time of the currently scheduled wakeup */
|
|
52
|
+
private wakeupTime: Date | null = null;
|
|
53
|
+
|
|
35
54
|
constructor(
|
|
36
55
|
private readonly ctx: SchedulerContext,
|
|
37
|
-
private readonly onPoll: () => Promise<void>,
|
|
56
|
+
private readonly onPoll: (targetNames?: ReadonlySet<string>) => Promise<void>,
|
|
38
57
|
) {}
|
|
39
58
|
|
|
40
59
|
/**
|
|
@@ -67,7 +86,10 @@ export class ChangeStreamHandler {
|
|
|
67
86
|
{ operationType: 'insert' },
|
|
68
87
|
{
|
|
69
88
|
operationType: 'update',
|
|
70
|
-
|
|
89
|
+
$or: [
|
|
90
|
+
{ 'updateDescription.updatedFields.status': { $exists: true } },
|
|
91
|
+
{ 'updateDescription.updatedFields.nextRunAt': { $exists: true } },
|
|
92
|
+
],
|
|
71
93
|
},
|
|
72
94
|
],
|
|
73
95
|
},
|
|
@@ -102,11 +124,20 @@ export class ChangeStreamHandler {
|
|
|
102
124
|
}
|
|
103
125
|
|
|
104
126
|
/**
|
|
105
|
-
* Handle a change stream event
|
|
127
|
+
* Handle a change stream event using the full document for intelligent routing.
|
|
128
|
+
*
|
|
129
|
+
* For **immediate jobs** (`nextRunAt <= now`): collects the job name and triggers
|
|
130
|
+
* a debounced targeted poll for only the relevant workers.
|
|
131
|
+
*
|
|
132
|
+
* For **future jobs** (`nextRunAt > now`): schedules a precise wakeup timer so
|
|
133
|
+
* the job is picked up near its scheduled time without blind polling.
|
|
106
134
|
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
135
|
+
* For **completed/failed jobs** (slot freed): triggers a targeted re-poll for that
|
|
136
|
+
* worker so the next pending job is picked up immediately, maintaining continuous
|
|
137
|
+
* throughput without waiting for the safety poll interval.
|
|
138
|
+
*
|
|
139
|
+
* Falls back to a full poll (no target names) if the document is missing
|
|
140
|
+
* required fields.
|
|
110
141
|
*
|
|
111
142
|
* @param change - The change stream event document
|
|
112
143
|
*/
|
|
@@ -121,25 +152,121 @@ export class ChangeStreamHandler {
|
|
|
121
152
|
|
|
122
153
|
// Get fullDocument if available (for insert or with updateLookup option)
|
|
123
154
|
const fullDocument = 'fullDocument' in change ? change.fullDocument : undefined;
|
|
124
|
-
const
|
|
155
|
+
const currentStatus = fullDocument?.['status'] as string | undefined;
|
|
156
|
+
const isPendingStatus = currentStatus === JobStatus.PENDING;
|
|
157
|
+
|
|
158
|
+
// A completed/failed status change means a concurrency slot was freed.
|
|
159
|
+
// Trigger a re-poll so the next pending job is picked up immediately,
|
|
160
|
+
// rather than waiting for the safety poll interval.
|
|
161
|
+
const isSlotFreed =
|
|
162
|
+
isUpdate && (currentStatus === JobStatus.COMPLETED || currentStatus === JobStatus.FAILED);
|
|
125
163
|
|
|
126
164
|
// For inserts: always trigger since new pending jobs need processing
|
|
127
|
-
// For updates
|
|
128
|
-
|
|
165
|
+
// For updates to pending: trigger (retry/release/recurring reschedule)
|
|
166
|
+
// For updates to completed/failed: trigger (concurrency slot freed)
|
|
167
|
+
const shouldTrigger = isInsert || (isUpdate && isPendingStatus) || isSlotFreed;
|
|
129
168
|
|
|
130
|
-
if (shouldTrigger) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
169
|
+
if (!shouldTrigger) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Slot-freed events: targeted poll for that worker to pick up waiting jobs
|
|
174
|
+
if (isSlotFreed) {
|
|
175
|
+
const jobName = fullDocument?.['name'] as string | undefined;
|
|
176
|
+
if (jobName) {
|
|
177
|
+
this.pendingTargetNames.add(jobName);
|
|
134
178
|
}
|
|
179
|
+
this.debouncedPoll();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Extract job metadata from the full document for smart routing
|
|
184
|
+
const jobName = fullDocument?.['name'] as string | undefined;
|
|
185
|
+
const nextRunAt = fullDocument?.['nextRunAt'] as Date | undefined;
|
|
186
|
+
|
|
187
|
+
if (jobName && nextRunAt) {
|
|
188
|
+
this.notifyPendingJob(jobName, nextRunAt);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Immediate job or missing metadata — collect for targeted/full poll
|
|
193
|
+
if (jobName) {
|
|
194
|
+
this.pendingTargetNames.add(jobName);
|
|
195
|
+
}
|
|
196
|
+
this.debouncedPoll();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Notify the handler about a pending job created or updated by this process.
|
|
201
|
+
*
|
|
202
|
+
* Reuses the same routing logic as change stream events so local writes don't
|
|
203
|
+
* depend on the MongoDB change stream cursor already being fully ready.
|
|
204
|
+
*
|
|
205
|
+
* @param jobName - Worker name for targeted polling
|
|
206
|
+
* @param nextRunAt - When the job becomes eligible for processing
|
|
207
|
+
*/
|
|
208
|
+
notifyPendingJob(jobName: string, nextRunAt: Date): void {
|
|
209
|
+
if (!this.ctx.isRunning()) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
135
212
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
this.ctx.emit('job:error', { error: toError(error) });
|
|
140
|
-
});
|
|
141
|
-
}, 100);
|
|
213
|
+
if (nextRunAt.getTime() > Date.now()) {
|
|
214
|
+
this.scheduleWakeup(nextRunAt);
|
|
215
|
+
return;
|
|
142
216
|
}
|
|
217
|
+
|
|
218
|
+
this.pendingTargetNames.add(jobName);
|
|
219
|
+
this.debouncedPoll();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Schedule a debounced poll with collected target names.
|
|
224
|
+
*
|
|
225
|
+
* Collects job names from multiple change stream events during the debounce
|
|
226
|
+
* window, then triggers a single targeted poll for only those workers.
|
|
227
|
+
*/
|
|
228
|
+
private debouncedPoll(): void {
|
|
229
|
+
if (this.debounceTimer) {
|
|
230
|
+
clearTimeout(this.debounceTimer);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
this.debounceTimer = setTimeout(() => {
|
|
234
|
+
this.debounceTimer = null;
|
|
235
|
+
const names = this.pendingTargetNames.size > 0 ? new Set(this.pendingTargetNames) : undefined;
|
|
236
|
+
this.pendingTargetNames.clear();
|
|
237
|
+
this.onPoll(names).catch((error: unknown) => {
|
|
238
|
+
this.ctx.emit('job:error', { error: toError(error) });
|
|
239
|
+
});
|
|
240
|
+
}, 100);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Schedule a wakeup timer for a future-dated job.
|
|
245
|
+
*
|
|
246
|
+
* Maintains a single timer set to the earliest known future job's `nextRunAt`.
|
|
247
|
+
* When the timer fires, triggers a full poll to pick up all due jobs.
|
|
248
|
+
*
|
|
249
|
+
* @param nextRunAt - When the future job should become ready
|
|
250
|
+
*/
|
|
251
|
+
private scheduleWakeup(nextRunAt: Date): void {
|
|
252
|
+
// Only update if this job is earlier than the current wakeup
|
|
253
|
+
if (this.wakeupTime && nextRunAt >= this.wakeupTime) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
this.clearWakeupTimer();
|
|
258
|
+
this.wakeupTime = nextRunAt;
|
|
259
|
+
|
|
260
|
+
const delay = Math.max(nextRunAt.getTime() - Date.now() + POLL_GRACE_PERIOD, MIN_POLL_INTERVAL);
|
|
261
|
+
|
|
262
|
+
this.wakeupTimer = setTimeout(() => {
|
|
263
|
+
this.wakeupTime = null;
|
|
264
|
+
this.wakeupTimer = null;
|
|
265
|
+
// Full poll — there may be multiple jobs due at this time
|
|
266
|
+
this.onPoll().catch((error: unknown) => {
|
|
267
|
+
this.ctx.emit('job:error', { error: toError(error) });
|
|
268
|
+
});
|
|
269
|
+
}, delay);
|
|
143
270
|
}
|
|
144
271
|
|
|
145
272
|
/**
|
|
@@ -158,12 +285,15 @@ export class ChangeStreamHandler {
|
|
|
158
285
|
|
|
159
286
|
this.reconnectAttempts++;
|
|
160
287
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
288
|
+
// Immediately reset active state: clears stale debounce/wakeup timers,
|
|
289
|
+
// closes the broken cursor, and sets isActive() to false so the lifecycle
|
|
290
|
+
// manager switches to fast polling during backoff.
|
|
291
|
+
this.resetActiveState();
|
|
292
|
+
this.closeChangeStream();
|
|
164
293
|
|
|
294
|
+
if (this.reconnectAttempts > this.maxReconnectAttempts) {
|
|
295
|
+
// Permanent fallback to polling-only mode
|
|
165
296
|
this.clearReconnectTimer();
|
|
166
|
-
this.closeChangeStream();
|
|
167
297
|
|
|
168
298
|
this.ctx.emit('changestream:fallback', {
|
|
169
299
|
reason: `Exhausted ${this.maxReconnectAttempts} reconnection attempts: ${error.message}`,
|
|
@@ -211,6 +341,32 @@ export class ChangeStreamHandler {
|
|
|
211
341
|
this.reconnectTimer = null;
|
|
212
342
|
}
|
|
213
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Reset all active change stream state: clear debounce timer, wakeup timer,
|
|
346
|
+
* pending target names, and mark as inactive.
|
|
347
|
+
*
|
|
348
|
+
* Does NOT close the cursor (callers handle sync vs async close) or clear
|
|
349
|
+
* the reconnect timer/attempts (callers manage reconnection lifecycle).
|
|
350
|
+
*/
|
|
351
|
+
private resetActiveState(): void {
|
|
352
|
+
if (this.debounceTimer) {
|
|
353
|
+
clearTimeout(this.debounceTimer);
|
|
354
|
+
this.debounceTimer = null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
this.pendingTargetNames.clear();
|
|
358
|
+
this.clearWakeupTimer();
|
|
359
|
+
this.usingChangeStreams = false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private clearWakeupTimer(): void {
|
|
363
|
+
if (this.wakeupTimer) {
|
|
364
|
+
clearTimeout(this.wakeupTimer);
|
|
365
|
+
this.wakeupTimer = null;
|
|
366
|
+
}
|
|
367
|
+
this.wakeupTime = null;
|
|
368
|
+
}
|
|
369
|
+
|
|
214
370
|
private closeChangeStream(): void {
|
|
215
371
|
if (!this.changeStream) {
|
|
216
372
|
return;
|
|
@@ -224,13 +380,10 @@ export class ChangeStreamHandler {
|
|
|
224
380
|
* Close the change stream cursor and emit closed event.
|
|
225
381
|
*/
|
|
226
382
|
async close(): Promise<void> {
|
|
227
|
-
|
|
228
|
-
if (this.debounceTimer) {
|
|
229
|
-
clearTimeout(this.debounceTimer);
|
|
230
|
-
this.debounceTimer = null;
|
|
231
|
-
}
|
|
383
|
+
const wasActive = this.usingChangeStreams;
|
|
232
384
|
|
|
233
|
-
// Clear
|
|
385
|
+
// Clear all active state (debounce, wakeup, pending names, flag)
|
|
386
|
+
this.resetActiveState();
|
|
234
387
|
this.clearReconnectTimer();
|
|
235
388
|
|
|
236
389
|
if (this.changeStream) {
|
|
@@ -241,12 +394,11 @@ export class ChangeStreamHandler {
|
|
|
241
394
|
}
|
|
242
395
|
this.changeStream = null;
|
|
243
396
|
|
|
244
|
-
if (
|
|
397
|
+
if (wasActive) {
|
|
245
398
|
this.ctx.emit('changestream:closed', undefined);
|
|
246
399
|
}
|
|
247
400
|
}
|
|
248
401
|
|
|
249
|
-
this.usingChangeStreams = false;
|
|
250
402
|
this.reconnectAttempts = 0;
|
|
251
403
|
}
|
|
252
404
|
|
|
@@ -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';
|