mongo-job-scheduler 0.1.0

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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/dist/core/scheduler.d.ts +30 -0
  4. package/dist/core/scheduler.js +65 -0
  5. package/dist/events/emitter.d.ts +5 -0
  6. package/dist/events/emitter.js +17 -0
  7. package/dist/events/index.d.ts +2 -0
  8. package/dist/events/index.js +2 -0
  9. package/dist/events/typed-emitter.d.ts +8 -0
  10. package/dist/events/typed-emitter.js +21 -0
  11. package/dist/index.d.ts +6 -0
  12. package/dist/index.js +6 -0
  13. package/dist/store/in-memory-job-store.d.ts +23 -0
  14. package/dist/store/in-memory-job-store.js +103 -0
  15. package/dist/store/index.d.ts +3 -0
  16. package/dist/store/index.js +3 -0
  17. package/dist/store/job-store.d.ts +42 -0
  18. package/dist/store/job-store.js +1 -0
  19. package/dist/store/mongo/connect.d.ts +6 -0
  20. package/dist/store/mongo/connect.js +6 -0
  21. package/dist/store/mongo/index.d.ts +2 -0
  22. package/dist/store/mongo/index.js +2 -0
  23. package/dist/store/mongo/mongo-job-store.d.ts +29 -0
  24. package/dist/store/mongo/mongo-job-store.js +133 -0
  25. package/dist/store/mutex.d.ts +5 -0
  26. package/dist/store/mutex.js +24 -0
  27. package/dist/store/store-errors.d.ts +6 -0
  28. package/dist/store/store-errors.js +12 -0
  29. package/dist/types/events.d.ts +23 -0
  30. package/dist/types/events.js +1 -0
  31. package/dist/types/index.d.ts +5 -0
  32. package/dist/types/index.js +5 -0
  33. package/dist/types/job.d.ts +20 -0
  34. package/dist/types/job.js +1 -0
  35. package/dist/types/lifecycle.d.ts +1 -0
  36. package/dist/types/lifecycle.js +1 -0
  37. package/dist/types/repeat.d.ts +14 -0
  38. package/dist/types/repeat.js +1 -0
  39. package/dist/types/retry.d.ts +12 -0
  40. package/dist/types/retry.js +1 -0
  41. package/dist/worker/index.d.ts +2 -0
  42. package/dist/worker/index.js +2 -0
  43. package/dist/worker/repeat.d.ts +2 -0
  44. package/dist/worker/repeat.js +14 -0
  45. package/dist/worker/retry.d.ts +2 -0
  46. package/dist/worker/retry.js +6 -0
  47. package/dist/worker/types.d.ts +20 -0
  48. package/dist/worker/types.js +1 -0
  49. package/dist/worker/worker.d.ts +19 -0
  50. package/dist/worker/worker.js +102 -0
  51. package/package.json +60 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Darshan Bhut
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # Mongo Job Scheduler
2
+
3
+ A production-grade MongoDB-backed job scheduler for Node.js.
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
12
+
13
+ ---
14
+
15
+ ## Features
16
+
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
+ - **Sharding-safe design**
25
+
26
+ ---
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ npm install mongo-job-scheduler
32
+ ```
33
+
34
+ ## Basic Usage
35
+
36
+ ```typescript
37
+ import { Scheduler, MongoJobStore } from "mongo-job-scheduler";
38
+ import { MongoClient } from "mongodb";
39
+
40
+ // ... connect to mongo ...
41
+ const client = new MongoClient("mongodb://localhost:27017");
42
+ await client.connect();
43
+ const db = client.db("my-app");
44
+
45
+ const scheduler = new Scheduler({
46
+ store: new MongoJobStore(db),
47
+ handler: async (job) => {
48
+ console.log("Running job:", job.name);
49
+ },
50
+ workers: 3, // default is 1
51
+ });
52
+
53
+ await scheduler.start();
54
+ ```
55
+
56
+ ## Cron with Timezone
57
+
58
+ ```typescript
59
+ await scheduler.schedule({
60
+ name: "daily-report",
61
+ repeat: {
62
+ cron: "0 9 * * *",
63
+ timezone: "Asia/Kolkata", // default is UTC
64
+ },
65
+ });
66
+ ```
67
+
68
+ ## Documentation
69
+
70
+ See `ARCHITECTURE.md` for:
71
+
72
+ - job lifecycle
73
+ - retry & repeat semantics
74
+ - MongoDB indexes
75
+ - sharding strategy
76
+ - production checklist
77
+
78
+ ## Status
79
+
80
+ **Early-stage but production-tested.**
81
+ API may evolve before 1.0.0.
@@ -0,0 +1,30 @@
1
+ import { SchedulerEventMap } from "../types/events";
2
+ import { JobStore } from "../store";
3
+ import { Job } from "../types/job";
4
+ export interface SchedulerOptions {
5
+ id?: string;
6
+ store?: JobStore;
7
+ handler?: (job: Job) => Promise<void>;
8
+ workers?: number;
9
+ pollIntervalMs?: number;
10
+ lockTimeoutMs?: number;
11
+ defaultTimezone?: string;
12
+ }
13
+ export declare class Scheduler {
14
+ private readonly emitter;
15
+ private readonly workers;
16
+ private started;
17
+ private readonly id;
18
+ private readonly store?;
19
+ private readonly handler?;
20
+ private readonly workerCount;
21
+ private readonly pollInterval;
22
+ private readonly lockTimeout;
23
+ private readonly defaultTimezone?;
24
+ constructor(options?: SchedulerOptions);
25
+ on<K extends keyof SchedulerEventMap>(event: K, listener: (payload: SchedulerEventMap[K]) => void): this;
26
+ start(): Promise<void>;
27
+ stop(): Promise<void>;
28
+ isRunning(): boolean;
29
+ getId(): string;
30
+ }
@@ -0,0 +1,65 @@
1
+ import { SchedulerEmitter } from "../events";
2
+ import { Worker } from "../worker";
3
+ export class Scheduler {
4
+ constructor(options = {}) {
5
+ this.emitter = new SchedulerEmitter();
6
+ this.workers = [];
7
+ this.started = false;
8
+ this.id = options.id ?? `scheduler-${Math.random().toString(36).slice(2)}`;
9
+ this.store = options.store;
10
+ this.handler = options.handler;
11
+ this.workerCount = options.workers ?? 1;
12
+ this.pollInterval = options.pollIntervalMs ?? 500;
13
+ this.lockTimeout = options.lockTimeoutMs ?? 30000;
14
+ this.defaultTimezone = options.defaultTimezone;
15
+ }
16
+ on(event, listener) {
17
+ this.emitter.on(event, listener);
18
+ return this;
19
+ }
20
+ async start() {
21
+ if (this.started)
22
+ return;
23
+ this.started = true;
24
+ this.emitter.emitSafe("scheduler:start", undefined);
25
+ if (this.store && typeof this.store.recoverStaleJobs === "function") {
26
+ await this.store.recoverStaleJobs({
27
+ now: new Date(),
28
+ lockTimeoutMs: this.lockTimeout,
29
+ });
30
+ }
31
+ // lifecycle-only mode (used by tests)
32
+ if (!this.store || !this.handler) {
33
+ return;
34
+ }
35
+ // -------------------------------
36
+ // start workers
37
+ // -------------------------------
38
+ for (let i = 0; i < this.workerCount; i++) {
39
+ const worker = new Worker(this.store, this.emitter, this.handler, {
40
+ pollIntervalMs: this.pollInterval,
41
+ lockTimeoutMs: this.lockTimeout,
42
+ workerId: `${this.id}-w${i}`,
43
+ defaultTimezone: this.defaultTimezone,
44
+ });
45
+ this.workers.push(worker);
46
+ await worker.start();
47
+ }
48
+ }
49
+ async stop() {
50
+ if (!this.started)
51
+ return;
52
+ this.started = false;
53
+ for (const worker of this.workers) {
54
+ await worker.stop();
55
+ }
56
+ this.workers.length = 0;
57
+ this.emitter.emitSafe("scheduler:stop", undefined);
58
+ }
59
+ isRunning() {
60
+ return this.started;
61
+ }
62
+ getId() {
63
+ return this.id;
64
+ }
65
+ }
@@ -0,0 +1,5 @@
1
+ import { TypedEventEmitter } from "./typed-emitter";
2
+ import { SchedulerEventMap } from "../types/events";
3
+ export declare class SchedulerEmitter extends TypedEventEmitter<SchedulerEventMap> {
4
+ emitSafe<K extends keyof SchedulerEventMap>(event: K, payload: SchedulerEventMap[K]): void;
5
+ }
@@ -0,0 +1,17 @@
1
+ import { TypedEventEmitter } from "./typed-emitter";
2
+ export class SchedulerEmitter extends TypedEventEmitter {
3
+ emitSafe(event, payload) {
4
+ try {
5
+ this.emitUnsafe(event, payload);
6
+ }
7
+ catch (err) {
8
+ // never allow listener failure to crash core
9
+ try {
10
+ this.emitUnsafe("scheduler:error", err);
11
+ }
12
+ catch {
13
+ // absolute last guard
14
+ }
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./emitter";
2
+ export * from "./typed-emitter";
@@ -0,0 +1,2 @@
1
+ export * from "./emitter";
2
+ export * from "./typed-emitter";
@@ -0,0 +1,8 @@
1
+ export type EventMap = Record<string, any>;
2
+ export declare class TypedEventEmitter<T extends EventMap> {
3
+ private emitter;
4
+ on<K extends keyof T>(event: K, listener: (payload: T[K]) => void): this;
5
+ once<K extends keyof T>(event: K, listener: (payload: T[K]) => void): this;
6
+ off<K extends keyof T>(event: K, listener: (payload: T[K]) => void): this;
7
+ protected emitUnsafe<K extends keyof T>(event: K, payload: T[K]): void;
8
+ }
@@ -0,0 +1,21 @@
1
+ import { EventEmitter } from "events";
2
+ export class TypedEventEmitter {
3
+ constructor() {
4
+ this.emitter = new EventEmitter();
5
+ }
6
+ on(event, listener) {
7
+ this.emitter.on(event, listener);
8
+ return this;
9
+ }
10
+ once(event, listener) {
11
+ this.emitter.once(event, listener);
12
+ return this;
13
+ }
14
+ off(event, listener) {
15
+ this.emitter.off(event, listener);
16
+ return this;
17
+ }
18
+ emitUnsafe(event, payload) {
19
+ this.emitter.emit(event, payload);
20
+ }
21
+ }
@@ -0,0 +1,6 @@
1
+ export { Scheduler } from "./core/scheduler";
2
+ export { MongoJobStore } from "./store/mongo/mongo-job-store";
3
+ export { InMemoryJobStore } from "./store/in-memory-job-store";
4
+ export * from "./types/job";
5
+ export * from "./types/retry";
6
+ export * from "./types/repeat";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { Scheduler } from "./core/scheduler";
2
+ export { MongoJobStore } from "./store/mongo/mongo-job-store";
3
+ export { InMemoryJobStore } from "./store/in-memory-job-store";
4
+ export * from "./types/job";
5
+ export * from "./types/retry";
6
+ export * from "./types/repeat";
@@ -0,0 +1,23 @@
1
+ import { Job } from "../types/job";
2
+ import { JobStore } from "./job-store";
3
+ export declare class InMemoryJobStore implements JobStore {
4
+ private jobs;
5
+ private mutex;
6
+ private generateId;
7
+ create(job: Job): Promise<Job>;
8
+ findAndLockNext({ now, workerId, lockTimeoutMs, }: {
9
+ now: Date;
10
+ workerId: string;
11
+ lockTimeoutMs: number;
12
+ }): Promise<Job | null>;
13
+ markCompleted(jobId: unknown): Promise<void>;
14
+ markFailed(jobId: unknown, error: string): Promise<void>;
15
+ reschedule(jobId: unknown, nextRunAt: Date, updates?: {
16
+ attempts?: number;
17
+ }): Promise<void>;
18
+ recoverStaleJobs({ now, lockTimeoutMs, }: {
19
+ now: Date;
20
+ lockTimeoutMs: number;
21
+ }): Promise<number>;
22
+ cancel(jobId: unknown): Promise<void>;
23
+ }
@@ -0,0 +1,103 @@
1
+ import { JobNotFoundError } from "./store-errors";
2
+ import { Mutex } from "./mutex";
3
+ export class InMemoryJobStore {
4
+ constructor() {
5
+ this.jobs = new Map();
6
+ this.mutex = new Mutex();
7
+ }
8
+ generateId() {
9
+ return Math.random().toString(36).slice(2);
10
+ }
11
+ async create(job) {
12
+ const id = this.generateId();
13
+ const stored = {
14
+ ...job,
15
+ _id: id,
16
+ createdAt: job.createdAt ?? new Date(),
17
+ updatedAt: job.updatedAt ?? new Date(),
18
+ };
19
+ this.jobs.set(id, stored);
20
+ return stored;
21
+ }
22
+ async findAndLockNext({ now, workerId, lockTimeoutMs, }) {
23
+ const release = await this.mutex.acquire();
24
+ try {
25
+ for (const job of this.jobs.values()) {
26
+ if (job.status !== "pending")
27
+ continue;
28
+ if (job.nextRunAt > now)
29
+ continue;
30
+ // lock expired?
31
+ if (job.lockedAt &&
32
+ now.getTime() - job.lockedAt.getTime() < lockTimeoutMs) {
33
+ continue;
34
+ }
35
+ job.status = "running";
36
+ job.lockedAt = now;
37
+ job.lockedBy = workerId;
38
+ job.updatedAt = new Date();
39
+ job.lastRunAt = now;
40
+ return { ...job };
41
+ }
42
+ return null;
43
+ }
44
+ finally {
45
+ release();
46
+ }
47
+ }
48
+ async markCompleted(jobId) {
49
+ const job = this.jobs.get(String(jobId));
50
+ if (!job)
51
+ throw new JobNotFoundError();
52
+ job.status = "completed";
53
+ job.lastRunAt = new Date();
54
+ job.updatedAt = new Date();
55
+ }
56
+ async markFailed(jobId, error) {
57
+ const job = this.jobs.get(String(jobId));
58
+ if (!job)
59
+ throw new JobNotFoundError();
60
+ job.status = "failed";
61
+ job.lastError = error;
62
+ job.updatedAt = new Date();
63
+ }
64
+ async reschedule(jobId, nextRunAt, updates) {
65
+ const job = this.jobs.get(String(jobId));
66
+ if (!job)
67
+ throw new JobNotFoundError();
68
+ job.status = "pending";
69
+ job.nextRunAt = nextRunAt;
70
+ if (updates?.attempts != null) {
71
+ job.attempts = updates.attempts;
72
+ }
73
+ else {
74
+ job.attempts = (job.attempts ?? 0) + 1;
75
+ }
76
+ job.lockedAt = undefined;
77
+ job.lockedBy = undefined;
78
+ job.updatedAt = new Date();
79
+ job.lastScheduledAt = nextRunAt;
80
+ }
81
+ async recoverStaleJobs({ now, lockTimeoutMs, }) {
82
+ let recovered = 0;
83
+ for (const job of this.jobs.values()) {
84
+ if (job.status === "running" &&
85
+ job.lockedAt &&
86
+ now.getTime() - job.lockedAt.getTime() > lockTimeoutMs) {
87
+ job.status = "pending";
88
+ job.lockedAt = undefined;
89
+ job.lockedBy = undefined;
90
+ job.updatedAt = new Date();
91
+ recovered++;
92
+ }
93
+ }
94
+ return recovered;
95
+ }
96
+ async cancel(jobId) {
97
+ const job = this.jobs.get(String(jobId));
98
+ if (!job)
99
+ throw new JobNotFoundError();
100
+ job.status = "cancelled";
101
+ job.updatedAt = new Date();
102
+ }
103
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./job-store";
2
+ export * from "./store-errors";
3
+ export * from "./in-memory-job-store";
@@ -0,0 +1,3 @@
1
+ export * from "./job-store";
2
+ export * from "./store-errors";
3
+ export * from "./in-memory-job-store";
@@ -0,0 +1,42 @@
1
+ import { Job } from "../types/job";
2
+ export interface JobStore {
3
+ /**
4
+ * Insert a new job
5
+ */
6
+ create(job: Job): Promise<Job>;
7
+ /**
8
+ * Find and lock the next runnable job.
9
+ * Must be atomic.
10
+ */
11
+ findAndLockNext(options: {
12
+ now: Date;
13
+ workerId: string;
14
+ lockTimeoutMs: number;
15
+ }): Promise<Job | null>;
16
+ /**
17
+ * Mark job as completed
18
+ */
19
+ markCompleted(jobId: unknown): Promise<void>;
20
+ /**
21
+ * Mark job as failed
22
+ */
23
+ markFailed(jobId: unknown, error: string): Promise<void>;
24
+ /**
25
+ * Reschedule job (used for retry or repeat)
26
+ */
27
+ reschedule(jobId: unknown, nextRunAt: Date, updates?: {
28
+ attempts?: number;
29
+ lastError?: string;
30
+ }): Promise<void>;
31
+ /**
32
+ * Recover jobs stuck in running state
33
+ */
34
+ recoverStaleJobs(options: {
35
+ now: Date;
36
+ lockTimeoutMs: number;
37
+ }): Promise<number>;
38
+ /**
39
+ * Cancel job explicitly
40
+ */
41
+ cancel(jobId: unknown): Promise<void>;
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import { Db } from "mongodb";
2
+ export interface MongoConnectionOptions {
3
+ uri: string;
4
+ dbName: string;
5
+ }
6
+ export declare function connectMongo(options: MongoConnectionOptions): Promise<Db>;
@@ -0,0 +1,6 @@
1
+ import { MongoClient } from "mongodb";
2
+ export async function connectMongo(options) {
3
+ const client = new MongoClient(options.uri);
4
+ await client.connect();
5
+ return client.db(options.dbName);
6
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./mongo-job-store";
2
+ export * from "./connect";
@@ -0,0 +1,2 @@
1
+ export * from "./mongo-job-store";
2
+ export * from "./connect";
@@ -0,0 +1,29 @@
1
+ import { Db, ObjectId } from "mongodb";
2
+ import { JobStore } from "../job-store";
3
+ import { Job } from "../../types/job";
4
+ export interface MongoJobStoreOptions {
5
+ collectionName?: string;
6
+ lockTimeoutMs?: number;
7
+ }
8
+ export declare class MongoJobStore implements JobStore {
9
+ private readonly collection;
10
+ private readonly defaultLockTimeoutMs;
11
+ constructor(db: Db, options?: MongoJobStoreOptions);
12
+ create(job: Job): Promise<Job>;
13
+ findAndLockNext(options: {
14
+ now: Date;
15
+ workerId: string;
16
+ lockTimeoutMs: number;
17
+ }): Promise<Job | null>;
18
+ markCompleted(id: ObjectId): Promise<void>;
19
+ markFailed(id: ObjectId, error: string): Promise<void>;
20
+ reschedule(id: ObjectId, nextRunAt: Date, updates?: {
21
+ attempts?: number;
22
+ lastError?: string;
23
+ }): Promise<void>;
24
+ cancel(id: ObjectId): Promise<void>;
25
+ recoverStaleJobs(options: {
26
+ now: Date;
27
+ lockTimeoutMs: number;
28
+ }): Promise<number>;
29
+ }
@@ -0,0 +1,133 @@
1
+ export class MongoJobStore {
2
+ constructor(db, options = {}) {
3
+ this.collection = db.collection(options.collectionName ?? "scheduler_jobs");
4
+ this.defaultLockTimeoutMs = options.lockTimeoutMs ?? 30000;
5
+ }
6
+ // --------------------------------------------------
7
+ // CREATE
8
+ // --------------------------------------------------
9
+ async create(job) {
10
+ const now = new Date();
11
+ // IMPORTANT: strip _id completely
12
+ const { _id, ...jobWithoutId } = job;
13
+ const doc = {
14
+ ...jobWithoutId,
15
+ status: job.status ?? "pending",
16
+ attempts: job.attempts ?? 0,
17
+ createdAt: now,
18
+ updatedAt: now,
19
+ };
20
+ const result = await this.collection.insertOne(doc);
21
+ return { ...doc, _id: result.insertedId };
22
+ }
23
+ // --------------------------------------------------
24
+ // ATOMIC FIND & LOCK
25
+ // --------------------------------------------------
26
+ async findAndLockNext(options) {
27
+ const { now, workerId, lockTimeoutMs } = options;
28
+ const lockExpiry = new Date(now.getTime() - lockTimeoutMs);
29
+ const result = await this.collection.findOneAndUpdate({
30
+ status: "pending",
31
+ nextRunAt: { $lte: now },
32
+ $or: [
33
+ { lockedAt: { $exists: false } },
34
+ { lockedAt: { $lte: lockExpiry } },
35
+ ],
36
+ }, {
37
+ $set: {
38
+ lockedAt: now,
39
+ lockedBy: workerId,
40
+ status: "running",
41
+ lastRunAt: now,
42
+ updatedAt: now,
43
+ },
44
+ }, {
45
+ sort: { nextRunAt: 1 },
46
+ returnDocument: "after",
47
+ });
48
+ return result;
49
+ }
50
+ // --------------------------------------------------
51
+ // MARK COMPLETED
52
+ // --------------------------------------------------
53
+ async markCompleted(id) {
54
+ await this.collection.updateOne({ _id: id }, {
55
+ $set: {
56
+ status: "completed",
57
+ updatedAt: new Date(),
58
+ },
59
+ $unset: {
60
+ lockedAt: "",
61
+ lockedBy: "",
62
+ },
63
+ });
64
+ }
65
+ // --------------------------------------------------
66
+ // MARK FAILED
67
+ // --------------------------------------------------
68
+ async markFailed(id, error) {
69
+ await this.collection.updateOne({ _id: id }, {
70
+ $set: {
71
+ status: "failed",
72
+ lastError: error,
73
+ updatedAt: new Date(),
74
+ },
75
+ $unset: {
76
+ lockedAt: "",
77
+ lockedBy: "",
78
+ },
79
+ });
80
+ }
81
+ // --------------------------------------------------
82
+ // RESCHEDULE
83
+ // --------------------------------------------------
84
+ async reschedule(id, nextRunAt, updates) {
85
+ await this.collection.updateOne({ _id: id }, {
86
+ $set: {
87
+ status: "pending",
88
+ nextRunAt,
89
+ updatedAt: new Date(),
90
+ ...(updates ?? {}),
91
+ },
92
+ $unset: {
93
+ lockedAt: "",
94
+ lockedBy: "",
95
+ },
96
+ });
97
+ }
98
+ // --------------------------------------------------
99
+ // CANCEL
100
+ // --------------------------------------------------
101
+ async cancel(id) {
102
+ await this.collection.updateOne({ _id: id }, {
103
+ $set: {
104
+ status: "failed",
105
+ updatedAt: new Date(),
106
+ },
107
+ $unset: {
108
+ lockedAt: "",
109
+ lockedBy: "",
110
+ },
111
+ });
112
+ }
113
+ // --------------------------------------------------
114
+ // RECOVER STALE JOBS
115
+ // --------------------------------------------------
116
+ async recoverStaleJobs(options) {
117
+ const { now, lockTimeoutMs } = options;
118
+ const expiry = new Date(now.getTime() - lockTimeoutMs);
119
+ const result = await this.collection.updateMany({
120
+ lockedAt: { $lte: expiry },
121
+ }, {
122
+ $set: {
123
+ status: "pending",
124
+ updatedAt: now,
125
+ },
126
+ $unset: {
127
+ lockedAt: "",
128
+ lockedBy: "",
129
+ },
130
+ });
131
+ return result.modifiedCount;
132
+ }
133
+ }
@@ -0,0 +1,5 @@
1
+ export declare class Mutex {
2
+ private locked;
3
+ private waiting;
4
+ acquire(): Promise<() => void>;
5
+ }
@@ -0,0 +1,24 @@
1
+ export class Mutex {
2
+ constructor() {
3
+ this.locked = false;
4
+ this.waiting = [];
5
+ }
6
+ async acquire() {
7
+ return new Promise((resolve) => {
8
+ const release = () => {
9
+ const next = this.waiting.shift();
10
+ if (next)
11
+ next();
12
+ else
13
+ this.locked = false;
14
+ };
15
+ if (!this.locked) {
16
+ this.locked = true;
17
+ resolve(release);
18
+ }
19
+ else {
20
+ this.waiting.push(() => resolve(release));
21
+ }
22
+ });
23
+ }
24
+ }
@@ -0,0 +1,6 @@
1
+ export declare class JobNotFoundError extends Error {
2
+ constructor(message?: string);
3
+ }
4
+ export declare class JobLockError extends Error {
5
+ constructor(message?: string);
6
+ }
@@ -0,0 +1,12 @@
1
+ export class JobNotFoundError extends Error {
2
+ constructor(message = "Job not found") {
3
+ super(message);
4
+ this.name = "JobNotFoundError";
5
+ }
6
+ }
7
+ export class JobLockError extends Error {
8
+ constructor(message = "Failed to acquire job lock") {
9
+ super(message);
10
+ this.name = "JobLockError";
11
+ }
12
+ }
@@ -0,0 +1,23 @@
1
+ import { Job } from "./job";
2
+ export type SchedulerEventMap = {
3
+ "scheduler:start": void;
4
+ "scheduler:stop": void;
5
+ "scheduler:error": Error;
6
+ "job:created": Job;
7
+ "job:queued": Job;
8
+ "job:start": Job;
9
+ "job:success": Job;
10
+ "job:fail": {
11
+ job: Job;
12
+ error: Error;
13
+ };
14
+ "job:retry": Job;
15
+ "job:complete": Job;
16
+ "job:cancel": Job;
17
+ "resume:start": void;
18
+ "resume:jobRecovered": Job;
19
+ "resume:complete": void;
20
+ "worker:start": string;
21
+ "worker:stop": string;
22
+ "worker:error": Error;
23
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ export * from "./job";
2
+ export * from "./retry";
3
+ export * from "./repeat";
4
+ export * from "./lifecycle";
5
+ export * from "./events";
@@ -0,0 +1,5 @@
1
+ export * from "./job";
2
+ export * from "./retry";
3
+ export * from "./repeat";
4
+ export * from "./lifecycle";
5
+ export * from "./events";
@@ -0,0 +1,20 @@
1
+ import { JobStatus } from "./lifecycle";
2
+ import { RetryOptions } from "./retry";
3
+ import { RepeatOptions } from "./repeat";
4
+ export interface Job<Data = unknown> {
5
+ _id?: unknown;
6
+ name: string;
7
+ data: Data;
8
+ status: JobStatus;
9
+ nextRunAt: Date;
10
+ lastRunAt?: Date;
11
+ lastScheduledAt?: Date;
12
+ lockedAt?: Date;
13
+ lockedBy?: string;
14
+ attempts: number;
15
+ lastError?: string;
16
+ retry?: RetryOptions;
17
+ repeat?: RepeatOptions;
18
+ createdAt: Date;
19
+ updatedAt: Date;
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export type JobStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ export interface RepeatOptions {
2
+ /**
3
+ * Run every N milliseconds
4
+ */
5
+ every?: number;
6
+ /**
7
+ * Cron expression (optional)
8
+ */
9
+ cron?: string;
10
+ /**
11
+ * Timezone for cron
12
+ */
13
+ timezone?: string;
14
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ export interface RetryOptions {
2
+ /**
3
+ * Total allowed attempts (including first)
4
+ */
5
+ maxAttempts: number;
6
+ /**
7
+ * Backoff strategy:
8
+ * - number = fixed delay (ms)
9
+ * - function = dynamic delay
10
+ */
11
+ delay: number | ((attempt: number) => number);
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export * from "./worker";
2
+ export * from "./types";
@@ -0,0 +1,2 @@
1
+ export * from "./worker";
2
+ export * from "./types";
@@ -0,0 +1,2 @@
1
+ import { RepeatOptions } from "../types/repeat";
2
+ export declare function getNextRunAt(repeat: RepeatOptions, base: Date, defaultTimezone?: string): Date;
@@ -0,0 +1,14 @@
1
+ import { parseExpression } from "cron-parser";
2
+ export function getNextRunAt(repeat, base, defaultTimezone) {
3
+ if (repeat.every != null) {
4
+ return new Date(base.getTime() + repeat.every);
5
+ }
6
+ if (repeat.cron) {
7
+ const interval = parseExpression(repeat.cron, {
8
+ currentDate: base,
9
+ tz: repeat.timezone ?? defaultTimezone ?? "UTC",
10
+ });
11
+ return interval.next().toDate();
12
+ }
13
+ throw new Error("Invalid repeat configuration");
14
+ }
@@ -0,0 +1,2 @@
1
+ import { RetryOptions } from "../types/retry";
2
+ export declare function getRetryDelay(retry: RetryOptions, attempt: number): number;
@@ -0,0 +1,6 @@
1
+ export function getRetryDelay(retry, attempt) {
2
+ if (typeof retry.delay === "function") {
3
+ return retry.delay(attempt);
4
+ }
5
+ return retry.delay;
6
+ }
@@ -0,0 +1,20 @@
1
+ import { Job } from "../types/job";
2
+ export type JobHandler<T = any> = (job: Job<T>) => Promise<void>;
3
+ export interface WorkerOptions {
4
+ /**
5
+ * Interval between polling attempts (ms)
6
+ */
7
+ pollIntervalMs?: number;
8
+ /**
9
+ * Lock timeout for stale job recovery
10
+ */
11
+ lockTimeoutMs?: number;
12
+ /**
13
+ * Worker id (used for locking)
14
+ */
15
+ workerId?: string;
16
+ /**
17
+ * Default timezone for cron scheduling
18
+ */
19
+ defaultTimezone?: string;
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import { JobStore } from "../store";
2
+ import { SchedulerEmitter } from "../events";
3
+ import { WorkerOptions, JobHandler } from "./types";
4
+ export declare class Worker {
5
+ private readonly store;
6
+ private readonly emitter;
7
+ private readonly handler;
8
+ private running;
9
+ private readonly pollInterval;
10
+ private readonly lockTimeout;
11
+ private readonly workerId;
12
+ private readonly defaultTimezone?;
13
+ constructor(store: JobStore, emitter: SchedulerEmitter, handler: JobHandler, options?: WorkerOptions);
14
+ start(): Promise<void>;
15
+ stop(): Promise<void>;
16
+ private loop;
17
+ private execute;
18
+ private sleep;
19
+ }
@@ -0,0 +1,102 @@
1
+ import { getRetryDelay } from "./retry";
2
+ import { getNextRunAt } from "./repeat";
3
+ export class Worker {
4
+ constructor(store, emitter, handler, options = {}) {
5
+ this.store = store;
6
+ this.emitter = emitter;
7
+ this.handler = handler;
8
+ this.running = false;
9
+ this.pollInterval = options.pollIntervalMs ?? 500;
10
+ this.lockTimeout = options.lockTimeoutMs ?? 30000;
11
+ this.workerId =
12
+ options.workerId ?? `worker-${Math.random().toString(36).slice(2)}`;
13
+ this.defaultTimezone = options.defaultTimezone;
14
+ }
15
+ async start() {
16
+ if (this.running)
17
+ return;
18
+ this.running = true;
19
+ this.emitter.emitSafe("worker:start", this.workerId);
20
+ this.loop().catch((err) => {
21
+ this.emitter.emitSafe("worker:error", err);
22
+ });
23
+ }
24
+ async stop() {
25
+ this.running = false;
26
+ this.emitter.emitSafe("worker:stop", this.workerId);
27
+ }
28
+ async loop() {
29
+ while (this.running) {
30
+ // stop requested before poll
31
+ if (!this.running)
32
+ break;
33
+ const job = await this.store.findAndLockNext({
34
+ now: new Date(),
35
+ workerId: this.workerId,
36
+ lockTimeoutMs: this.lockTimeout,
37
+ });
38
+ // stop requested after polling
39
+ if (!this.running)
40
+ break;
41
+ if (!job) {
42
+ await this.sleep(this.pollInterval);
43
+ continue;
44
+ }
45
+ await this.execute(job);
46
+ }
47
+ }
48
+ async execute(job) {
49
+ this.emitter.emitSafe("job:start", job);
50
+ const now = Date.now();
51
+ // ---------------------------
52
+ // CRON: pre-schedule BEFORE execution
53
+ // ---------------------------
54
+ if (job.repeat?.cron) {
55
+ let base = job.lastScheduledAt ?? job.nextRunAt ?? new Date(now);
56
+ let next = getNextRunAt(job.repeat, base, this.defaultTimezone);
57
+ // skip missed cron slots
58
+ while (next.getTime() <= now) {
59
+ base = next;
60
+ next = getNextRunAt(job.repeat, base, this.defaultTimezone);
61
+ }
62
+ // persist schedule immediately
63
+ job.lastScheduledAt = next;
64
+ await this.store.reschedule(job._id, next);
65
+ }
66
+ try {
67
+ await this.handler(job);
68
+ // ---------------------------
69
+ // INTERVAL: schedule AFTER execution
70
+ // ---------------------------
71
+ if (job.repeat?.every != null) {
72
+ const next = new Date(Date.now() + Math.max(job.repeat.every, 100));
73
+ await this.store.reschedule(job._id, next);
74
+ }
75
+ if (!job.repeat) {
76
+ await this.store.markCompleted(job._id);
77
+ this.emitter.emitSafe("job:success", job);
78
+ }
79
+ this.emitter.emitSafe("job:complete", job);
80
+ }
81
+ catch (err) {
82
+ const error = err instanceof Error ? err : new Error(String(err));
83
+ const attempts = (job.attempts ?? 0) + 1;
84
+ const retry = job.retry;
85
+ if (retry && attempts < retry.maxAttempts) {
86
+ const nextRunAt = new Date(Date.now() + getRetryDelay(retry, attempts));
87
+ await this.store.reschedule(job._id, nextRunAt, { attempts });
88
+ this.emitter.emitSafe("job:retry", {
89
+ ...job,
90
+ attempts,
91
+ lastError: error.message,
92
+ });
93
+ return;
94
+ }
95
+ await this.store.markFailed(job._id, error.message);
96
+ this.emitter.emitSafe("job:fail", { job, error });
97
+ }
98
+ }
99
+ sleep(ms) {
100
+ return new Promise((resolve) => setTimeout(resolve, ms));
101
+ }
102
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "mongo-job-scheduler",
3
+ "version": "0.1.0",
4
+ "description": "Production-grade MongoDB-backed job scheduler with retries, cron, timezone support, and crash recovery",
5
+ "license": "MIT",
6
+ "author": "Darshan Bhut",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/darshanpatel14/mongo-job-scheduler.git"
10
+ },
11
+ "homepage": "https://github.com/darshanpatel14/mongo-job-scheduler#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/darshanpatel14/mongo-job-scheduler/issues"
14
+ },
15
+ "main": "dist/index.js",
16
+ "types": "dist/index.d.ts",
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "engines": {
21
+ "node": ">=16.0.0"
22
+ },
23
+ "keywords": [
24
+ "job-scheduler",
25
+ "mongodb",
26
+ "mongo",
27
+ "cron",
28
+ "cron-jobs",
29
+ "background-jobs",
30
+ "task-scheduler",
31
+ "distributed-jobs",
32
+ "queue",
33
+ "retry",
34
+ "backoff",
35
+ "worker",
36
+ "agenda",
37
+ "bull",
38
+ "node-cron",
39
+ "timezone",
40
+ "scheduler"
41
+ ],
42
+ "scripts": {
43
+ "build": "tsc",
44
+ "test": "jest",
45
+ "test:mongo": "jest tests/mongo",
46
+ "test:stress": "jest tests/stress",
47
+ "prepublishOnly": "npm run build && npm test"
48
+ },
49
+ "dependencies": {
50
+ "cron-parser": "^4.9.0",
51
+ "mongodb": "^7.0.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/jest": "^30.0.0",
55
+ "jest": "^30.2.0",
56
+ "ts-jest": "^29.4.6",
57
+ "ts-node": "^10.9.2",
58
+ "typescript": "^5.9.3"
59
+ }
60
+ }