@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monque/core",
3
- "version": "1.6.0",
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.12.0",
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.2",
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
@@ -18,11 +18,18 @@ import {
18
18
  type QueueStats,
19
19
  type ScheduleOptions,
20
20
  } from '@/jobs';
21
- import { ConnectionError, ShutdownTimeoutError, WorkerRegistrationError } from '@/shared';
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
- const activeJobs = this.getActiveJobs();
1120
- if (activeJobs.length === 0) {
1177
+ if (this.getActiveJobCount() === 0) {
1121
1178
  return;
1122
1179
  }
1123
1180
 
1124
- // Create a promise that resolves when all jobs are done
1125
- let checkInterval: ReturnType<typeof setInterval> | undefined;
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
- checkInterval = setInterval(() => {
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
- let result: undefined | 'timeout';
1192
+ const result = await Promise.race([waitForJobs, timeout]);
1141
1193
 
1142
- try {
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 array of active job IDs across all workers.
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 Array of job ID strings currently being processed
1292
+ * @returns Number of jobs currently being processed
1232
1293
  */
1233
- private getActiveJobs(): string[] {
1234
- const activeJobs: string[] = [];
1294
+ private getActiveJobCount(): number {
1295
+ let count = 0;
1235
1296
  for (const worker of this.workers.values()) {
1236
- activeJobs.push(...worker.activeJobs.keys());
1297
+ count += worker.activeJobs.size;
1237
1298
  }
1238
- return activeJobs;
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
- // Fetch job first to allow emitting the full job object in the event
43
- const jobDoc = await this.ctx.collection.findOne({ _id });
44
- if (!jobDoc) return null;
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
- if (jobDoc['status'] === JobStatus.CANCELLED) {
47
- return this.ctx.documentToPersistedJob(jobDoc);
48
- }
55
+ if (!result) {
56
+ const jobDoc = await this.ctx.collection.findOne({ _id });
57
+ if (!jobDoc) return null;
49
58
 
50
- if (jobDoc['status'] !== JobStatus.PENDING) {
51
- throw new JobStateError(
52
- `Cannot cancel job in status '${jobDoc['status']}'`,
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
- const result = await this.ctx.collection.findOneAndUpdate(
60
- { _id, status: JobStatus.PENDING },
61
- {
62
- $set: {
63
- status: JobStatus.CANCELLED,
64
- updatedAt: new Date(),
65
- },
66
- },
67
- { returnDocument: 'after' },
68
- );
69
-
70
- if (!result) {
71
- // Race condition: job changed state between check and update
72
- throw new JobStateError(
73
- 'Job status changed during cancellation attempt',
74
- jobId,
75
- 'unknown',
76
- 'cancel',
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
- if (currentJob['status'] !== JobStatus.FAILED && currentJob['status'] !== JobStatus.CANCELLED) {
112
- throw new JobStateError(
113
- `Cannot retry job in status '${currentJob['status']}'`,
114
- jobId,
115
- currentJob['status'],
116
- 'retry',
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
- const previousStatus = currentJob['status'] as 'failed' | 'cancelled';
133
+ if (!result) {
134
+ const currentJob = await this.ctx.collection.findOne({ _id });
135
+ if (!currentJob) return null;
121
136
 
122
- const result = await this.ctx.collection.findOneAndUpdate(
123
- {
124
- _id,
125
- status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] },
126
- },
127
- {
128
- $set: {
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
- if (!result) {
146
- throw new JobStateError('Job status changed during retry attempt', jobId, 'unknown', 'retry');
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
- if (currentJobDoc['status'] !== JobStatus.PENDING) {
180
- throw new JobStateError(
181
- `Cannot reschedule job in status '${currentJobDoc['status']}'`,
182
- jobId,
183
- currentJobDoc['status'],
184
- 'reschedule',
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
- const result = await this.ctx.collection.findOneAndUpdate(
189
- { _id, status: JobStatus.PENDING },
190
- {
191
- $set: {
192
- nextRunAt: runAt,
193
- updatedAt: new Date(),
194
- },
195
- },
196
- { returnDocument: 'after' },
197
- );
198
-
199
- if (!result) {
200
- throw new JobStateError(
201
- 'Job status changed during reschedule attempt',
202
- jobId,
203
- 'unknown',
204
- 'reschedule',
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
- const result = await this.ctx.collection.deleteOne({ _id });
256
+ try {
257
+ const result = await this.ctx.collection.deleteOne({ _id });
236
258
 
237
- if (result.deletedCount > 0) {
238
- this.ctx.emit('job:deleted', { jobId });
239
- return true;
240
- }
259
+ if (result.deletedCount > 0) {
260
+ this.ctx.emit('job:deleted', { jobId });
261
+ return true;
262
+ }
241
263
 
242
- return false;
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: new Date(),
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: [new Date(), { $multiply: [{ $rand: {} }, spreadWindowMs] }],
389
+ $add: [now, { $multiply: [{ $rand: {} }, spreadWindowMs] }],
356
390
  },
357
- updatedAt: new Date(),
391
+ updatedAt: now,
358
392
  },
359
393
  },
360
394
  {
361
- $unset: ['failReason', 'lockedAt', 'claimedBy', 'lastHeartbeat', 'heartbeatInterval'],
395
+ $unset: ['failReason', 'lockedAt', 'claimedBy', 'lastHeartbeat'],
362
396
  },
363
397
  ]);
364
398