@zudojs/scheduler 0.1.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +57 -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 +154 -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 +135 -11
- package/dist/scheduler/scheduler.core.js +478 -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 +26 -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,57 @@ 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 = [];
|
|
55
|
+
/**
|
|
56
|
+
* Final state of schedules that have been retired, so a handle to a
|
|
57
|
+
* one-shot that already fired reports "completed" instead of the "active"
|
|
58
|
+
* it was created with. Bounded to {@link MAX_SCHEDULES} entries.
|
|
59
|
+
*/
|
|
60
|
+
retiredStates = new Map();
|
|
19
61
|
running = false;
|
|
20
62
|
timer;
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
63
|
+
/**
|
|
64
|
+
* @param options - Scheduler options, or a {@link JobRegistry} for the
|
|
65
|
+
* positional form kept for backwards compatibility.
|
|
66
|
+
*/
|
|
67
|
+
constructor(options, executor, queue, clock) {
|
|
68
|
+
const opts = options instanceof JobRegistry
|
|
69
|
+
? { jobs: options, executor, queue, clock }
|
|
70
|
+
: (options ?? {});
|
|
71
|
+
// Resolve the clock once and share it. Building a second SystemClock for
|
|
72
|
+
// the executor meant a custom clock reached only one of the two.
|
|
73
|
+
this.clock = opts.clock ?? new SystemClock();
|
|
74
|
+
this.jobs = opts.jobs ?? new JobRegistry();
|
|
75
|
+
this.executor = opts.executor ?? new JobExecutor(this.clock);
|
|
76
|
+
this.queue = opts.queue ?? new PriorityQueue();
|
|
77
|
+
this.maxConcurrency = opts.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
|
78
|
+
this.onError = opts.onError;
|
|
79
|
+
}
|
|
80
|
+
/** Whether the scheduler is currently running. */
|
|
81
|
+
get isRunning() {
|
|
82
|
+
return this.running;
|
|
83
|
+
}
|
|
84
|
+
/** Number of live schedules. */
|
|
85
|
+
get scheduleCount() {
|
|
86
|
+
return this.schedules.size;
|
|
26
87
|
}
|
|
27
88
|
/**
|
|
28
89
|
* Starts the scheduler.
|
|
@@ -35,9 +96,12 @@ export class Scheduler {
|
|
|
35
96
|
this.tick();
|
|
36
97
|
}
|
|
37
98
|
/**
|
|
38
|
-
* Stops the scheduler.
|
|
99
|
+
* Stops the scheduler, aborting in-flight jobs and waiting for them to settle.
|
|
100
|
+
*
|
|
101
|
+
* @param options - `drain` waits for running jobs to finish instead of
|
|
102
|
+
* aborting them; `timeoutMs` bounds the wait either way.
|
|
39
103
|
*/
|
|
40
|
-
stop() {
|
|
104
|
+
async stop(options) {
|
|
41
105
|
if (!this.running) {
|
|
42
106
|
throw new SchedulerStoppedError();
|
|
43
107
|
}
|
|
@@ -46,66 +110,213 @@ export class Scheduler {
|
|
|
46
110
|
clearTimeout(this.timer);
|
|
47
111
|
this.timer = undefined;
|
|
48
112
|
}
|
|
113
|
+
if (options?.drain !== true) {
|
|
114
|
+
// Abort every in-flight execution. Previously the AbortController was
|
|
115
|
+
// created and immediately discarded, so nothing could ever be cancelled.
|
|
116
|
+
for (const controller of this.runningControllers) {
|
|
117
|
+
controller.abort(new Error("Scheduler stopped"));
|
|
118
|
+
}
|
|
119
|
+
// Runs held back by overlap or a concurrency ceiling are discarded with
|
|
120
|
+
// them; keeping them would fire on a later start for a time long past.
|
|
121
|
+
for (const record of this.pending)
|
|
122
|
+
record.pendingRuns = 0;
|
|
123
|
+
this.pending.clear();
|
|
124
|
+
}
|
|
125
|
+
await this.settle(options?.timeoutMs);
|
|
126
|
+
}
|
|
127
|
+
/** Waits for in-flight executions to finish, up to an optional timeout. */
|
|
128
|
+
async settle(timeoutMs) {
|
|
129
|
+
if (this.inFlight.size === 0)
|
|
130
|
+
return;
|
|
131
|
+
const all = Promise.allSettled([...this.inFlight]).then(() => undefined);
|
|
132
|
+
if (timeoutMs === undefined) {
|
|
133
|
+
await all;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
await Promise.race([
|
|
137
|
+
all,
|
|
138
|
+
new Promise((resolve) => {
|
|
139
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
140
|
+
if (timer.unref)
|
|
141
|
+
timer.unref();
|
|
142
|
+
}),
|
|
143
|
+
]);
|
|
49
144
|
}
|
|
50
145
|
/**
|
|
51
146
|
* Defines a new job.
|
|
52
147
|
*/
|
|
53
148
|
define(job) {
|
|
149
|
+
if (typeof job?.id !== "string" || job.id.trim().length === 0) {
|
|
150
|
+
throw new InvalidJobError("Job definition requires a non-empty string id.", String(job?.id));
|
|
151
|
+
}
|
|
152
|
+
if (typeof job.handler !== "function") {
|
|
153
|
+
throw new InvalidJobError(`Job "${job.id}" requires a handler function.`, job.id);
|
|
154
|
+
}
|
|
155
|
+
if (job.options?.timeout !== undefined &&
|
|
156
|
+
(!Number.isFinite(job.options.timeout) ||
|
|
157
|
+
job.options.timeout <= 0 ||
|
|
158
|
+
job.options.timeout > MAX_TIMER_DELAY)) {
|
|
159
|
+
// `Infinity` and anything past the 32-bit timer ceiling are clamped
|
|
160
|
+
// by Node to 1ms, so a job declared with "no timeout" was failing on
|
|
161
|
+
// its first millisecond.
|
|
162
|
+
throw new InvalidJobError(`Job "${job.id}" timeout must be a positive number no greater than ${MAX_TIMER_DELAY}ms, got ${job.options.timeout}.`, job.id);
|
|
163
|
+
}
|
|
54
164
|
this.jobs.register(job);
|
|
55
165
|
}
|
|
56
166
|
/**
|
|
57
167
|
* Schedules a job to run once after a delay.
|
|
58
168
|
*/
|
|
59
|
-
after(delay, jobId) {
|
|
169
|
+
after(delay, jobId, options) {
|
|
60
170
|
const delayMs = parseDuration(delay);
|
|
61
|
-
|
|
62
|
-
return this.scheduleJob(jobId, trigger, "delay");
|
|
171
|
+
return this.scheduleJob(jobId, new DelayTrigger(delayMs), "delay", options);
|
|
63
172
|
}
|
|
64
173
|
/**
|
|
65
174
|
* Schedules a job to run at a specific date.
|
|
66
175
|
*/
|
|
67
|
-
at(date, jobId) {
|
|
68
|
-
|
|
69
|
-
return this.scheduleJob(jobId, trigger, "once");
|
|
176
|
+
at(date, jobId, options) {
|
|
177
|
+
return this.scheduleJob(jobId, new DateTrigger(date), "once", options);
|
|
70
178
|
}
|
|
71
179
|
/**
|
|
72
180
|
* Schedules a job to run at a fixed interval.
|
|
73
181
|
*/
|
|
74
|
-
every(interval, jobId) {
|
|
182
|
+
every(interval, jobId, options) {
|
|
75
183
|
const intervalMs = parseDuration(interval);
|
|
76
|
-
|
|
77
|
-
return this.scheduleJob(jobId, trigger, "interval");
|
|
184
|
+
return this.scheduleJob(jobId, new IntervalTrigger(intervalMs), "interval", options);
|
|
78
185
|
}
|
|
79
186
|
/**
|
|
80
187
|
* Schedules a job using a cron expression.
|
|
81
188
|
*/
|
|
82
|
-
cron(expression, jobId) {
|
|
83
|
-
const trigger = new CronTrigger(expression);
|
|
84
|
-
return this.scheduleJob(jobId, trigger, "cron",
|
|
189
|
+
cron(expression, jobId, options) {
|
|
190
|
+
const trigger = new CronTrigger(expression, options?.timezone);
|
|
191
|
+
return this.scheduleJob(jobId, trigger, "cron", options, expression);
|
|
192
|
+
}
|
|
193
|
+
/** Returns a snapshot of a schedule, or undefined. */
|
|
194
|
+
getSchedule(scheduleId) {
|
|
195
|
+
return this.schedules.get(scheduleId)?.schedule;
|
|
196
|
+
}
|
|
197
|
+
/** Returns snapshots of every live schedule. */
|
|
198
|
+
listSchedules() {
|
|
199
|
+
return [...this.schedules.values()].map((record) => record.schedule);
|
|
85
200
|
}
|
|
86
201
|
/**
|
|
87
202
|
* Schedules a job with a trigger.
|
|
88
203
|
*/
|
|
89
|
-
scheduleJob(jobId, trigger, type, options) {
|
|
204
|
+
scheduleJob(jobId, trigger, type, options, expression) {
|
|
90
205
|
if (!this.jobs.has(jobId)) {
|
|
91
206
|
throw new InvalidJobError(`Job "${jobId}" is not registered.`, jobId);
|
|
92
207
|
}
|
|
208
|
+
if (this.schedules.size >= MAX_SCHEDULES) {
|
|
209
|
+
throw new InvalidScheduleError(`Maximum number of schedules (${MAX_SCHEDULES}) exceeded.`, jobId);
|
|
210
|
+
}
|
|
93
211
|
const scheduleId = crypto.randomUUID();
|
|
94
|
-
const
|
|
212
|
+
const now = this.clock.now();
|
|
213
|
+
let nextRunAt = trigger.next(now);
|
|
95
214
|
if (nextRunAt === null) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
215
|
+
// The fire time has already passed. That is the misfire case, not an
|
|
216
|
+
// error — `at(pastDate)` and a schedule restored after a restart both
|
|
217
|
+
// land here.
|
|
218
|
+
const misfire = options?.misfire ?? DEFAULT_MISFIRE_POLICY;
|
|
219
|
+
if (misfire === "skip") {
|
|
220
|
+
throw new InvalidScheduleError("Trigger has no future fire time and the misfire policy is 'skip'.", scheduleId);
|
|
221
|
+
}
|
|
222
|
+
// "run-once" and "catch-up" both start by running immediately.
|
|
223
|
+
nextRunAt = now;
|
|
224
|
+
}
|
|
225
|
+
if (Number.isNaN(nextRunAt.getTime())) {
|
|
226
|
+
throw new InvalidScheduleError("Trigger produced an invalid date.", scheduleId);
|
|
227
|
+
}
|
|
228
|
+
const schedule = createSchedule(scheduleId, jobId, type, nextRunAt, options ?? {}, expression);
|
|
229
|
+
const record = {
|
|
230
|
+
schedule,
|
|
231
|
+
trigger,
|
|
232
|
+
options: options ?? {},
|
|
233
|
+
running: new Set(),
|
|
234
|
+
pendingRuns: 0,
|
|
105
235
|
};
|
|
106
|
-
this.schedules.set(scheduleId,
|
|
236
|
+
this.schedules.set(scheduleId, record);
|
|
107
237
|
this.queue.enqueue(schedule);
|
|
108
|
-
|
|
238
|
+
// The handle holds a reference to this scheduler, so pause, resume and
|
|
239
|
+
// cancel actually reach the queue instead of mutating a detached copy.
|
|
240
|
+
return new ScheduleHandleImpl(scheduleId, "active", {
|
|
241
|
+
setState: (state) => this.setScheduleState(scheduleId, state),
|
|
242
|
+
getState: () => this.schedules.get(scheduleId)?.schedule.state ??
|
|
243
|
+
this.retiredStates.get(scheduleId),
|
|
244
|
+
getNextRun: () => this.schedules.get(scheduleId)?.schedule.nextRunAt,
|
|
245
|
+
abortRunning: () => this.abortSchedule(scheduleId),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/** Applies a state change to a live schedule. */
|
|
249
|
+
setScheduleState(scheduleId, state) {
|
|
250
|
+
const record = this.schedules.get(scheduleId);
|
|
251
|
+
if (!record)
|
|
252
|
+
return;
|
|
253
|
+
record.schedule = { ...record.schedule, state };
|
|
254
|
+
if (state === "cancelled" || state === "completed") {
|
|
255
|
+
// Held-back runs belong to a schedule that no longer exists.
|
|
256
|
+
this.retire(record, state, { dropPending: true });
|
|
257
|
+
}
|
|
258
|
+
else if (state === "paused") {
|
|
259
|
+
this.queue.remove(scheduleId);
|
|
260
|
+
}
|
|
261
|
+
else if (state === "active") {
|
|
262
|
+
// Resuming: recompute from now so a schedule paused across its fire time
|
|
263
|
+
// does not immediately fire for every occurrence it missed.
|
|
264
|
+
const now = this.clock.now();
|
|
265
|
+
let next = record.trigger.next(now);
|
|
266
|
+
if (next === null) {
|
|
267
|
+
// The fire time passed while paused. A one-shot used to stay "active"
|
|
268
|
+
// here with nothing ever able to dispatch it; apply the misfire
|
|
269
|
+
// policy exactly as `scheduleJob` does for a fire time already past.
|
|
270
|
+
const misfire = record.options.misfire ?? DEFAULT_MISFIRE_POLICY;
|
|
271
|
+
const isRecurring = record.schedule.type === "interval" ||
|
|
272
|
+
record.schedule.type === "cron";
|
|
273
|
+
if (misfire === "skip" || isRecurring) {
|
|
274
|
+
// Nothing left to fire: retire it rather than leaking an entry.
|
|
275
|
+
this.retire(record, "completed", { dropPending: true });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
next = now;
|
|
279
|
+
}
|
|
280
|
+
if (Number.isNaN(next.getTime()))
|
|
281
|
+
return;
|
|
282
|
+
record.schedule = { ...record.schedule, nextRunAt: next };
|
|
283
|
+
this.queue.remove(scheduleId);
|
|
284
|
+
this.queue.enqueue(record.schedule);
|
|
285
|
+
this.rearm();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Drops a schedule that will never fire again, remembering why.
|
|
290
|
+
*
|
|
291
|
+
* Runs already held back by `overlap: "queue"` or a concurrency ceiling
|
|
292
|
+
* are kept by default: they are fire times that have arrived, and a
|
|
293
|
+
* one-shot retired at dispatch still owes them. Only a cancel, or a
|
|
294
|
+
* misfire policy that says skip, discards them.
|
|
295
|
+
*/
|
|
296
|
+
retire(record, state, options) {
|
|
297
|
+
record.schedule = { ...record.schedule, state };
|
|
298
|
+
if (options?.dropPending === true) {
|
|
299
|
+
record.pendingRuns = 0;
|
|
300
|
+
this.pending.delete(record);
|
|
301
|
+
}
|
|
302
|
+
this.queue.remove(record.schedule.id);
|
|
303
|
+
this.schedules.delete(record.schedule.id);
|
|
304
|
+
this.retiredStates.set(record.schedule.id, state);
|
|
305
|
+
while (this.retiredStates.size > MAX_SCHEDULES) {
|
|
306
|
+
const oldest = this.retiredStates.keys().next().value;
|
|
307
|
+
if (oldest === undefined)
|
|
308
|
+
break;
|
|
309
|
+
this.retiredStates.delete(oldest);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Aborts every in-flight execution of one schedule. */
|
|
313
|
+
abortSchedule(scheduleId) {
|
|
314
|
+
const record = this.schedules.get(scheduleId);
|
|
315
|
+
if (!record)
|
|
316
|
+
return;
|
|
317
|
+
for (const controller of record.running) {
|
|
318
|
+
controller.abort(new Error("Schedule cancelled"));
|
|
319
|
+
}
|
|
109
320
|
}
|
|
110
321
|
/**
|
|
111
322
|
* Internal tick method for processing due jobs.
|
|
@@ -121,13 +332,229 @@ export class Scheduler {
|
|
|
121
332
|
break;
|
|
122
333
|
}
|
|
123
334
|
this.queue.dequeue();
|
|
124
|
-
|
|
335
|
+
const record = this.schedules.get(schedule.id);
|
|
336
|
+
if (!record || record.schedule.state !== "active") {
|
|
125
337
|
continue;
|
|
126
338
|
}
|
|
127
|
-
|
|
339
|
+
// Concurrency ceiling: without one, N due schedules start N executions
|
|
340
|
+
// at once. Anything over the ceiling is left for the next tick.
|
|
341
|
+
if (this.inFlight.size >= this.maxConcurrency) {
|
|
342
|
+
this.queue.enqueue(record.schedule);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
this.dispatch(record);
|
|
346
|
+
this.reschedule(record);
|
|
128
347
|
}
|
|
348
|
+
this.rearm();
|
|
349
|
+
}
|
|
350
|
+
/** (Re)arms the tick timer for the next due schedule. */
|
|
351
|
+
rearm() {
|
|
352
|
+
if (!this.running)
|
|
353
|
+
return;
|
|
354
|
+
if (this.timer)
|
|
355
|
+
clearTimeout(this.timer);
|
|
129
356
|
const delay = this.calculateDelay();
|
|
130
357
|
this.timer = setTimeout(() => this.tick(), delay);
|
|
358
|
+
if (this.timer.unref)
|
|
359
|
+
this.timer.unref();
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Computes the next fire time and puts the schedule back on the queue.
|
|
363
|
+
*
|
|
364
|
+
* Nothing did this before, so `every()` and `cron()` fired exactly once and
|
|
365
|
+
* then sat in the map forever.
|
|
366
|
+
*/
|
|
367
|
+
reschedule(record) {
|
|
368
|
+
const isRecurring = record.schedule.type === "interval" || record.schedule.type === "cron";
|
|
369
|
+
if (!isRecurring) {
|
|
370
|
+
// One-shot: retire it rather than leaking the entry.
|
|
371
|
+
record.schedule = { ...record.schedule, lastRunAt: this.clock.now() };
|
|
372
|
+
this.retire(record, "completed");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// "catch-up" advances from the fire time that just ran, so a schedule
|
|
376
|
+
// that fell behind replays each missed occurrence instead of silently
|
|
377
|
+
// skipping to the next future one. Every other policy resumes from now.
|
|
378
|
+
const misfire = record.options.misfire ?? DEFAULT_MISFIRE_POLICY;
|
|
379
|
+
const now = this.clock.now();
|
|
380
|
+
const from = misfire === "catch-up" ? record.schedule.nextRunAt : now;
|
|
381
|
+
const next = record.trigger.next(from);
|
|
382
|
+
if (!next || Number.isNaN(next.getTime())) {
|
|
383
|
+
// A trigger with no further fire time is finished.
|
|
384
|
+
this.retire(record, "completed");
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
record.schedule = {
|
|
388
|
+
...record.schedule,
|
|
389
|
+
nextRunAt: next,
|
|
390
|
+
lastRunAt: now,
|
|
391
|
+
};
|
|
392
|
+
this.queue.enqueue(record.schedule);
|
|
393
|
+
}
|
|
394
|
+
/** Starts one execution of a schedule and tracks it. */
|
|
395
|
+
dispatch(record) {
|
|
396
|
+
const job = this.jobs.get(record.schedule.jobId);
|
|
397
|
+
if (!job)
|
|
398
|
+
return;
|
|
399
|
+
// A schedule cancelled or paused while a run was held back must not fire.
|
|
400
|
+
if (record.schedule.state === "cancelled" ||
|
|
401
|
+
record.schedule.state === "paused") {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
// A schedule's own policy wins over the job's default.
|
|
405
|
+
const overlap = record.options.overlap ?? job.options?.overlap ?? DEFAULT_OVERLAP_POLICY;
|
|
406
|
+
if (record.running.size > 0) {
|
|
407
|
+
if (overlap === "skip")
|
|
408
|
+
return;
|
|
409
|
+
if (overlap === "queue") {
|
|
410
|
+
// Hold the fire time rather than dropping it; it is dispatched when
|
|
411
|
+
// the running execution finishes. "queue" used to fall through to
|
|
412
|
+
// "allow" and start a concurrent run.
|
|
413
|
+
this.defer(record);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (overlap === "replace") {
|
|
417
|
+
for (const controller of record.running) {
|
|
418
|
+
controller.abort(new Error("Superseded by a newer execution"));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Per-job concurrency ceiling. `JobOptions.concurrency` was accepted and
|
|
423
|
+
// read by nothing, so a job declaring `concurrency: 1` still ran as many
|
|
424
|
+
// executions at once as it had due schedules.
|
|
425
|
+
const concurrency = job.options?.concurrency;
|
|
426
|
+
if (concurrency !== undefined &&
|
|
427
|
+
(this.runningByJob.get(job.id) ?? 0) >= Math.max(1, concurrency)) {
|
|
428
|
+
this.defer(record);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const executionId = crypto.randomUUID();
|
|
432
|
+
const controller = new AbortController();
|
|
433
|
+
record.running.add(controller);
|
|
434
|
+
this.runningControllers.add(controller);
|
|
435
|
+
this.runningByJob.set(job.id, (this.runningByJob.get(job.id) ?? 0) + 1);
|
|
436
|
+
const scheduleId = record.schedule.id;
|
|
437
|
+
const scheduledAt = record.schedule.nextRunAt;
|
|
438
|
+
const startedAt = this.clock.now();
|
|
439
|
+
this.beginExecution({
|
|
440
|
+
id: executionId,
|
|
441
|
+
jobId: job.id,
|
|
442
|
+
scheduleId,
|
|
443
|
+
status: "running",
|
|
444
|
+
scheduledAt,
|
|
445
|
+
startedAt,
|
|
446
|
+
attempt: 1,
|
|
447
|
+
});
|
|
448
|
+
const execution = this.executor
|
|
449
|
+
.execute(job, executionId, scheduledAt, 1, controller.signal, record.options.data)
|
|
450
|
+
.then(() => {
|
|
451
|
+
this.finishExecution(executionId, "completed", startedAt);
|
|
452
|
+
})
|
|
453
|
+
.catch((error) => {
|
|
454
|
+
this.finishExecution(executionId, statusFor(error), startedAt, error);
|
|
455
|
+
// A failure used to vanish into an empty catch block with a "retry
|
|
456
|
+
// logic would go here" comment. Retries now live in the executor, and
|
|
457
|
+
// whatever survives them is reported.
|
|
458
|
+
this.reportError({
|
|
459
|
+
scheduleId,
|
|
460
|
+
jobId: job.id,
|
|
461
|
+
executionId,
|
|
462
|
+
error,
|
|
463
|
+
});
|
|
464
|
+
})
|
|
465
|
+
.finally(() => {
|
|
466
|
+
record.running.delete(controller);
|
|
467
|
+
this.runningControllers.delete(controller);
|
|
468
|
+
this.inFlight.delete(execution);
|
|
469
|
+
const remaining = (this.runningByJob.get(job.id) ?? 1) - 1;
|
|
470
|
+
if (remaining <= 0)
|
|
471
|
+
this.runningByJob.delete(job.id);
|
|
472
|
+
else
|
|
473
|
+
this.runningByJob.set(job.id, remaining);
|
|
474
|
+
this.drainPending();
|
|
475
|
+
// Capacity freed: a schedule held back by the ceiling is waiting for
|
|
476
|
+
// exactly this moment, and no timer is armed for it.
|
|
477
|
+
this.rearm();
|
|
478
|
+
});
|
|
479
|
+
this.inFlight.add(execution);
|
|
480
|
+
}
|
|
481
|
+
/** Holds a fire time back until capacity frees up. */
|
|
482
|
+
defer(record) {
|
|
483
|
+
if (record.pendingRuns >= MAX_PENDING_RUNS)
|
|
484
|
+
return;
|
|
485
|
+
record.pendingRuns += 1;
|
|
486
|
+
this.pending.add(record);
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Dispatches held-back runs that can now proceed.
|
|
490
|
+
*
|
|
491
|
+
* A record that is still blocked re-defers itself, which shows up as its
|
|
492
|
+
* counter returning to where it started; that is the loop's exit condition,
|
|
493
|
+
* so a blocked record cannot spin.
|
|
494
|
+
*/
|
|
495
|
+
drainPending() {
|
|
496
|
+
if (!this.running || this.pending.size === 0)
|
|
497
|
+
return;
|
|
498
|
+
for (const record of [...this.pending]) {
|
|
499
|
+
while (record.pendingRuns > 0) {
|
|
500
|
+
if (this.inFlight.size >= this.maxConcurrency)
|
|
501
|
+
return;
|
|
502
|
+
const before = record.pendingRuns;
|
|
503
|
+
record.pendingRuns -= 1;
|
|
504
|
+
this.dispatch(record);
|
|
505
|
+
if (record.pendingRuns >= before)
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
if (record.pendingRuns === 0)
|
|
509
|
+
this.pending.delete(record);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/** Records a started execution, evicting the oldest beyond the cap. */
|
|
513
|
+
beginExecution(execution) {
|
|
514
|
+
this.executionHistory.push(execution);
|
|
515
|
+
while (this.executionHistory.length > MAX_EXECUTION_HISTORY) {
|
|
516
|
+
this.executionHistory.shift();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** Completes the record for an execution, if it is still in the history. */
|
|
520
|
+
finishExecution(executionId, status, startedAt, error) {
|
|
521
|
+
const index = this.executionHistory.findIndex((entry) => entry.id === executionId);
|
|
522
|
+
if (index === -1)
|
|
523
|
+
return;
|
|
524
|
+
const completedAt = this.clock.now();
|
|
525
|
+
this.executionHistory[index] = {
|
|
526
|
+
...this.executionHistory[index],
|
|
527
|
+
status,
|
|
528
|
+
completedAt,
|
|
529
|
+
duration: completedAt.getTime() - startedAt.getTime(),
|
|
530
|
+
...(error === undefined ? {} : { error }),
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Returns the recorded executions, oldest first.
|
|
535
|
+
*
|
|
536
|
+
* At most {@link MAX_EXECUTION_HISTORY} are kept. `JobExecution` and that
|
|
537
|
+
* constant were both exported from the beginning and nothing produced or
|
|
538
|
+
* read either.
|
|
539
|
+
*
|
|
540
|
+
* @param jobId - Restrict to one job's executions.
|
|
541
|
+
*/
|
|
542
|
+
getExecutions(jobId) {
|
|
543
|
+
const all = [...this.executionHistory];
|
|
544
|
+
return jobId === undefined
|
|
545
|
+
? all
|
|
546
|
+
: all.filter((execution) => execution.jobId === jobId);
|
|
547
|
+
}
|
|
548
|
+
/** Hands an execution failure to the error listener. */
|
|
549
|
+
reportError(event) {
|
|
550
|
+
if (!this.onError)
|
|
551
|
+
return;
|
|
552
|
+
try {
|
|
553
|
+
this.onError(event);
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// A throwing error listener must not take the scheduler down with it.
|
|
557
|
+
}
|
|
131
558
|
}
|
|
132
559
|
/**
|
|
133
560
|
* Calculates the delay until the next tick.
|
|
@@ -136,6 +563,13 @@ export class Scheduler {
|
|
|
136
563
|
if (this.queue.isEmpty) {
|
|
137
564
|
return MAX_TIMER_DELAY;
|
|
138
565
|
}
|
|
566
|
+
// At the ceiling a due schedule cannot be dispatched, and arming a
|
|
567
|
+
// zero-delay timer for it spun the event loop — about a tick per
|
|
568
|
+
// millisecond — until an execution finished. The finishing execution
|
|
569
|
+
// re-arms the timer, so waiting here loses nothing.
|
|
570
|
+
if (this.inFlight.size >= this.maxConcurrency) {
|
|
571
|
+
return MAX_TIMER_DELAY;
|
|
572
|
+
}
|
|
139
573
|
const next = this.queue.peek();
|
|
140
574
|
if (!next) {
|
|
141
575
|
return MAX_TIMER_DELAY;
|
|
@@ -143,22 +577,13 @@ export class Scheduler {
|
|
|
143
577
|
const delay = next.nextRunAt.getTime() - this.clock.nowMs();
|
|
144
578
|
return Math.max(0, Math.min(delay, MAX_TIMER_DELAY));
|
|
145
579
|
}
|
|
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
|
-
}
|
|
580
|
+
}
|
|
581
|
+
/** Maps a failed execution onto the job state that describes it. */
|
|
582
|
+
function statusFor(error) {
|
|
583
|
+
if (error instanceof SchedulerJobTimeoutError)
|
|
584
|
+
return "timed_out";
|
|
585
|
+
if (error instanceof SchedulerJobCancelledError)
|
|
586
|
+
return "cancelled";
|
|
587
|
+
return "failed";
|
|
163
588
|
}
|
|
164
589
|
//# 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
|