mongo-job-scheduler 0.1.16 → 1.0.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/README.md +5 -0
- package/dist/core/scheduler.js +2 -0
- package/dist/store/in-memory-job-store.js +4 -0
- package/dist/store/mongo/mongo-job-store.d.ts +0 -3
- package/dist/store/mongo/mongo-job-store.js +130 -69
- package/dist/types/job.d.ts +9 -0
- package/dist/worker/worker.js +32 -25
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -23,6 +23,10 @@ A production-grade MongoDB-backed job scheduler for Node.js with distributed loc
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
+
> 🚀 **Ready to start?** Check out the [Complete Example Repository](https://github.com/darshanpatel14/mongo-job-scheduler-example) which demonstrates all features (Priority, Retries, Cron, UI) in a production-ready Express app with Docker.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
26
30
|
## Quick Start
|
|
27
31
|
|
|
28
32
|
### Requirements
|
|
@@ -43,6 +47,7 @@ For a visual web dashboard to manage and monitor your jobs, check out:
|
|
|
43
47
|
|
|
44
48
|
- **NPM**: [`mongo-scheduler-ui`](https://www.npmjs.com/package/mongo-scheduler-ui)
|
|
45
49
|
- **GitHub**: [mongo-scheduler-ui](https://github.com/darshanpatel14/mongo-job-scheduler-ui)
|
|
50
|
+
- **Full Example**: [mongo-job-scheduler-example](https://github.com/darshanpatel14/mongo-job-scheduler-example) (Backend + UI + Docker)
|
|
46
51
|
- **API Server**: [mongo-job-scheduler-api](https://github.com/darshanpatel14/mongo-job-scheduler-api)
|
|
47
52
|
|
|
48
53
|
### Basic Usage
|
package/dist/core/scheduler.js
CHANGED
|
@@ -66,6 +66,7 @@ class Scheduler {
|
|
|
66
66
|
dedupeKey: options.dedupeKey,
|
|
67
67
|
priority: options.priority,
|
|
68
68
|
concurrency: options.concurrency,
|
|
69
|
+
lockVersion: 0,
|
|
69
70
|
createdAt: now,
|
|
70
71
|
updatedAt: now,
|
|
71
72
|
};
|
|
@@ -111,6 +112,7 @@ class Scheduler {
|
|
|
111
112
|
dedupeKey: options.dedupeKey,
|
|
112
113
|
priority: options.priority,
|
|
113
114
|
concurrency: options.concurrency,
|
|
115
|
+
lockVersion: 0,
|
|
114
116
|
};
|
|
115
117
|
if (isNaN(job.nextRunAt.getTime())) {
|
|
116
118
|
throw new Error("Invalid Date provided for runAt");
|
|
@@ -24,6 +24,7 @@ class InMemoryJobStore {
|
|
|
24
24
|
...job,
|
|
25
25
|
_id: id,
|
|
26
26
|
priority: job.priority ?? 5,
|
|
27
|
+
lockVersion: job.lockVersion ?? 0,
|
|
27
28
|
createdAt: job.createdAt ?? new Date(),
|
|
28
29
|
updatedAt: job.updatedAt ?? new Date(),
|
|
29
30
|
};
|
|
@@ -65,6 +66,8 @@ class InMemoryJobStore {
|
|
|
65
66
|
job.status = "running";
|
|
66
67
|
job.lockedAt = now;
|
|
67
68
|
job.lockedBy = workerId;
|
|
69
|
+
job.lockUntil = new Date(now.getTime() + lockTimeoutMs);
|
|
70
|
+
job.lockVersion = (job.lockVersion ?? 0) + 1;
|
|
68
71
|
job.updatedAt = new Date();
|
|
69
72
|
job.lastRunAt = now;
|
|
70
73
|
return { ...job };
|
|
@@ -117,6 +120,7 @@ class InMemoryJobStore {
|
|
|
117
120
|
job.status = "pending";
|
|
118
121
|
job.lockedAt = undefined;
|
|
119
122
|
job.lockedBy = undefined;
|
|
123
|
+
job.lockUntil = undefined;
|
|
120
124
|
job.updatedAt = new Date();
|
|
121
125
|
recovered++;
|
|
122
126
|
}
|
|
@@ -10,9 +10,6 @@ export declare class MongoJobStore implements JobStore {
|
|
|
10
10
|
private readonly collection;
|
|
11
11
|
private readonly defaultLockTimeoutMs;
|
|
12
12
|
constructor(db: Db, options?: MongoJobStoreOptions);
|
|
13
|
-
/**
|
|
14
|
-
* Create necessary indexes for optimal query performance
|
|
15
|
-
*/
|
|
16
13
|
private ensureIndexes;
|
|
17
14
|
create(job: Job): Promise<Job>;
|
|
18
15
|
createBulk(jobs: Job[]): Promise<Job[]>;
|
|
@@ -10,33 +10,23 @@ class MongoJobStore {
|
|
|
10
10
|
console.error("Failed to create indexes:", err);
|
|
11
11
|
});
|
|
12
12
|
}
|
|
13
|
-
/**
|
|
14
|
-
* Create necessary indexes for optimal query performance
|
|
15
|
-
*/
|
|
16
13
|
async ensureIndexes() {
|
|
17
14
|
await Promise.all([
|
|
18
|
-
// Primary index for job polling (findAndLockNext) with priority
|
|
19
15
|
this.collection.createIndex({ status: 1, priority: 1, nextRunAt: 1 }, { background: true }),
|
|
20
|
-
// Index for deduplication
|
|
21
16
|
this.collection.createIndex({ dedupeKey: 1 }, { unique: true, sparse: true, background: true }),
|
|
22
|
-
|
|
23
|
-
this.collection.createIndex({ lockedAt: 1 }, { sparse: true, background: true }),
|
|
24
|
-
// Index for concurrency counting
|
|
17
|
+
this.collection.createIndex({ lockUntil: 1 }, { sparse: true, background: true }),
|
|
25
18
|
this.collection.createIndex({ name: 1, status: 1 }, { background: true }),
|
|
26
19
|
]);
|
|
27
20
|
}
|
|
28
|
-
// --------------------------------------------------
|
|
29
|
-
// CREATE
|
|
30
|
-
// --------------------------------------------------
|
|
31
21
|
async create(job) {
|
|
32
22
|
const now = new Date();
|
|
33
|
-
// IMPORTANT: strip _id completely
|
|
34
23
|
const { _id, ...jobWithoutId } = job;
|
|
35
24
|
const doc = {
|
|
36
25
|
...jobWithoutId,
|
|
37
26
|
status: job.status ?? "pending",
|
|
38
27
|
attempts: job.attempts ?? 0,
|
|
39
28
|
priority: job.priority ?? 5,
|
|
29
|
+
lockVersion: job.lockVersion ?? 0,
|
|
40
30
|
createdAt: now,
|
|
41
31
|
updatedAt: now,
|
|
42
32
|
};
|
|
@@ -44,7 +34,6 @@ class MongoJobStore {
|
|
|
44
34
|
delete doc.dedupeKey;
|
|
45
35
|
}
|
|
46
36
|
if (job.dedupeKey) {
|
|
47
|
-
// Idempotent insert
|
|
48
37
|
const result = await this.collection.findOneAndUpdate({ dedupeKey: job.dedupeKey }, { $setOnInsert: doc }, { upsert: true, returnDocument: "after" });
|
|
49
38
|
return result;
|
|
50
39
|
}
|
|
@@ -54,13 +43,13 @@ class MongoJobStore {
|
|
|
54
43
|
async createBulk(jobs) {
|
|
55
44
|
const now = new Date();
|
|
56
45
|
const docs = jobs.map((job) => {
|
|
57
|
-
// IMPORTANT: strip _id completely
|
|
58
46
|
const { _id, ...jobWithoutId } = job;
|
|
59
47
|
const doc = {
|
|
60
48
|
...jobWithoutId,
|
|
61
49
|
status: job.status ?? "pending",
|
|
62
50
|
attempts: job.attempts ?? 0,
|
|
63
51
|
priority: job.priority ?? 5,
|
|
52
|
+
lockVersion: job.lockVersion ?? 0,
|
|
64
53
|
createdAt: now,
|
|
65
54
|
updatedAt: now,
|
|
66
55
|
};
|
|
@@ -77,73 +66,150 @@ class MongoJobStore {
|
|
|
77
66
|
_id: result.insertedIds[index],
|
|
78
67
|
}));
|
|
79
68
|
}
|
|
80
|
-
//
|
|
81
|
-
// ATOMIC FIND & LOCK (with concurrency support)
|
|
82
|
-
// --------------------------------------------------
|
|
69
|
+
// Atomic find & lock with version-based optimistic locking
|
|
83
70
|
async findAndLockNext(options) {
|
|
84
71
|
const { now, workerId, lockTimeoutMs } = options;
|
|
85
|
-
const
|
|
86
|
-
//
|
|
87
|
-
const
|
|
88
|
-
status: "pending",
|
|
89
|
-
nextRunAt: { $lte: now },
|
|
72
|
+
const lockUntil = new Date(now.getTime() + lockTimeoutMs);
|
|
73
|
+
// Fast path: jobs without concurrency limits
|
|
74
|
+
const simpleQuery = {
|
|
90
75
|
$or: [
|
|
91
|
-
|
|
92
|
-
{
|
|
76
|
+
// Pending jobs (not locked)
|
|
77
|
+
{
|
|
78
|
+
status: "pending",
|
|
79
|
+
nextRunAt: { $lte: now },
|
|
80
|
+
$or: [{ lockedBy: { $exists: false } }, { lockedBy: null }],
|
|
81
|
+
},
|
|
82
|
+
// Stale running jobs (lock expired - crash recovery)
|
|
83
|
+
{
|
|
84
|
+
status: "running",
|
|
85
|
+
nextRunAt: { $lte: now },
|
|
86
|
+
lockUntil: { $lte: now },
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
$and: [
|
|
90
|
+
{ $or: [{ concurrency: { $exists: false } }, { concurrency: null }] },
|
|
93
91
|
],
|
|
94
92
|
};
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
const simpleResult = await this.collection.findOneAndUpdate(simpleQuery, {
|
|
94
|
+
$set: {
|
|
95
|
+
lockedAt: now,
|
|
96
|
+
lockedBy: workerId,
|
|
97
|
+
lockUntil: lockUntil,
|
|
98
|
+
status: "running",
|
|
99
|
+
lastRunAt: now,
|
|
100
|
+
updatedAt: now,
|
|
101
|
+
},
|
|
102
|
+
$inc: { lockVersion: 1 },
|
|
103
|
+
}, {
|
|
104
|
+
sort: { priority: 1, nextRunAt: 1 },
|
|
105
|
+
returnDocument: "after",
|
|
106
|
+
});
|
|
107
|
+
if (simpleResult) {
|
|
108
|
+
return simpleResult;
|
|
109
|
+
}
|
|
110
|
+
// Now handle jobs with concurrency limits
|
|
111
|
+
// We need to check concurrency before locking
|
|
112
|
+
const maxAttempts = 20;
|
|
113
|
+
const checkedNames = new Set();
|
|
98
114
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
99
|
-
// Find candidate
|
|
100
|
-
const
|
|
115
|
+
// Find a candidate with concurrency limit that we haven't checked yet
|
|
116
|
+
const concurrencyQuery = {
|
|
117
|
+
$or: [
|
|
118
|
+
{
|
|
119
|
+
status: "pending",
|
|
120
|
+
nextRunAt: { $lte: now },
|
|
121
|
+
// Pending jobs should not have lockedBy set
|
|
122
|
+
$or: [{ lockedBy: { $exists: false } }, { lockedBy: null }],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
// Stale running jobs (lock expired)
|
|
126
|
+
status: "running",
|
|
127
|
+
nextRunAt: { $lte: now },
|
|
128
|
+
lockUntil: { $lte: now },
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
concurrency: { $exists: true, $gt: 0 },
|
|
132
|
+
};
|
|
133
|
+
if (checkedNames.size > 0) {
|
|
134
|
+
concurrencyQuery.name = { $nin: Array.from(checkedNames) };
|
|
135
|
+
}
|
|
136
|
+
const candidate = await this.collection.findOne(concurrencyQuery, {
|
|
101
137
|
sort: { priority: 1, nextRunAt: 1 },
|
|
102
|
-
|
|
138
|
+
projection: { name: 1, concurrency: 1, lockVersion: 1 },
|
|
103
139
|
});
|
|
104
140
|
if (!candidate) {
|
|
105
|
-
return null; // No more candidates
|
|
141
|
+
return null; // No more candidates with concurrency limits
|
|
106
142
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
143
|
+
const runningCount = await this.collection.countDocuments({
|
|
144
|
+
name: candidate.name,
|
|
145
|
+
status: "running",
|
|
146
|
+
});
|
|
147
|
+
if (runningCount >= candidate.concurrency) {
|
|
148
|
+
// At limit for this job name, skip all jobs with this name
|
|
149
|
+
checkedNames.add(candidate.name);
|
|
150
|
+
continue;
|
|
117
151
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
status: "pending", // Re-verify status
|
|
152
|
+
const lockResult = await this.collection.findOneAndUpdate({
|
|
153
|
+
name: candidate.name,
|
|
154
|
+
concurrency: candidate.concurrency,
|
|
122
155
|
$or: [
|
|
123
|
-
{
|
|
124
|
-
|
|
156
|
+
{
|
|
157
|
+
status: "pending",
|
|
158
|
+
$or: [{ lockedBy: { $exists: false } }, { lockedBy: null }],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
status: "running",
|
|
162
|
+
lockUntil: { $lte: now },
|
|
163
|
+
},
|
|
125
164
|
],
|
|
165
|
+
nextRunAt: { $lte: now },
|
|
126
166
|
}, {
|
|
127
167
|
$set: {
|
|
128
168
|
lockedAt: now,
|
|
129
169
|
lockedBy: workerId,
|
|
170
|
+
lockUntil: lockUntil,
|
|
130
171
|
status: "running",
|
|
131
172
|
lastRunAt: now,
|
|
132
173
|
updatedAt: now,
|
|
133
174
|
},
|
|
175
|
+
$inc: { lockVersion: 1 },
|
|
134
176
|
}, {
|
|
177
|
+
sort: { priority: 1, nextRunAt: 1 },
|
|
135
178
|
returnDocument: "after",
|
|
136
179
|
});
|
|
137
|
-
if (
|
|
138
|
-
|
|
180
|
+
if (lockResult) {
|
|
181
|
+
// Verify concurrency wasn't exceeded by race condition
|
|
182
|
+
const currentRunning = await this.collection.countDocuments({
|
|
183
|
+
name: lockResult.name,
|
|
184
|
+
status: "running",
|
|
185
|
+
});
|
|
186
|
+
if (currentRunning > lockResult.concurrency) {
|
|
187
|
+
// We exceeded concurrency - release this job back to pending
|
|
188
|
+
await this.collection.updateOne({
|
|
189
|
+
_id: lockResult._id,
|
|
190
|
+
lockedBy: workerId,
|
|
191
|
+
lockVersion: lockResult.lockVersion,
|
|
192
|
+
}, {
|
|
193
|
+
$set: {
|
|
194
|
+
status: "pending",
|
|
195
|
+
updatedAt: new Date(),
|
|
196
|
+
},
|
|
197
|
+
$unset: {
|
|
198
|
+
lockedAt: "",
|
|
199
|
+
lockedBy: "",
|
|
200
|
+
lockUntil: "",
|
|
201
|
+
lastRunAt: "",
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
return lockResult;
|
|
139
207
|
}
|
|
140
|
-
//
|
|
208
|
+
// Lock failed (another worker got it), try next job name
|
|
209
|
+
checkedNames.add(candidate.name);
|
|
141
210
|
}
|
|
142
211
|
return null;
|
|
143
212
|
}
|
|
144
|
-
// --------------------------------------------------
|
|
145
|
-
// MARK COMPLETED
|
|
146
|
-
// --------------------------------------------------
|
|
147
213
|
async markCompleted(id) {
|
|
148
214
|
await this.collection.updateOne({ _id: id }, {
|
|
149
215
|
$set: {
|
|
@@ -153,12 +219,10 @@ class MongoJobStore {
|
|
|
153
219
|
$unset: {
|
|
154
220
|
lockedAt: "",
|
|
155
221
|
lockedBy: "",
|
|
222
|
+
lockUntil: "",
|
|
156
223
|
},
|
|
157
224
|
});
|
|
158
225
|
}
|
|
159
|
-
// --------------------------------------------------
|
|
160
|
-
// MARK FAILED
|
|
161
|
-
// --------------------------------------------------
|
|
162
226
|
async markFailed(id, error) {
|
|
163
227
|
await this.collection.updateOne({ _id: id }, {
|
|
164
228
|
$set: {
|
|
@@ -169,12 +233,10 @@ class MongoJobStore {
|
|
|
169
233
|
$unset: {
|
|
170
234
|
lockedAt: "",
|
|
171
235
|
lockedBy: "",
|
|
236
|
+
lockUntil: "",
|
|
172
237
|
},
|
|
173
238
|
});
|
|
174
239
|
}
|
|
175
|
-
// --------------------------------------------------
|
|
176
|
-
// RESCHEDULE
|
|
177
|
-
// --------------------------------------------------
|
|
178
240
|
async reschedule(id, nextRunAt, updates) {
|
|
179
241
|
const result = await this.collection.updateOne({ _id: id }, {
|
|
180
242
|
$set: {
|
|
@@ -186,12 +248,10 @@ class MongoJobStore {
|
|
|
186
248
|
$unset: {
|
|
187
249
|
lockedAt: "",
|
|
188
250
|
lockedBy: "",
|
|
251
|
+
lockUntil: "",
|
|
189
252
|
},
|
|
190
253
|
});
|
|
191
254
|
}
|
|
192
|
-
// --------------------------------------------------
|
|
193
|
-
// CANCEL
|
|
194
|
-
// --------------------------------------------------
|
|
195
255
|
async cancel(id) {
|
|
196
256
|
await this.collection.updateOne({ _id: id }, {
|
|
197
257
|
$set: {
|
|
@@ -201,6 +261,7 @@ class MongoJobStore {
|
|
|
201
261
|
$unset: {
|
|
202
262
|
lockedAt: "",
|
|
203
263
|
lockedBy: "",
|
|
264
|
+
lockUntil: "",
|
|
204
265
|
},
|
|
205
266
|
});
|
|
206
267
|
}
|
|
@@ -210,14 +271,14 @@ class MongoJobStore {
|
|
|
210
271
|
return null;
|
|
211
272
|
return doc;
|
|
212
273
|
}
|
|
213
|
-
// --------------------------------------------------
|
|
214
|
-
// RECOVER STALE JOBS
|
|
215
|
-
// --------------------------------------------------
|
|
216
274
|
async recoverStaleJobs(options) {
|
|
217
275
|
const { now, lockTimeoutMs } = options;
|
|
218
276
|
const expiry = new Date(now.getTime() - lockTimeoutMs);
|
|
219
277
|
const result = await this.collection.updateMany({
|
|
220
|
-
|
|
278
|
+
$or: [
|
|
279
|
+
{ lockUntil: { $lte: now } },
|
|
280
|
+
{ lockUntil: { $exists: false }, lockedAt: { $lte: expiry } },
|
|
281
|
+
],
|
|
221
282
|
}, {
|
|
222
283
|
$set: {
|
|
223
284
|
status: "pending",
|
|
@@ -226,6 +287,7 @@ class MongoJobStore {
|
|
|
226
287
|
$unset: {
|
|
227
288
|
lockedAt: "",
|
|
228
289
|
lockedBy: "",
|
|
290
|
+
lockUntil: "",
|
|
229
291
|
},
|
|
230
292
|
});
|
|
231
293
|
return result.modifiedCount;
|
|
@@ -240,7 +302,9 @@ class MongoJobStore {
|
|
|
240
302
|
$set: {
|
|
241
303
|
lockedAt: now,
|
|
242
304
|
updatedAt: now,
|
|
305
|
+
lockUntil: new Date(now.getTime() + this.defaultLockTimeoutMs),
|
|
243
306
|
},
|
|
307
|
+
$inc: { lockVersion: 1 },
|
|
244
308
|
});
|
|
245
309
|
if (result.matchedCount === 0) {
|
|
246
310
|
throw new Error("Job lock lost or owner changed");
|
|
@@ -268,9 +332,6 @@ class MongoJobStore {
|
|
|
268
332
|
$set.concurrency = updates.concurrency;
|
|
269
333
|
await this.collection.updateOne({ _id: id }, { $set });
|
|
270
334
|
}
|
|
271
|
-
// --------------------------------------------------
|
|
272
|
-
// COUNT RUNNING (for concurrency limits)
|
|
273
|
-
// --------------------------------------------------
|
|
274
335
|
async countRunning(jobName) {
|
|
275
336
|
return this.collection.countDocuments({
|
|
276
337
|
name: jobName,
|
package/dist/types/job.d.ts
CHANGED
|
@@ -11,6 +11,15 @@ export interface Job<Data = unknown> {
|
|
|
11
11
|
lastScheduledAt?: Date;
|
|
12
12
|
lockedAt?: Date;
|
|
13
13
|
lockedBy?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Lock expiry time. Job can be taken by another worker after this time.
|
|
16
|
+
*/
|
|
17
|
+
lockUntil?: Date;
|
|
18
|
+
/**
|
|
19
|
+
* Optimistic locking version. Incremented on each lock acquisition.
|
|
20
|
+
* Prevents race conditions in distributed environments.
|
|
21
|
+
*/
|
|
22
|
+
lockVersion?: number;
|
|
14
23
|
attempts: number;
|
|
15
24
|
lastError?: string;
|
|
16
25
|
retry?: RetryOptions | number;
|
package/dist/worker/worker.js
CHANGED
|
@@ -44,7 +44,6 @@ class Worker {
|
|
|
44
44
|
}
|
|
45
45
|
async loop() {
|
|
46
46
|
while (this.running) {
|
|
47
|
-
// stop requested before poll
|
|
48
47
|
if (!this.running)
|
|
49
48
|
break;
|
|
50
49
|
const job = await this.store.findAndLockNext({
|
|
@@ -52,7 +51,6 @@ class Worker {
|
|
|
52
51
|
workerId: this.workerId,
|
|
53
52
|
lockTimeoutMs: this.lockTimeout,
|
|
54
53
|
});
|
|
55
|
-
// stop requested after polling
|
|
56
54
|
if (!this.running)
|
|
57
55
|
break;
|
|
58
56
|
if (!job) {
|
|
@@ -65,24 +63,7 @@ class Worker {
|
|
|
65
63
|
async execute(job) {
|
|
66
64
|
this.emitter.emitSafe("job:start", job);
|
|
67
65
|
const now = Date.now();
|
|
68
|
-
//
|
|
69
|
-
// CRON: pre-schedule BEFORE execution
|
|
70
|
-
// ---------------------------
|
|
71
|
-
if (job.repeat?.cron) {
|
|
72
|
-
let base = job.lastScheduledAt ?? job.nextRunAt ?? new Date(now);
|
|
73
|
-
let next = (0, repeat_1.getNextRunAt)(job.repeat, base, this.defaultTimezone);
|
|
74
|
-
// skip missed cron slots
|
|
75
|
-
while (next.getTime() <= now) {
|
|
76
|
-
base = next;
|
|
77
|
-
next = (0, repeat_1.getNextRunAt)(job.repeat, base, this.defaultTimezone);
|
|
78
|
-
}
|
|
79
|
-
// persist schedule immediately
|
|
80
|
-
job.lastScheduledAt = next;
|
|
81
|
-
await this.store.reschedule(job._id, next);
|
|
82
|
-
}
|
|
83
|
-
// ---------------------------
|
|
84
|
-
// HEARTBEAT
|
|
85
|
-
// ---------------------------
|
|
66
|
+
// Heartbeat to prevent lock expiry during long jobs
|
|
86
67
|
const heartbeatIntervalMs = Math.max(50, this.lockTimeout / 2);
|
|
87
68
|
const heartbeatParams = {
|
|
88
69
|
jobId: job._id,
|
|
@@ -105,16 +86,42 @@ class Worker {
|
|
|
105
86
|
};
|
|
106
87
|
const heartbeatPromise = heartbeatLoop();
|
|
107
88
|
try {
|
|
89
|
+
// Verify we still own the lock before any modifications
|
|
90
|
+
// (another worker might have stolen it via stale recovery)
|
|
108
91
|
const current = await this.store.findById(job._id);
|
|
109
|
-
if (current
|
|
92
|
+
if (!current) {
|
|
93
|
+
stopHeartbeat = true;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (current.status === "cancelled") {
|
|
110
97
|
this.emitter.emitSafe("job:complete", job);
|
|
111
|
-
stopHeartbeat = true;
|
|
98
|
+
stopHeartbeat = true;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (current.lockedBy !== this.workerId) {
|
|
102
|
+
this.emitter.emitSafe("worker:error", new Error(`Lock stolen for job ${job._id}: owned by ${current.lockedBy}, we are ${this.workerId}`));
|
|
103
|
+
stopHeartbeat = true;
|
|
112
104
|
return;
|
|
113
105
|
}
|
|
106
|
+
if (current.status !== "running") {
|
|
107
|
+
this.emitter.emitSafe("worker:error", new Error(`Job ${job._id} is no longer running (status: ${current.status})`));
|
|
108
|
+
stopHeartbeat = true;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// CRON: pre-schedule before execution (after lock verification)
|
|
112
|
+
if (job.repeat?.cron) {
|
|
113
|
+
let base = job.lastScheduledAt ?? job.nextRunAt ?? new Date(now);
|
|
114
|
+
let next = (0, repeat_1.getNextRunAt)(job.repeat, base, this.defaultTimezone);
|
|
115
|
+
// skip missed cron slots
|
|
116
|
+
while (next.getTime() <= now) {
|
|
117
|
+
base = next;
|
|
118
|
+
next = (0, repeat_1.getNextRunAt)(job.repeat, base, this.defaultTimezone);
|
|
119
|
+
}
|
|
120
|
+
job.lastScheduledAt = next;
|
|
121
|
+
await this.store.reschedule(job._id, next);
|
|
122
|
+
}
|
|
114
123
|
await this.handler(job);
|
|
115
|
-
//
|
|
116
|
-
// INTERVAL: schedule AFTER execution
|
|
117
|
-
// ---------------------------
|
|
124
|
+
// INTERVAL: schedule after execution
|
|
118
125
|
if (job.repeat?.every != null) {
|
|
119
126
|
const next = new Date(Date.now() + Math.max(job.repeat.every, 100));
|
|
120
127
|
await this.store.reschedule(job._id, next);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mongo-job-scheduler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
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",
|
|
@@ -43,7 +43,6 @@
|
|
|
43
43
|
"build": "tsc -p tsconfig.build.json",
|
|
44
44
|
"test": "jest",
|
|
45
45
|
"test:mongo": "jest tests/mongo",
|
|
46
|
-
"test:stress": "jest tests/stress",
|
|
47
46
|
"prepublishOnly": "npm run build && npm test"
|
|
48
47
|
},
|
|
49
48
|
"dependencies": {
|