mongo-job-scheduler 0.1.3 → 0.1.4

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
@@ -21,6 +21,7 @@ Designed for distributed systems that need:
21
21
  - **Interval jobs**
22
22
  - **Resume on restart**
23
23
  - **Stale lock recovery**
24
+ - **Automatic Lock Renewal** (Heartbeats): Long-running jobs automatically extend their lock.
24
25
  - **Sharding-safe design**
25
26
 
26
27
  ## Distributed Systems
@@ -76,6 +77,20 @@ await scheduler.schedule(
76
77
  );
77
78
  ```
78
79
 
80
+ ## Interval Scheduling
81
+
82
+ Run a job repeatedly with a fixed delay between executions (e.g., every 5 minutes).
83
+
84
+ ```typescript
85
+ await scheduler.schedule({
86
+ name: "cleanup-logs",
87
+ data: {},
88
+ repeat: {
89
+ every: 5 * 60 * 1000, // 5 minutes in milliseconds
90
+ },
91
+ });
92
+ ```
93
+
79
94
  ## Job Deduplication
80
95
 
81
96
  Prevent duplicate jobs using `dedupeKey`. If a job with the same key exists, it returns the existing job instead of creating a new one.
@@ -22,4 +22,5 @@ export declare class InMemoryJobStore implements JobStore {
22
22
  }): Promise<number>;
23
23
  cancel(jobId: unknown): Promise<void>;
24
24
  findById(jobId: unknown): Promise<Job | null>;
25
+ renewLock(jobId: unknown, workerId: string): Promise<void>;
25
26
  }
@@ -119,5 +119,17 @@ class InMemoryJobStore {
119
119
  const job = this.jobs.get(String(jobId));
120
120
  return job ? { ...job } : null;
121
121
  }
122
+ async renewLock(jobId, workerId) {
123
+ const job = this.jobs.get(String(jobId));
124
+ if (!job)
125
+ throw new store_errors_1.JobNotFoundError();
126
+ if (job.status === "running" && job.lockedBy === workerId) {
127
+ job.lockedAt = new Date();
128
+ job.updatedAt = new Date();
129
+ }
130
+ else {
131
+ throw new Error("Job lock lost or owner changed");
132
+ }
133
+ }
122
134
  }
123
135
  exports.InMemoryJobStore = InMemoryJobStore;
@@ -47,4 +47,8 @@ export interface JobStore {
47
47
  * Get job by ID
48
48
  */
49
49
  findById(jobId: unknown): Promise<Job | null>;
50
+ /**
51
+ * Renew the lock for a running job (heartbeat)
52
+ */
53
+ renewLock(jobId: unknown, workerId: string): Promise<void>;
50
54
  }
@@ -28,4 +28,5 @@ export declare class MongoJobStore implements JobStore {
28
28
  now: Date;
29
29
  lockTimeoutMs: number;
30
30
  }): Promise<number>;
31
+ renewLock(id: ObjectId, workerId: string): Promise<void>;
31
32
  }
@@ -165,5 +165,21 @@ class MongoJobStore {
165
165
  });
166
166
  return result.modifiedCount;
167
167
  }
168
+ async renewLock(id, workerId) {
169
+ const now = new Date();
170
+ const result = await this.collection.updateOne({
171
+ _id: id,
172
+ lockedBy: workerId,
173
+ status: "running",
174
+ }, {
175
+ $set: {
176
+ lockedAt: now,
177
+ updatedAt: now,
178
+ },
179
+ });
180
+ if (result.matchedCount === 0) {
181
+ throw new Error("Job lock lost or owner changed");
182
+ }
183
+ }
168
184
  }
169
185
  exports.MongoJobStore = MongoJobStore;
@@ -80,10 +80,35 @@ class Worker {
80
80
  job.lastScheduledAt = next;
81
81
  await this.store.reschedule(job._id, next);
82
82
  }
83
+ // ---------------------------
84
+ // HEARTBEAT
85
+ // ---------------------------
86
+ const heartbeatIntervalMs = Math.max(50, this.lockTimeout / 2);
87
+ const heartbeatParams = {
88
+ jobId: job._id,
89
+ workerId: this.workerId,
90
+ };
91
+ let stopHeartbeat = false;
92
+ const heartbeatLoop = async () => {
93
+ while (!stopHeartbeat) {
94
+ await this.sleep(heartbeatIntervalMs);
95
+ if (stopHeartbeat)
96
+ break;
97
+ try {
98
+ await this.store.renewLock(heartbeatParams.jobId, heartbeatParams.workerId);
99
+ }
100
+ catch (err) {
101
+ this.emitter.emitSafe("worker:error", new Error(`Heartbeat failed for job ${heartbeatParams.jobId}: ${err}`));
102
+ break;
103
+ }
104
+ }
105
+ };
106
+ const heartbeatPromise = heartbeatLoop();
83
107
  try {
84
108
  const current = await this.store.findById(job._id);
85
109
  if (current && current.status === "cancelled") {
86
110
  this.emitter.emitSafe("job:complete", job);
111
+ stopHeartbeat = true; // stop fast
87
112
  return;
88
113
  }
89
114
  await this.handler(job);
@@ -112,10 +137,14 @@ class Worker {
112
137
  attempts,
113
138
  lastError: error.message,
114
139
  });
115
- return;
116
140
  }
117
- await this.store.markFailed(job._id, error.message);
118
- this.emitter.emitSafe("job:fail", { job, error });
141
+ else {
142
+ await this.store.markFailed(job._id, error.message);
143
+ this.emitter.emitSafe("job:fail", { job, error });
144
+ }
145
+ }
146
+ finally {
147
+ stopHeartbeat = true;
119
148
  }
120
149
  }
121
150
  sleep(ms) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mongo-job-scheduler",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",