@monque/core 1.3.0 → 1.5.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.
@@ -16,6 +16,11 @@ import { AggregationTimeoutError, ConnectionError } from '@/shared';
16
16
  import { buildSelectorQuery, decodeCursor, encodeCursor } from '../helpers.js';
17
17
  import type { SchedulerContext } from './types.js';
18
18
 
19
+ interface StatsCacheEntry {
20
+ data: QueueStats;
21
+ expiresAt: number;
22
+ }
23
+
19
24
  /**
20
25
  * Internal service for job query operations.
21
26
  *
@@ -25,6 +30,9 @@ import type { SchedulerContext } from './types.js';
25
30
  * @internal Not part of public API - use Monque class methods instead.
26
31
  */
27
32
  export class JobQueryService {
33
+ private readonly statsCache = new Map<string, StatsCacheEntry>();
34
+ private static readonly MAX_CACHE_SIZE = 100;
35
+
28
36
  constructor(private readonly ctx: SchedulerContext) {}
29
37
 
30
38
  /**
@@ -264,12 +272,24 @@ export class JobQueryService {
264
272
  };
265
273
  }
266
274
 
275
+ /**
276
+ * Clear all cached getQueueStats() results.
277
+ * Called on scheduler stop() for clean state on restart.
278
+ * @internal
279
+ */
280
+ clearStatsCache(): void {
281
+ this.statsCache.clear();
282
+ }
283
+
267
284
  /**
268
285
  * Get aggregate statistics for the job queue.
269
286
  *
270
287
  * Uses MongoDB aggregation pipeline for efficient server-side calculation.
271
288
  * Returns counts per status and optional average processing duration for completed jobs.
272
289
  *
290
+ * Results are cached per unique filter with a configurable TTL (default 5s).
291
+ * Set `statsCacheTtlMs: 0` to disable caching.
292
+ *
273
293
  * @param filter - Optional filter to scope statistics by job name
274
294
  * @returns Promise resolving to queue statistics
275
295
  * @throws {AggregationTimeoutError} If aggregation exceeds 30 second timeout
@@ -288,6 +308,16 @@ export class JobQueryService {
288
308
  * ```
289
309
  */
290
310
  async getQueueStats(filter?: Pick<JobSelector, 'name'>): Promise<QueueStats> {
311
+ const ttl = this.ctx.options.statsCacheTtlMs;
312
+ const cacheKey = filter?.name ?? '';
313
+
314
+ if (ttl > 0) {
315
+ const cached = this.statsCache.get(cacheKey);
316
+ if (cached && cached.expiresAt > Date.now()) {
317
+ return { ...cached.data };
318
+ }
319
+ }
320
+
291
321
  const matchStage: Document = {};
292
322
 
293
323
  if (filter?.name) {
@@ -350,48 +380,63 @@ export class JobQueryService {
350
380
  total: 0,
351
381
  };
352
382
 
353
- if (!result) {
354
- return stats;
355
- }
383
+ if (result) {
384
+ // Map status counts to stats
385
+ const statusCounts = result['statusCounts'] as Array<{ _id: string; count: number }>;
386
+ for (const entry of statusCounts) {
387
+ const status = entry._id;
388
+ const count = entry.count;
389
+
390
+ switch (status) {
391
+ case JobStatus.PENDING:
392
+ stats.pending = count;
393
+ break;
394
+ case JobStatus.PROCESSING:
395
+ stats.processing = count;
396
+ break;
397
+ case JobStatus.COMPLETED:
398
+ stats.completed = count;
399
+ break;
400
+ case JobStatus.FAILED:
401
+ stats.failed = count;
402
+ break;
403
+ case JobStatus.CANCELLED:
404
+ stats.cancelled = count;
405
+ break;
406
+ }
407
+ }
356
408
 
357
- // Map status counts to stats
358
- const statusCounts = result['statusCounts'] as Array<{ _id: string; count: number }>;
359
- for (const entry of statusCounts) {
360
- const status = entry._id;
361
- const count = entry.count;
362
-
363
- switch (status) {
364
- case JobStatus.PENDING:
365
- stats.pending = count;
366
- break;
367
- case JobStatus.PROCESSING:
368
- stats.processing = count;
369
- break;
370
- case JobStatus.COMPLETED:
371
- stats.completed = count;
372
- break;
373
- case JobStatus.FAILED:
374
- stats.failed = count;
375
- break;
376
- case JobStatus.CANCELLED:
377
- stats.cancelled = count;
378
- break;
409
+ // Extract total
410
+ const totalResult = result['total'] as Array<{ count: number }>;
411
+ if (totalResult.length > 0 && totalResult[0]) {
412
+ stats.total = totalResult[0].count;
379
413
  }
380
- }
381
414
 
382
- // Extract total
383
- const totalResult = result['total'] as Array<{ count: number }>;
384
- if (totalResult.length > 0 && totalResult[0]) {
385
- stats.total = totalResult[0].count;
415
+ // Extract average processing duration
416
+ const avgDurationResult = result['avgDuration'] as Array<{ avgMs: number }>;
417
+ if (avgDurationResult.length > 0 && avgDurationResult[0]) {
418
+ const avgMs = avgDurationResult[0].avgMs;
419
+ if (typeof avgMs === 'number' && !Number.isNaN(avgMs)) {
420
+ stats.avgProcessingDurationMs = Math.round(avgMs);
421
+ }
422
+ }
386
423
  }
387
424
 
388
- // Extract average processing duration
389
- const avgDurationResult = result['avgDuration'] as Array<{ avgMs: number }>;
390
- if (avgDurationResult.length > 0 && avgDurationResult[0]) {
391
- const avgMs = avgDurationResult[0].avgMs;
392
- if (typeof avgMs === 'number' && !Number.isNaN(avgMs)) {
393
- stats.avgProcessingDurationMs = Math.round(avgMs);
425
+ // Cache the result if TTL is enabled
426
+ if (ttl > 0) {
427
+ // Delete existing entry first so re-insertion moves it to end (Map insertion order = LRU)
428
+ this.statsCache.delete(cacheKey);
429
+ // LRU eviction: if cache is still full after removing existing key, evict the oldest entry
430
+ if (this.statsCache.size >= JobQueryService.MAX_CACHE_SIZE) {
431
+ const oldestKey = this.statsCache.keys().next().value;
432
+ if (oldestKey !== undefined) {
433
+ this.statsCache.delete(oldestKey);
434
+ }
394
435
  }
436
+ this.statsCache.set(cacheKey, {
437
+ data: { ...stats },
438
+ expiresAt: Date.now() + ttl,
439
+ });
395
440
  }
396
441
 
397
442
  return stats;
@@ -1,4 +1,4 @@
1
- import type { Document } from 'mongodb';
1
+ import { BSON, type Document } from 'mongodb';
2
2
 
3
3
  import {
4
4
  type EnqueueOptions,
@@ -7,7 +7,7 @@ import {
7
7
  type PersistedJob,
8
8
  type ScheduleOptions,
9
9
  } from '@/jobs';
10
- import { ConnectionError, getNextCronDate, MonqueError } from '@/shared';
10
+ import { ConnectionError, getNextCronDate, MonqueError, PayloadTooLargeError } from '@/shared';
11
11
 
12
12
  import type { SchedulerContext } from './types.js';
13
13
 
@@ -22,6 +22,41 @@ import type { SchedulerContext } from './types.js';
22
22
  export class JobScheduler {
23
23
  constructor(private readonly ctx: SchedulerContext) {}
24
24
 
25
+ /**
26
+ * Validate that the job data payload does not exceed the configured maximum BSON byte size.
27
+ *
28
+ * @param data - The job data payload to validate
29
+ * @throws {PayloadTooLargeError} If the payload exceeds `maxPayloadSize`
30
+ */
31
+ private validatePayloadSize(data: unknown): void {
32
+ const maxSize = this.ctx.options.maxPayloadSize;
33
+ if (maxSize === undefined) {
34
+ return;
35
+ }
36
+
37
+ let size: number;
38
+ try {
39
+ size = BSON.calculateObjectSize({ data } as Document);
40
+ } catch (error) {
41
+ const cause = error instanceof Error ? error : new Error(String(error));
42
+ const sizeError = new PayloadTooLargeError(
43
+ `Failed to calculate job payload size: ${cause.message}`,
44
+ -1,
45
+ maxSize,
46
+ );
47
+ sizeError.cause = cause;
48
+ throw sizeError;
49
+ }
50
+
51
+ if (size > maxSize) {
52
+ throw new PayloadTooLargeError(
53
+ `Job payload exceeds maximum size: ${size} bytes > ${maxSize} bytes`,
54
+ size,
55
+ maxSize,
56
+ );
57
+ }
58
+ }
59
+
25
60
  /**
26
61
  * Enqueue a job for processing.
27
62
  *
@@ -40,6 +75,7 @@ export class JobScheduler {
40
75
  * @param options - Scheduling and deduplication options
41
76
  * @returns Promise resolving to the created or existing job document
42
77
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
78
+ * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
43
79
  *
44
80
  * @example Basic job enqueueing
45
81
  * ```typescript
@@ -67,6 +103,7 @@ export class JobScheduler {
67
103
  * ```
68
104
  */
69
105
  async enqueue<T>(name: string, data: T, options: EnqueueOptions = {}): Promise<PersistedJob<T>> {
106
+ this.validatePayloadSize(data);
70
107
  const now = new Date();
71
108
  const job: Omit<Job<T>, '_id'> = {
72
109
  name,
@@ -174,6 +211,7 @@ export class JobScheduler {
174
211
  * @returns Promise resolving to the created job document with `repeatInterval` set
175
212
  * @throws {InvalidCronError} If cron expression is invalid
176
213
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
214
+ * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
177
215
  *
178
216
  * @example Hourly cleanup job
179
217
  * ```typescript
@@ -204,6 +242,8 @@ export class JobScheduler {
204
242
  data: T,
205
243
  options: ScheduleOptions = {},
206
244
  ): Promise<PersistedJob<T>> {
245
+ this.validatePayloadSize(data);
246
+
207
247
  // Validate cron and get next run date (throws InvalidCronError if invalid)
208
248
  const nextRunAt = getNextCronDate(cron);
209
249
 
@@ -0,0 +1,154 @@
1
+ import type { DeleteResult } from 'mongodb';
2
+
3
+ import { JobStatus } from '@/jobs';
4
+ import { toError } from '@/shared';
5
+
6
+ import type { SchedulerContext } from './types.js';
7
+
8
+ /**
9
+ * Default retention check interval (1 hour).
10
+ */
11
+ const DEFAULT_RETENTION_INTERVAL = 3600_000;
12
+
13
+ /**
14
+ * Callbacks for timer-driven operations.
15
+ *
16
+ * These are provided by the Monque facade to wire LifecycleManager's timers
17
+ * to JobProcessor methods without creating a direct dependency.
18
+ */
19
+ interface TimerCallbacks {
20
+ /** Poll for pending jobs */
21
+ poll: () => Promise<void>;
22
+ /** Update heartbeats for claimed jobs */
23
+ updateHeartbeats: () => Promise<void>;
24
+ }
25
+
26
+ /**
27
+ * Manages scheduler lifecycle timers and job cleanup.
28
+ *
29
+ * Owns poll interval, heartbeat interval, cleanup interval, and the
30
+ * cleanupJobs logic. Extracted from Monque to keep the facade thin.
31
+ *
32
+ * @internal Not part of public API.
33
+ */
34
+ export class LifecycleManager {
35
+ private readonly ctx: SchedulerContext;
36
+ private pollIntervalId: ReturnType<typeof setInterval> | null = null;
37
+ private heartbeatIntervalId: ReturnType<typeof setInterval> | null = null;
38
+ private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
39
+
40
+ constructor(ctx: SchedulerContext) {
41
+ this.ctx = ctx;
42
+ }
43
+
44
+ /**
45
+ * Start all lifecycle timers.
46
+ *
47
+ * Sets up poll interval, heartbeat interval, and (if configured)
48
+ * cleanup interval. Runs an initial poll immediately.
49
+ *
50
+ * @param callbacks - Functions to invoke on each timer tick
51
+ */
52
+ 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);
59
+
60
+ // Start heartbeat interval for claimed jobs
61
+ this.heartbeatIntervalId = setInterval(() => {
62
+ callbacks.updateHeartbeats().catch((error: unknown) => {
63
+ this.ctx.emit('job:error', { error: toError(error) });
64
+ });
65
+ }, this.ctx.options.heartbeatInterval);
66
+
67
+ // Start cleanup interval if retention is configured
68
+ if (this.ctx.options.jobRetention) {
69
+ const interval = this.ctx.options.jobRetention.interval ?? DEFAULT_RETENTION_INTERVAL;
70
+
71
+ // Run immediately on start
72
+ this.cleanupJobs().catch((error: unknown) => {
73
+ this.ctx.emit('job:error', { error: toError(error) });
74
+ });
75
+
76
+ this.cleanupIntervalId = setInterval(() => {
77
+ this.cleanupJobs().catch((error: unknown) => {
78
+ this.ctx.emit('job:error', { error: toError(error) });
79
+ });
80
+ }, interval);
81
+ }
82
+
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
+ });
87
+ }
88
+
89
+ /**
90
+ * Stop all lifecycle timers.
91
+ *
92
+ * Clears poll, heartbeat, and cleanup intervals.
93
+ */
94
+ stopTimers(): void {
95
+ if (this.cleanupIntervalId) {
96
+ clearInterval(this.cleanupIntervalId);
97
+ this.cleanupIntervalId = null;
98
+ }
99
+
100
+ if (this.pollIntervalId) {
101
+ clearInterval(this.pollIntervalId);
102
+ this.pollIntervalId = null;
103
+ }
104
+
105
+ if (this.heartbeatIntervalId) {
106
+ clearInterval(this.heartbeatIntervalId);
107
+ this.heartbeatIntervalId = null;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Clean up old completed and failed jobs based on retention policy.
113
+ *
114
+ * - Removes completed jobs older than `jobRetention.completed`
115
+ * - Removes failed jobs older than `jobRetention.failed`
116
+ *
117
+ * The cleanup runs concurrently for both statuses if configured.
118
+ *
119
+ * @returns Promise resolving when all deletion operations complete
120
+ */
121
+ async cleanupJobs(): Promise<void> {
122
+ if (!this.ctx.options.jobRetention) {
123
+ return;
124
+ }
125
+
126
+ const { completed, failed } = this.ctx.options.jobRetention;
127
+ const now = Date.now();
128
+ const deletions: Promise<DeleteResult>[] = [];
129
+
130
+ if (completed != null) {
131
+ const cutoff = new Date(now - completed);
132
+ deletions.push(
133
+ this.ctx.collection.deleteMany({
134
+ status: JobStatus.COMPLETED,
135
+ updatedAt: { $lt: cutoff },
136
+ }),
137
+ );
138
+ }
139
+
140
+ if (failed != null) {
141
+ const cutoff = new Date(now - failed);
142
+ deletions.push(
143
+ this.ctx.collection.deleteMany({
144
+ status: JobStatus.FAILED,
145
+ updatedAt: { $lt: cutoff },
146
+ }),
147
+ );
148
+ }
149
+
150
+ if (deletions.length > 0) {
151
+ await Promise.all(deletions);
152
+ }
153
+ }
154
+ }
@@ -19,11 +19,15 @@ export interface ResolvedMonqueOptions
19
19
  | 'maxBackoffDelay'
20
20
  | 'jobRetention'
21
21
  | 'instanceConcurrency'
22
+ | 'maxPayloadSize'
22
23
  | 'defaultConcurrency'
23
24
  | 'maxConcurrency'
24
25
  >
25
26
  >,
26
- Pick<MonqueOptions, 'maxBackoffDelay' | 'jobRetention' | 'instanceConcurrency'> {
27
+ Pick<
28
+ MonqueOptions,
29
+ 'maxBackoffDelay' | 'jobRetention' | 'instanceConcurrency' | 'maxPayloadSize'
30
+ > {
27
31
  // Ensure resolved options use the new naming convention
28
32
  workerConcurrency: number;
29
33
  }
@@ -162,4 +162,38 @@ 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;
176
+
177
+ /**
178
+ * Maximum allowed BSON byte size for job data payloads.
179
+ *
180
+ * When set, `enqueue()`, `now()`, and `schedule()` validate the payload size
181
+ * using `BSON.calculateObjectSize()` before insertion. Jobs exceeding this limit
182
+ * throw `PayloadTooLargeError`.
183
+ *
184
+ * When undefined, no size validation occurs.
185
+ */
186
+ maxPayloadSize?: number | undefined;
187
+
188
+ /**
189
+ * TTL in milliseconds for getQueueStats() result caching.
190
+ *
191
+ * When set to a positive value, repeated getQueueStats() calls with the same
192
+ * filter return cached results instead of re-executing the aggregation pipeline.
193
+ * Each unique filter (job name) maintains its own cache entry.
194
+ *
195
+ * Set to 0 to disable caching entirely.
196
+ * @default 5000
197
+ */
198
+ statsCacheTtlMs?: number;
165
199
  }
@@ -223,3 +223,34 @@ export class AggregationTimeoutError extends MonqueError {
223
223
  }
224
224
  }
225
225
  }
226
+
227
+ /**
228
+ * Error thrown when a job payload exceeds the configured maximum BSON byte size.
229
+ *
230
+ * @example
231
+ * ```typescript
232
+ * const monque = new Monque(db, { maxPayloadSize: 1_000_000 }); // 1 MB
233
+ *
234
+ * try {
235
+ * await monque.enqueue('job', hugePayload);
236
+ * } catch (error) {
237
+ * if (error instanceof PayloadTooLargeError) {
238
+ * console.error(`Payload ${error.actualSize} bytes exceeds limit ${error.maxSize} bytes`);
239
+ * }
240
+ * }
241
+ * ```
242
+ */
243
+ export class PayloadTooLargeError extends MonqueError {
244
+ constructor(
245
+ message: string,
246
+ public readonly actualSize: number,
247
+ public readonly maxSize: number,
248
+ ) {
249
+ super(message);
250
+ this.name = 'PayloadTooLargeError';
251
+ /* istanbul ignore next -- @preserve captureStackTrace is always available in Node.js */
252
+ if (Error.captureStackTrace) {
253
+ Error.captureStackTrace(this, PayloadTooLargeError);
254
+ }
255
+ }
256
+ }
@@ -5,6 +5,7 @@ export {
5
5
  InvalidCursorError,
6
6
  JobStateError,
7
7
  MonqueError,
8
+ PayloadTooLargeError,
8
9
  ShutdownTimeoutError,
9
10
  WorkerRegistrationError,
10
11
  } from './errors.js';
@@ -14,5 +15,6 @@ export {
14
15
  DEFAULT_BASE_INTERVAL,
15
16
  DEFAULT_MAX_BACKOFF_DELAY,
16
17
  getNextCronDate,
18
+ toError,
17
19
  validateCronExpression,
18
20
  } from './utils/index.js';
@@ -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
+ }
@@ -5,3 +5,4 @@ export {
5
5
  DEFAULT_MAX_BACKOFF_DELAY,
6
6
  } from './backoff.js';
7
7
  export { getNextCronDate, validateCronExpression } from './cron.js';
8
+ export { toError } from './error.js';