@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.
@@ -19,20 +19,16 @@ export class JobProcessor {
19
19
  /** Flag to request a re-poll after the current poll finishes */
20
20
  private _repollRequested = false;
21
21
 
22
- constructor(private readonly ctx: SchedulerContext) {}
23
-
24
22
  /**
25
- * Get the total number of active jobs across all workers.
23
+ * O(1) counter tracking the total number of active jobs across all workers.
26
24
  *
27
- * Used for instance-level throttling when `instanceConcurrency` is configured.
25
+ * Incremented when a job is added to `worker.activeJobs` in `_doPoll`,
26
+ * decremented in the `processJob` finally block. Replaces the previous
27
+ * O(workers) loop in `getTotalActiveJobs()` for instance-level throttling.
28
28
  */
29
- private getTotalActiveJobs(): number {
30
- let total = 0;
31
- for (const worker of this.ctx.workers.values()) {
32
- total += worker.activeJobs.size;
33
- }
34
- return total;
35
- }
29
+ private _totalActiveJobs = 0;
30
+
31
+ constructor(private readonly ctx: SchedulerContext) {}
36
32
 
37
33
  /**
38
34
  * Get the number of available slots considering the global instanceConcurrency limit.
@@ -47,8 +43,7 @@ export class JobProcessor {
47
43
  return workerAvailableSlots;
48
44
  }
49
45
 
50
- const totalActive = this.getTotalActiveJobs();
51
- const globalAvailable = instanceConcurrency - totalActive;
46
+ const globalAvailable = instanceConcurrency - this._totalActiveJobs;
52
47
 
53
48
  return Math.min(workerAvailableSlots, globalAvailable);
54
49
  }
@@ -100,7 +95,7 @@ export class JobProcessor {
100
95
  // Early exit if global instanceConcurrency is reached
101
96
  const { instanceConcurrency } = this.ctx.options;
102
97
 
103
- if (instanceConcurrency !== undefined && this.getTotalActiveJobs() >= instanceConcurrency) {
98
+ if (instanceConcurrency !== undefined && this._totalActiveJobs >= instanceConcurrency) {
104
99
  return;
105
100
  }
106
101
 
@@ -125,31 +120,57 @@ export class JobProcessor {
125
120
  return;
126
121
  }
127
122
 
128
- // Try to acquire jobs up to available slots
123
+ // Try to acquire jobs up to available slots in parallel
124
+ if (!this.ctx.isRunning()) {
125
+ return;
126
+ }
127
+
128
+ const acquisitionPromises: Promise<void>[] = [];
129
129
  for (let i = 0; i < availableSlots; i++) {
130
- if (!this.ctx.isRunning()) {
131
- return;
132
- }
133
-
134
- // Re-check global limit before each acquisition
135
- if (instanceConcurrency !== undefined && this.getTotalActiveJobs() >= instanceConcurrency) {
136
- return;
137
- }
138
-
139
- const job = await this.acquireJob(name);
140
-
141
- if (job) {
142
- // Add to activeJobs immediately to correctly track concurrency
143
- worker.activeJobs.set(job._id.toString(), job);
144
-
145
- this.processJob(job, worker).catch((error: unknown) => {
146
- this.ctx.emit('job:error', { error: toError(error), job });
147
- });
148
- } else {
149
- // No more jobs available for this worker
150
- break;
151
- }
130
+ acquisitionPromises.push(
131
+ this.acquireJob(name)
132
+ .then(async (job) => {
133
+ if (!job) {
134
+ return;
135
+ }
136
+
137
+ if (this.ctx.isRunning()) {
138
+ // Add to activeJobs immediately to correctly track concurrency
139
+ worker.activeJobs.set(job._id.toString(), job);
140
+ this._totalActiveJobs++;
141
+
142
+ this.processJob(job, worker).catch((error: unknown) => {
143
+ this.ctx.emit('job:error', { error: toError(error), job });
144
+ });
145
+ } else {
146
+ // Revert claim if shut down while acquiring
147
+ try {
148
+ await this.ctx.collection.updateOne(
149
+ { _id: job._id, status: JobStatus.PROCESSING, claimedBy: this.ctx.instanceId },
150
+ {
151
+ $set: {
152
+ status: JobStatus.PENDING,
153
+ updatedAt: new Date(),
154
+ },
155
+ $unset: {
156
+ lockedAt: '',
157
+ claimedBy: '',
158
+ lastHeartbeat: '',
159
+ },
160
+ },
161
+ );
162
+ } catch (error) {
163
+ this.ctx.emit('job:error', { error: toError(error) });
164
+ }
165
+ }
166
+ })
167
+ .catch((error: unknown) => {
168
+ this.ctx.emit('job:error', { error: toError(error) });
169
+ }),
170
+ );
152
171
  }
172
+
173
+ await Promise.allSettled(acquisitionPromises);
153
174
  }
154
175
  }
155
176
 
@@ -197,10 +218,6 @@ export class JobProcessor {
197
218
  },
198
219
  );
199
220
 
200
- if (!this.ctx.isRunning()) {
201
- return null;
202
- }
203
-
204
221
  if (!result) {
205
222
  return null;
206
223
  }
@@ -248,6 +265,8 @@ export class JobProcessor {
248
265
  }
249
266
  } finally {
250
267
  worker.activeJobs.delete(jobId);
268
+ this._totalActiveJobs--;
269
+ this.ctx.notifyJobFinished();
251
270
  }
252
271
  }
253
272
 
@@ -271,6 +290,8 @@ export class JobProcessor {
271
290
  return null;
272
291
  }
273
292
 
293
+ const now = new Date();
294
+
274
295
  if (job.repeatInterval) {
275
296
  // Recurring job - schedule next run
276
297
  const nextRunAt = getNextCronDate(job.repeatInterval);
@@ -281,13 +302,12 @@ export class JobProcessor {
281
302
  status: JobStatus.PENDING,
282
303
  nextRunAt,
283
304
  failCount: 0,
284
- updatedAt: new Date(),
305
+ updatedAt: now,
285
306
  },
286
307
  $unset: {
287
308
  lockedAt: '',
288
309
  claimedBy: '',
289
310
  lastHeartbeat: '',
290
- heartbeatInterval: '',
291
311
  failReason: '',
292
312
  },
293
313
  },
@@ -309,13 +329,12 @@ export class JobProcessor {
309
329
  {
310
330
  $set: {
311
331
  status: JobStatus.COMPLETED,
312
- updatedAt: new Date(),
332
+ updatedAt: now,
313
333
  },
314
334
  $unset: {
315
335
  lockedAt: '',
316
336
  claimedBy: '',
317
337
  lastHeartbeat: '',
318
- heartbeatInterval: '',
319
338
  failReason: '',
320
339
  },
321
340
  },
@@ -353,6 +372,7 @@ export class JobProcessor {
353
372
  return null;
354
373
  }
355
374
 
375
+ const now = new Date();
356
376
  const newFailCount = job.failCount + 1;
357
377
 
358
378
  if (newFailCount >= this.ctx.options.maxRetries) {
@@ -364,13 +384,12 @@ export class JobProcessor {
364
384
  status: JobStatus.FAILED,
365
385
  failCount: newFailCount,
366
386
  failReason: error.message,
367
- updatedAt: new Date(),
387
+ updatedAt: now,
368
388
  },
369
389
  $unset: {
370
390
  lockedAt: '',
371
391
  claimedBy: '',
372
392
  lastHeartbeat: '',
373
- heartbeatInterval: '',
374
393
  },
375
394
  },
376
395
  { returnDocument: 'after' },
@@ -394,13 +413,12 @@ export class JobProcessor {
394
413
  failCount: newFailCount,
395
414
  failReason: error.message,
396
415
  nextRunAt,
397
- updatedAt: new Date(),
416
+ updatedAt: now,
398
417
  },
399
418
  $unset: {
400
419
  lockedAt: '',
401
420
  claimedBy: '',
402
421
  lastHeartbeat: '',
403
- heartbeatInterval: '',
404
422
  },
405
423
  },
406
424
  { returnDocument: 'after' },
@@ -7,7 +7,14 @@ import {
7
7
  type PersistedJob,
8
8
  type ScheduleOptions,
9
9
  } from '@/jobs';
10
- import { ConnectionError, getNextCronDate, MonqueError, PayloadTooLargeError } from '@/shared';
10
+ import {
11
+ ConnectionError,
12
+ getNextCronDate,
13
+ MonqueError,
14
+ PayloadTooLargeError,
15
+ validateJobName,
16
+ validateUniqueKey,
17
+ } from '@/shared';
11
18
 
12
19
  import type { SchedulerContext } from './types.js';
13
20
 
@@ -22,6 +29,14 @@ import type { SchedulerContext } from './types.js';
22
29
  export class JobScheduler {
23
30
  constructor(private readonly ctx: SchedulerContext) {}
24
31
 
32
+ private validateJobIdentifiers(name: string, uniqueKey?: string): void {
33
+ validateJobName(name);
34
+
35
+ if (uniqueKey !== undefined) {
36
+ validateUniqueKey(uniqueKey);
37
+ }
38
+ }
39
+
25
40
  /**
26
41
  * Validate that the job data payload does not exceed the configured maximum BSON byte size.
27
42
  *
@@ -74,6 +89,7 @@ export class JobScheduler {
74
89
  * @param data - Job payload, will be passed to the worker handler
75
90
  * @param options - Scheduling and deduplication options
76
91
  * @returns Promise resolving to the created or existing job document
92
+ * @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
77
93
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
78
94
  * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
79
95
  *
@@ -103,6 +119,7 @@ export class JobScheduler {
103
119
  * ```
104
120
  */
105
121
  async enqueue<T>(name: string, data: T, options: EnqueueOptions = {}): Promise<PersistedJob<T>> {
122
+ this.validateJobIdentifiers(name, options.uniqueKey);
106
123
  this.validatePayloadSize(data);
107
124
  const now = new Date();
108
125
  const job: Omit<Job<T>, '_id'> = {
@@ -115,12 +132,12 @@ export class JobScheduler {
115
132
  updatedAt: now,
116
133
  };
117
134
 
118
- if (options.uniqueKey) {
135
+ if (options.uniqueKey !== undefined) {
119
136
  job.uniqueKey = options.uniqueKey;
120
137
  }
121
138
 
122
139
  try {
123
- if (options.uniqueKey) {
140
+ if (options.uniqueKey !== undefined) {
124
141
  // Use upsert with $setOnInsert for deduplication (scoped by name + uniqueKey)
125
142
  const result = await this.ctx.collection.findOneAndUpdate(
126
143
  {
@@ -216,6 +233,7 @@ export class JobScheduler {
216
233
  * @param data - Job payload, will be passed to the worker handler on each run
217
234
  * @param options - Scheduling options (uniqueKey for deduplication)
218
235
  * @returns Promise resolving to the created job document with `repeatInterval` set
236
+ * @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
219
237
  * @throws {InvalidCronError} If cron expression is invalid
220
238
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
221
239
  * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
@@ -249,6 +267,7 @@ export class JobScheduler {
249
267
  data: T,
250
268
  options: ScheduleOptions = {},
251
269
  ): Promise<PersistedJob<T>> {
270
+ this.validateJobIdentifiers(name, options.uniqueKey);
252
271
  this.validatePayloadSize(data);
253
272
 
254
273
  // Validate cron and get next run date (throws InvalidCronError if invalid)
@@ -266,12 +285,12 @@ export class JobScheduler {
266
285
  updatedAt: now,
267
286
  };
268
287
 
269
- if (options.uniqueKey) {
288
+ if (options.uniqueKey !== undefined) {
270
289
  job.uniqueKey = options.uniqueKey;
271
290
  }
272
291
 
273
292
  try {
274
- if (options.uniqueKey) {
293
+ if (options.uniqueKey !== undefined) {
275
294
  // Use upsert with $setOnInsert for deduplication (scoped by name + uniqueKey)
276
295
  const result = await this.ctx.collection.findOneAndUpdate(
277
296
  {
@@ -10,6 +10,11 @@ import type { SchedulerContext } from './types.js';
10
10
  */
11
11
  const DEFAULT_RETENTION_INTERVAL = 3600_000;
12
12
 
13
+ /**
14
+ * Statuses that are eligible for cleanup by the retention policy.
15
+ */
16
+ export const CLEANUP_STATUSES = [JobStatus.COMPLETED, JobStatus.FAILED] as const;
17
+
13
18
  /**
14
19
  * Callbacks for timer-driven operations.
15
20
  *
@@ -133,7 +138,7 @@ export class LifecycleManager {
133
138
  .catch((error: unknown) => {
134
139
  this.ctx.emit('job:error', { error: toError(error) });
135
140
  })
136
- .then(() => {
141
+ .finally(() => {
137
142
  this.scheduleNextPoll();
138
143
  });
139
144
  }
@@ -63,6 +63,9 @@ export interface SchedulerContext {
63
63
  /** Notify the local scheduler about a pending job transition */
64
64
  notifyPendingJob: (name: string, nextRunAt: Date) => void;
65
65
 
66
+ /** Notify that a job has finished processing (for reactive shutdown drain) */
67
+ notifyJobFinished: () => void;
68
+
66
69
  /** Convert MongoDB document to typed PersistedJob */
67
70
  documentToPersistedJob: <T>(doc: WithId<Document>) => PersistedJob<T>;
68
71
  }
@@ -199,6 +199,35 @@ export class InvalidCursorError extends MonqueError {
199
199
  }
200
200
  }
201
201
 
202
+ /**
203
+ * Error thrown when a public job identifier fails validation.
204
+ *
205
+ * @example
206
+ * ```typescript
207
+ * try {
208
+ * await monque.enqueue('invalid job name', {});
209
+ * } catch (error) {
210
+ * if (error instanceof InvalidJobIdentifierError) {
211
+ * console.error(`Invalid ${error.field}: ${error.message}`);
212
+ * }
213
+ * }
214
+ * ```
215
+ */
216
+ export class InvalidJobIdentifierError extends MonqueError {
217
+ constructor(
218
+ public readonly field: 'name' | 'uniqueKey',
219
+ public readonly value: string,
220
+ message: string,
221
+ ) {
222
+ super(message);
223
+ this.name = 'InvalidJobIdentifierError';
224
+ /* istanbul ignore next -- @preserve captureStackTrace is always available in Node.js */
225
+ if (Error.captureStackTrace) {
226
+ Error.captureStackTrace(this, InvalidJobIdentifierError);
227
+ }
228
+ }
229
+ }
230
+
202
231
  /**
203
232
  * Error thrown when a statistics aggregation times out.
204
233
  *
@@ -3,6 +3,7 @@ export {
3
3
  ConnectionError,
4
4
  InvalidCronError,
5
5
  InvalidCursorError,
6
+ InvalidJobIdentifierError,
6
7
  JobStateError,
7
8
  MonqueError,
8
9
  PayloadTooLargeError,
@@ -17,4 +18,6 @@ export {
17
18
  getNextCronDate,
18
19
  toError,
19
20
  validateCronExpression,
21
+ validateJobName,
22
+ validateUniqueKey,
20
23
  } from './utils/index.js';
@@ -6,3 +6,4 @@ export {
6
6
  } from './backoff.js';
7
7
  export { getNextCronDate, validateCronExpression } from './cron.js';
8
8
  export { toError } from './error.js';
9
+ export { validateJobName, validateUniqueKey } from './job-identifiers.js';
@@ -0,0 +1,71 @@
1
+ import { InvalidJobIdentifierError } from '../errors.js';
2
+
3
+ const JOB_NAME_PATTERN = /^[^\s\p{Cc}]+$/u;
4
+ const CONTROL_CHARACTER_PATTERN = /\p{Cc}/u;
5
+
6
+ const MAX_JOB_NAME_LENGTH = 255;
7
+ const MAX_UNIQUE_KEY_LENGTH = 1024;
8
+
9
+ /**
10
+ * Validate a public job name before it is registered or scheduled.
11
+ *
12
+ * @param name - The job name to validate
13
+ * @throws {InvalidJobIdentifierError} If the job name is empty, too long, or contains unsupported characters
14
+ */
15
+ export function validateJobName(name: string): void {
16
+ if (name.length === 0 || name.trim().length === 0) {
17
+ throw new InvalidJobIdentifierError(
18
+ 'name',
19
+ name,
20
+ 'Job name cannot be empty or whitespace only.',
21
+ );
22
+ }
23
+
24
+ if (name.length > MAX_JOB_NAME_LENGTH) {
25
+ throw new InvalidJobIdentifierError(
26
+ 'name',
27
+ name,
28
+ `Job name cannot exceed ${MAX_JOB_NAME_LENGTH} characters.`,
29
+ );
30
+ }
31
+
32
+ if (!JOB_NAME_PATTERN.test(name)) {
33
+ throw new InvalidJobIdentifierError(
34
+ 'name',
35
+ name,
36
+ 'Job name cannot contain whitespace or control characters.',
37
+ );
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Validate a deduplication key before it is stored or used in a unique query.
43
+ *
44
+ * @param uniqueKey - The unique key to validate
45
+ * @throws {InvalidJobIdentifierError} If the key is empty, too long, or contains control characters
46
+ */
47
+ export function validateUniqueKey(uniqueKey: string): void {
48
+ if (uniqueKey.length === 0 || uniqueKey.trim().length === 0) {
49
+ throw new InvalidJobIdentifierError(
50
+ 'uniqueKey',
51
+ uniqueKey,
52
+ 'Unique key cannot be empty or whitespace only.',
53
+ );
54
+ }
55
+
56
+ if (uniqueKey.length > MAX_UNIQUE_KEY_LENGTH) {
57
+ throw new InvalidJobIdentifierError(
58
+ 'uniqueKey',
59
+ uniqueKey,
60
+ `Unique key cannot exceed ${MAX_UNIQUE_KEY_LENGTH} characters.`,
61
+ );
62
+ }
63
+
64
+ if (CONTROL_CHARACTER_PATTERN.test(uniqueKey)) {
65
+ throw new InvalidJobIdentifierError(
66
+ 'uniqueKey',
67
+ uniqueKey,
68
+ 'Unique key cannot contain control characters.',
69
+ );
70
+ }
71
+ }