mongo-job-scheduler 0.1.14 → 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
@@ -10,6 +10,8 @@ A production-grade MongoDB-backed job scheduler for Node.js with distributed loc
10
10
 
11
11
  - ✅ **Distributed locking** — safe for multiple instances
12
12
  - ✅ **Atomic job execution** — no double processing
13
+ - ✅ **Job priority** — process important jobs first
14
+ - ✅ **Concurrency limits** — rate-limit job execution
13
15
  - ✅ **Automatic retries** — with configurable backoff
14
16
  - ✅ **Cron scheduling** — timezone-aware, non-drifting
15
17
  - ✅ **Interval jobs** — repeated execution
@@ -178,6 +180,55 @@ await scheduler.cancel(jobId);
178
180
 
179
181
  ## Advanced Features
180
182
 
183
+ ### Job Priority
184
+
185
+ Process important jobs first using priority levels (1-10, where 1 is highest priority):
186
+
187
+ ```typescript
188
+ // High priority job - runs first
189
+ await scheduler.schedule({
190
+ name: "urgent-alert",
191
+ priority: 1,
192
+ });
193
+
194
+ // Normal priority (default is 5)
195
+ await scheduler.schedule({
196
+ name: "regular-task",
197
+ });
198
+
199
+ // Low priority job - runs last
200
+ await scheduler.schedule({
201
+ name: "background-cleanup",
202
+ priority: 10,
203
+ });
204
+
205
+ // Update priority of existing job
206
+ await scheduler.updateJob(jobId, { priority: 2 });
207
+ ```
208
+
209
+ > **Priority Scale**: 1 (highest) → 10 (lowest). Jobs with equal priority run in FIFO order by `nextRunAt`.
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
+
181
232
  ### Retries with Backoff
182
233
 
183
234
  ```typescript
@@ -246,7 +297,7 @@ await scheduler.stop({
246
297
 
247
298
  The library creates three indexes in background mode:
248
299
 
249
- - `{ status: 1, nextRunAt: 1 }` — for job polling (critical)
300
+ - `{ status: 1, priority: 1, nextRunAt: 1 }` — for priority-based job polling (critical)
250
301
  - `{ dedupeKey: 1 }` — for deduplication (unique)
251
302
  - `{ lockedAt: 1 }` — for stale lock recovery
252
303
 
@@ -260,8 +311,6 @@ Run **multiple scheduler instances** (different servers, pods, or processes) con
260
311
  - **Concurrency Control** — only one worker executes a job instance
261
312
  - **Horizontally Scalable** — supports MongoDB sharding
262
313
 
263
- - **Horizontally Scalable** — supports MongoDB sharding
264
-
265
314
  ---
266
315
 
267
316
  ## Documentation
@@ -34,6 +34,20 @@ class Scheduler {
34
34
  if (options.repeat?.cron && options.repeat?.every != null) {
35
35
  throw new Error("Use either cron or every, not both");
36
36
  }
37
+ // Priority validation
38
+ if (options.priority !== undefined) {
39
+ if (!Number.isInteger(options.priority) ||
40
+ options.priority < 1 ||
41
+ options.priority > 10) {
42
+ throw new Error("Priority must be an integer between 1 and 10");
43
+ }
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
+ }
37
51
  // ------------------------
38
52
  // Normalize run time
39
53
  // ------------------------
@@ -50,6 +64,8 @@ class Scheduler {
50
64
  retry: options.retry,
51
65
  repeat: options.repeat,
52
66
  dedupeKey: options.dedupeKey,
67
+ priority: options.priority,
68
+ concurrency: options.concurrency,
53
69
  createdAt: now,
54
70
  updatedAt: now,
55
71
  };
@@ -71,6 +87,20 @@ class Scheduler {
71
87
  if (options.repeat?.cron && options.repeat.every) {
72
88
  throw new Error("Cannot specify both cron and every");
73
89
  }
90
+ // Priority validation
91
+ if (options.priority !== undefined) {
92
+ if (!Number.isInteger(options.priority) ||
93
+ options.priority < 1 ||
94
+ options.priority > 10) {
95
+ throw new Error("Priority must be an integer between 1 and 10");
96
+ }
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
+ }
74
104
  const job = {
75
105
  name: options.name,
76
106
  data: options.data,
@@ -79,6 +109,8 @@ class Scheduler {
79
109
  repeat: options.repeat,
80
110
  retry: options.retry,
81
111
  dedupeKey: options.dedupeKey,
112
+ priority: options.priority,
113
+ concurrency: options.concurrency,
82
114
  };
83
115
  if (isNaN(job.nextRunAt.getTime())) {
84
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
  }
@@ -23,6 +23,7 @@ class InMemoryJobStore {
23
23
  const stored = {
24
24
  ...job,
25
25
  _id: id,
26
+ priority: job.priority ?? 5,
26
27
  createdAt: job.createdAt ?? new Date(),
27
28
  updatedAt: job.updatedAt ?? new Date(),
28
29
  };
@@ -35,7 +36,15 @@ class InMemoryJobStore {
35
36
  async findAndLockNext({ now, workerId, lockTimeoutMs, }) {
36
37
  const release = await this.mutex.acquire();
37
38
  try {
38
- for (const job of this.jobs.values()) {
39
+ // Sort jobs by priority (ascending), then nextRunAt (ascending)
40
+ const sortedJobs = Array.from(this.jobs.values()).sort((a, b) => {
41
+ const priorityA = a.priority ?? 5;
42
+ const priorityB = b.priority ?? 5;
43
+ if (priorityA !== priorityB)
44
+ return priorityA - priorityB;
45
+ return a.nextRunAt.getTime() - b.nextRunAt.getTime();
46
+ });
47
+ for (const job of sortedJobs) {
39
48
  if (job.status !== "pending")
40
49
  continue;
41
50
  if (job.nextRunAt > now)
@@ -45,6 +54,14 @@ class InMemoryJobStore {
45
54
  now.getTime() - job.lockedAt.getTime() < lockTimeoutMs) {
46
55
  continue;
47
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
+ }
48
65
  job.status = "running";
49
66
  job.lockedAt = now;
50
67
  job.lockedBy = workerId;
@@ -153,8 +170,17 @@ class InMemoryJobStore {
153
170
  if (updates.attempts !== undefined) {
154
171
  job.attempts = updates.attempts;
155
172
  }
173
+ if (updates.priority !== undefined) {
174
+ job.priority = updates.priority;
175
+ }
176
+ if (updates.concurrency !== undefined) {
177
+ job.concurrency = updates.concurrency;
178
+ }
156
179
  job.updatedAt = new Date();
157
180
  }
181
+ async countRunning(jobName) {
182
+ return Array.from(this.jobs.values()).filter((j) => j.name === jobName && j.status === "running").length;
183
+ }
158
184
  async findAll(query) {
159
185
  let jobs = Array.from(this.jobs.values());
160
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";
@@ -71,4 +75,6 @@ export interface JobUpdates {
71
75
  repeat?: RepeatOptions;
72
76
  status?: JobStatus;
73
77
  attempts?: number;
78
+ priority?: number;
79
+ concurrency?: number;
74
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
  }
@@ -15,12 +15,14 @@ class MongoJobStore {
15
15
  */
16
16
  async ensureIndexes() {
17
17
  await Promise.all([
18
- // Primary index for job polling (findAndLockNext)
19
- this.collection.createIndex({ status: 1, nextRunAt: 1 }, { background: true }),
18
+ // Primary index for job polling (findAndLockNext) with priority
19
+ this.collection.createIndex({ status: 1, priority: 1, nextRunAt: 1 }, { background: true }),
20
20
  // Index for deduplication
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
  // --------------------------------------------------
@@ -34,6 +36,7 @@ class MongoJobStore {
34
36
  ...jobWithoutId,
35
37
  status: job.status ?? "pending",
36
38
  attempts: job.attempts ?? 0,
39
+ priority: job.priority ?? 5,
37
40
  createdAt: now,
38
41
  updatedAt: now,
39
42
  };
@@ -57,6 +60,7 @@ class MongoJobStore {
57
60
  ...jobWithoutId,
58
61
  status: job.status ?? "pending",
59
62
  attempts: job.attempts ?? 0,
63
+ priority: job.priority ?? 5,
60
64
  createdAt: now,
61
65
  updatedAt: now,
62
66
  };
@@ -74,31 +78,68 @@ class MongoJobStore {
74
78
  }));
75
79
  }
76
80
  // --------------------------------------------------
77
- // ATOMIC FIND & LOCK
81
+ // ATOMIC FIND & LOCK (with concurrency support)
78
82
  // --------------------------------------------------
79
83
  async findAndLockNext(options) {
80
84
  const { now, workerId, lockTimeoutMs } = options;
81
85
  const lockExpiry = new Date(now.getTime() - lockTimeoutMs);
82
- const result = await this.collection.findOneAndUpdate({
86
+ // Base query for runnable jobs
87
+ const baseQuery = {
83
88
  status: "pending",
84
89
  nextRunAt: { $lte: now },
85
90
  $or: [
86
91
  { lockedAt: { $exists: false } },
87
92
  { lockedAt: { $lte: lockExpiry } },
88
93
  ],
89
- }, {
90
- $set: {
91
- lockedAt: now,
92
- lockedBy: workerId,
93
- status: "running",
94
- lastRunAt: now,
95
- updatedAt: now,
96
- },
97
- }, {
98
- sort: { nextRunAt: 1 },
99
- returnDocument: "after",
100
- });
101
- 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;
102
143
  }
103
144
  // --------------------------------------------------
104
145
  // MARK COMPLETED
@@ -221,8 +262,21 @@ class MongoJobStore {
221
262
  $set.status = updates.status;
222
263
  if (updates.attempts !== undefined)
223
264
  $set.attempts = updates.attempts;
265
+ if (updates.priority !== undefined)
266
+ $set.priority = updates.priority;
267
+ if (updates.concurrency !== undefined)
268
+ $set.concurrency = updates.concurrency;
224
269
  await this.collection.updateOne({ _id: id }, { $set });
225
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
+ }
226
280
  async findAll(query) {
227
281
  const filter = {};
228
282
  if (query.name) {
@@ -16,6 +16,16 @@ export interface Job<Data = unknown> {
16
16
  retry?: RetryOptions | number;
17
17
  repeat?: RepeatOptions;
18
18
  dedupeKey?: string;
19
+ /**
20
+ * Job priority (1-10). Lower values = higher priority.
21
+ * Default: 5
22
+ */
23
+ priority?: number;
24
+ /**
25
+ * Max concurrent running jobs with this name.
26
+ * undefined = no limit.
27
+ */
28
+ concurrency?: number;
19
29
  createdAt: Date;
20
30
  updatedAt: Date;
21
31
  }
@@ -20,4 +20,14 @@ export interface ScheduleOptions<T = unknown> {
20
20
  * Idempotency key to prevent duplicate jobs
21
21
  */
22
22
  dedupeKey?: string;
23
+ /**
24
+ * Job priority (1-10). Lower values = higher priority.
25
+ * Default: 5
26
+ */
27
+ priority?: number;
28
+ /**
29
+ * Max concurrent running jobs with this name.
30
+ * Useful for rate-limiting external API calls.
31
+ */
32
+ concurrency?: number;
23
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mongo-job-scheduler",
3
- "version": "0.1.14",
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",