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 +85 -0
- package/dist/core/scheduler.d.ts +16 -1
- package/dist/core/scheduler.js +67 -9
- package/dist/events/emitter.js +6 -2
- package/dist/events/index.js +18 -2
- package/dist/events/typed-emitter.js +7 -3
- package/dist/index.js +26 -6
- package/dist/store/in-memory-job-store.d.ts +2 -0
- package/dist/store/in-memory-job-store.js +28 -8
- package/dist/store/index.js +19 -3
- package/dist/store/job-store.d.ts +8 -0
- package/dist/store/job-store.js +2 -1
- package/dist/store/mongo/connect.js +6 -3
- package/dist/store/mongo/index.js +18 -2
- package/dist/store/mongo/mongo-job-store.d.ts +2 -0
- package/dist/store/mongo/mongo-job-store.js +38 -2
- package/dist/store/mutex.js +5 -1
- package/dist/store/store-errors.js +7 -2
- package/dist/types/events.js +2 -1
- package/dist/types/index.js +22 -6
- package/dist/types/job.d.ts +1 -0
- package/dist/types/job.js +2 -1
- package/dist/types/lifecycle.js +2 -1
- package/dist/types/repeat.js +2 -1
- package/dist/types/retry.js +2 -1
- package/dist/types/schedule.d.ts +4 -0
- package/dist/types/schedule.js +2 -1
- package/dist/worker/index.js +18 -2
- package/dist/worker/repeat.js +6 -3
- package/dist/worker/retry.js +4 -1
- package/dist/worker/types.js +2 -1
- package/dist/worker/worker.d.ts +5 -1
- package/dist/worker/worker.js +31 -8
- package/package.json +1 -1
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.**
|
package/dist/core/scheduler.d.ts
CHANGED
|
@@ -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(
|
|
41
|
+
stop(options?: {
|
|
42
|
+
graceful?: boolean;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
}): Promise<void>;
|
|
30
45
|
isRunning(): boolean;
|
|
31
46
|
getId(): string;
|
|
32
47
|
}
|
package/dist/core/scheduler.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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;
|
package/dist/events/emitter.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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;
|
package/dist/events/index.js
CHANGED
|
@@ -1,2 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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;
|
package/dist/store/index.js
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
}
|
package/dist/store/job-store.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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: "
|
|
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;
|
package/dist/store/mutex.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/types/events.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/dist/types/index.js
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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);
|
package/dist/types/job.d.ts
CHANGED
package/dist/types/job.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/dist/types/lifecycle.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/dist/types/repeat.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/dist/types/retry.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/dist/types/schedule.d.ts
CHANGED
package/dist/types/schedule.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/dist/worker/index.js
CHANGED
|
@@ -1,2 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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);
|
package/dist/worker/repeat.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
});
|
package/dist/worker/retry.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
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
|
}
|
package/dist/worker/types.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/dist/worker/worker.d.ts
CHANGED
|
@@ -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(
|
|
16
|
+
stop(options?: {
|
|
17
|
+
graceful?: boolean;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
}): Promise<void>;
|
|
16
20
|
private loop;
|
|
17
21
|
private execute;
|
|
18
22
|
private sleep;
|
package/dist/worker/worker.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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()
|
|
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