@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.
- package/dist/CHANGELOG.md +31 -0
- package/dist/index.cjs +589 -325
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -34
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +109 -34
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +590 -327
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/events/types.ts +2 -2
- package/src/index.ts +1 -0
- package/src/jobs/document-to-persisted-job.ts +52 -0
- package/src/jobs/index.ts +2 -0
- package/src/scheduler/monque.ts +124 -179
- package/src/scheduler/services/change-stream-handler.ts +2 -1
- package/src/scheduler/services/index.ts +1 -0
- package/src/scheduler/services/job-manager.ts +112 -140
- package/src/scheduler/services/job-processor.ts +94 -62
- package/src/scheduler/services/job-query.ts +81 -36
- package/src/scheduler/services/job-scheduler.ts +42 -2
- package/src/scheduler/services/lifecycle-manager.ts +154 -0
- package/src/scheduler/services/types.ts +5 -1
- package/src/scheduler/types.ts +34 -0
- package/src/shared/errors.ts +31 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/utils/error.ts +33 -0
- package/src/shared/utils/index.ts +1 -0
|
@@ -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 (
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if
|
|
393
|
-
|
|
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
|
|
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<
|
|
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
|
}
|
package/src/scheduler/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/shared/errors.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -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
|
+
}
|