mongo-job-scheduler 0.1.6 → 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.
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,133 +50,204 @@ const scheduler = new Scheduler({
62
50
  await scheduler.start();
63
51
  ```
64
52
 
65
- ## Cron with Timezone
53
+ ---
54
+
55
+ ## Scheduling Jobs
56
+
57
+ ### One-Time Job
58
+
59
+ ```typescript
60
+ await scheduler.schedule({
61
+ name: "send-email",
62
+ data: { userId: 123 },
63
+ runAt: new Date(Date.now() + 60000), // run in 1 minute
64
+ });
65
+ ```
66
+
67
+ ### Cron Jobs (Timezone-Aware)
66
68
 
67
69
  ```typescript
68
70
  await scheduler.schedule({
69
71
  name: "daily-report",
70
- // data: { type: "report" }, // optional payload
71
72
  repeat: {
72
- cron: "0 9 * * *",
73
+ cron: "0 9 * * *", // every day at 9 AM
73
74
  timezone: "Asia/Kolkata", // default is UTC
74
75
  },
75
76
  });
76
77
  ```
77
78
 
78
- ## Interval Scheduling
79
-
80
- Run a job repeatedly with a fixed delay between executions (e.g., every 5 minutes).
79
+ ### Interval Jobs
81
80
 
82
81
  ```typescript
83
82
  await scheduler.schedule({
84
83
  name: "cleanup-logs",
85
84
  data: {},
86
85
  repeat: {
87
- every: 5 * 60 * 1000, // 5 minutes in milliseconds
86
+ every: 5 * 60 * 1000, // every 5 minutes (in milliseconds)
88
87
  },
89
88
  });
90
89
  ```
91
90
 
92
- ## Job Deduplication
91
+ ### Bulk Scheduling
93
92
 
94
- 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:
95
94
 
96
95
  ```typescript
97
- await scheduler.schedule({
98
- name: "email",
99
- data: { userId: 123 },
100
- dedupeKey: "email:user:123",
101
- });
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
+ ]);
102
101
  ```
103
102
 
104
- ## Job Cancellation
103
+ ---
104
+
105
+ ## Job Management
106
+
107
+ ### Get Job by ID
105
108
 
106
109
  ```typescript
107
- // Cancel a pending or running job
108
- await scheduler.cancel(jobId);
110
+ const job = await scheduler.getJob(jobId);
109
111
  ```
110
112
 
111
- ## Job Querying
113
+ ### Query Jobs
114
+
115
+ List jobs with filtering, sorting, and pagination:
112
116
 
113
117
  ```typescript
114
- 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
+ });
115
125
  ```
116
126
 
117
- ## Job Persistence & Updates
127
+ ### Update Job
118
128
 
119
- Update job `data`, reschedule, or modify configuration safely.
129
+ Update job data, reschedule, or modify configuration:
120
130
 
121
131
  ```typescript
122
132
  await scheduler.updateJob(jobId, {
123
133
  data: { page: 2 },
124
134
  nextRunAt: new Date(Date.now() + 60000), // delay by 1 min
125
- repeat: { every: 60000 }, // Change to run every minute
135
+ repeat: { every: 60000 }, // change to run every minute
126
136
  });
127
137
  ```
128
138
 
129
- ## Bulk Scheduling
139
+ ### Cancel Job
130
140
 
131
- 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
132
150
 
133
151
  ```typescript
134
- const jobs = await scheduler.scheduleBulk([
135
- { name: "email", data: { userId: 1 } },
136
- { name: "email", data: { userId: 2 } },
137
- ]);
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
+ });
138
167
  ```
139
168
 
140
- ## Events
169
+ ### Job Deduplication
141
170
 
142
- The scheduler emits typed events for lifecycle monitoring.
171
+ Prevent duplicate jobs using idempotency keys:
143
172
 
144
173
  ```typescript
145
- // Scheduler events
146
- scheduler.on("scheduler:start", () => console.log("Scheduler started"));
147
- scheduler.on("scheduler:stop", () => console.log("Scheduler stopped"));
148
- scheduler.on("scheduler:error", (err) =>
149
- console.error("Scheduler error:", err)
150
- );
174
+ await scheduler.schedule({
175
+ name: "email",
176
+ data: { userId: 123 },
177
+ dedupeKey: "email:user:123", // only one job with this key
178
+ });
179
+ ```
151
180
 
152
- // Worker events
153
- scheduler.on("worker:start", (workerId) =>
154
- console.log("Worker started:", workerId)
155
- );
156
- scheduler.on("worker:stop", (workerId) =>
157
- console.log("Worker stopped:", workerId)
158
- );
181
+ ### Event Monitoring
159
182
 
160
- // Job events
161
- scheduler.on("job:created", (job) => console.log("Job created:", job._id));
162
- scheduler.on("job:start", (job) => console.log("Job processing:", job._id));
183
+ ```typescript
163
184
  scheduler.on("job:success", (job) => console.log("Job done:", job._id));
164
185
  scheduler.on("job:fail", ({ job, error }) =>
165
186
  console.error("Job failed:", job._id, error)
166
187
  );
167
188
  scheduler.on("job:retry", (job) =>
168
- console.warn("Job retrying:", job._id, job.attempts)
189
+ console.warn("Retrying:", job._id, "attempt", job.attempts)
169
190
  );
170
- 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
171
194
  ```
172
195
 
173
- ## Documentation
196
+ ### Graceful Shutdown
174
197
 
175
- See `ARCHITECTURE.md` for:
198
+ Wait for in-flight jobs to complete:
176
199
 
177
- - job lifecycle
178
- - retry & repeat semantics
179
- - MongoDB indexes
180
- - sharding strategy
181
- - production checklist
200
+ ```typescript
201
+ await scheduler.stop({
202
+ graceful: true,
203
+ timeoutMs: 30000,
204
+ });
205
+ ```
182
206
 
183
- ## Graceful Shutdown
207
+ ---
184
208
 
185
- Stop the scheduler and wait for in-flight jobs to complete:
209
+ ## Performance & Scaling
186
210
 
187
- ```typescript
188
- await scheduler.stop({ graceful: true, timeoutMs: 30000 });
189
- ```
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
+ ---
190
243
 
191
244
  ## Status
192
245
 
193
- **Early-stage but production-tested.**
246
+ **Early-stage but production-tested.**
194
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
  */
@@ -13,7 +13,7 @@ class Scheduler {
13
13
  this.handler = options.handler;
14
14
  this.workerCount = options.workers ?? 1;
15
15
  this.pollInterval = options.pollIntervalMs ?? 500;
16
- this.lockTimeout = options.lockTimeoutMs ?? 30000;
16
+ this.lockTimeout = options.lockTimeoutMs ?? 10 * 60 * 1000; // default 10 minutes
17
17
  this.defaultTimezone = options.defaultTimezone;
18
18
  }
19
19
  on(event, listener) {
@@ -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;
@@ -13,7 +13,7 @@ export interface Job<Data = unknown> {
13
13
  lockedBy?: string;
14
14
  attempts: number;
15
15
  lastError?: string;
16
- retry?: RetryOptions;
16
+ retry?: RetryOptions | number;
17
17
  repeat?: RepeatOptions;
18
18
  dedupeKey?: string;
19
19
  createdAt: Date;
@@ -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 });
@@ -11,7 +11,7 @@ export interface ScheduleOptions<T = unknown> {
11
11
  /**
12
12
  * Retry configuration
13
13
  */
14
- retry?: RetryOptions;
14
+ retry?: RetryOptions | number;
15
15
  /**
16
16
  * Repeat configuration (cron or every)
17
17
  */
@@ -10,7 +10,7 @@ class Worker {
10
10
  this.handler = handler;
11
11
  this.running = false;
12
12
  this.pollInterval = options.pollIntervalMs ?? 500;
13
- this.lockTimeout = options.lockTimeoutMs ?? 30000;
13
+ this.lockTimeout = options.lockTimeoutMs ?? 10 * 60 * 1000; // default 10 minutes
14
14
  this.workerId =
15
15
  options.workerId ?? `worker-${Math.random().toString(36).slice(2)}`;
16
16
  this.defaultTimezone = options.defaultTimezone;
@@ -128,7 +128,10 @@ class Worker {
128
128
  catch (err) {
129
129
  const error = err instanceof Error ? err : new Error(String(err));
130
130
  const attempts = (job.attempts ?? 0) + 1;
131
- const retry = job.retry;
131
+ let retry = job.retry;
132
+ if (typeof retry === "number") {
133
+ retry = { maxAttempts: retry, delay: 0 };
134
+ }
132
135
  if (retry && attempts < retry.maxAttempts) {
133
136
  const nextRunAt = new Date(Date.now() + (0, retry_1.getRetryDelay)(retry, attempts));
134
137
  await this.store.reschedule(job._id, nextRunAt, { attempts });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mongo-job-scheduler",
3
- "version": "0.1.6",
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",