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 +52 -3
- package/dist/core/scheduler.js +32 -0
- package/dist/store/in-memory-job-store.d.ts +1 -0
- package/dist/store/in-memory-job-store.js +27 -1
- package/dist/store/job-store.d.ts +6 -0
- package/dist/store/mongo/mongo-job-store.d.ts +1 -0
- package/dist/store/mongo/mongo-job-store.js +71 -17
- package/dist/types/job.d.ts +10 -0
- package/dist/types/schedule.d.ts +10 -0
- package/package.json +1 -1
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
|
package/dist/core/scheduler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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) {
|
package/dist/types/job.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/types/schedule.d.ts
CHANGED
|
@@ -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