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 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.**
@@ -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(): Promise<void>;
41
+ stop(options?: {
42
+ graceful?: boolean;
43
+ timeoutMs?: number;
44
+ }): Promise<void>;
30
45
  isRunning(): boolean;
31
46
  getId(): string;
32
47
  }
@@ -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
- for (const worker of this.workers) {
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: "failed",
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;
@@ -15,6 +15,7 @@ export interface Job<Data = unknown> {
15
15
  lastError?: string;
16
16
  retry?: RetryOptions;
17
17
  repeat?: RepeatOptions;
18
+ dedupeKey?: string;
18
19
  createdAt: Date;
19
20
  updatedAt: Date;
20
21
  }
@@ -16,4 +16,8 @@ export interface ScheduleOptions<T = unknown> {
16
16
  * Repeat configuration (cron or every)
17
17
  */
18
18
  repeat?: RepeatOptions;
19
+ /**
20
+ * Idempotency key to prevent duplicate jobs
21
+ */
22
+ dedupeKey?: string;
19
23
  }
@@ -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(): Promise<void>;
16
+ stop(options?: {
17
+ graceful?: boolean;
18
+ timeoutMs?: number;
19
+ }): Promise<void>;
16
20
  private loop;
17
21
  private execute;
18
22
  private sleep;
@@ -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().catch((err) => {
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
- await this.store.markFailed(job._id, error.message);
99
- 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;
100
148
  }
101
149
  }
102
150
  sleep(ms) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mongo-job-scheduler",
3
- "version": "0.1.2",
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",