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 +15 -0
- package/dist/store/in-memory-job-store.d.ts +1 -0
- package/dist/store/in-memory-job-store.js +12 -0
- package/dist/store/job-store.d.ts +4 -0
- package/dist/store/mongo/mongo-job-store.d.ts +1 -0
- package/dist/store/mongo/mongo-job-store.js +16 -0
- package/dist/worker/worker.js +32 -3
- package/package.json +1 -1
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.
|
|
@@ -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;
|
|
@@ -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;
|
package/dist/worker/worker.js
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
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