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 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:
@@ -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
  */
@@ -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;
@@ -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.5",
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",