@monque/core 1.1.2 → 1.3.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.1.2",
3
+ "version": "1.3.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": {
@@ -53,7 +53,7 @@
53
53
  "test:watch": "vitest",
54
54
  "test:watch:unit": "vitest --config vitest.unit.config.ts",
55
55
  "test:watch:integration": "vitest integration/",
56
- "lint": "biome check src/"
56
+ "lint": "biome check ."
57
57
  },
58
58
  "keywords": [
59
59
  "job",
@@ -72,21 +72,17 @@
72
72
  "cron-parser": "^5.5.0"
73
73
  },
74
74
  "peerDependencies": {
75
- "mongodb": "^7.0.0"
75
+ "mongodb": "^7.1.0"
76
76
  },
77
77
  "devDependencies": {
78
- "@arethetypeswrong/cli": "^0.18.2",
79
- "@faker-js/faker": "^10.2.0",
80
- "@testcontainers/mongodb": "^11.11.0",
78
+ "@faker-js/faker": "^10.3.0",
79
+ "@testcontainers/mongodb": "^11.12.0",
81
80
  "@total-typescript/ts-reset": "^0.6.1",
82
- "@types/bun": "^1.3.6",
81
+ "@types/node": "^22.19.11",
83
82
  "@vitest/coverage-v8": "^4.0.18",
84
83
  "fishery": "^2.4.0",
85
- "mongodb": "^7.0.0",
86
- "publint": "^0.3.17",
87
- "tsdown": "^0.20.1",
88
- "typescript": "^5.9.3",
89
- "unplugin-unused": "^0.5.7",
84
+ "mongodb": "^7.1.0",
85
+ "tsdown": "^0.20.3",
90
86
  "vitest": "^4.0.18"
91
87
  }
92
88
  }
@@ -41,7 +41,7 @@ const DEFAULTS = {
41
41
  maxRetries: 10,
42
42
  baseRetryInterval: 1000,
43
43
  shutdownTimeout: 30000,
44
- defaultConcurrency: 5,
44
+ workerConcurrency: 5,
45
45
  lockTimeout: 1_800_000, // 30 minutes
46
46
  recoverStaleJobs: true,
47
47
  heartbeatInterval: 30000, // 30 seconds
@@ -139,10 +139,12 @@ export class Monque extends EventEmitter {
139
139
  maxRetries: options.maxRetries ?? DEFAULTS.maxRetries,
140
140
  baseRetryInterval: options.baseRetryInterval ?? DEFAULTS.baseRetryInterval,
141
141
  shutdownTimeout: options.shutdownTimeout ?? DEFAULTS.shutdownTimeout,
142
- defaultConcurrency: options.defaultConcurrency ?? DEFAULTS.defaultConcurrency,
142
+ workerConcurrency:
143
+ options.workerConcurrency ?? options.defaultConcurrency ?? DEFAULTS.workerConcurrency,
143
144
  lockTimeout: options.lockTimeout ?? DEFAULTS.lockTimeout,
144
145
  recoverStaleJobs: options.recoverStaleJobs ?? DEFAULTS.recoverStaleJobs,
145
146
  maxBackoffDelay: options.maxBackoffDelay,
147
+ instanceConcurrency: options.instanceConcurrency ?? options.maxConcurrency,
146
148
  schedulerInstanceId: options.schedulerInstanceId ?? randomUUID(),
147
149
  heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
148
150
  jobRetention: options.jobRetention,
@@ -918,7 +920,7 @@ export class Monque extends EventEmitter {
918
920
  * ```
919
921
  */
920
922
  register<T>(name: string, handler: JobHandler<T>, options: WorkerOptions = {}): void {
921
- const concurrency = options.concurrency ?? this.options.defaultConcurrency;
923
+ const concurrency = options.concurrency ?? this.options.workerConcurrency;
922
924
 
923
925
  // Check for existing worker and throw unless replace is explicitly true
924
926
  if (this.workers.has(name) && options.replace !== true) {
@@ -13,36 +13,109 @@ import type { SchedulerContext } from './types.js';
13
13
  * @internal Not part of public API.
14
14
  */
15
15
  export class JobProcessor {
16
+ /** Guard flag to prevent concurrent poll() execution */
17
+ private _isPolling = false;
18
+
16
19
  constructor(private readonly ctx: SchedulerContext) {}
17
20
 
21
+ /**
22
+ * Get the total number of active jobs across all workers.
23
+ *
24
+ * Used for instance-level throttling when `instanceConcurrency` is configured.
25
+ */
26
+ private getTotalActiveJobs(): number {
27
+ let total = 0;
28
+ for (const worker of this.ctx.workers.values()) {
29
+ total += worker.activeJobs.size;
30
+ }
31
+ return total;
32
+ }
33
+
34
+ /**
35
+ * Get the number of available slots considering the global instanceConcurrency limit.
36
+ *
37
+ * @param workerAvailableSlots - Available slots for the specific worker
38
+ * @returns Number of slots available after applying global limit
39
+ */
40
+ private getGloballyAvailableSlots(workerAvailableSlots: number): number {
41
+ const { instanceConcurrency } = this.ctx.options;
42
+
43
+ if (instanceConcurrency === undefined) {
44
+ return workerAvailableSlots;
45
+ }
46
+
47
+ const totalActive = this.getTotalActiveJobs();
48
+ const globalAvailable = instanceConcurrency - totalActive;
49
+
50
+ return Math.min(workerAvailableSlots, globalAvailable);
51
+ }
52
+
18
53
  /**
19
54
  * Poll for available jobs and process them.
20
55
  *
21
56
  * Called at regular intervals (configured by `pollInterval`). For each registered worker,
22
57
  * attempts to acquire jobs up to the worker's available concurrency slots.
23
- * Aborts early if the scheduler is stopping (`isRunning` is false).
58
+ * Aborts early if the scheduler is stopping (`isRunning` is false) or if
59
+ * the instance-level `instanceConcurrency` limit is reached.
24
60
  */
25
61
  async poll(): Promise<void> {
26
- if (!this.ctx.isRunning()) {
62
+ if (!this.ctx.isRunning() || this._isPolling) {
63
+ return;
64
+ }
65
+
66
+ this._isPolling = true;
67
+
68
+ try {
69
+ await this._doPoll();
70
+ } finally {
71
+ this._isPolling = false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Internal poll implementation.
77
+ */
78
+ private async _doPoll(): Promise<void> {
79
+ // Early exit if global instanceConcurrency is reached
80
+ const { instanceConcurrency } = this.ctx.options;
81
+
82
+ if (instanceConcurrency !== undefined && this.getTotalActiveJobs() >= instanceConcurrency) {
27
83
  return;
28
84
  }
29
85
 
30
86
  for (const [name, worker] of this.ctx.workers) {
31
87
  // Check if worker has capacity
32
- const availableSlots = worker.concurrency - worker.activeJobs.size;
88
+ const workerAvailableSlots = worker.concurrency - worker.activeJobs.size;
33
89
 
34
- if (availableSlots <= 0) {
90
+ if (workerAvailableSlots <= 0) {
35
91
  continue;
36
92
  }
37
93
 
94
+ // Apply global concurrency limit
95
+ const availableSlots = this.getGloballyAvailableSlots(workerAvailableSlots);
96
+
97
+ if (availableSlots <= 0) {
98
+ // Global limit reached, stop processing all workers
99
+ return;
100
+ }
101
+
38
102
  // Try to acquire jobs up to available slots
39
103
  for (let i = 0; i < availableSlots; i++) {
40
104
  if (!this.ctx.isRunning()) {
41
105
  return;
42
106
  }
107
+
108
+ // Re-check global limit before each acquisition
109
+ if (instanceConcurrency !== undefined && this.getTotalActiveJobs() >= instanceConcurrency) {
110
+ return;
111
+ }
112
+
43
113
  const job = await this.acquireJob(name);
44
114
 
45
115
  if (job) {
116
+ // Add to activeJobs immediately to correctly track concurrency
117
+ worker.activeJobs.set(job._id.toString(), job);
118
+
46
119
  this.processJob(job, worker).catch((error: unknown) => {
47
120
  this.ctx.emit('job:error', { error: error as Error, job });
48
121
  });
@@ -121,8 +194,6 @@ export class JobProcessor {
121
194
  */
122
195
  async processJob(job: PersistedJob, worker: WorkerRegistration): Promise<void> {
123
196
  const jobId = job._id.toString();
124
- worker.activeJobs.set(jobId, job);
125
-
126
197
  const startTime = Date.now();
127
198
  this.ctx.emit('job:start', job);
128
199
 
@@ -10,11 +10,23 @@ import type { MonqueOptions } from '../types.js';
10
10
  * Resolved Monque options with all defaults applied.
11
11
  *
12
12
  * Required options have their defaults filled in, while truly optional
13
- * options (`maxBackoffDelay`, `jobRetention`) remain optional.
13
+ * options (`maxBackoffDelay`, `jobRetention`, `instanceConcurrency`) remain optional.
14
14
  */
15
15
  export interface ResolvedMonqueOptions
16
- extends Required<Omit<MonqueOptions, 'maxBackoffDelay' | 'jobRetention'>>,
17
- Pick<MonqueOptions, 'maxBackoffDelay' | 'jobRetention'> {}
16
+ extends Required<
17
+ Omit<
18
+ MonqueOptions,
19
+ | 'maxBackoffDelay'
20
+ | 'jobRetention'
21
+ | 'instanceConcurrency'
22
+ | 'defaultConcurrency'
23
+ | 'maxConcurrency'
24
+ >
25
+ >,
26
+ Pick<MonqueOptions, 'maxBackoffDelay' | 'jobRetention' | 'instanceConcurrency'> {
27
+ // Ensure resolved options use the new naming convention
28
+ workerConcurrency: number;
29
+ }
18
30
  /**
19
31
  * Shared context provided to all internal Monque services.
20
32
  *
@@ -9,7 +9,8 @@
9
9
  * maxRetries: 10,
10
10
  * baseRetryInterval: 1000,
11
11
  * shutdownTimeout: 30000,
12
- * defaultConcurrency: 5,
12
+ * workerConcurrency: 5, // Per-worker default
13
+ * instanceConcurrency: 20, // Global instance limit
13
14
  * });
14
15
  * ```
15
16
  */
@@ -56,7 +57,19 @@ export interface MonqueOptions {
56
57
 
57
58
  /**
58
59
  * Default number of concurrent jobs per worker.
60
+ *
61
+ * This is the per-worker concurrency limit applied when a worker is registered
62
+ * without specifying its own `concurrency` option.
63
+ *
64
+ * @default 5
65
+ */
66
+ workerConcurrency?: number;
67
+
68
+ /**
69
+ * Default number of concurrent jobs per worker.
70
+ *
59
71
  * @default 5
72
+ * @deprecated Use `workerConcurrency` instead. Will be removed in a future major version.
60
73
  */
61
74
  defaultConcurrency?: number;
62
75
 
@@ -120,4 +133,33 @@ export interface MonqueOptions {
120
133
  interval?: number;
121
134
  }
122
135
  | undefined;
136
+
137
+ /**
138
+ * Maximum number of concurrent jobs processed by this instance across all registered workers.
139
+ *
140
+ * If reached, the scheduler will stop claiming new jobs until active jobs complete.
141
+ * Use this to prevent a single instance from overwhelming system resources.
142
+ *
143
+ * Note: This is an instance-level limit. Each worker still respects its own `concurrency`
144
+ * setting, but the total across all workers cannot exceed this limit.
145
+ *
146
+ * @example
147
+ * ```typescript
148
+ * const monque = new Monque(db, {
149
+ * instanceConcurrency: 10, // Instance processes max 10 jobs total
150
+ * workerConcurrency: 5, // Each worker defaults to 5 concurrent jobs
151
+ * });
152
+ *
153
+ * // With 3 workers at concurrency 5, normally 15 jobs could run.
154
+ * // With instanceConcurrency: 10, only 10 jobs run at any time.
155
+ * ```
156
+ */
157
+ instanceConcurrency?: number | undefined;
158
+
159
+ /**
160
+ * Maximum number of concurrent jobs processed by this instance across all registered workers.
161
+ *
162
+ * @deprecated Use `instanceConcurrency` instead. Will be removed in a future major version.
163
+ */
164
+ maxConcurrency?: number | undefined;
123
165
  }