mongo-job-scheduler 0.1.16 → 0.1.17

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.
@@ -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
- // Index for stale lock recovery
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 lockExpiry = new Date(now.getTime() - lockTimeoutMs);
86
- // Base query for runnable jobs
87
- const baseQuery = {
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
- { lockedAt: { $exists: false } },
92
- { lockedAt: { $lte: lockExpiry } },
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
- // 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
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 without locking first
100
- const candidate = await this.collection.findOne(baseQuery, {
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
- skip: attempt, // Skip previously checked candidates
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
- // 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
- }
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
- // Attempt atomic lock on this specific job
119
- const result = await this.collection.findOneAndUpdate({
120
- _id: candidate._id,
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
- { lockedAt: { $exists: false } },
124
- { lockedAt: { $lte: lockExpiry } },
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 (result) {
138
- return result;
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
- // Job was taken by another worker, try next
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
- lockedAt: { $lte: expiry },
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,
@@ -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;
@@ -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 && current.status === "cancelled") {
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; // stop fast
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.1.16",
3
+ "version": "0.1.17",
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": {