@zudojs/scheduler 0.1.0 → 1.0.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.
- package/LICENSE +21 -0
- package/README.md +170 -16
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -3
- package/dist/scheduler/constants/index.d.ts +1 -1
- package/dist/scheduler/constants/index.js +1 -1
- package/dist/scheduler/constants/schedulerConstants.core.d.ts +0 -4
- package/dist/scheduler/constants/schedulerConstants.core.js +0 -4
- package/dist/scheduler/duration/duration.parser.d.ts +13 -5
- package/dist/scheduler/duration/duration.parser.js +54 -19
- package/dist/scheduler/errors/index.d.ts +1 -1
- package/dist/scheduler/errors/index.js +1 -1
- package/dist/scheduler/errors/scheduler.errors.d.ts +7 -1
- package/dist/scheduler/errors/scheduler.errors.js +7 -1
- package/dist/scheduler/executor/index.d.ts +1 -1
- package/dist/scheduler/executor/index.js +1 -1
- package/dist/scheduler/executor/jobExecutor.core.d.ts +24 -4
- package/dist/scheduler/executor/jobExecutor.core.js +151 -28
- package/dist/scheduler/job/jobOptions.type.d.ts +11 -0
- package/dist/scheduler/priorityQueue/schedulerPriorityQueue.core.d.ts +12 -0
- package/dist/scheduler/priorityQueue/schedulerPriorityQueue.core.js +32 -5
- package/dist/scheduler/registry/jobRegistry.core.d.ts +8 -0
- package/dist/scheduler/registry/jobRegistry.core.js +14 -0
- package/dist/scheduler/schedule/schedule.type.d.ts +11 -6
- package/dist/scheduler/schedule/schedule.type.js +10 -1
- package/dist/scheduler/schedule/scheduleOptions.type.d.ts +24 -1
- package/dist/scheduler/scheduleHandle/index.d.ts +1 -0
- package/dist/scheduler/scheduleHandle/scheduleHandle.type.d.ts +26 -3
- package/dist/scheduler/scheduleHandle/scheduleHandle.type.js +23 -7
- package/dist/scheduler/scheduler.core.d.ts +120 -11
- package/dist/scheduler/scheduler.core.js +424 -53
- package/dist/scheduler/trigger/cron.parser.d.ts +44 -0
- package/dist/scheduler/trigger/cron.parser.js +256 -0
- package/dist/scheduler/trigger/index.d.ts +2 -0
- package/dist/scheduler/trigger/index.js +1 -0
- package/dist/scheduler/trigger/schedulerTrigger.core.d.ts +22 -1
- package/dist/scheduler/trigger/schedulerTrigger.core.js +69 -6
- package/dist/scheduler/types/index.d.ts +12 -1
- package/dist/scheduler/types/index.js +7 -0
- package/dist/scheduler/types/schedulerTypes.core.d.ts +7 -87
- package/dist/scheduler/types/schedulerTypes.core.js +7 -1
- package/package.json +22 -15
- package/dist/.tsbuildinfo +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/scheduler/clock/index.d.ts.map +0 -1
- package/dist/scheduler/clock/index.js.map +0 -1
- package/dist/scheduler/clock/schedulerClock.type.d.ts.map +0 -1
- package/dist/scheduler/clock/schedulerClock.type.js.map +0 -1
- package/dist/scheduler/constants/index.d.ts.map +0 -1
- package/dist/scheduler/constants/index.js.map +0 -1
- package/dist/scheduler/constants/schedulerConstants.core.d.ts.map +0 -1
- package/dist/scheduler/constants/schedulerConstants.core.js.map +0 -1
- package/dist/scheduler/duration/duration.parser.d.ts.map +0 -1
- package/dist/scheduler/duration/duration.parser.js.map +0 -1
- package/dist/scheduler/duration/index.d.ts.map +0 -1
- package/dist/scheduler/duration/index.js.map +0 -1
- package/dist/scheduler/errors/index.d.ts.map +0 -1
- package/dist/scheduler/errors/index.js.map +0 -1
- package/dist/scheduler/errors/scheduler.errors.d.ts.map +0 -1
- package/dist/scheduler/errors/scheduler.errors.js.map +0 -1
- package/dist/scheduler/executor/index.d.ts.map +0 -1
- package/dist/scheduler/executor/index.js.map +0 -1
- package/dist/scheduler/executor/jobExecutor.core.d.ts.map +0 -1
- package/dist/scheduler/executor/jobExecutor.core.js.map +0 -1
- package/dist/scheduler/job/index.d.ts.map +0 -1
- package/dist/scheduler/job/index.js.map +0 -1
- package/dist/scheduler/job/jobContext.type.d.ts.map +0 -1
- package/dist/scheduler/job/jobContext.type.js.map +0 -1
- package/dist/scheduler/job/jobDefinition.type.d.ts.map +0 -1
- package/dist/scheduler/job/jobDefinition.type.js.map +0 -1
- package/dist/scheduler/job/jobHandler.type.d.ts.map +0 -1
- package/dist/scheduler/job/jobHandler.type.js.map +0 -1
- package/dist/scheduler/job/jobOptions.type.d.ts.map +0 -1
- package/dist/scheduler/job/jobOptions.type.js.map +0 -1
- package/dist/scheduler/priorityQueue/index.d.ts.map +0 -1
- package/dist/scheduler/priorityQueue/index.js.map +0 -1
- package/dist/scheduler/priorityQueue/schedulerPriorityQueue.core.d.ts.map +0 -1
- package/dist/scheduler/priorityQueue/schedulerPriorityQueue.core.js.map +0 -1
- package/dist/scheduler/registry/index.d.ts.map +0 -1
- package/dist/scheduler/registry/index.js.map +0 -1
- package/dist/scheduler/registry/jobRegistry.core.d.ts.map +0 -1
- package/dist/scheduler/registry/jobRegistry.core.js.map +0 -1
- package/dist/scheduler/schedule/index.d.ts.map +0 -1
- package/dist/scheduler/schedule/index.js.map +0 -1
- package/dist/scheduler/schedule/schedule.type.d.ts.map +0 -1
- package/dist/scheduler/schedule/schedule.type.js.map +0 -1
- package/dist/scheduler/schedule/scheduleOptions.type.d.ts.map +0 -1
- package/dist/scheduler/schedule/scheduleOptions.type.js.map +0 -1
- package/dist/scheduler/scheduleHandle/index.d.ts.map +0 -1
- package/dist/scheduler/scheduleHandle/index.js.map +0 -1
- package/dist/scheduler/scheduleHandle/scheduleHandle.type.d.ts.map +0 -1
- package/dist/scheduler/scheduleHandle/scheduleHandle.type.js.map +0 -1
- package/dist/scheduler/scheduler.core.d.ts.map +0 -1
- package/dist/scheduler/scheduler.core.js.map +0 -1
- package/dist/scheduler/trigger/index.d.ts.map +0 -1
- package/dist/scheduler/trigger/index.js.map +0 -1
- package/dist/scheduler/trigger/schedulerTrigger.core.d.ts.map +0 -1
- package/dist/scheduler/trigger/schedulerTrigger.core.js.map +0 -1
- package/dist/scheduler/trigger/trigger.type.d.ts.map +0 -1
- package/dist/scheduler/trigger/trigger.type.js.map +0 -1
- package/dist/scheduler/types/index.d.ts.map +0 -1
- package/dist/scheduler/types/index.js.map +0 -1
- package/dist/scheduler/types/schedulerTypes.core.d.ts.map +0 -1
- package/dist/scheduler/types/schedulerTypes.core.js.map +0 -1
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
import { ScheduleHandleImpl } from "./scheduleHandle/scheduleHandle.type.js";
|
|
2
|
-
import {
|
|
2
|
+
import { createSchedule } from "./schedule/schedule.type.js";
|
|
3
|
+
import { SchedulerAlreadyStartedError, SchedulerStoppedError, SchedulerJobCancelledError, SchedulerJobTimeoutError, InvalidScheduleError, InvalidJobError, } from "./errors/scheduler.errors.js";
|
|
3
4
|
import { DateTrigger, DelayTrigger, IntervalTrigger, CronTrigger, } from "./trigger/schedulerTrigger.core.js";
|
|
4
5
|
import { SystemClock } from "./clock/schedulerClock.type.js";
|
|
5
6
|
import { JobRegistry } from "./registry/jobRegistry.core.js";
|
|
6
7
|
import { JobExecutor } from "./executor/jobExecutor.core.js";
|
|
7
8
|
import { PriorityQueue } from "./priorityQueue/schedulerPriorityQueue.core.js";
|
|
8
9
|
import { parseDuration } from "./duration/duration.parser.js";
|
|
9
|
-
import { MAX_SCHEDULES, MAX_TIMER_DELAY, } from "./constants/schedulerConstants.core.js";
|
|
10
|
+
import { MAX_SCHEDULES, MAX_TIMER_DELAY, MAX_EXECUTION_HISTORY, DEFAULT_MAX_CONCURRENCY, DEFAULT_MISFIRE_POLICY, DEFAULT_OVERLAP_POLICY, } from "./constants/schedulerConstants.core.js";
|
|
11
|
+
/**
|
|
12
|
+
* Ceiling on fire times held back by `overlap: "queue"` or a job concurrency
|
|
13
|
+
* limit. Without one, a schedule whose job never keeps up would grow an
|
|
14
|
+
* unbounded backlog of runs nobody wants any more.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_PENDING_RUNS = 1024;
|
|
10
17
|
/**
|
|
11
18
|
* Scheduler for time-based job execution.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* const scheduler = new Scheduler({ onError: (e) => log.error(e) });
|
|
23
|
+
* scheduler.define({ id: "cleanup", name: "Cleanup", handler: run });
|
|
24
|
+
* scheduler.every("5m", "cleanup");
|
|
25
|
+
* scheduler.start();
|
|
26
|
+
* // …later
|
|
27
|
+
* await scheduler.stop();
|
|
28
|
+
* ```
|
|
12
29
|
*/
|
|
13
30
|
export class Scheduler {
|
|
14
31
|
jobs;
|
|
@@ -16,13 +33,51 @@ export class Scheduler {
|
|
|
16
33
|
queue;
|
|
17
34
|
clock;
|
|
18
35
|
schedules = new Map();
|
|
36
|
+
maxConcurrency;
|
|
37
|
+
onError;
|
|
38
|
+
/** Executions currently in flight, across all schedules. */
|
|
39
|
+
inFlight = new Set();
|
|
40
|
+
/**
|
|
41
|
+
* Abort controllers for every in-flight execution.
|
|
42
|
+
*
|
|
43
|
+
* Tracked here as well as on the schedule record: a one-shot schedule is
|
|
44
|
+
* retired as soon as it is dispatched, so by the time `stop()` runs its
|
|
45
|
+
* record is already gone and a per-record set alone would have nothing left
|
|
46
|
+
* to abort.
|
|
47
|
+
*/
|
|
48
|
+
runningControllers = new Set();
|
|
49
|
+
/** Executions in flight per job id, for the per-job concurrency ceiling. */
|
|
50
|
+
runningByJob = new Map();
|
|
51
|
+
/** Records with `pendingRuns > 0`, drained as executions finish. */
|
|
52
|
+
pending = new Set();
|
|
53
|
+
/** Bounded ring of execution records, newest last. */
|
|
54
|
+
executionHistory = [];
|
|
19
55
|
running = false;
|
|
20
56
|
timer;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
57
|
+
/**
|
|
58
|
+
* @param options - Scheduler options, or a {@link JobRegistry} for the
|
|
59
|
+
* positional form kept for backwards compatibility.
|
|
60
|
+
*/
|
|
61
|
+
constructor(options, executor, queue, clock) {
|
|
62
|
+
const opts = options instanceof JobRegistry
|
|
63
|
+
? { jobs: options, executor, queue, clock }
|
|
64
|
+
: (options ?? {});
|
|
65
|
+
// Resolve the clock once and share it. Building a second SystemClock for
|
|
66
|
+
// the executor meant a custom clock reached only one of the two.
|
|
67
|
+
this.clock = opts.clock ?? new SystemClock();
|
|
68
|
+
this.jobs = opts.jobs ?? new JobRegistry();
|
|
69
|
+
this.executor = opts.executor ?? new JobExecutor(this.clock);
|
|
70
|
+
this.queue = opts.queue ?? new PriorityQueue();
|
|
71
|
+
this.maxConcurrency = opts.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
|
72
|
+
this.onError = opts.onError;
|
|
73
|
+
}
|
|
74
|
+
/** Whether the scheduler is currently running. */
|
|
75
|
+
get isRunning() {
|
|
76
|
+
return this.running;
|
|
77
|
+
}
|
|
78
|
+
/** Number of live schedules. */
|
|
79
|
+
get scheduleCount() {
|
|
80
|
+
return this.schedules.size;
|
|
26
81
|
}
|
|
27
82
|
/**
|
|
28
83
|
* Starts the scheduler.
|
|
@@ -35,9 +90,12 @@ export class Scheduler {
|
|
|
35
90
|
this.tick();
|
|
36
91
|
}
|
|
37
92
|
/**
|
|
38
|
-
* Stops the scheduler.
|
|
93
|
+
* Stops the scheduler, aborting in-flight jobs and waiting for them to settle.
|
|
94
|
+
*
|
|
95
|
+
* @param options - `drain` waits for running jobs to finish instead of
|
|
96
|
+
* aborting them; `timeoutMs` bounds the wait either way.
|
|
39
97
|
*/
|
|
40
|
-
stop() {
|
|
98
|
+
async stop(options) {
|
|
41
99
|
if (!this.running) {
|
|
42
100
|
throw new SchedulerStoppedError();
|
|
43
101
|
}
|
|
@@ -46,66 +104,170 @@ export class Scheduler {
|
|
|
46
104
|
clearTimeout(this.timer);
|
|
47
105
|
this.timer = undefined;
|
|
48
106
|
}
|
|
107
|
+
if (options?.drain !== true) {
|
|
108
|
+
// Abort every in-flight execution. Previously the AbortController was
|
|
109
|
+
// created and immediately discarded, so nothing could ever be cancelled.
|
|
110
|
+
for (const controller of this.runningControllers) {
|
|
111
|
+
controller.abort(new Error("Scheduler stopped"));
|
|
112
|
+
}
|
|
113
|
+
// Runs held back by overlap or a concurrency ceiling are discarded with
|
|
114
|
+
// them; keeping them would fire on a later start for a time long past.
|
|
115
|
+
for (const record of this.pending)
|
|
116
|
+
record.pendingRuns = 0;
|
|
117
|
+
this.pending.clear();
|
|
118
|
+
}
|
|
119
|
+
await this.settle(options?.timeoutMs);
|
|
120
|
+
}
|
|
121
|
+
/** Waits for in-flight executions to finish, up to an optional timeout. */
|
|
122
|
+
async settle(timeoutMs) {
|
|
123
|
+
if (this.inFlight.size === 0)
|
|
124
|
+
return;
|
|
125
|
+
const all = Promise.allSettled([...this.inFlight]).then(() => undefined);
|
|
126
|
+
if (timeoutMs === undefined) {
|
|
127
|
+
await all;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
await Promise.race([
|
|
131
|
+
all,
|
|
132
|
+
new Promise((resolve) => {
|
|
133
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
134
|
+
if (timer.unref)
|
|
135
|
+
timer.unref();
|
|
136
|
+
}),
|
|
137
|
+
]);
|
|
49
138
|
}
|
|
50
139
|
/**
|
|
51
140
|
* Defines a new job.
|
|
52
141
|
*/
|
|
53
142
|
define(job) {
|
|
143
|
+
if (typeof job?.id !== "string" || job.id.trim().length === 0) {
|
|
144
|
+
throw new InvalidJobError("Job definition requires a non-empty string id.", String(job?.id));
|
|
145
|
+
}
|
|
146
|
+
if (typeof job.handler !== "function") {
|
|
147
|
+
throw new InvalidJobError(`Job "${job.id}" requires a handler function.`, job.id);
|
|
148
|
+
}
|
|
149
|
+
if (job.options?.timeout !== undefined && job.options.timeout <= 0) {
|
|
150
|
+
throw new InvalidJobError(`Job "${job.id}" timeout must be positive, got ${job.options.timeout}.`, job.id);
|
|
151
|
+
}
|
|
54
152
|
this.jobs.register(job);
|
|
55
153
|
}
|
|
56
154
|
/**
|
|
57
155
|
* Schedules a job to run once after a delay.
|
|
58
156
|
*/
|
|
59
|
-
after(delay, jobId) {
|
|
157
|
+
after(delay, jobId, options) {
|
|
60
158
|
const delayMs = parseDuration(delay);
|
|
61
|
-
|
|
62
|
-
return this.scheduleJob(jobId, trigger, "delay");
|
|
159
|
+
return this.scheduleJob(jobId, new DelayTrigger(delayMs), "delay", options);
|
|
63
160
|
}
|
|
64
161
|
/**
|
|
65
162
|
* Schedules a job to run at a specific date.
|
|
66
163
|
*/
|
|
67
|
-
at(date, jobId) {
|
|
68
|
-
|
|
69
|
-
return this.scheduleJob(jobId, trigger, "once");
|
|
164
|
+
at(date, jobId, options) {
|
|
165
|
+
return this.scheduleJob(jobId, new DateTrigger(date), "once", options);
|
|
70
166
|
}
|
|
71
167
|
/**
|
|
72
168
|
* Schedules a job to run at a fixed interval.
|
|
73
169
|
*/
|
|
74
|
-
every(interval, jobId) {
|
|
170
|
+
every(interval, jobId, options) {
|
|
75
171
|
const intervalMs = parseDuration(interval);
|
|
76
|
-
|
|
77
|
-
return this.scheduleJob(jobId, trigger, "interval");
|
|
172
|
+
return this.scheduleJob(jobId, new IntervalTrigger(intervalMs), "interval", options);
|
|
78
173
|
}
|
|
79
174
|
/**
|
|
80
175
|
* Schedules a job using a cron expression.
|
|
81
176
|
*/
|
|
82
|
-
cron(expression, jobId) {
|
|
83
|
-
const trigger = new CronTrigger(expression);
|
|
84
|
-
return this.scheduleJob(jobId, trigger, "cron",
|
|
177
|
+
cron(expression, jobId, options) {
|
|
178
|
+
const trigger = new CronTrigger(expression, options?.timezone);
|
|
179
|
+
return this.scheduleJob(jobId, trigger, "cron", options, expression);
|
|
180
|
+
}
|
|
181
|
+
/** Returns a snapshot of a schedule, or undefined. */
|
|
182
|
+
getSchedule(scheduleId) {
|
|
183
|
+
return this.schedules.get(scheduleId)?.schedule;
|
|
184
|
+
}
|
|
185
|
+
/** Returns snapshots of every live schedule. */
|
|
186
|
+
listSchedules() {
|
|
187
|
+
return [...this.schedules.values()].map((record) => record.schedule);
|
|
85
188
|
}
|
|
86
189
|
/**
|
|
87
190
|
* Schedules a job with a trigger.
|
|
88
191
|
*/
|
|
89
|
-
scheduleJob(jobId, trigger, type, options) {
|
|
192
|
+
scheduleJob(jobId, trigger, type, options, expression) {
|
|
90
193
|
if (!this.jobs.has(jobId)) {
|
|
91
194
|
throw new InvalidJobError(`Job "${jobId}" is not registered.`, jobId);
|
|
92
195
|
}
|
|
196
|
+
if (this.schedules.size >= MAX_SCHEDULES) {
|
|
197
|
+
throw new InvalidScheduleError(`Maximum number of schedules (${MAX_SCHEDULES}) exceeded.`, jobId);
|
|
198
|
+
}
|
|
93
199
|
const scheduleId = crypto.randomUUID();
|
|
94
|
-
const
|
|
200
|
+
const now = this.clock.now();
|
|
201
|
+
let nextRunAt = trigger.next(now);
|
|
95
202
|
if (nextRunAt === null) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
203
|
+
// The fire time has already passed. That is the misfire case, not an
|
|
204
|
+
// error — `at(pastDate)` and a schedule restored after a restart both
|
|
205
|
+
// land here.
|
|
206
|
+
const misfire = options?.misfire ?? DEFAULT_MISFIRE_POLICY;
|
|
207
|
+
if (misfire === "skip") {
|
|
208
|
+
throw new InvalidScheduleError("Trigger has no future fire time and the misfire policy is 'skip'.", scheduleId);
|
|
209
|
+
}
|
|
210
|
+
// "run-once" and "catch-up" both start by running immediately.
|
|
211
|
+
nextRunAt = now;
|
|
212
|
+
}
|
|
213
|
+
if (Number.isNaN(nextRunAt.getTime())) {
|
|
214
|
+
throw new InvalidScheduleError("Trigger produced an invalid date.", scheduleId);
|
|
215
|
+
}
|
|
216
|
+
const schedule = createSchedule(scheduleId, jobId, type, nextRunAt, options ?? {}, expression);
|
|
217
|
+
const record = {
|
|
218
|
+
schedule,
|
|
219
|
+
trigger,
|
|
220
|
+
options: options ?? {},
|
|
221
|
+
running: new Set(),
|
|
222
|
+
pendingRuns: 0,
|
|
105
223
|
};
|
|
106
|
-
this.schedules.set(scheduleId,
|
|
224
|
+
this.schedules.set(scheduleId, record);
|
|
107
225
|
this.queue.enqueue(schedule);
|
|
108
|
-
|
|
226
|
+
// The handle holds a reference to this scheduler, so pause, resume and
|
|
227
|
+
// cancel actually reach the queue instead of mutating a detached copy.
|
|
228
|
+
return new ScheduleHandleImpl(scheduleId, "active", {
|
|
229
|
+
setState: (state) => this.setScheduleState(scheduleId, state),
|
|
230
|
+
getState: () => this.schedules.get(scheduleId)?.schedule.state,
|
|
231
|
+
getNextRun: () => this.schedules.get(scheduleId)?.schedule.nextRunAt,
|
|
232
|
+
abortRunning: () => this.abortSchedule(scheduleId),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/** Applies a state change to a live schedule. */
|
|
236
|
+
setScheduleState(scheduleId, state) {
|
|
237
|
+
const record = this.schedules.get(scheduleId);
|
|
238
|
+
if (!record)
|
|
239
|
+
return;
|
|
240
|
+
record.schedule = { ...record.schedule, state };
|
|
241
|
+
if (state === "cancelled" || state === "completed") {
|
|
242
|
+
// Held-back runs belong to a schedule that no longer exists.
|
|
243
|
+
record.pendingRuns = 0;
|
|
244
|
+
this.pending.delete(record);
|
|
245
|
+
this.queue.remove(scheduleId);
|
|
246
|
+
this.schedules.delete(scheduleId);
|
|
247
|
+
}
|
|
248
|
+
else if (state === "paused") {
|
|
249
|
+
this.queue.remove(scheduleId);
|
|
250
|
+
}
|
|
251
|
+
else if (state === "active") {
|
|
252
|
+
// Resuming: recompute from now so a schedule paused across its fire time
|
|
253
|
+
// does not immediately fire for every occurrence it missed.
|
|
254
|
+
const next = record.trigger.next(this.clock.now());
|
|
255
|
+
if (next && !Number.isNaN(next.getTime())) {
|
|
256
|
+
record.schedule = { ...record.schedule, nextRunAt: next };
|
|
257
|
+
this.queue.remove(scheduleId);
|
|
258
|
+
this.queue.enqueue(record.schedule);
|
|
259
|
+
this.rearm();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/** Aborts every in-flight execution of one schedule. */
|
|
264
|
+
abortSchedule(scheduleId) {
|
|
265
|
+
const record = this.schedules.get(scheduleId);
|
|
266
|
+
if (!record)
|
|
267
|
+
return;
|
|
268
|
+
for (const controller of record.running) {
|
|
269
|
+
controller.abort(new Error("Schedule cancelled"));
|
|
270
|
+
}
|
|
109
271
|
}
|
|
110
272
|
/**
|
|
111
273
|
* Internal tick method for processing due jobs.
|
|
@@ -121,13 +283,231 @@ export class Scheduler {
|
|
|
121
283
|
break;
|
|
122
284
|
}
|
|
123
285
|
this.queue.dequeue();
|
|
124
|
-
|
|
286
|
+
const record = this.schedules.get(schedule.id);
|
|
287
|
+
if (!record || record.schedule.state !== "active") {
|
|
125
288
|
continue;
|
|
126
289
|
}
|
|
127
|
-
|
|
290
|
+
// Concurrency ceiling: without one, N due schedules start N executions
|
|
291
|
+
// at once. Anything over the ceiling is left for the next tick.
|
|
292
|
+
if (this.inFlight.size >= this.maxConcurrency) {
|
|
293
|
+
this.queue.enqueue(record.schedule);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
this.dispatch(record);
|
|
297
|
+
this.reschedule(record);
|
|
128
298
|
}
|
|
299
|
+
this.rearm();
|
|
300
|
+
}
|
|
301
|
+
/** (Re)arms the tick timer for the next due schedule. */
|
|
302
|
+
rearm() {
|
|
303
|
+
if (!this.running)
|
|
304
|
+
return;
|
|
305
|
+
if (this.timer)
|
|
306
|
+
clearTimeout(this.timer);
|
|
129
307
|
const delay = this.calculateDelay();
|
|
130
308
|
this.timer = setTimeout(() => this.tick(), delay);
|
|
309
|
+
if (this.timer.unref)
|
|
310
|
+
this.timer.unref();
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Computes the next fire time and puts the schedule back on the queue.
|
|
314
|
+
*
|
|
315
|
+
* Nothing did this before, so `every()` and `cron()` fired exactly once and
|
|
316
|
+
* then sat in the map forever.
|
|
317
|
+
*/
|
|
318
|
+
reschedule(record) {
|
|
319
|
+
const isRecurring = record.schedule.type === "interval" || record.schedule.type === "cron";
|
|
320
|
+
if (!isRecurring) {
|
|
321
|
+
// One-shot: retire it rather than leaking the entry.
|
|
322
|
+
record.schedule = {
|
|
323
|
+
...record.schedule,
|
|
324
|
+
state: "completed",
|
|
325
|
+
lastRunAt: this.clock.now(),
|
|
326
|
+
};
|
|
327
|
+
this.schedules.delete(record.schedule.id);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// "catch-up" advances from the fire time that just ran, so a schedule
|
|
331
|
+
// that fell behind replays each missed occurrence instead of silently
|
|
332
|
+
// skipping to the next future one. Every other policy resumes from now.
|
|
333
|
+
const misfire = record.options.misfire ?? DEFAULT_MISFIRE_POLICY;
|
|
334
|
+
const now = this.clock.now();
|
|
335
|
+
const from = misfire === "catch-up" ? record.schedule.nextRunAt : now;
|
|
336
|
+
const next = record.trigger.next(from);
|
|
337
|
+
if (!next || Number.isNaN(next.getTime())) {
|
|
338
|
+
// A trigger with no further fire time is finished.
|
|
339
|
+
record.schedule = { ...record.schedule, state: "completed" };
|
|
340
|
+
this.schedules.delete(record.schedule.id);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
record.schedule = {
|
|
344
|
+
...record.schedule,
|
|
345
|
+
nextRunAt: next,
|
|
346
|
+
lastRunAt: now,
|
|
347
|
+
};
|
|
348
|
+
this.queue.enqueue(record.schedule);
|
|
349
|
+
}
|
|
350
|
+
/** Starts one execution of a schedule and tracks it. */
|
|
351
|
+
dispatch(record) {
|
|
352
|
+
const job = this.jobs.get(record.schedule.jobId);
|
|
353
|
+
if (!job)
|
|
354
|
+
return;
|
|
355
|
+
// A schedule cancelled or paused while a run was held back must not fire.
|
|
356
|
+
if (record.schedule.state === "cancelled" ||
|
|
357
|
+
record.schedule.state === "paused") {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// A schedule's own policy wins over the job's default.
|
|
361
|
+
const overlap = record.options.overlap ?? job.options?.overlap ?? DEFAULT_OVERLAP_POLICY;
|
|
362
|
+
if (record.running.size > 0) {
|
|
363
|
+
if (overlap === "skip")
|
|
364
|
+
return;
|
|
365
|
+
if (overlap === "queue") {
|
|
366
|
+
// Hold the fire time rather than dropping it; it is dispatched when
|
|
367
|
+
// the running execution finishes. "queue" used to fall through to
|
|
368
|
+
// "allow" and start a concurrent run.
|
|
369
|
+
this.defer(record);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (overlap === "replace") {
|
|
373
|
+
for (const controller of record.running) {
|
|
374
|
+
controller.abort(new Error("Superseded by a newer execution"));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Per-job concurrency ceiling. `JobOptions.concurrency` was accepted and
|
|
379
|
+
// read by nothing, so a job declaring `concurrency: 1` still ran as many
|
|
380
|
+
// executions at once as it had due schedules.
|
|
381
|
+
const concurrency = job.options?.concurrency;
|
|
382
|
+
if (concurrency !== undefined &&
|
|
383
|
+
(this.runningByJob.get(job.id) ?? 0) >= Math.max(1, concurrency)) {
|
|
384
|
+
this.defer(record);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const executionId = crypto.randomUUID();
|
|
388
|
+
const controller = new AbortController();
|
|
389
|
+
record.running.add(controller);
|
|
390
|
+
this.runningControllers.add(controller);
|
|
391
|
+
this.runningByJob.set(job.id, (this.runningByJob.get(job.id) ?? 0) + 1);
|
|
392
|
+
const scheduleId = record.schedule.id;
|
|
393
|
+
const scheduledAt = record.schedule.nextRunAt;
|
|
394
|
+
const startedAt = this.clock.now();
|
|
395
|
+
this.beginExecution({
|
|
396
|
+
id: executionId,
|
|
397
|
+
jobId: job.id,
|
|
398
|
+
scheduleId,
|
|
399
|
+
status: "running",
|
|
400
|
+
scheduledAt,
|
|
401
|
+
startedAt,
|
|
402
|
+
attempt: 1,
|
|
403
|
+
});
|
|
404
|
+
const execution = this.executor
|
|
405
|
+
.execute(job, executionId, scheduledAt, 1, controller.signal, record.options.data)
|
|
406
|
+
.then(() => {
|
|
407
|
+
this.finishExecution(executionId, "completed", startedAt);
|
|
408
|
+
})
|
|
409
|
+
.catch((error) => {
|
|
410
|
+
this.finishExecution(executionId, statusFor(error), startedAt, error);
|
|
411
|
+
// A failure used to vanish into an empty catch block with a "retry
|
|
412
|
+
// logic would go here" comment. Retries now live in the executor, and
|
|
413
|
+
// whatever survives them is reported.
|
|
414
|
+
this.reportError({
|
|
415
|
+
scheduleId,
|
|
416
|
+
jobId: job.id,
|
|
417
|
+
executionId,
|
|
418
|
+
error,
|
|
419
|
+
});
|
|
420
|
+
})
|
|
421
|
+
.finally(() => {
|
|
422
|
+
record.running.delete(controller);
|
|
423
|
+
this.runningControllers.delete(controller);
|
|
424
|
+
this.inFlight.delete(execution);
|
|
425
|
+
const remaining = (this.runningByJob.get(job.id) ?? 1) - 1;
|
|
426
|
+
if (remaining <= 0)
|
|
427
|
+
this.runningByJob.delete(job.id);
|
|
428
|
+
else
|
|
429
|
+
this.runningByJob.set(job.id, remaining);
|
|
430
|
+
this.drainPending();
|
|
431
|
+
});
|
|
432
|
+
this.inFlight.add(execution);
|
|
433
|
+
}
|
|
434
|
+
/** Holds a fire time back until capacity frees up. */
|
|
435
|
+
defer(record) {
|
|
436
|
+
if (record.pendingRuns >= MAX_PENDING_RUNS)
|
|
437
|
+
return;
|
|
438
|
+
record.pendingRuns += 1;
|
|
439
|
+
this.pending.add(record);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Dispatches held-back runs that can now proceed.
|
|
443
|
+
*
|
|
444
|
+
* A record that is still blocked re-defers itself, which shows up as its
|
|
445
|
+
* counter returning to where it started; that is the loop's exit condition,
|
|
446
|
+
* so a blocked record cannot spin.
|
|
447
|
+
*/
|
|
448
|
+
drainPending() {
|
|
449
|
+
if (!this.running || this.pending.size === 0)
|
|
450
|
+
return;
|
|
451
|
+
for (const record of [...this.pending]) {
|
|
452
|
+
while (record.pendingRuns > 0) {
|
|
453
|
+
if (this.inFlight.size >= this.maxConcurrency)
|
|
454
|
+
return;
|
|
455
|
+
const before = record.pendingRuns;
|
|
456
|
+
record.pendingRuns -= 1;
|
|
457
|
+
this.dispatch(record);
|
|
458
|
+
if (record.pendingRuns >= before)
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
if (record.pendingRuns === 0)
|
|
462
|
+
this.pending.delete(record);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
/** Records a started execution, evicting the oldest beyond the cap. */
|
|
466
|
+
beginExecution(execution) {
|
|
467
|
+
this.executionHistory.push(execution);
|
|
468
|
+
while (this.executionHistory.length > MAX_EXECUTION_HISTORY) {
|
|
469
|
+
this.executionHistory.shift();
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/** Completes the record for an execution, if it is still in the history. */
|
|
473
|
+
finishExecution(executionId, status, startedAt, error) {
|
|
474
|
+
const index = this.executionHistory.findIndex((entry) => entry.id === executionId);
|
|
475
|
+
if (index === -1)
|
|
476
|
+
return;
|
|
477
|
+
const completedAt = this.clock.now();
|
|
478
|
+
this.executionHistory[index] = {
|
|
479
|
+
...this.executionHistory[index],
|
|
480
|
+
status,
|
|
481
|
+
completedAt,
|
|
482
|
+
duration: completedAt.getTime() - startedAt.getTime(),
|
|
483
|
+
...(error === undefined ? {} : { error }),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Returns the recorded executions, oldest first.
|
|
488
|
+
*
|
|
489
|
+
* At most {@link MAX_EXECUTION_HISTORY} are kept. `JobExecution` and that
|
|
490
|
+
* constant were both exported from the beginning and nothing produced or
|
|
491
|
+
* read either.
|
|
492
|
+
*
|
|
493
|
+
* @param jobId - Restrict to one job's executions.
|
|
494
|
+
*/
|
|
495
|
+
getExecutions(jobId) {
|
|
496
|
+
const all = [...this.executionHistory];
|
|
497
|
+
return jobId === undefined
|
|
498
|
+
? all
|
|
499
|
+
: all.filter((execution) => execution.jobId === jobId);
|
|
500
|
+
}
|
|
501
|
+
/** Hands an execution failure to the error listener. */
|
|
502
|
+
reportError(event) {
|
|
503
|
+
if (!this.onError)
|
|
504
|
+
return;
|
|
505
|
+
try {
|
|
506
|
+
this.onError(event);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// A throwing error listener must not take the scheduler down with it.
|
|
510
|
+
}
|
|
131
511
|
}
|
|
132
512
|
/**
|
|
133
513
|
* Calculates the delay until the next tick.
|
|
@@ -143,22 +523,13 @@ export class Scheduler {
|
|
|
143
523
|
const delay = next.nextRunAt.getTime() - this.clock.nowMs();
|
|
144
524
|
return Math.max(0, Math.min(delay, MAX_TIMER_DELAY));
|
|
145
525
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const executionId = crypto.randomUUID();
|
|
155
|
-
const signal = new AbortController().signal;
|
|
156
|
-
try {
|
|
157
|
-
await this.executor.execute(job, executionId, schedule.nextRunAt, 1, signal);
|
|
158
|
-
}
|
|
159
|
-
catch (error) {
|
|
160
|
-
// Job execution failed - retry logic would go here
|
|
161
|
-
}
|
|
162
|
-
}
|
|
526
|
+
}
|
|
527
|
+
/** Maps a failed execution onto the job state that describes it. */
|
|
528
|
+
function statusFor(error) {
|
|
529
|
+
if (error instanceof SchedulerJobTimeoutError)
|
|
530
|
+
return "timed_out";
|
|
531
|
+
if (error instanceof SchedulerJobCancelledError)
|
|
532
|
+
return "cancelled";
|
|
533
|
+
return "failed";
|
|
163
534
|
}
|
|
164
535
|
//# sourceMappingURL=scheduler.core.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/scheduler/trigger/cron
|
|
3
|
+
*
|
|
4
|
+
* Standard five-field cron expression parsing and next-fire computation.
|
|
5
|
+
*
|
|
6
|
+
* Fields, in order: minute, hour, day-of-month, month, day-of-week.
|
|
7
|
+
* Each supports `*`, a value, a `a-b` range, a `a-b/n` or `*\/n` step, and a
|
|
8
|
+
* comma-separated list of any of those. Month and day-of-week also accept the
|
|
9
|
+
* usual three-letter names. A leading `@yearly`-style macro is expanded first.
|
|
10
|
+
*/
|
|
11
|
+
/** A parsed cron expression: the permitted values for each field. */
|
|
12
|
+
export interface ParsedCron {
|
|
13
|
+
readonly minute: ReadonlySet<number>;
|
|
14
|
+
readonly hour: ReadonlySet<number>;
|
|
15
|
+
readonly dayOfMonth: ReadonlySet<number>;
|
|
16
|
+
readonly month: ReadonlySet<number>;
|
|
17
|
+
readonly dayOfWeek: ReadonlySet<number>;
|
|
18
|
+
/** True when day-of-month was `*`, which changes how the two day fields combine. */
|
|
19
|
+
readonly dayOfMonthUnrestricted: boolean;
|
|
20
|
+
/** True when day-of-week was `*`. */
|
|
21
|
+
readonly dayOfWeekUnrestricted: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parses a cron expression into the set of values each field permits.
|
|
25
|
+
*
|
|
26
|
+
* @param expression - A five-field cron expression or a supported macro.
|
|
27
|
+
* @returns The parsed expression.
|
|
28
|
+
* @throws {CronParseError} when the expression is not valid.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseCron(expression: string): ParsedCron;
|
|
31
|
+
/**
|
|
32
|
+
* Computes the next time a parsed cron expression fires, strictly after `after`.
|
|
33
|
+
*
|
|
34
|
+
* Search is minute-by-minute with whole-field skips, bounded by
|
|
35
|
+
* {@link MAX_SEARCH_YEARS} so an unsatisfiable expression (30 February) fails
|
|
36
|
+
* rather than looping.
|
|
37
|
+
*
|
|
38
|
+
* @param parsed - The parsed expression.
|
|
39
|
+
* @param after - The instant to search forward from (exclusive).
|
|
40
|
+
* @param utc - Interpret the fields in UTC rather than local time.
|
|
41
|
+
* @returns The next fire time, or null when none exists within the horizon.
|
|
42
|
+
*/
|
|
43
|
+
export declare function nextCronDate(parsed: ParsedCron, after: Date, utc?: boolean): Date | null;
|
|
44
|
+
//# sourceMappingURL=cron.parser.d.ts.map
|