mongo-job-scheduler 0.1.2 → 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 +100 -0
- package/dist/core/scheduler.d.ts +16 -1
- package/dist/core/scheduler.js +58 -4
- package/dist/store/in-memory-job-store.d.ts +3 -0
- package/dist/store/in-memory-job-store.js +28 -0
- package/dist/store/job-store.d.ts +12 -0
- package/dist/store/mongo/mongo-job-store.d.ts +3 -0
- package/dist/store/mongo/mongo-job-store.js +49 -1
- package/dist/types/job.d.ts +1 -0
- package/dist/types/schedule.d.ts +4 -0
- package/dist/worker/worker.d.ts +5 -1
- package/dist/worker/worker.js +53 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,8 +21,17 @@ 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
|
|
|
27
|
+
## Distributed Systems
|
|
28
|
+
|
|
29
|
+
This library is designed for distributed environments. You can run **multiple scheduler instances** (on different servers, pods, or processes) connected to the same MongoDB.
|
|
30
|
+
|
|
31
|
+
- **Atomic Locking**: Uses `findOneAndUpdate` to safe-guard against race conditions.
|
|
32
|
+
- **Concurrency**: Only one worker will execute a given job instance.
|
|
33
|
+
- **Scalable**: Horizontal scaling is supported via MongoDB sharding.
|
|
34
|
+
|
|
26
35
|
---
|
|
27
36
|
|
|
28
37
|
## Install
|
|
@@ -68,6 +77,89 @@ await scheduler.schedule(
|
|
|
68
77
|
);
|
|
69
78
|
```
|
|
70
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
|
+
|
|
94
|
+
## Job Deduplication
|
|
95
|
+
|
|
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.
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
await scheduler.schedule({
|
|
100
|
+
name: "email",
|
|
101
|
+
data: { userId: 123 },
|
|
102
|
+
dedupeKey: "email:user:123",
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Job Cancellation
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
// Cancel a pending or running job
|
|
110
|
+
await scheduler.cancel(jobId);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Job Querying
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
const job = await scheduler.getJob(jobId);
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Bulk Scheduling
|
|
120
|
+
|
|
121
|
+
For high-performance ingestion, use `scheduleBulk` to insert multiple jobs in a single database operation:
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
const jobs = await scheduler.scheduleBulk([
|
|
125
|
+
{ name: "email", data: { userId: 1 } },
|
|
126
|
+
{ name: "email", data: { userId: 2 } },
|
|
127
|
+
]);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Events
|
|
131
|
+
|
|
132
|
+
The scheduler emits typed events for lifecycle monitoring.
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
// Scheduler events
|
|
136
|
+
scheduler.on("scheduler:start", () => console.log("Scheduler started"));
|
|
137
|
+
scheduler.on("scheduler:stop", () => console.log("Scheduler stopped"));
|
|
138
|
+
scheduler.on("scheduler:error", (err) =>
|
|
139
|
+
console.error("Scheduler error:", err)
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Worker events
|
|
143
|
+
scheduler.on("worker:start", (workerId) =>
|
|
144
|
+
console.log("Worker started:", workerId)
|
|
145
|
+
);
|
|
146
|
+
scheduler.on("worker:stop", (workerId) =>
|
|
147
|
+
console.log("Worker stopped:", workerId)
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// Job events
|
|
151
|
+
scheduler.on("job:created", (job) => console.log("Job created:", job._id));
|
|
152
|
+
scheduler.on("job:start", (job) => console.log("Job processing:", job._id));
|
|
153
|
+
scheduler.on("job:success", (job) => console.log("Job done:", job._id));
|
|
154
|
+
scheduler.on("job:fail", ({ job, error }) =>
|
|
155
|
+
console.error("Job failed:", job._id, error)
|
|
156
|
+
);
|
|
157
|
+
scheduler.on("job:retry", (job) =>
|
|
158
|
+
console.warn("Job retrying:", job._id, job.attempts)
|
|
159
|
+
);
|
|
160
|
+
scheduler.on("job:cancel", (job) => console.log("Job cancelled:", job._id));
|
|
161
|
+
```
|
|
162
|
+
|
|
71
163
|
## Documentation
|
|
72
164
|
|
|
73
165
|
See `ARCHITECTURE.md` for:
|
|
@@ -78,6 +170,14 @@ See `ARCHITECTURE.md` for:
|
|
|
78
170
|
- sharding strategy
|
|
79
171
|
- production checklist
|
|
80
172
|
|
|
173
|
+
## Graceful Shutdown
|
|
174
|
+
|
|
175
|
+
Stop the scheduler and wait for in-flight jobs to complete:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
await scheduler.stop({ graceful: true, timeoutMs: 30000 });
|
|
179
|
+
```
|
|
180
|
+
|
|
81
181
|
## Status
|
|
82
182
|
|
|
83
183
|
**Early-stage but production-tested.**
|
package/dist/core/scheduler.d.ts
CHANGED
|
@@ -25,8 +25,23 @@ export declare class Scheduler {
|
|
|
25
25
|
constructor(options?: SchedulerOptions);
|
|
26
26
|
on<K extends keyof SchedulerEventMap>(event: K, listener: (payload: SchedulerEventMap[K]) => void): this;
|
|
27
27
|
schedule<T = unknown>(options: ScheduleOptions<T>): Promise<Job<T>>;
|
|
28
|
+
/**
|
|
29
|
+
* Schedule multiple jobs in bulk
|
|
30
|
+
*/
|
|
31
|
+
scheduleBulk<T = any>(optionsList: ScheduleOptions<T>[]): Promise<Job<T>[]>;
|
|
32
|
+
/**
|
|
33
|
+
* Get a job by ID
|
|
34
|
+
*/
|
|
35
|
+
getJob(jobId: unknown): Promise<Job | null>;
|
|
36
|
+
/**
|
|
37
|
+
* Cancel a job
|
|
38
|
+
*/
|
|
39
|
+
cancel(jobId: unknown): Promise<void>;
|
|
28
40
|
start(): Promise<void>;
|
|
29
|
-
stop(
|
|
41
|
+
stop(options?: {
|
|
42
|
+
graceful?: boolean;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
}): Promise<void>;
|
|
30
45
|
isRunning(): boolean;
|
|
31
46
|
getId(): string;
|
|
32
47
|
}
|
package/dist/core/scheduler.js
CHANGED
|
@@ -46,12 +46,68 @@ class Scheduler {
|
|
|
46
46
|
nextRunAt,
|
|
47
47
|
retry: options.retry,
|
|
48
48
|
repeat: options.repeat,
|
|
49
|
+
dedupeKey: options.dedupeKey,
|
|
49
50
|
createdAt: now,
|
|
50
51
|
updatedAt: now,
|
|
51
52
|
};
|
|
52
53
|
const created = await this.store.create(job);
|
|
54
|
+
this.emitter.emitSafe("job:created", created);
|
|
53
55
|
return created;
|
|
54
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Schedule multiple jobs in bulk
|
|
59
|
+
*/
|
|
60
|
+
async scheduleBulk(optionsList) {
|
|
61
|
+
if (!this.store) {
|
|
62
|
+
throw new Error("Scheduler not started or no store configured");
|
|
63
|
+
}
|
|
64
|
+
const jobs = optionsList.map((options) => {
|
|
65
|
+
if (!options.name) {
|
|
66
|
+
throw new Error("Job name is required");
|
|
67
|
+
}
|
|
68
|
+
if (options.repeat?.cron && options.repeat.every) {
|
|
69
|
+
throw new Error("Cannot specify both cron and every");
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
name: options.name,
|
|
73
|
+
data: options.data,
|
|
74
|
+
status: "pending",
|
|
75
|
+
nextRunAt: options.runAt ?? new Date(),
|
|
76
|
+
repeat: options.repeat,
|
|
77
|
+
retry: options.retry,
|
|
78
|
+
dedupeKey: options.dedupeKey,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
const createdJobs = await this.store.createBulk(jobs);
|
|
82
|
+
// Emit events for all created jobs
|
|
83
|
+
for (const job of createdJobs) {
|
|
84
|
+
this.emitter.emitSafe("job:created", job);
|
|
85
|
+
}
|
|
86
|
+
return createdJobs;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Get a job by ID
|
|
90
|
+
*/
|
|
91
|
+
async getJob(jobId) {
|
|
92
|
+
if (!this.store) {
|
|
93
|
+
throw new Error("Scheduler has no JobStore configured");
|
|
94
|
+
}
|
|
95
|
+
return this.store.findById(jobId);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Cancel a job
|
|
99
|
+
*/
|
|
100
|
+
async cancel(jobId) {
|
|
101
|
+
if (!this.store) {
|
|
102
|
+
throw new Error("Scheduler has no JobStore configured");
|
|
103
|
+
}
|
|
104
|
+
const job = await this.store.findById(jobId);
|
|
105
|
+
await this.store.cancel(jobId);
|
|
106
|
+
if (job) {
|
|
107
|
+
const cancelledJob = { ...job, status: "cancelled" };
|
|
108
|
+
this.emitter.emitSafe("job:cancel", cancelledJob);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
55
111
|
async start() {
|
|
56
112
|
if (this.started)
|
|
57
113
|
return;
|
|
@@ -81,13 +137,11 @@ class Scheduler {
|
|
|
81
137
|
await worker.start();
|
|
82
138
|
}
|
|
83
139
|
}
|
|
84
|
-
async stop() {
|
|
140
|
+
async stop(options) {
|
|
85
141
|
if (!this.started)
|
|
86
142
|
return;
|
|
87
143
|
this.started = false;
|
|
88
|
-
|
|
89
|
-
await worker.stop();
|
|
90
|
-
}
|
|
144
|
+
await Promise.all(this.workers.map((w) => w.stop(options)));
|
|
91
145
|
this.workers.length = 0;
|
|
92
146
|
this.emitter.emitSafe("scheduler:stop", undefined);
|
|
93
147
|
}
|
|
@@ -5,6 +5,7 @@ export declare class InMemoryJobStore implements JobStore {
|
|
|
5
5
|
private mutex;
|
|
6
6
|
private generateId;
|
|
7
7
|
create(job: Job): Promise<Job>;
|
|
8
|
+
createBulk(jobs: Job[]): Promise<Job[]>;
|
|
8
9
|
findAndLockNext({ now, workerId, lockTimeoutMs, }: {
|
|
9
10
|
now: Date;
|
|
10
11
|
workerId: string;
|
|
@@ -20,4 +21,6 @@ export declare class InMemoryJobStore implements JobStore {
|
|
|
20
21
|
lockTimeoutMs: number;
|
|
21
22
|
}): Promise<number>;
|
|
22
23
|
cancel(jobId: unknown): Promise<void>;
|
|
24
|
+
findById(jobId: unknown): Promise<Job | null>;
|
|
25
|
+
renewLock(jobId: unknown, workerId: string): Promise<void>;
|
|
23
26
|
}
|
|
@@ -12,6 +12,13 @@ class InMemoryJobStore {
|
|
|
12
12
|
return Math.random().toString(36).slice(2);
|
|
13
13
|
}
|
|
14
14
|
async create(job) {
|
|
15
|
+
if (job.dedupeKey) {
|
|
16
|
+
for (const existing of this.jobs.values()) {
|
|
17
|
+
if (existing.dedupeKey === job.dedupeKey) {
|
|
18
|
+
return existing;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
15
22
|
const id = this.generateId();
|
|
16
23
|
const stored = {
|
|
17
24
|
...job,
|
|
@@ -22,6 +29,9 @@ class InMemoryJobStore {
|
|
|
22
29
|
this.jobs.set(id, stored);
|
|
23
30
|
return stored;
|
|
24
31
|
}
|
|
32
|
+
async createBulk(jobs) {
|
|
33
|
+
return Promise.all(jobs.map((job) => this.create(job)));
|
|
34
|
+
}
|
|
25
35
|
async findAndLockNext({ now, workerId, lockTimeoutMs, }) {
|
|
26
36
|
const release = await this.mutex.acquire();
|
|
27
37
|
try {
|
|
@@ -102,6 +112,24 @@ class InMemoryJobStore {
|
|
|
102
112
|
throw new store_errors_1.JobNotFoundError();
|
|
103
113
|
job.status = "cancelled";
|
|
104
114
|
job.updatedAt = new Date();
|
|
115
|
+
job.lockedAt = undefined;
|
|
116
|
+
job.lockedBy = undefined;
|
|
117
|
+
}
|
|
118
|
+
async findById(jobId) {
|
|
119
|
+
const job = this.jobs.get(String(jobId));
|
|
120
|
+
return job ? { ...job } : null;
|
|
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
|
+
}
|
|
105
133
|
}
|
|
106
134
|
}
|
|
107
135
|
exports.InMemoryJobStore = InMemoryJobStore;
|
|
@@ -4,6 +4,10 @@ export interface JobStore {
|
|
|
4
4
|
* Insert a new job
|
|
5
5
|
*/
|
|
6
6
|
create(job: Job): Promise<Job>;
|
|
7
|
+
/**
|
|
8
|
+
* Create multiple jobs in bulk
|
|
9
|
+
*/
|
|
10
|
+
createBulk(jobs: Job[]): Promise<Job[]>;
|
|
7
11
|
/**
|
|
8
12
|
* Find and lock the next runnable job.
|
|
9
13
|
* Must be atomic.
|
|
@@ -39,4 +43,12 @@ export interface JobStore {
|
|
|
39
43
|
* Cancel job explicitly
|
|
40
44
|
*/
|
|
41
45
|
cancel(jobId: unknown): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* Get job by ID
|
|
48
|
+
*/
|
|
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>;
|
|
42
54
|
}
|
|
@@ -10,6 +10,7 @@ export declare class MongoJobStore implements JobStore {
|
|
|
10
10
|
private readonly defaultLockTimeoutMs;
|
|
11
11
|
constructor(db: Db, options?: MongoJobStoreOptions);
|
|
12
12
|
create(job: Job): Promise<Job>;
|
|
13
|
+
createBulk(jobs: Job[]): Promise<Job[]>;
|
|
13
14
|
findAndLockNext(options: {
|
|
14
15
|
now: Date;
|
|
15
16
|
workerId: string;
|
|
@@ -22,8 +23,10 @@ export declare class MongoJobStore implements JobStore {
|
|
|
22
23
|
lastError?: string;
|
|
23
24
|
}): Promise<void>;
|
|
24
25
|
cancel(id: ObjectId): Promise<void>;
|
|
26
|
+
findById(id: ObjectId): Promise<Job | null>;
|
|
25
27
|
recoverStaleJobs(options: {
|
|
26
28
|
now: Date;
|
|
27
29
|
lockTimeoutMs: number;
|
|
28
30
|
}): Promise<number>;
|
|
31
|
+
renewLock(id: ObjectId, workerId: string): Promise<void>;
|
|
29
32
|
}
|
|
@@ -20,9 +20,35 @@ class MongoJobStore {
|
|
|
20
20
|
createdAt: now,
|
|
21
21
|
updatedAt: now,
|
|
22
22
|
};
|
|
23
|
+
if (job.dedupeKey) {
|
|
24
|
+
// Idempotent insert
|
|
25
|
+
const result = await this.collection.findOneAndUpdate({ dedupeKey: job.dedupeKey }, { $setOnInsert: doc }, { upsert: true, returnDocument: "after" });
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
23
28
|
const result = await this.collection.insertOne(doc);
|
|
24
29
|
return { ...doc, _id: result.insertedId };
|
|
25
30
|
}
|
|
31
|
+
async createBulk(jobs) {
|
|
32
|
+
const now = new Date();
|
|
33
|
+
const docs = jobs.map((job) => {
|
|
34
|
+
// IMPORTANT: strip _id completely
|
|
35
|
+
const { _id, ...jobWithoutId } = job;
|
|
36
|
+
return {
|
|
37
|
+
...jobWithoutId,
|
|
38
|
+
status: job.status ?? "pending",
|
|
39
|
+
attempts: job.attempts ?? 0,
|
|
40
|
+
createdAt: now,
|
|
41
|
+
updatedAt: now,
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
if (docs.length === 0)
|
|
45
|
+
return [];
|
|
46
|
+
const result = await this.collection.insertMany(docs);
|
|
47
|
+
return docs.map((doc, index) => ({
|
|
48
|
+
...doc,
|
|
49
|
+
_id: result.insertedIds[index],
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
26
52
|
// --------------------------------------------------
|
|
27
53
|
// ATOMIC FIND & LOCK
|
|
28
54
|
// --------------------------------------------------
|
|
@@ -104,7 +130,7 @@ class MongoJobStore {
|
|
|
104
130
|
async cancel(id) {
|
|
105
131
|
await this.collection.updateOne({ _id: id }, {
|
|
106
132
|
$set: {
|
|
107
|
-
status: "
|
|
133
|
+
status: "cancelled",
|
|
108
134
|
updatedAt: new Date(),
|
|
109
135
|
},
|
|
110
136
|
$unset: {
|
|
@@ -113,6 +139,12 @@ class MongoJobStore {
|
|
|
113
139
|
},
|
|
114
140
|
});
|
|
115
141
|
}
|
|
142
|
+
async findById(id) {
|
|
143
|
+
const doc = await this.collection.findOne({ _id: id });
|
|
144
|
+
if (!doc)
|
|
145
|
+
return null;
|
|
146
|
+
return doc;
|
|
147
|
+
}
|
|
116
148
|
// --------------------------------------------------
|
|
117
149
|
// RECOVER STALE JOBS
|
|
118
150
|
// --------------------------------------------------
|
|
@@ -133,5 +165,21 @@ class MongoJobStore {
|
|
|
133
165
|
});
|
|
134
166
|
return result.modifiedCount;
|
|
135
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
|
+
}
|
|
136
184
|
}
|
|
137
185
|
exports.MongoJobStore = MongoJobStore;
|
package/dist/types/job.d.ts
CHANGED
package/dist/types/schedule.d.ts
CHANGED
package/dist/worker/worker.d.ts
CHANGED
|
@@ -11,8 +11,12 @@ export declare class Worker {
|
|
|
11
11
|
private readonly workerId;
|
|
12
12
|
private readonly defaultTimezone?;
|
|
13
13
|
constructor(store: JobStore, emitter: SchedulerEmitter, handler: JobHandler, options?: WorkerOptions);
|
|
14
|
+
private loopPromise?;
|
|
14
15
|
start(): Promise<void>;
|
|
15
|
-
stop(
|
|
16
|
+
stop(options?: {
|
|
17
|
+
graceful?: boolean;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}): Promise<void>;
|
|
16
20
|
private loop;
|
|
17
21
|
private execute;
|
|
18
22
|
private sleep;
|
package/dist/worker/worker.js
CHANGED
|
@@ -20,13 +20,27 @@ class Worker {
|
|
|
20
20
|
return;
|
|
21
21
|
this.running = true;
|
|
22
22
|
this.emitter.emitSafe("worker:start", this.workerId);
|
|
23
|
-
this.loop()
|
|
23
|
+
this.loopPromise = this.loop();
|
|
24
|
+
this.loopPromise.catch((err) => {
|
|
24
25
|
this.emitter.emitSafe("worker:error", err);
|
|
25
26
|
});
|
|
26
27
|
}
|
|
27
|
-
async stop() {
|
|
28
|
+
async stop(options) {
|
|
28
29
|
this.running = false;
|
|
29
30
|
this.emitter.emitSafe("worker:stop", this.workerId);
|
|
31
|
+
if (options?.graceful && this.loopPromise) {
|
|
32
|
+
const timeoutMs = options.timeoutMs ?? 30000; // default 30s
|
|
33
|
+
const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Worker stop timed out")), timeoutMs));
|
|
34
|
+
try {
|
|
35
|
+
await Promise.race([this.loopPromise, timeout]);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err instanceof Error && err.message === "Worker stop timed out") {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
30
44
|
}
|
|
31
45
|
async loop() {
|
|
32
46
|
while (this.running) {
|
|
@@ -66,7 +80,37 @@ class Worker {
|
|
|
66
80
|
job.lastScheduledAt = next;
|
|
67
81
|
await this.store.reschedule(job._id, next);
|
|
68
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();
|
|
69
107
|
try {
|
|
108
|
+
const current = await this.store.findById(job._id);
|
|
109
|
+
if (current && current.status === "cancelled") {
|
|
110
|
+
this.emitter.emitSafe("job:complete", job);
|
|
111
|
+
stopHeartbeat = true; // stop fast
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
70
114
|
await this.handler(job);
|
|
71
115
|
// ---------------------------
|
|
72
116
|
// INTERVAL: schedule AFTER execution
|
|
@@ -93,10 +137,14 @@ class Worker {
|
|
|
93
137
|
attempts,
|
|
94
138
|
lastError: error.message,
|
|
95
139
|
});
|
|
96
|
-
return;
|
|
97
140
|
}
|
|
98
|
-
|
|
99
|
-
|
|
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;
|
|
100
148
|
}
|
|
101
149
|
}
|
|
102
150
|
sleep(ms) {
|
package/package.json
CHANGED