mongo-job-scheduler 0.1.7 → 0.1.9

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
@@ -1,52 +1,40 @@
1
1
  # Mongo Job Scheduler
2
2
 
3
- A production-grade MongoDB-backed job scheduler for Node.js.
3
+ A production-grade MongoDB-backed job scheduler for Node.js with distributed locking, retries, cron scheduling, and crash recovery.
4
4
 
5
- Designed for distributed systems that need:
6
-
7
- - reliable background jobs
8
- - retries with backoff
9
- - cron & interval scheduling
10
- - crash recovery
11
- - MongoDB sharding safety
5
+ [![npm version](https://img.shields.io/npm/v/mongo-job-scheduler.svg)](https://www.npmjs.com/package/mongo-job-scheduler)
12
6
 
13
7
  ---
14
8
 
15
9
  ## Features
16
10
 
17
- - **Distributed locking** using MongoDB
18
- - **Multiple workers** support
19
- - **Retry with backoff**
20
- - **Cron jobs** (timezone-aware, non-drifting)
21
- - **Interval jobs**
22
- - **Resume on restart**
23
- - **Stale lock recovery**
24
- - **Automatic Lock Renewal** (Heartbeats): Long-running jobs automatically extend their lock. Default lock timeout is 10 minutes.
25
- - **Sharding-safe design**
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.
11
+ - **Distributed locking** safe for multiple instances
12
+ - **Atomic job execution** — no double processing
13
+ - **Automatic retries** — with configurable backoff
14
+ - **Cron scheduling** timezone-aware, non-drifting
15
+ - **Interval jobs** — repeated execution
16
+ - **Crash recovery** — resume on restart
17
+ - **Heartbeats** — automatic lock renewal for long jobs
18
+ - **Query API** filter, sort, paginate jobs
19
+ - **Auto-indexing** — performance optimized out of the box
20
+ - ✅ **Sharding-safe** — designed for MongoDB sharding
34
21
 
35
22
  ---
36
23
 
37
- ## Install
24
+ ## Quick Start
25
+
26
+ ### Installation
38
27
 
39
28
  ```bash
40
29
  npm install mongo-job-scheduler
41
30
  ```
42
31
 
43
- ## Basic Usage
32
+ ### Basic Usage
44
33
 
45
34
  ```typescript
46
35
  import { Scheduler, MongoJobStore } from "mongo-job-scheduler";
47
36
  import { MongoClient } from "mongodb";
48
37
 
49
- // ... connect to mongo ...
50
38
  const client = new MongoClient("mongodb://localhost:27017");
51
39
  await client.connect();
52
40
  const db = client.db("my-app");
@@ -62,155 +50,222 @@ const scheduler = new Scheduler({
62
50
  await scheduler.start();
63
51
  ```
64
52
 
65
- ## Retries
53
+ ---
66
54
 
67
- You can configure retries using a simple number (for attempts with instant retry) or a detailed object:
55
+ ## Scheduling Jobs
68
56
 
69
- ```typescript
70
- // Shorthand: 3 max attempts, 0 delay
71
- await scheduler.schedule({
72
- name: "email",
73
- retry: 3,
74
- });
57
+ ### One-Time Job
75
58
 
76
- // Full configuration
59
+ ```typescript
77
60
  await scheduler.schedule({
78
- name: "webhook",
79
- retry: {
80
- maxAttempts: 5,
81
- delay: 1000, // 1 second fixed delay
82
- // delay: (attempt) => attempt * 1000 // or dynamic backoff
83
- },
61
+ name: "send-email",
62
+ data: { userId: 123 },
63
+ runAt: new Date(Date.now() + 60000), // run in 1 minute
84
64
  });
85
65
  ```
86
66
 
87
- ## Cron with Timezone
67
+ ### Cron Jobs (Timezone-Aware)
88
68
 
89
69
  ```typescript
90
70
  await scheduler.schedule({
91
71
  name: "daily-report",
92
- // data: { type: "report" }, // optional payload
93
72
  repeat: {
94
- cron: "0 9 * * *",
73
+ cron: "0 9 * * *", // every day at 9 AM
95
74
  timezone: "Asia/Kolkata", // default is UTC
96
75
  },
97
76
  });
98
77
  ```
99
78
 
100
- ## Interval Scheduling
101
-
102
- Run a job repeatedly with a fixed delay between executions (e.g., every 5 minutes).
79
+ ### Interval Jobs
103
80
 
104
81
  ```typescript
82
+ // Using milliseconds directly
105
83
  await scheduler.schedule({
106
84
  name: "cleanup-logs",
107
85
  data: {},
108
86
  repeat: {
109
- every: 5 * 60 * 1000, // 5 minutes in milliseconds
87
+ every: 5 * 60 * 1000, // every 5 minutes
110
88
  },
111
89
  });
112
- ```
113
90
 
114
- ## Job Deduplication
91
+ // Helper pattern for human-readable intervals
92
+ const minutes = (n: number) => n * 60 * 1000;
93
+ const hours = (n: number) => n * 60 * 60 * 1000;
94
+ const days = (n: number) => n * 24 * 60 * 60 * 1000;
115
95
 
116
- Prevent duplicate jobs using `dedupeKey`. If a job with the same key exists, it returns the existing job instead of creating a new one.
96
+ await scheduler.schedule({
97
+ name: "daily-backup",
98
+ repeat: { every: days(1) }, // 1 day
99
+ });
117
100
 
118
- ```typescript
119
101
  await scheduler.schedule({
120
- name: "email",
121
- data: { userId: 123 },
122
- dedupeKey: "email:user:123",
102
+ name: "hourly-sync",
103
+ repeat: { every: hours(2) }, // 2 hours
123
104
  });
124
105
  ```
125
106
 
126
- ## Job Cancellation
107
+ > **📌 Repeat Job Status**: Repeating jobs cycle through `pending` → `running` → `pending` (rescheduled). The same job document is reused with an updated `nextRunAt`. Jobs stay in the database until cancelled.
108
+
109
+ ### Bulk Scheduling
110
+
111
+ For high-performance ingestion:
127
112
 
128
113
  ```typescript
129
- // Cancel a pending or running job
130
- await scheduler.cancel(jobId);
114
+ const jobs = await scheduler.scheduleBulk([
115
+ { name: "email", data: { userId: 1 } },
116
+ { name: "email", data: { userId: 2 } },
117
+ { name: "email", data: { userId: 3 } },
118
+ ]);
131
119
  ```
132
120
 
133
- ## Job Querying
121
+ ---
122
+
123
+ ## Job Management
124
+
125
+ ### Get Job by ID
134
126
 
135
127
  ```typescript
136
128
  const job = await scheduler.getJob(jobId);
137
129
  ```
138
130
 
139
- ## Job Persistence & Updates
131
+ ### Query Jobs
140
132
 
141
- Update job `data`, reschedule, or modify configuration safely.
133
+ List jobs with filtering, sorting, and pagination:
134
+
135
+ ```typescript
136
+ const jobs = await scheduler.getJobs({
137
+ name: "daily-report",
138
+ status: "failed", // or ["failed", "pending"]
139
+ sort: { field: "updatedAt", order: "desc" },
140
+ limit: 10,
141
+ skip: 0,
142
+ });
143
+ ```
144
+
145
+ ### Update Job
146
+
147
+ Update job data, reschedule, or modify configuration:
142
148
 
143
149
  ```typescript
144
150
  await scheduler.updateJob(jobId, {
145
151
  data: { page: 2 },
146
152
  nextRunAt: new Date(Date.now() + 60000), // delay by 1 min
147
- repeat: { every: 60000 }, // Change to run every minute
153
+ repeat: { every: 60000 }, // change to run every minute
148
154
  });
149
155
  ```
150
156
 
151
- ## Bulk Scheduling
157
+ ### Cancel Job
158
+
159
+ ```typescript
160
+ await scheduler.cancel(jobId);
161
+ ```
162
+
163
+ ---
164
+
165
+ ## Advanced Features
152
166
 
153
- For high-performance ingestion, use `scheduleBulk` to insert multiple jobs in a single database operation:
167
+ ### Retries with Backoff
154
168
 
155
169
  ```typescript
156
- const jobs = await scheduler.scheduleBulk([
157
- { name: "email", data: { userId: 1 } },
158
- { name: "email", data: { userId: 2 } },
159
- ]);
170
+ // Simple: 3 attempts with instant retry
171
+ await scheduler.schedule({
172
+ name: "webhook",
173
+ retry: 3,
174
+ });
175
+
176
+ // Advanced: custom delay and backoff
177
+ await scheduler.schedule({
178
+ name: "api-call",
179
+ retry: {
180
+ maxAttempts: 5,
181
+ delay: 1000, // 1 second fixed delay
182
+ // or: delay: (attempt) => attempt * 1000 // dynamic backoff
183
+ },
184
+ });
160
185
  ```
161
186
 
162
- ## Events
187
+ ### Job Deduplication
163
188
 
164
- The scheduler emits typed events for lifecycle monitoring.
189
+ Prevent duplicate jobs using idempotency keys:
165
190
 
166
191
  ```typescript
167
- // Scheduler events
168
- scheduler.on("scheduler:start", () => console.log("Scheduler started"));
169
- scheduler.on("scheduler:stop", () => console.log("Scheduler stopped"));
170
- scheduler.on("scheduler:error", (err) =>
171
- console.error("Scheduler error:", err)
172
- );
192
+ await scheduler.schedule({
193
+ name: "email",
194
+ data: { userId: 123 },
195
+ dedupeKey: "email:user:123", // only one job with this key
196
+ });
197
+ ```
173
198
 
174
- // Worker events
175
- scheduler.on("worker:start", (workerId) =>
176
- console.log("Worker started:", workerId)
177
- );
178
- scheduler.on("worker:stop", (workerId) =>
179
- console.log("Worker stopped:", workerId)
180
- );
199
+ ### Event Monitoring
181
200
 
182
- // Job events
183
- scheduler.on("job:created", (job) => console.log("Job created:", job._id));
184
- scheduler.on("job:start", (job) => console.log("Job processing:", job._id));
201
+ ```typescript
185
202
  scheduler.on("job:success", (job) => console.log("Job done:", job._id));
186
203
  scheduler.on("job:fail", ({ job, error }) =>
187
204
  console.error("Job failed:", job._id, error)
188
205
  );
189
206
  scheduler.on("job:retry", (job) =>
190
- console.warn("Job retrying:", job._id, job.attempts)
207
+ console.warn("Retrying:", job._id, "attempt", job.attempts)
191
208
  );
192
- scheduler.on("job:cancel", (job) => console.log("Job cancelled:", job._id));
209
+
210
+ // More events: scheduler:start, scheduler:stop, worker:start,
211
+ // worker:stop, job:created, job:start, job:cancel
193
212
  ```
194
213
 
195
- ## Documentation
214
+ ### Graceful Shutdown
196
215
 
197
- See `ARCHITECTURE.md` for:
216
+ Wait for in-flight jobs to complete:
198
217
 
199
- - job lifecycle
200
- - retry & repeat semantics
201
- - MongoDB indexes
202
- - sharding strategy
203
- - production checklist
218
+ ```typescript
219
+ await scheduler.stop({
220
+ graceful: true,
221
+ timeoutMs: 30000,
222
+ });
223
+ ```
204
224
 
205
- ## Graceful Shutdown
225
+ ---
206
226
 
207
- Stop the scheduler and wait for in-flight jobs to complete:
227
+ ## Performance & Scaling
208
228
 
209
- ```typescript
210
- await scheduler.stop({ graceful: true, timeoutMs: 30000 });
211
- ```
229
+ ### Automatic Indexing
230
+
231
+ **MongoDB indexes are created automatically** when you initialize `MongoJobStore`. No manual setup required.
232
+
233
+ The library creates three indexes in background mode:
234
+
235
+ - `{ status: 1, nextRunAt: 1 }` — for job polling (critical)
236
+ - `{ dedupeKey: 1 }` — for deduplication (unique)
237
+ - `{ lockedAt: 1 }` — for stale lock recovery
238
+
239
+ These indexes prevent query time from degrading from O(log n) to O(n) at scale.
240
+
241
+ ### Distributed Systems
242
+
243
+ Run **multiple scheduler instances** (different servers, pods, or processes) connected to the same MongoDB:
244
+
245
+ - **Atomic Locking** — uses `findOneAndUpdate` to prevent race conditions
246
+ - **Concurrency Control** — only one worker executes a job instance
247
+ - **Horizontally Scalable** — supports MongoDB sharding
248
+
249
+ See `architecture.md` for sharding strategy and production guidelines.
250
+
251
+ ---
252
+
253
+ ## Documentation
254
+
255
+ - **`architecture.md`** — Internal design, MongoDB schema, sharding strategy, production checklist
256
+ - **Job lifecycle** — pending → running → completed/failed
257
+ - **Retry & repeat semantics** — at-most-once guarantees
258
+ - **Correctness guarantees** — what we ensure and what we don't
259
+
260
+ ---
212
261
 
213
262
  ## Status
214
263
 
215
- **Early-stage but production-tested.**
264
+ **Early-stage but production-tested.**
216
265
  API may evolve before 1.0.0.
266
+
267
+ ---
268
+
269
+ ## License
270
+
271
+ MIT
@@ -2,6 +2,7 @@ import { SchedulerEventMap } from "../types/events";
2
2
  import { JobStore, JobUpdates } from "../store";
3
3
  import { Job } from "../types/job";
4
4
  import { ScheduleOptions } from "../types/schedule";
5
+ import { JobQuery } from "../types/query";
5
6
  export interface SchedulerOptions {
6
7
  id?: string;
7
8
  store?: JobStore;
@@ -33,6 +34,10 @@ export declare class Scheduler {
33
34
  * Get a job by ID
34
35
  */
35
36
  getJob(jobId: unknown): Promise<Job | null>;
37
+ /**
38
+ * Query jobs
39
+ */
40
+ getJobs(query: JobQuery): Promise<Job[]>;
36
41
  /**
37
42
  * Update job data or schedule
38
43
  */
@@ -94,6 +94,15 @@ class Scheduler {
94
94
  }
95
95
  return this.store.findById(jobId);
96
96
  }
97
+ /**
98
+ * Query jobs
99
+ */
100
+ async getJobs(query) {
101
+ if (!this.store) {
102
+ throw new Error("Scheduler has no JobStore configured");
103
+ }
104
+ return this.store.findAll(query);
105
+ }
97
106
  /**
98
107
  * Update job data or schedule
99
108
  */
@@ -1,5 +1,6 @@
1
1
  import { Job } from "../types/job";
2
2
  import { JobStore, JobUpdates } from "./job-store";
3
+ import { JobQuery } from "../types/query";
3
4
  export declare class InMemoryJobStore implements JobStore {
4
5
  private jobs;
5
6
  private mutex;
@@ -24,4 +25,5 @@ export declare class InMemoryJobStore implements JobStore {
24
25
  findById(jobId: unknown): Promise<Job | null>;
25
26
  renewLock(jobId: unknown, workerId: string): Promise<void>;
26
27
  update(jobId: unknown, updates: JobUpdates): Promise<void>;
28
+ findAll(query: JobQuery): Promise<Job[]>;
27
29
  }
@@ -149,5 +149,31 @@ class InMemoryJobStore {
149
149
  }
150
150
  job.updatedAt = new Date();
151
151
  }
152
+ async findAll(query) {
153
+ let jobs = Array.from(this.jobs.values());
154
+ // Filter
155
+ if (query.name) {
156
+ jobs = jobs.filter((j) => j.name === query.name);
157
+ }
158
+ if (query.status) {
159
+ const statuses = Array.isArray(query.status)
160
+ ? query.status
161
+ : [query.status];
162
+ jobs = jobs.filter((j) => statuses.includes(j.status));
163
+ }
164
+ // Sort
165
+ if (query.sort) {
166
+ const { field, order } = query.sort;
167
+ jobs.sort((a, b) => {
168
+ const valA = a[field].getTime();
169
+ const valB = b[field].getTime();
170
+ return order === "asc" ? valA - valB : valB - valA;
171
+ });
172
+ }
173
+ // Skip/Limit
174
+ const start = query.skip ?? 0;
175
+ const end = query.limit ? start + query.limit : undefined;
176
+ return jobs.slice(start, end);
177
+ }
152
178
  }
153
179
  exports.InMemoryJobStore = InMemoryJobStore;
@@ -55,9 +55,14 @@ export interface JobStore {
55
55
  * Update job properties (data persistence)
56
56
  */
57
57
  update(jobId: unknown, updates: JobUpdates): Promise<void>;
58
+ /**
59
+ * Find all jobs matching query
60
+ */
61
+ findAll(query: JobQuery): Promise<Job[]>;
58
62
  }
59
63
  import { RetryOptions } from "../types/retry";
60
64
  import { RepeatOptions } from "../types/repeat";
65
+ import { JobQuery } from "../types/query";
61
66
  export interface JobUpdates {
62
67
  data?: unknown;
63
68
  nextRunAt?: Date;
@@ -1,6 +1,7 @@
1
1
  import { Db, ObjectId } from "mongodb";
2
2
  import { JobStore, JobUpdates } from "../job-store";
3
3
  import { Job } from "../../types/job";
4
+ import { JobQuery } from "../../types/query";
4
5
  export interface MongoJobStoreOptions {
5
6
  collectionName?: string;
6
7
  lockTimeoutMs?: number;
@@ -9,6 +10,10 @@ export declare class MongoJobStore implements JobStore {
9
10
  private readonly collection;
10
11
  private readonly defaultLockTimeoutMs;
11
12
  constructor(db: Db, options?: MongoJobStoreOptions);
13
+ /**
14
+ * Create necessary indexes for optimal query performance
15
+ */
16
+ private ensureIndexes;
12
17
  create(job: Job): Promise<Job>;
13
18
  createBulk(jobs: Job[]): Promise<Job[]>;
14
19
  findAndLockNext(options: {
@@ -30,4 +35,5 @@ export declare class MongoJobStore implements JobStore {
30
35
  }): Promise<number>;
31
36
  renewLock(id: ObjectId, workerId: string): Promise<void>;
32
37
  update(id: ObjectId, updates: JobUpdates): Promise<void>;
38
+ findAll(query: JobQuery): Promise<Job[]>;
33
39
  }
@@ -5,6 +5,23 @@ class MongoJobStore {
5
5
  constructor(db, options = {}) {
6
6
  this.collection = db.collection(options.collectionName ?? "scheduler_jobs");
7
7
  this.defaultLockTimeoutMs = options.lockTimeoutMs ?? 30000;
8
+ // Auto-create indexes for performance
9
+ this.ensureIndexes().catch((err) => {
10
+ console.error("Failed to create indexes:", err);
11
+ });
12
+ }
13
+ /**
14
+ * Create necessary indexes for optimal query performance
15
+ */
16
+ async ensureIndexes() {
17
+ await Promise.all([
18
+ // Primary index for job polling (findAndLockNext)
19
+ this.collection.createIndex({ status: 1, nextRunAt: 1 }, { background: true }),
20
+ // Index for deduplication
21
+ this.collection.createIndex({ dedupeKey: 1 }, { unique: true, sparse: true, background: true }),
22
+ // Index for stale lock recovery
23
+ this.collection.createIndex({ lockedAt: 1 }, { sparse: true, background: true }),
24
+ ]);
8
25
  }
9
26
  // --------------------------------------------------
10
27
  // CREATE
@@ -195,5 +212,30 @@ class MongoJobStore {
195
212
  $set.repeat = updates.repeat;
196
213
  await this.collection.updateOne({ _id: id }, { $set });
197
214
  }
215
+ async findAll(query) {
216
+ const filter = {};
217
+ if (query.name) {
218
+ filter.name = query.name;
219
+ }
220
+ if (query.status) {
221
+ filter.status = Array.isArray(query.status)
222
+ ? { $in: query.status }
223
+ : query.status;
224
+ }
225
+ let cursor = this.collection.find(filter);
226
+ if (query.sort) {
227
+ cursor = cursor.sort({
228
+ [query.sort.field]: query.sort.order === "asc" ? 1 : -1,
229
+ });
230
+ }
231
+ if (query.skip) {
232
+ cursor = cursor.skip(query.skip);
233
+ }
234
+ if (query.limit) {
235
+ cursor = cursor.limit(query.limit);
236
+ }
237
+ const docs = await cursor.toArray();
238
+ return docs;
239
+ }
198
240
  }
199
241
  exports.MongoJobStore = MongoJobStore;
@@ -0,0 +1,11 @@
1
+ import { JobStatus } from "./lifecycle";
2
+ export interface JobQuery {
3
+ name?: string;
4
+ status?: JobStatus | JobStatus[];
5
+ limit?: number;
6
+ skip?: number;
7
+ sort?: {
8
+ field: "nextRunAt" | "createdAt" | "updatedAt";
9
+ order: "asc" | "desc";
10
+ };
11
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mongo-job-scheduler",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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",