@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.
@@ -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
  {
@@ -141,12 +158,19 @@ export class JobScheduler {
141
158
  throw new ConnectionError('Failed to enqueue job: findOneAndUpdate returned no document');
142
159
  }
143
160
 
144
- return this.ctx.documentToPersistedJob<T>(result);
161
+ const persistedJob = this.ctx.documentToPersistedJob<T>(result);
162
+ if (persistedJob.status === JobStatus.PENDING) {
163
+ this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
164
+ }
165
+
166
+ return persistedJob;
145
167
  }
146
168
 
147
169
  const result = await this.ctx.collection.insertOne(job as Document);
170
+ const persistedJob = { ...job, _id: result.insertedId } as PersistedJob<T>;
171
+ this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
148
172
 
149
- return { ...job, _id: result.insertedId } as PersistedJob<T>;
173
+ return persistedJob;
150
174
  } catch (error) {
151
175
  if (error instanceof ConnectionError) {
152
176
  throw error;
@@ -209,6 +233,7 @@ export class JobScheduler {
209
233
  * @param data - Job payload, will be passed to the worker handler on each run
210
234
  * @param options - Scheduling options (uniqueKey for deduplication)
211
235
  * @returns Promise resolving to the created job document with `repeatInterval` set
236
+ * @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
212
237
  * @throws {InvalidCronError} If cron expression is invalid
213
238
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
214
239
  * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
@@ -242,6 +267,7 @@ export class JobScheduler {
242
267
  data: T,
243
268
  options: ScheduleOptions = {},
244
269
  ): Promise<PersistedJob<T>> {
270
+ this.validateJobIdentifiers(name, options.uniqueKey);
245
271
  this.validatePayloadSize(data);
246
272
 
247
273
  // Validate cron and get next run date (throws InvalidCronError if invalid)
@@ -259,12 +285,12 @@ export class JobScheduler {
259
285
  updatedAt: now,
260
286
  };
261
287
 
262
- if (options.uniqueKey) {
288
+ if (options.uniqueKey !== undefined) {
263
289
  job.uniqueKey = options.uniqueKey;
264
290
  }
265
291
 
266
292
  try {
267
- if (options.uniqueKey) {
293
+ if (options.uniqueKey !== undefined) {
268
294
  // Use upsert with $setOnInsert for deduplication (scoped by name + uniqueKey)
269
295
  const result = await this.ctx.collection.findOneAndUpdate(
270
296
  {
@@ -287,12 +313,19 @@ export class JobScheduler {
287
313
  );
288
314
  }
289
315
 
290
- return this.ctx.documentToPersistedJob<T>(result);
316
+ const persistedJob = this.ctx.documentToPersistedJob<T>(result);
317
+ if (persistedJob.status === JobStatus.PENDING) {
318
+ this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
319
+ }
320
+
321
+ return persistedJob;
291
322
  }
292
323
 
293
324
  const result = await this.ctx.collection.insertOne(job as Document);
325
+ const persistedJob = { ...job, _id: result.insertedId } as PersistedJob<T>;
326
+ this.ctx.notifyPendingJob(persistedJob.name, persistedJob.nextRunAt);
294
327
 
295
- return { ...job, _id: result.insertedId } as PersistedJob<T>;
328
+ return persistedJob;
296
329
  } catch (error) {
297
330
  if (error instanceof MonqueError) {
298
331
  throw error;
@@ -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
  *
@@ -21,19 +26,26 @@ interface TimerCallbacks {
21
26
  poll: () => Promise<void>;
22
27
  /** Update heartbeats for claimed jobs */
23
28
  updateHeartbeats: () => Promise<void>;
29
+ /** Whether change streams are currently active */
30
+ isChangeStreamActive: () => boolean;
24
31
  }
25
32
 
26
33
  /**
27
34
  * Manages scheduler lifecycle timers and job cleanup.
28
35
  *
29
- * Owns poll interval, heartbeat interval, cleanup interval, and the
36
+ * Owns poll scheduling, heartbeat interval, cleanup interval, and the
30
37
  * cleanupJobs logic. Extracted from Monque to keep the facade thin.
31
38
  *
39
+ * Uses adaptive poll scheduling: when change streams are active, polls at
40
+ * `safetyPollInterval` (safety net only). When change streams are inactive,
41
+ * polls at `pollInterval` (primary discovery mechanism).
42
+ *
32
43
  * @internal Not part of public API.
33
44
  */
34
45
  export class LifecycleManager {
35
46
  private readonly ctx: SchedulerContext;
36
- private pollIntervalId: ReturnType<typeof setInterval> | null = null;
47
+ private callbacks: TimerCallbacks | null = null;
48
+ private pollTimeoutId: ReturnType<typeof setTimeout> | null = null;
37
49
  private heartbeatIntervalId: ReturnType<typeof setInterval> | null = null;
38
50
  private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
39
51
 
@@ -44,18 +56,13 @@ export class LifecycleManager {
44
56
  /**
45
57
  * Start all lifecycle timers.
46
58
  *
47
- * Sets up poll interval, heartbeat interval, and (if configured)
59
+ * Sets up adaptive poll scheduling, heartbeat interval, and (if configured)
48
60
  * cleanup interval. Runs an initial poll immediately.
49
61
  *
50
62
  * @param callbacks - Functions to invoke on each timer tick
51
63
  */
52
64
  startTimers(callbacks: TimerCallbacks): void {
53
- // Set up polling as backup (runs at configured interval)
54
- this.pollIntervalId = setInterval(() => {
55
- callbacks.poll().catch((error: unknown) => {
56
- this.ctx.emit('job:error', { error: toError(error) });
57
- });
58
- }, this.ctx.options.pollInterval);
65
+ this.callbacks = callbacks;
59
66
 
60
67
  // Start heartbeat interval for claimed jobs
61
68
  this.heartbeatIntervalId = setInterval(() => {
@@ -80,26 +87,26 @@ export class LifecycleManager {
80
87
  }, interval);
81
88
  }
82
89
 
83
- // Run initial poll immediately to pick up any existing jobs
84
- callbacks.poll().catch((error: unknown) => {
85
- this.ctx.emit('job:error', { error: toError(error) });
86
- });
90
+ // Run initial poll immediately, then schedule the next one adaptively
91
+ this.executePollAndScheduleNext();
87
92
  }
88
93
 
89
94
  /**
90
95
  * Stop all lifecycle timers.
91
96
  *
92
- * Clears poll, heartbeat, and cleanup intervals.
97
+ * Clears poll timeout, heartbeat interval, and cleanup interval.
93
98
  */
94
99
  stopTimers(): void {
100
+ this.callbacks = null;
101
+
95
102
  if (this.cleanupIntervalId) {
96
103
  clearInterval(this.cleanupIntervalId);
97
104
  this.cleanupIntervalId = null;
98
105
  }
99
106
 
100
- if (this.pollIntervalId) {
101
- clearInterval(this.pollIntervalId);
102
- this.pollIntervalId = null;
107
+ if (this.pollTimeoutId) {
108
+ clearTimeout(this.pollTimeoutId);
109
+ this.pollTimeoutId = null;
103
110
  }
104
111
 
105
112
  if (this.heartbeatIntervalId) {
@@ -108,6 +115,59 @@ export class LifecycleManager {
108
115
  }
109
116
  }
110
117
 
118
+ /**
119
+ * Reset the poll timer to reschedule the next poll.
120
+ *
121
+ * Called after change-stream-triggered polls to ensure the safety poll timer
122
+ * is recalculated (not fired redundantly from an old schedule).
123
+ */
124
+ resetPollTimer(): void {
125
+ this.scheduleNextPoll();
126
+ }
127
+
128
+ /**
129
+ * Execute a poll and schedule the next one adaptively.
130
+ */
131
+ private executePollAndScheduleNext(): void {
132
+ if (!this.callbacks) {
133
+ return;
134
+ }
135
+
136
+ this.callbacks
137
+ .poll()
138
+ .catch((error: unknown) => {
139
+ this.ctx.emit('job:error', { error: toError(error) });
140
+ })
141
+ .finally(() => {
142
+ this.scheduleNextPoll();
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Schedule the next poll using adaptive timing.
148
+ *
149
+ * When change streams are active, uses `safetyPollInterval` (longer, safety net only).
150
+ * When change streams are inactive, uses `pollInterval` (shorter, primary discovery).
151
+ */
152
+ private scheduleNextPoll(): void {
153
+ if (this.pollTimeoutId) {
154
+ clearTimeout(this.pollTimeoutId);
155
+ this.pollTimeoutId = null;
156
+ }
157
+
158
+ if (!this.ctx.isRunning() || !this.callbacks) {
159
+ return;
160
+ }
161
+
162
+ const delay = this.callbacks.isChangeStreamActive()
163
+ ? this.ctx.options.safetyPollInterval
164
+ : this.ctx.options.pollInterval;
165
+
166
+ this.pollTimeoutId = setTimeout(() => {
167
+ this.executePollAndScheduleNext();
168
+ }, delay);
169
+ }
170
+
111
171
  /**
112
172
  * Clean up old completed and failed jobs based on retention policy.
113
173
  *
@@ -30,6 +30,7 @@ export interface ResolvedMonqueOptions
30
30
  > {
31
31
  // Ensure resolved options use the new naming convention
32
32
  workerConcurrency: number;
33
+ safetyPollInterval: number;
33
34
  }
34
35
  /**
35
36
  * Shared context provided to all internal Monque services.
@@ -59,6 +60,12 @@ export interface SchedulerContext {
59
60
  /** Type-safe event emitter */
60
61
  emit: <K extends keyof MonqueEventMap>(event: K, payload: MonqueEventMap[K]) => boolean;
61
62
 
63
+ /** Notify the local scheduler about a pending job transition */
64
+ notifyPendingJob: (name: string, nextRunAt: Date) => void;
65
+
66
+ /** Notify that a job has finished processing (for reactive shutdown drain) */
67
+ notifyJobFinished: () => void;
68
+
62
69
  /** Convert MongoDB document to typed PersistedJob */
63
70
  documentToPersistedJob: <T>(doc: WithId<Document>) => PersistedJob<T>;
64
71
  }
@@ -196,4 +196,18 @@ export interface MonqueOptions {
196
196
  * @default 5000
197
197
  */
198
198
  statsCacheTtlMs?: number;
199
+
200
+ /**
201
+ * Interval in milliseconds between safety polls when change streams are active.
202
+ *
203
+ * When change streams are connected, the scheduler uses them as the primary
204
+ * notification mechanism and only polls at this longer interval as a safety net
205
+ * to catch any missed events. When change streams are unavailable, the scheduler
206
+ * falls back to the standard `pollInterval`.
207
+ *
208
+ * This is separate from `heartbeatInterval`, which updates job liveness signals.
209
+ *
210
+ * @default 30000 (30 seconds)
211
+ */
212
+ safetyPollInterval?: number;
199
213
  }
@@ -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
+ }