mongo-job-scheduler 0.1.3 → 0.1.5
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 +27 -0
- package/dist/core/scheduler.d.ts +5 -1
- package/dist/core/scheduler.js +9 -0
- package/dist/store/in-memory-job-store.d.ts +3 -1
- package/dist/store/in-memory-job-store.js +30 -0
- package/dist/store/job-store.d.ts +16 -0
- package/dist/store/mongo/mongo-job-store.d.ts +3 -1
- package/dist/store/mongo/mongo-job-store.js +30 -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.
|
|
@@ -101,6 +116,18 @@ await scheduler.cancel(jobId);
|
|
|
101
116
|
const job = await scheduler.getJob(jobId);
|
|
102
117
|
```
|
|
103
118
|
|
|
119
|
+
## Job Persistence & Updates
|
|
120
|
+
|
|
121
|
+
Update job `data`, reschedule, or modify configuration safely.
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
await scheduler.updateJob(jobId, {
|
|
125
|
+
data: { page: 2 },
|
|
126
|
+
nextRunAt: new Date(Date.now() + 60000), // delay by 1 min
|
|
127
|
+
repeat: { every: 60000 }, // Change to run every minute
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
104
131
|
## Bulk Scheduling
|
|
105
132
|
|
|
106
133
|
For high-performance ingestion, use `scheduleBulk` to insert multiple jobs in a single database operation:
|
package/dist/core/scheduler.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SchedulerEventMap } from "../types/events";
|
|
2
|
-
import { JobStore } from "../store";
|
|
2
|
+
import { JobStore, JobUpdates } from "../store";
|
|
3
3
|
import { Job } from "../types/job";
|
|
4
4
|
import { ScheduleOptions } from "../types/schedule";
|
|
5
5
|
export interface SchedulerOptions {
|
|
@@ -33,6 +33,10 @@ export declare class Scheduler {
|
|
|
33
33
|
* Get a job by ID
|
|
34
34
|
*/
|
|
35
35
|
getJob(jobId: unknown): Promise<Job | null>;
|
|
36
|
+
/**
|
|
37
|
+
* Update job data or schedule
|
|
38
|
+
*/
|
|
39
|
+
updateJob(jobId: unknown, updates: JobUpdates): Promise<void>;
|
|
36
40
|
/**
|
|
37
41
|
* Cancel a job
|
|
38
42
|
*/
|
package/dist/core/scheduler.js
CHANGED
|
@@ -94,6 +94,15 @@ class Scheduler {
|
|
|
94
94
|
}
|
|
95
95
|
return this.store.findById(jobId);
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Update job data or schedule
|
|
99
|
+
*/
|
|
100
|
+
async updateJob(jobId, updates) {
|
|
101
|
+
if (!this.store) {
|
|
102
|
+
throw new Error("Scheduler has no JobStore configured");
|
|
103
|
+
}
|
|
104
|
+
await this.store.update(jobId, updates);
|
|
105
|
+
}
|
|
97
106
|
/**
|
|
98
107
|
* Cancel a job
|
|
99
108
|
*/
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Job } from "../types/job";
|
|
2
|
-
import { JobStore } from "./job-store";
|
|
2
|
+
import { JobStore, JobUpdates } from "./job-store";
|
|
3
3
|
export declare class InMemoryJobStore implements JobStore {
|
|
4
4
|
private jobs;
|
|
5
5
|
private mutex;
|
|
@@ -22,4 +22,6 @@ 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>;
|
|
26
|
+
update(jobId: unknown, updates: JobUpdates): Promise<void>;
|
|
25
27
|
}
|
|
@@ -119,5 +119,35 @@ 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
|
+
}
|
|
134
|
+
async update(jobId, updates) {
|
|
135
|
+
const job = this.jobs.get(String(jobId));
|
|
136
|
+
if (!job)
|
|
137
|
+
throw new store_errors_1.JobNotFoundError();
|
|
138
|
+
if (updates.data !== undefined) {
|
|
139
|
+
job.data = updates.data;
|
|
140
|
+
}
|
|
141
|
+
if (updates.nextRunAt !== undefined) {
|
|
142
|
+
job.nextRunAt = updates.nextRunAt;
|
|
143
|
+
}
|
|
144
|
+
if (updates.retry !== undefined) {
|
|
145
|
+
job.retry = updates.retry;
|
|
146
|
+
}
|
|
147
|
+
if (updates.repeat !== undefined) {
|
|
148
|
+
job.repeat = updates.repeat;
|
|
149
|
+
}
|
|
150
|
+
job.updatedAt = new Date();
|
|
151
|
+
}
|
|
122
152
|
}
|
|
123
153
|
exports.InMemoryJobStore = InMemoryJobStore;
|
|
@@ -47,4 +47,20 @@ 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>;
|
|
54
|
+
/**
|
|
55
|
+
* Update job properties (data persistence)
|
|
56
|
+
*/
|
|
57
|
+
update(jobId: unknown, updates: JobUpdates): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
import { RetryOptions } from "../types/retry";
|
|
60
|
+
import { RepeatOptions } from "../types/repeat";
|
|
61
|
+
export interface JobUpdates {
|
|
62
|
+
data?: unknown;
|
|
63
|
+
nextRunAt?: Date;
|
|
64
|
+
retry?: RetryOptions;
|
|
65
|
+
repeat?: RepeatOptions;
|
|
50
66
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Db, ObjectId } from "mongodb";
|
|
2
|
-
import { JobStore } from "../job-store";
|
|
2
|
+
import { JobStore, JobUpdates } from "../job-store";
|
|
3
3
|
import { Job } from "../../types/job";
|
|
4
4
|
export interface MongoJobStoreOptions {
|
|
5
5
|
collectionName?: string;
|
|
@@ -28,4 +28,6 @@ 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>;
|
|
32
|
+
update(id: ObjectId, updates: JobUpdates): Promise<void>;
|
|
31
33
|
}
|
|
@@ -165,5 +165,35 @@ 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
|
+
}
|
|
184
|
+
async update(id, updates) {
|
|
185
|
+
if (Object.keys(updates).length === 0)
|
|
186
|
+
return;
|
|
187
|
+
const $set = { updatedAt: new Date() };
|
|
188
|
+
if (updates.data !== undefined)
|
|
189
|
+
$set.data = updates.data;
|
|
190
|
+
if (updates.nextRunAt !== undefined)
|
|
191
|
+
$set.nextRunAt = updates.nextRunAt;
|
|
192
|
+
if (updates.retry !== undefined)
|
|
193
|
+
$set.retry = updates.retry;
|
|
194
|
+
if (updates.repeat !== undefined)
|
|
195
|
+
$set.repeat = updates.repeat;
|
|
196
|
+
await this.collection.updateOne({ _id: id }, { $set });
|
|
197
|
+
}
|
|
168
198
|
}
|
|
169
199
|
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