mongo-job-scheduler 0.1.15 → 0.1.16

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/README.md CHANGED
@@ -11,6 +11,7 @@ A production-grade MongoDB-backed job scheduler for Node.js with distributed loc
11
11
  - ✅ **Distributed locking** — safe for multiple instances
12
12
  - ✅ **Atomic job execution** — no double processing
13
13
  - ✅ **Job priority** — process important jobs first
14
+ - ✅ **Concurrency limits** — rate-limit job execution
14
15
  - ✅ **Automatic retries** — with configurable backoff
15
16
  - ✅ **Cron scheduling** — timezone-aware, non-drifting
16
17
  - ✅ **Interval jobs** — repeated execution
@@ -207,6 +208,27 @@ await scheduler.updateJob(jobId, { priority: 2 });
207
208
 
208
209
  > **Priority Scale**: 1 (highest) → 10 (lowest). Jobs with equal priority run in FIFO order by `nextRunAt`.
209
210
 
211
+ ### Concurrency Limits
212
+
213
+ Limit how many instances of a job type can run simultaneously (useful for rate-limiting API calls):
214
+
215
+ ```typescript
216
+ // Max 5 concurrent "api-sync" jobs globally
217
+ await scheduler.schedule({
218
+ name: "api-sync",
219
+ concurrency: 5,
220
+ });
221
+
222
+ // Max 2 concurrent "webhook" jobs
223
+ await scheduler.schedule({
224
+ name: "webhook",
225
+ data: { url: "https://..." },
226
+ concurrency: 2,
227
+ });
228
+ ```
229
+
230
+ > **Note**: Concurrency is enforced globally across all workers. Jobs exceeding the limit wait until a slot frees up.
231
+
210
232
  ### Retries with Backoff
211
233
 
212
234
  ```typescript
@@ -42,6 +42,12 @@ class Scheduler {
42
42
  throw new Error("Priority must be an integer between 1 and 10");
43
43
  }
44
44
  }
45
+ // Concurrency validation
46
+ if (options.concurrency !== undefined) {
47
+ if (!Number.isInteger(options.concurrency) || options.concurrency < 1) {
48
+ throw new Error("Concurrency must be a positive integer");
49
+ }
50
+ }
45
51
  // ------------------------
46
52
  // Normalize run time
47
53
  // ------------------------
@@ -59,6 +65,7 @@ class Scheduler {
59
65
  repeat: options.repeat,
60
66
  dedupeKey: options.dedupeKey,
61
67
  priority: options.priority,
68
+ concurrency: options.concurrency,
62
69
  createdAt: now,
63
70
  updatedAt: now,
64
71
  };
@@ -88,6 +95,12 @@ class Scheduler {
88
95
  throw new Error("Priority must be an integer between 1 and 10");
89
96
  }
90
97
  }
98
+ // Concurrency validation
99
+ if (options.concurrency !== undefined) {
100
+ if (!Number.isInteger(options.concurrency) || options.concurrency < 1) {
101
+ throw new Error("Concurrency must be a positive integer");
102
+ }
103
+ }
91
104
  const job = {
92
105
  name: options.name,
93
106
  data: options.data,
@@ -97,6 +110,7 @@ class Scheduler {
97
110
  retry: options.retry,
98
111
  dedupeKey: options.dedupeKey,
99
112
  priority: options.priority,
113
+ concurrency: options.concurrency,
100
114
  };
101
115
  if (isNaN(job.nextRunAt.getTime())) {
102
116
  throw new Error("Invalid Date provided for runAt");
@@ -25,5 +25,6 @@ export declare class InMemoryJobStore implements JobStore {
25
25
  findById(jobId: unknown): Promise<Job | null>;
26
26
  renewLock(jobId: unknown, workerId: string): Promise<void>;
27
27
  update(jobId: unknown, updates: JobUpdates): Promise<void>;
28
+ countRunning(jobName: string): Promise<number>;
28
29
  findAll(query: JobQuery): Promise<Job[]>;
29
30
  }
@@ -54,6 +54,14 @@ class InMemoryJobStore {
54
54
  now.getTime() - job.lockedAt.getTime() < lockTimeoutMs) {
55
55
  continue;
56
56
  }
57
+ // Check concurrency limit if defined
58
+ if (job.concurrency !== undefined && job.concurrency > 0) {
59
+ const runningCount = Array.from(this.jobs.values()).filter((j) => j.name === job.name && j.status === "running").length;
60
+ if (runningCount >= job.concurrency) {
61
+ // At concurrency limit, skip this job
62
+ continue;
63
+ }
64
+ }
57
65
  job.status = "running";
58
66
  job.lockedAt = now;
59
67
  job.lockedBy = workerId;
@@ -165,8 +173,14 @@ class InMemoryJobStore {
165
173
  if (updates.priority !== undefined) {
166
174
  job.priority = updates.priority;
167
175
  }
176
+ if (updates.concurrency !== undefined) {
177
+ job.concurrency = updates.concurrency;
178
+ }
168
179
  job.updatedAt = new Date();
169
180
  }
181
+ async countRunning(jobName) {
182
+ return Array.from(this.jobs.values()).filter((j) => j.name === jobName && j.status === "running").length;
183
+ }
170
184
  async findAll(query) {
171
185
  let jobs = Array.from(this.jobs.values());
172
186
  // Filter
@@ -60,6 +60,10 @@ export interface JobStore {
60
60
  * Find all jobs matching query
61
61
  */
62
62
  findAll(query: JobQuery): Promise<Job[]>;
63
+ /**
64
+ * Count running jobs by name (for concurrency limits)
65
+ */
66
+ countRunning(jobName: string): Promise<number>;
63
67
  }
64
68
  import { RetryOptions } from "../types/retry";
65
69
  import { RepeatOptions } from "../types/repeat";
@@ -72,4 +76,5 @@ export interface JobUpdates {
72
76
  status?: JobStatus;
73
77
  attempts?: number;
74
78
  priority?: number;
79
+ concurrency?: number;
75
80
  }
@@ -35,5 +35,6 @@ export declare class MongoJobStore implements JobStore {
35
35
  }): Promise<number>;
36
36
  renewLock(id: ObjectId, workerId: string): Promise<void>;
37
37
  update(id: ObjectId, updates: JobUpdates): Promise<void>;
38
+ countRunning(jobName: string): Promise<number>;
38
39
  findAll(query: JobQuery): Promise<Job[]>;
39
40
  }
@@ -21,6 +21,8 @@ class MongoJobStore {
21
21
  this.collection.createIndex({ dedupeKey: 1 }, { unique: true, sparse: true, background: true }),
22
22
  // Index for stale lock recovery
23
23
  this.collection.createIndex({ lockedAt: 1 }, { sparse: true, background: true }),
24
+ // Index for concurrency counting
25
+ this.collection.createIndex({ name: 1, status: 1 }, { background: true }),
24
26
  ]);
25
27
  }
26
28
  // --------------------------------------------------
@@ -76,31 +78,68 @@ class MongoJobStore {
76
78
  }));
77
79
  }
78
80
  // --------------------------------------------------
79
- // ATOMIC FIND & LOCK
81
+ // ATOMIC FIND & LOCK (with concurrency support)
80
82
  // --------------------------------------------------
81
83
  async findAndLockNext(options) {
82
84
  const { now, workerId, lockTimeoutMs } = options;
83
85
  const lockExpiry = new Date(now.getTime() - lockTimeoutMs);
84
- const result = await this.collection.findOneAndUpdate({
86
+ // Base query for runnable jobs
87
+ const baseQuery = {
85
88
  status: "pending",
86
89
  nextRunAt: { $lte: now },
87
90
  $or: [
88
91
  { lockedAt: { $exists: false } },
89
92
  { lockedAt: { $lte: lockExpiry } },
90
93
  ],
91
- }, {
92
- $set: {
93
- lockedAt: now,
94
- lockedBy: workerId,
95
- status: "running",
96
- lastRunAt: now,
97
- updatedAt: now,
98
- },
99
- }, {
100
- sort: { priority: 1, nextRunAt: 1 },
101
- returnDocument: "after",
102
- });
103
- return result;
94
+ };
95
+ // Try to find and lock a job, respecting concurrency limits
96
+ // We use a loop to handle cases where a job has a concurrency limit
97
+ const maxAttempts = 10; // Prevent infinite loops
98
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
99
+ // Find candidate without locking first
100
+ const candidate = await this.collection.findOne(baseQuery, {
101
+ sort: { priority: 1, nextRunAt: 1 },
102
+ skip: attempt, // Skip previously checked candidates
103
+ });
104
+ if (!candidate) {
105
+ return null; // No more candidates
106
+ }
107
+ // Check concurrency limit if defined
108
+ if (candidate.concurrency !== undefined && candidate.concurrency > 0) {
109
+ const runningCount = await this.collection.countDocuments({
110
+ name: candidate.name,
111
+ status: "running",
112
+ });
113
+ if (runningCount >= candidate.concurrency) {
114
+ // At concurrency limit, skip this job and try next
115
+ continue;
116
+ }
117
+ }
118
+ // Attempt atomic lock on this specific job
119
+ const result = await this.collection.findOneAndUpdate({
120
+ _id: candidate._id,
121
+ status: "pending", // Re-verify status
122
+ $or: [
123
+ { lockedAt: { $exists: false } },
124
+ { lockedAt: { $lte: lockExpiry } },
125
+ ],
126
+ }, {
127
+ $set: {
128
+ lockedAt: now,
129
+ lockedBy: workerId,
130
+ status: "running",
131
+ lastRunAt: now,
132
+ updatedAt: now,
133
+ },
134
+ }, {
135
+ returnDocument: "after",
136
+ });
137
+ if (result) {
138
+ return result;
139
+ }
140
+ // Job was taken by another worker, try next
141
+ }
142
+ return null;
104
143
  }
105
144
  // --------------------------------------------------
106
145
  // MARK COMPLETED
@@ -225,8 +264,19 @@ class MongoJobStore {
225
264
  $set.attempts = updates.attempts;
226
265
  if (updates.priority !== undefined)
227
266
  $set.priority = updates.priority;
267
+ if (updates.concurrency !== undefined)
268
+ $set.concurrency = updates.concurrency;
228
269
  await this.collection.updateOne({ _id: id }, { $set });
229
270
  }
271
+ // --------------------------------------------------
272
+ // COUNT RUNNING (for concurrency limits)
273
+ // --------------------------------------------------
274
+ async countRunning(jobName) {
275
+ return this.collection.countDocuments({
276
+ name: jobName,
277
+ status: "running",
278
+ });
279
+ }
230
280
  async findAll(query) {
231
281
  const filter = {};
232
282
  if (query.name) {
@@ -21,6 +21,11 @@ export interface Job<Data = unknown> {
21
21
  * Default: 5
22
22
  */
23
23
  priority?: number;
24
+ /**
25
+ * Max concurrent running jobs with this name.
26
+ * undefined = no limit.
27
+ */
28
+ concurrency?: number;
24
29
  createdAt: Date;
25
30
  updatedAt: Date;
26
31
  }
@@ -25,4 +25,9 @@ export interface ScheduleOptions<T = unknown> {
25
25
  * Default: 5
26
26
  */
27
27
  priority?: number;
28
+ /**
29
+ * Max concurrent running jobs with this name.
30
+ * Useful for rate-limiting external API calls.
31
+ */
32
+ concurrency?: number;
28
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mongo-job-scheduler",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Production-grade MongoDB-backed job scheduler with retries, cron, timezone support, and crash recovery",
5
5
  "license": "MIT",
6
6
  "author": "Darshan Bhut",