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 +22 -0
- package/dist/core/scheduler.js +14 -0
- package/dist/store/in-memory-job-store.d.ts +1 -0
- package/dist/store/in-memory-job-store.js +14 -0
- package/dist/store/job-store.d.ts +5 -0
- package/dist/store/mongo/mongo-job-store.d.ts +1 -0
- package/dist/store/mongo/mongo-job-store.js +65 -15
- package/dist/types/job.d.ts +5 -0
- package/dist/types/schedule.d.ts +5 -0
- package/package.json +1 -1
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
|
package/dist/core/scheduler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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) {
|
package/dist/types/job.d.ts
CHANGED
package/dist/types/schedule.d.ts
CHANGED
package/package.json
CHANGED