mongo-job-scheduler 0.1.1 → 0.1.3

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
@@ -23,6 +23,14 @@ Designed for distributed systems that need:
23
23
  - **Stale lock recovery**
24
24
  - **Sharding-safe design**
25
25
 
26
+ ## Distributed Systems
27
+
28
+ This library is designed for distributed environments. You can run **multiple scheduler instances** (on different servers, pods, or processes) connected to the same MongoDB.
29
+
30
+ - **Atomic Locking**: Uses `findOneAndUpdate` to safe-guard against race conditions.
31
+ - **Concurrency**: Only one worker will execute a given job instance.
32
+ - **Scalable**: Horizontal scaling is supported via MongoDB sharding.
33
+
26
34
  ---
27
35
 
28
36
  ## Install
@@ -68,6 +76,75 @@ await scheduler.schedule(
68
76
  );
69
77
  ```
70
78
 
79
+ ## Job Deduplication
80
+
81
+ Prevent duplicate jobs using `dedupeKey`. If a job with the same key exists, it returns the existing job instead of creating a new one.
82
+
83
+ ```typescript
84
+ await scheduler.schedule({
85
+ name: "email",
86
+ data: { userId: 123 },
87
+ dedupeKey: "email:user:123",
88
+ });
89
+ ```
90
+
91
+ ## Job Cancellation
92
+
93
+ ```typescript
94
+ // Cancel a pending or running job
95
+ await scheduler.cancel(jobId);
96
+ ```
97
+
98
+ ## Job Querying
99
+
100
+ ```typescript
101
+ const job = await scheduler.getJob(jobId);
102
+ ```
103
+
104
+ ## Bulk Scheduling
105
+
106
+ For high-performance ingestion, use `scheduleBulk` to insert multiple jobs in a single database operation:
107
+
108
+ ```typescript
109
+ const jobs = await scheduler.scheduleBulk([
110
+ { name: "email", data: { userId: 1 } },
111
+ { name: "email", data: { userId: 2 } },
112
+ ]);
113
+ ```
114
+
115
+ ## Events
116
+
117
+ The scheduler emits typed events for lifecycle monitoring.
118
+
119
+ ```typescript
120
+ // Scheduler events
121
+ scheduler.on("scheduler:start", () => console.log("Scheduler started"));
122
+ scheduler.on("scheduler:stop", () => console.log("Scheduler stopped"));
123
+ scheduler.on("scheduler:error", (err) =>
124
+ console.error("Scheduler error:", err)
125
+ );
126
+
127
+ // Worker events
128
+ scheduler.on("worker:start", (workerId) =>
129
+ console.log("Worker started:", workerId)
130
+ );
131
+ scheduler.on("worker:stop", (workerId) =>
132
+ console.log("Worker stopped:", workerId)
133
+ );
134
+
135
+ // Job events
136
+ scheduler.on("job:created", (job) => console.log("Job created:", job._id));
137
+ scheduler.on("job:start", (job) => console.log("Job processing:", job._id));
138
+ scheduler.on("job:success", (job) => console.log("Job done:", job._id));
139
+ scheduler.on("job:fail", ({ job, error }) =>
140
+ console.error("Job failed:", job._id, error)
141
+ );
142
+ scheduler.on("job:retry", (job) =>
143
+ console.warn("Job retrying:", job._id, job.attempts)
144
+ );
145
+ scheduler.on("job:cancel", (job) => console.log("Job cancelled:", job._id));
146
+ ```
147
+
71
148
  ## Documentation
72
149
 
73
150
  See `ARCHITECTURE.md` for:
@@ -78,6 +155,14 @@ See `ARCHITECTURE.md` for:
78
155
  - sharding strategy
79
156
  - production checklist
80
157
 
158
+ ## Graceful Shutdown
159
+
160
+ Stop the scheduler and wait for in-flight jobs to complete:
161
+
162
+ ```typescript
163
+ await scheduler.stop({ graceful: true, timeoutMs: 30000 });
164
+ ```
165
+
81
166
  ## Status
82
167
 
83
168
  **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
  }
@@ -1,8 +1,11 @@
1
- import { SchedulerEmitter } from "../events";
2
- import { Worker } from "../worker";
3
- export class Scheduler {
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Scheduler = void 0;
4
+ const events_1 = require("../events");
5
+ const worker_1 = require("../worker");
6
+ class Scheduler {
4
7
  constructor(options = {}) {
5
- this.emitter = new SchedulerEmitter();
8
+ this.emitter = new events_1.SchedulerEmitter();
6
9
  this.workers = [];
7
10
  this.started = false;
8
11
  this.id = options.id ?? `scheduler-${Math.random().toString(36).slice(2)}`;
@@ -43,12 +46,68 @@ export class Scheduler {
43
46
  nextRunAt,
44
47
  retry: options.retry,
45
48
  repeat: options.repeat,
49
+ dedupeKey: options.dedupeKey,
46
50
  createdAt: now,
47
51
  updatedAt: now,
48
52
  };
49
53
  const created = await this.store.create(job);
54
+ this.emitter.emitSafe("job:created", created);
50
55
  return created;
51
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
+ }
52
111
  async start() {
53
112
  if (this.started)
54
113
  return;
@@ -68,7 +127,7 @@ export class Scheduler {
68
127
  // start workers
69
128
  // -------------------------------
70
129
  for (let i = 0; i < this.workerCount; i++) {
71
- const worker = new Worker(this.store, this.emitter, this.handler, {
130
+ const worker = new worker_1.Worker(this.store, this.emitter, this.handler, {
72
131
  pollIntervalMs: this.pollInterval,
73
132
  lockTimeoutMs: this.lockTimeout,
74
133
  workerId: `${this.id}-w${i}`,
@@ -78,13 +137,11 @@ export class Scheduler {
78
137
  await worker.start();
79
138
  }
80
139
  }
81
- async stop() {
140
+ async stop(options) {
82
141
  if (!this.started)
83
142
  return;
84
143
  this.started = false;
85
- for (const worker of this.workers) {
86
- await worker.stop();
87
- }
144
+ await Promise.all(this.workers.map((w) => w.stop(options)));
88
145
  this.workers.length = 0;
89
146
  this.emitter.emitSafe("scheduler:stop", undefined);
90
147
  }
@@ -95,3 +152,4 @@ export class Scheduler {
95
152
  return this.id;
96
153
  }
97
154
  }
155
+ exports.Scheduler = Scheduler;
@@ -1,5 +1,8 @@
1
- import { TypedEventEmitter } from "./typed-emitter";
2
- export class SchedulerEmitter extends TypedEventEmitter {
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SchedulerEmitter = void 0;
4
+ const typed_emitter_1 = require("./typed-emitter");
5
+ class SchedulerEmitter extends typed_emitter_1.TypedEventEmitter {
3
6
  emitSafe(event, payload) {
4
7
  try {
5
8
  this.emitUnsafe(event, payload);
@@ -15,3 +18,4 @@ export class SchedulerEmitter extends TypedEventEmitter {
15
18
  }
16
19
  }
17
20
  }
21
+ exports.SchedulerEmitter = SchedulerEmitter;
@@ -1,2 +1,18 @@
1
- export * from "./emitter";
2
- export * from "./typed-emitter";
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./emitter"), exports);
18
+ __exportStar(require("./typed-emitter"), exports);
@@ -1,7 +1,10 @@
1
- import { EventEmitter } from "events";
2
- export class TypedEventEmitter {
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TypedEventEmitter = void 0;
4
+ const events_1 = require("events");
5
+ class TypedEventEmitter {
3
6
  constructor() {
4
- this.emitter = new EventEmitter();
7
+ this.emitter = new events_1.EventEmitter();
5
8
  }
6
9
  on(event, listener) {
7
10
  this.emitter.on(event, listener);
@@ -19,3 +22,4 @@ export class TypedEventEmitter {
19
22
  this.emitter.emit(event, payload);
20
23
  }
21
24
  }
25
+ exports.TypedEventEmitter = TypedEventEmitter;
package/dist/index.js CHANGED
@@ -1,6 +1,26 @@
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";
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.InMemoryJobStore = exports.MongoJobStore = exports.Scheduler = void 0;
18
+ var scheduler_1 = require("./core/scheduler");
19
+ Object.defineProperty(exports, "Scheduler", { enumerable: true, get: function () { return scheduler_1.Scheduler; } });
20
+ var mongo_job_store_1 = require("./store/mongo/mongo-job-store");
21
+ Object.defineProperty(exports, "MongoJobStore", { enumerable: true, get: function () { return mongo_job_store_1.MongoJobStore; } });
22
+ var in_memory_job_store_1 = require("./store/in-memory-job-store");
23
+ Object.defineProperty(exports, "InMemoryJobStore", { enumerable: true, get: function () { return in_memory_job_store_1.InMemoryJobStore; } });
24
+ __exportStar(require("./types/job"), exports);
25
+ __exportStar(require("./types/retry"), exports);
26
+ __exportStar(require("./types/repeat"), exports);
@@ -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,5 @@ 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>;
23
25
  }
@@ -1,14 +1,24 @@
1
- import { JobNotFoundError } from "./store-errors";
2
- import { Mutex } from "./mutex";
3
- export class InMemoryJobStore {
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InMemoryJobStore = void 0;
4
+ const store_errors_1 = require("./store-errors");
5
+ const mutex_1 = require("./mutex");
6
+ class InMemoryJobStore {
4
7
  constructor() {
5
8
  this.jobs = new Map();
6
- this.mutex = new Mutex();
9
+ this.mutex = new mutex_1.Mutex();
7
10
  }
8
11
  generateId() {
9
12
  return Math.random().toString(36).slice(2);
10
13
  }
11
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
+ }
12
22
  const id = this.generateId();
13
23
  const stored = {
14
24
  ...job,
@@ -19,6 +29,9 @@ export class InMemoryJobStore {
19
29
  this.jobs.set(id, stored);
20
30
  return stored;
21
31
  }
32
+ async createBulk(jobs) {
33
+ return Promise.all(jobs.map((job) => this.create(job)));
34
+ }
22
35
  async findAndLockNext({ now, workerId, lockTimeoutMs, }) {
23
36
  const release = await this.mutex.acquire();
24
37
  try {
@@ -48,7 +61,7 @@ export class InMemoryJobStore {
48
61
  async markCompleted(jobId) {
49
62
  const job = this.jobs.get(String(jobId));
50
63
  if (!job)
51
- throw new JobNotFoundError();
64
+ throw new store_errors_1.JobNotFoundError();
52
65
  job.status = "completed";
53
66
  job.lastRunAt = new Date();
54
67
  job.updatedAt = new Date();
@@ -56,7 +69,7 @@ export class InMemoryJobStore {
56
69
  async markFailed(jobId, error) {
57
70
  const job = this.jobs.get(String(jobId));
58
71
  if (!job)
59
- throw new JobNotFoundError();
72
+ throw new store_errors_1.JobNotFoundError();
60
73
  job.status = "failed";
61
74
  job.lastError = error;
62
75
  job.updatedAt = new Date();
@@ -64,7 +77,7 @@ export class InMemoryJobStore {
64
77
  async reschedule(jobId, nextRunAt, updates) {
65
78
  const job = this.jobs.get(String(jobId));
66
79
  if (!job)
67
- throw new JobNotFoundError();
80
+ throw new store_errors_1.JobNotFoundError();
68
81
  job.status = "pending";
69
82
  job.nextRunAt = nextRunAt;
70
83
  if (updates?.attempts != null) {
@@ -96,8 +109,15 @@ export class InMemoryJobStore {
96
109
  async cancel(jobId) {
97
110
  const job = this.jobs.get(String(jobId));
98
111
  if (!job)
99
- throw new JobNotFoundError();
112
+ throw new store_errors_1.JobNotFoundError();
100
113
  job.status = "cancelled";
101
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;
102
121
  }
103
122
  }
123
+ exports.InMemoryJobStore = InMemoryJobStore;
@@ -1,3 +1,19 @@
1
- export * from "./job-store";
2
- export * from "./store-errors";
3
- export * from "./in-memory-job-store";
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./job-store"), exports);
18
+ __exportStar(require("./store-errors"), exports);
19
+ __exportStar(require("./in-memory-job-store"), exports);
@@ -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,8 @@ 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>;
42
50
  }
@@ -1 +1,2 @@
1
- export {};
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,6 +1,9 @@
1
- import { MongoClient } from "mongodb";
2
- export async function connectMongo(options) {
3
- const client = new MongoClient(options.uri);
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.connectMongo = connectMongo;
4
+ const mongodb_1 = require("mongodb");
5
+ async function connectMongo(options) {
6
+ const client = new mongodb_1.MongoClient(options.uri);
4
7
  await client.connect();
5
8
  return client.db(options.dbName);
6
9
  }
@@ -1,2 +1,18 @@
1
- export * from "./mongo-job-store";
2
- export * from "./connect";
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./mongo-job-store"), exports);
18
+ __exportStar(require("./connect"), exports);
@@ -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,6 +23,7 @@ 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;
@@ -1,4 +1,7 @@
1
- export class MongoJobStore {
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MongoJobStore = void 0;
4
+ class MongoJobStore {
2
5
  constructor(db, options = {}) {
3
6
  this.collection = db.collection(options.collectionName ?? "scheduler_jobs");
4
7
  this.defaultLockTimeoutMs = options.lockTimeoutMs ?? 30000;
@@ -17,9 +20,35 @@ export class MongoJobStore {
17
20
  createdAt: now,
18
21
  updatedAt: now,
19
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
+ }
20
28
  const result = await this.collection.insertOne(doc);
21
29
  return { ...doc, _id: result.insertedId };
22
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
+ }
23
52
  // --------------------------------------------------
24
53
  // ATOMIC FIND & LOCK
25
54
  // --------------------------------------------------
@@ -101,7 +130,7 @@ export class MongoJobStore {
101
130
  async cancel(id) {
102
131
  await this.collection.updateOne({ _id: id }, {
103
132
  $set: {
104
- status: "failed",
133
+ status: "cancelled",
105
134
  updatedAt: new Date(),
106
135
  },
107
136
  $unset: {
@@ -110,6 +139,12 @@ export class MongoJobStore {
110
139
  },
111
140
  });
112
141
  }
142
+ async findById(id) {
143
+ const doc = await this.collection.findOne({ _id: id });
144
+ if (!doc)
145
+ return null;
146
+ return doc;
147
+ }
113
148
  // --------------------------------------------------
114
149
  // RECOVER STALE JOBS
115
150
  // --------------------------------------------------
@@ -131,3 +166,4 @@ export class MongoJobStore {
131
166
  return result.modifiedCount;
132
167
  }
133
168
  }
169
+ exports.MongoJobStore = MongoJobStore;
@@ -1,4 +1,7 @@
1
- export class Mutex {
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Mutex = void 0;
4
+ class Mutex {
2
5
  constructor() {
3
6
  this.locked = false;
4
7
  this.waiting = [];
@@ -22,3 +25,4 @@ export class Mutex {
22
25
  });
23
26
  }
24
27
  }
28
+ exports.Mutex = Mutex;
@@ -1,12 +1,17 @@
1
- export class JobNotFoundError extends Error {
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.JobLockError = exports.JobNotFoundError = void 0;
4
+ class JobNotFoundError extends Error {
2
5
  constructor(message = "Job not found") {
3
6
  super(message);
4
7
  this.name = "JobNotFoundError";
5
8
  }
6
9
  }
7
- export class JobLockError extends Error {
10
+ exports.JobNotFoundError = JobNotFoundError;
11
+ class JobLockError extends Error {
8
12
  constructor(message = "Failed to acquire job lock") {
9
13
  super(message);
10
14
  this.name = "JobLockError";
11
15
  }
12
16
  }
17
+ exports.JobLockError = JobLockError;
@@ -1 +1,2 @@
1
- export {};
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,6 +1,22 @@
1
- export * from "./job";
2
- export * from "./retry";
3
- export * from "./repeat";
4
- export * from "./lifecycle";
5
- export * from "./events";
6
- export * from "./schedule";
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./job"), exports);
18
+ __exportStar(require("./retry"), exports);
19
+ __exportStar(require("./repeat"), exports);
20
+ __exportStar(require("./lifecycle"), exports);
21
+ __exportStar(require("./events"), exports);
22
+ __exportStar(require("./schedule"), exports);
@@ -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
  }
package/dist/types/job.js CHANGED
@@ -1 +1,2 @@
1
- export {};
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1 +1,2 @@
1
- export {};
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1 +1,2 @@
1
- export {};
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1 +1,2 @@
1
- export {};
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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
  }
@@ -1 +1,2 @@
1
- export {};
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,2 +1,18 @@
1
- export * from "./worker";
2
- export * from "./types";
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./worker"), exports);
18
+ __exportStar(require("./types"), exports);
@@ -1,10 +1,13 @@
1
- import { parseExpression } from "cron-parser";
2
- export function getNextRunAt(repeat, base, defaultTimezone) {
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getNextRunAt = getNextRunAt;
4
+ const cron_parser_1 = require("cron-parser");
5
+ function getNextRunAt(repeat, base, defaultTimezone) {
3
6
  if (repeat.every != null) {
4
7
  return new Date(base.getTime() + repeat.every);
5
8
  }
6
9
  if (repeat.cron) {
7
- const interval = parseExpression(repeat.cron, {
10
+ const interval = (0, cron_parser_1.parseExpression)(repeat.cron, {
8
11
  currentDate: base,
9
12
  tz: repeat.timezone ?? defaultTimezone ?? "UTC",
10
13
  });
@@ -1,4 +1,7 @@
1
- export function getRetryDelay(retry, attempt) {
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getRetryDelay = getRetryDelay;
4
+ function getRetryDelay(retry, attempt) {
2
5
  if (typeof retry.delay === "function") {
3
6
  return retry.delay(attempt);
4
7
  }
@@ -1 +1,2 @@
1
- export {};
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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;
@@ -1,6 +1,9 @@
1
- import { getRetryDelay } from "./retry";
2
- import { getNextRunAt } from "./repeat";
3
- export class Worker {
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Worker = void 0;
4
+ const retry_1 = require("./retry");
5
+ const repeat_1 = require("./repeat");
6
+ class Worker {
4
7
  constructor(store, emitter, handler, options = {}) {
5
8
  this.store = store;
6
9
  this.emitter = emitter;
@@ -17,13 +20,27 @@ export class Worker {
17
20
  return;
18
21
  this.running = true;
19
22
  this.emitter.emitSafe("worker:start", this.workerId);
20
- this.loop().catch((err) => {
23
+ this.loopPromise = this.loop();
24
+ this.loopPromise.catch((err) => {
21
25
  this.emitter.emitSafe("worker:error", err);
22
26
  });
23
27
  }
24
- async stop() {
28
+ async stop(options) {
25
29
  this.running = false;
26
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
+ }
27
44
  }
28
45
  async loop() {
29
46
  while (this.running) {
@@ -53,17 +70,22 @@ export class Worker {
53
70
  // ---------------------------
54
71
  if (job.repeat?.cron) {
55
72
  let base = job.lastScheduledAt ?? job.nextRunAt ?? new Date(now);
56
- let next = getNextRunAt(job.repeat, base, this.defaultTimezone);
73
+ let next = (0, repeat_1.getNextRunAt)(job.repeat, base, this.defaultTimezone);
57
74
  // skip missed cron slots
58
75
  while (next.getTime() <= now) {
59
76
  base = next;
60
- next = getNextRunAt(job.repeat, base, this.defaultTimezone);
77
+ next = (0, repeat_1.getNextRunAt)(job.repeat, base, this.defaultTimezone);
61
78
  }
62
79
  // persist schedule immediately
63
80
  job.lastScheduledAt = next;
64
81
  await this.store.reschedule(job._id, next);
65
82
  }
66
83
  try {
84
+ const current = await this.store.findById(job._id);
85
+ if (current && current.status === "cancelled") {
86
+ this.emitter.emitSafe("job:complete", job);
87
+ return;
88
+ }
67
89
  await this.handler(job);
68
90
  // ---------------------------
69
91
  // INTERVAL: schedule AFTER execution
@@ -83,7 +105,7 @@ export class Worker {
83
105
  const attempts = (job.attempts ?? 0) + 1;
84
106
  const retry = job.retry;
85
107
  if (retry && attempts < retry.maxAttempts) {
86
- const nextRunAt = new Date(Date.now() + getRetryDelay(retry, attempts));
108
+ const nextRunAt = new Date(Date.now() + (0, retry_1.getRetryDelay)(retry, attempts));
87
109
  await this.store.reschedule(job._id, nextRunAt, { attempts });
88
110
  this.emitter.emitSafe("job:retry", {
89
111
  ...job,
@@ -100,3 +122,4 @@ export class Worker {
100
122
  return new Promise((resolve) => setTimeout(resolve, ms));
101
123
  }
102
124
  }
125
+ exports.Worker = Worker;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mongo-job-scheduler",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",