mongo-job-scheduler 0.1.7 → 0.1.8

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,204 @@ const scheduler = new Scheduler({
62
50
  await scheduler.start();
63
51
  ```
64
52
 
65
- ## Retries
53
+ ---
54
+
55
+ ## Scheduling Jobs
66
56
 
67
- You can configure retries using a simple number (for attempts with instant retry) or a detailed object:
57
+ ### One-Time Job
68
58
 
69
59
  ```typescript
70
- // Shorthand: 3 max attempts, 0 delay
71
- await scheduler.schedule({
72
- name: "email",
73
- retry: 3,
74
- });
75
-
76
- // Full configuration
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
105
82
  await scheduler.schedule({
106
83
  name: "cleanup-logs",
107
84
  data: {},
108
85
  repeat: {
109
- every: 5 * 60 * 1000, // 5 minutes in milliseconds
86
+ every: 5 * 60 * 1000, // every 5 minutes (in milliseconds)
110
87
  },
111
88
  });
112
89
  ```
113
90
 
114
- ## Job Deduplication
91
+ ### Bulk Scheduling
115
92
 
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.
93
+ For high-performance ingestion:
117
94
 
118
95
  ```typescript
119
- await scheduler.schedule({
120
- name: "email",
121
- data: { userId: 123 },
122
- dedupeKey: "email:user:123",
123
- });
96
+ const jobs = await scheduler.scheduleBulk([
97
+ { name: "email", data: { userId: 1 } },
98
+ { name: "email", data: { userId: 2 } },
99
+ { name: "email", data: { userId: 3 } },
100
+ ]);
124
101
  ```
125
102
 
126
- ## Job Cancellation
103
+ ---
104
+
105
+ ## Job Management
106
+
107
+ ### Get Job by ID
127
108
 
128
109
  ```typescript
129
- // Cancel a pending or running job
130
- await scheduler.cancel(jobId);
110
+ const job = await scheduler.getJob(jobId);
131
111
  ```
132
112
 
133
- ## Job Querying
113
+ ### Query Jobs
114
+
115
+ List jobs with filtering, sorting, and pagination:
134
116
 
135
117
  ```typescript
136
- const job = await scheduler.getJob(jobId);
118
+ const jobs = await scheduler.getJobs({
119
+ name: "daily-report",
120
+ status: "failed", // or ["failed", "pending"]
121
+ sort: { field: "updatedAt", order: "desc" },
122
+ limit: 10,
123
+ skip: 0,
124
+ });
137
125
  ```
138
126
 
139
- ## Job Persistence & Updates
127
+ ### Update Job
140
128
 
141
- Update job `data`, reschedule, or modify configuration safely.
129
+ Update job data, reschedule, or modify configuration:
142
130
 
143
131
  ```typescript
144
132
  await scheduler.updateJob(jobId, {
145
133
  data: { page: 2 },
146
134
  nextRunAt: new Date(Date.now() + 60000), // delay by 1 min
147
- repeat: { every: 60000 }, // Change to run every minute
135
+ repeat: { every: 60000 }, // change to run every minute
148
136
  });
149
137
  ```
150
138
 
151
- ## Bulk Scheduling
139
+ ### Cancel Job
152
140
 
153
- For high-performance ingestion, use `scheduleBulk` to insert multiple jobs in a single database operation:
141
+ ```typescript
142
+ await scheduler.cancel(jobId);
143
+ ```
144
+
145
+ ---
146
+
147
+ ## Advanced Features
148
+
149
+ ### Retries with Backoff
154
150
 
155
151
  ```typescript
156
- const jobs = await scheduler.scheduleBulk([
157
- { name: "email", data: { userId: 1 } },
158
- { name: "email", data: { userId: 2 } },
159
- ]);
152
+ // Simple: 3 attempts with instant retry
153
+ await scheduler.schedule({
154
+ name: "webhook",
155
+ retry: 3,
156
+ });
157
+
158
+ // Advanced: custom delay and backoff
159
+ await scheduler.schedule({
160
+ name: "api-call",
161
+ retry: {
162
+ maxAttempts: 5,
163
+ delay: 1000, // 1 second fixed delay
164
+ // or: delay: (attempt) => attempt * 1000 // dynamic backoff
165
+ },
166
+ });
160
167
  ```
161
168
 
162
- ## Events
169
+ ### Job Deduplication
163
170
 
164
- The scheduler emits typed events for lifecycle monitoring.
171
+ Prevent duplicate jobs using idempotency keys:
165
172
 
166
173
  ```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
- );
174
+ await scheduler.schedule({
175
+ name: "email",
176
+ data: { userId: 123 },
177
+ dedupeKey: "email:user:123", // only one job with this key
178
+ });
179
+ ```
173
180
 
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
- );
181
+ ### Event Monitoring
181
182
 
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));
183
+ ```typescript
185
184
  scheduler.on("job:success", (job) => console.log("Job done:", job._id));
186
185
  scheduler.on("job:fail", ({ job, error }) =>
187
186
  console.error("Job failed:", job._id, error)
188
187
  );
189
188
  scheduler.on("job:retry", (job) =>
190
- console.warn("Job retrying:", job._id, job.attempts)
189
+ console.warn("Retrying:", job._id, "attempt", job.attempts)
191
190
  );
192
- scheduler.on("job:cancel", (job) => console.log("Job cancelled:", job._id));
191
+
192
+ // More events: scheduler:start, scheduler:stop, worker:start,
193
+ // worker:stop, job:created, job:start, job:cancel
193
194
  ```
194
195
 
195
- ## Documentation
196
+ ### Graceful Shutdown
196
197
 
197
- See `ARCHITECTURE.md` for:
198
+ Wait for in-flight jobs to complete:
198
199
 
199
- - job lifecycle
200
- - retry & repeat semantics
201
- - MongoDB indexes
202
- - sharding strategy
203
- - production checklist
200
+ ```typescript
201
+ await scheduler.stop({
202
+ graceful: true,
203
+ timeoutMs: 30000,
204
+ });
205
+ ```
204
206
 
205
- ## Graceful Shutdown
207
+ ---
206
208
 
207
- Stop the scheduler and wait for in-flight jobs to complete:
209
+ ## Performance & Scaling
208
210
 
209
- ```typescript
210
- await scheduler.stop({ graceful: true, timeoutMs: 30000 });
211
- ```
211
+ ### Automatic Indexing
212
+
213
+ **MongoDB indexes are created automatically** when you initialize `MongoJobStore`. No manual setup required.
214
+
215
+ The library creates three indexes in background mode:
216
+
217
+ - `{ status: 1, nextRunAt: 1 }` — for job polling (critical)
218
+ - `{ dedupeKey: 1 }` — for deduplication (unique)
219
+ - `{ lockedAt: 1 }` — for stale lock recovery
220
+
221
+ These indexes prevent query time from degrading from O(log n) to O(n) at scale.
222
+
223
+ ### Distributed Systems
224
+
225
+ Run **multiple scheduler instances** (different servers, pods, or processes) connected to the same MongoDB:
226
+
227
+ - **Atomic Locking** — uses `findOneAndUpdate` to prevent race conditions
228
+ - **Concurrency Control** — only one worker executes a job instance
229
+ - **Horizontally Scalable** — supports MongoDB sharding
230
+
231
+ See `architecture.md` for sharding strategy and production guidelines.
232
+
233
+ ---
234
+
235
+ ## Documentation
236
+
237
+ - **`architecture.md`** — Internal design, MongoDB schema, sharding strategy, production checklist
238
+ - **Job lifecycle** — pending → running → completed/failed
239
+ - **Retry & repeat semantics** — at-most-once guarantees
240
+ - **Correctness guarantees** — what we ensure and what we don't
241
+
242
+ ---
212
243
 
213
244
  ## Status
214
245
 
215
- **Early-stage but production-tested.**
246
+ **Early-stage but production-tested.**
216
247
  API may evolve before 1.0.0.
248
+
249
+ ---
250
+
251
+ ## License
252
+
253
+ 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.8",
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",