@zudojs/scheduler 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
In-process scheduling for delayed, recurring and cron-driven jobs.
|
|
4
4
|
|
|
5
|
+
<!-- zudo-docs:start -->
|
|
6
|
+
|
|
7
|
+
**Documentation:** [zudojs.oyinlola.site/docs/packages-scheduler](https://zudojs.oyinlola.site/docs/packages-scheduler) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-scheduler.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
|
|
8
|
+
|
|
9
|
+
<!-- zudo-docs:end -->
|
|
10
|
+
|
|
5
11
|
## Installation
|
|
6
12
|
|
|
7
13
|
```bash
|
|
@@ -151,6 +157,13 @@ for a recurring schedule whose process was blocked past its fire time.
|
|
|
151
157
|
scheduler.every("1h", "hourly-rollup", { misfire: "catch-up" });
|
|
152
158
|
```
|
|
153
159
|
|
|
160
|
+
A cron expression that can never fire (`0 0 30 2 *`, 30 February) is not a
|
|
161
|
+
misfire: `cron()` throws `InvalidScheduleError` at registration.
|
|
162
|
+
|
|
163
|
+
Schedules may be added before or after `start()`. One added to a running
|
|
164
|
+
scheduler re-arms the timer immediately, so it fires on time even when it is
|
|
165
|
+
due sooner than every existing schedule.
|
|
166
|
+
|
|
154
167
|
## Execution history
|
|
155
168
|
|
|
156
169
|
The scheduler keeps the last 100 executions (`MAX_EXECUTION_HISTORY`), each a
|
|
@@ -11,6 +11,10 @@ import { PriorityQueue } from "./priorityQueue/schedulerPriorityQueue.core.js";
|
|
|
11
11
|
export interface SchedulerErrorEvent {
|
|
12
12
|
readonly scheduleId: string;
|
|
13
13
|
readonly jobId: string;
|
|
14
|
+
/**
|
|
15
|
+
* The execution that failed. Empty when the failure happened before any
|
|
16
|
+
* execution could start — a schedule whose job is no longer registered.
|
|
17
|
+
*/
|
|
14
18
|
readonly executionId: string;
|
|
15
19
|
readonly error: unknown;
|
|
16
20
|
}
|
|
@@ -57,6 +61,16 @@ export declare class Scheduler {
|
|
|
57
61
|
* to abort.
|
|
58
62
|
*/
|
|
59
63
|
private readonly runningControllers;
|
|
64
|
+
/**
|
|
65
|
+
* In-flight controllers per schedule id.
|
|
66
|
+
*
|
|
67
|
+
* Indexed by id for the same reason {@link runningControllers} exists: a
|
|
68
|
+
* one-shot is retired at dispatch time, so `cancel()` arrives after its
|
|
69
|
+
* record has already been deleted and the record's own `running` set is
|
|
70
|
+
* unreachable. Without this, `handle.cancel()` aborted a recurring
|
|
71
|
+
* schedule's run and silently did nothing for a one-shot's.
|
|
72
|
+
*/
|
|
73
|
+
private readonly runningByScheduleId;
|
|
60
74
|
/** Executions in flight per job id, for the per-job concurrency ceiling. */
|
|
61
75
|
private readonly runningByJob;
|
|
62
76
|
/** Records with `pendingRuns > 0`, drained as executions finish. */
|
|
@@ -137,7 +151,13 @@ export declare class Scheduler {
|
|
|
137
151
|
* misfire policy that says skip, discards them.
|
|
138
152
|
*/
|
|
139
153
|
private retire;
|
|
140
|
-
/**
|
|
154
|
+
/**
|
|
155
|
+
* Aborts every in-flight execution of one schedule.
|
|
156
|
+
*
|
|
157
|
+
* Resolved through {@link runningByScheduleId} rather than the schedule
|
|
158
|
+
* map: a one-shot is retired as soon as it is dispatched, so its record is
|
|
159
|
+
* already gone while its execution is still running.
|
|
160
|
+
*/
|
|
141
161
|
private abortSchedule;
|
|
142
162
|
/**
|
|
143
163
|
* Internal tick method for processing due jobs.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ScheduleHandleImpl } from "./scheduleHandle/scheduleHandle.type.js";
|
|
2
2
|
import { createSchedule } from "./schedule/schedule.type.js";
|
|
3
|
-
import { SchedulerAlreadyStartedError, SchedulerStoppedError, SchedulerJobCancelledError, SchedulerJobTimeoutError, InvalidScheduleError, InvalidJobError, } from "./errors/scheduler.errors.js";
|
|
3
|
+
import { SchedulerAlreadyStartedError, SchedulerStoppedError, SchedulerJobCancelledError, SchedulerJobNotFoundError, SchedulerJobTimeoutError, InvalidScheduleError, InvalidJobError, } from "./errors/scheduler.errors.js";
|
|
4
4
|
import { DateTrigger, DelayTrigger, IntervalTrigger, CronTrigger, } from "./trigger/schedulerTrigger.core.js";
|
|
5
5
|
import { SystemClock } from "./clock/schedulerClock.type.js";
|
|
6
6
|
import { JobRegistry } from "./registry/jobRegistry.core.js";
|
|
@@ -46,6 +46,16 @@ export class Scheduler {
|
|
|
46
46
|
* to abort.
|
|
47
47
|
*/
|
|
48
48
|
runningControllers = new Set();
|
|
49
|
+
/**
|
|
50
|
+
* In-flight controllers per schedule id.
|
|
51
|
+
*
|
|
52
|
+
* Indexed by id for the same reason {@link runningControllers} exists: a
|
|
53
|
+
* one-shot is retired at dispatch time, so `cancel()` arrives after its
|
|
54
|
+
* record has already been deleted and the record's own `running` set is
|
|
55
|
+
* unreachable. Without this, `handle.cancel()` aborted a recurring
|
|
56
|
+
* schedule's run and silently did nothing for a one-shot's.
|
|
57
|
+
*/
|
|
58
|
+
runningByScheduleId = new Map();
|
|
49
59
|
/** Executions in flight per job id, for the per-job concurrency ceiling. */
|
|
50
60
|
runningByJob = new Map();
|
|
51
61
|
/** Records with `pendingRuns > 0`, drained as executions finish. */
|
|
@@ -211,6 +221,12 @@ export class Scheduler {
|
|
|
211
221
|
const scheduleId = crypto.randomUUID();
|
|
212
222
|
const now = this.clock.now();
|
|
213
223
|
let nextRunAt = trigger.next(now);
|
|
224
|
+
if (nextRunAt === null && (type === "cron" || type === "interval")) {
|
|
225
|
+
// A recurring trigger with no next fire time is unsatisfiable (30
|
|
226
|
+
// February) — not a misfire. Running it "once now" fired a job at an
|
|
227
|
+
// arbitrary moment and then retired it silently.
|
|
228
|
+
throw new InvalidScheduleError(`Recurring trigger has no future fire time${expression === undefined ? "" : ` ("${expression}")`}.`, jobId);
|
|
229
|
+
}
|
|
214
230
|
if (nextRunAt === null) {
|
|
215
231
|
// The fire time has already passed. That is the misfire case, not an
|
|
216
232
|
// error — `at(pastDate)` and a schedule restored after a restart both
|
|
@@ -235,6 +251,10 @@ export class Scheduler {
|
|
|
235
251
|
};
|
|
236
252
|
this.schedules.set(scheduleId, record);
|
|
237
253
|
this.queue.enqueue(schedule);
|
|
254
|
+
// A schedule added after start() must re-arm the timer: it may be due
|
|
255
|
+
// sooner than whatever the timer is currently armed for (up to ~24.8
|
|
256
|
+
// days on an empty scheduler).
|
|
257
|
+
this.rearm();
|
|
238
258
|
// The handle holds a reference to this scheduler, so pause, resume and
|
|
239
259
|
// cancel actually reach the queue instead of mutating a detached copy.
|
|
240
260
|
return new ScheduleHandleImpl(scheduleId, "active", {
|
|
@@ -250,6 +270,7 @@ export class Scheduler {
|
|
|
250
270
|
const record = this.schedules.get(scheduleId);
|
|
251
271
|
if (!record)
|
|
252
272
|
return;
|
|
273
|
+
const previous = record.schedule.state;
|
|
253
274
|
record.schedule = { ...record.schedule, state };
|
|
254
275
|
if (state === "cancelled" || state === "completed") {
|
|
255
276
|
// Held-back runs belong to a schedule that no longer exists.
|
|
@@ -259,6 +280,11 @@ export class Scheduler {
|
|
|
259
280
|
this.queue.remove(scheduleId);
|
|
260
281
|
}
|
|
261
282
|
else if (state === "active") {
|
|
283
|
+
// Only a paused schedule resumes. `resume()` on a running one used to
|
|
284
|
+
// recompute its next fire time from now, so a supervisor calling it
|
|
285
|
+
// idempotently postponed the schedule indefinitely.
|
|
286
|
+
if (previous !== "paused")
|
|
287
|
+
return;
|
|
262
288
|
// Resuming: recompute from now so a schedule paused across its fire time
|
|
263
289
|
// does not immediately fire for every occurrence it missed.
|
|
264
290
|
const now = this.clock.now();
|
|
@@ -309,12 +335,18 @@ export class Scheduler {
|
|
|
309
335
|
this.retiredStates.delete(oldest);
|
|
310
336
|
}
|
|
311
337
|
}
|
|
312
|
-
/**
|
|
338
|
+
/**
|
|
339
|
+
* Aborts every in-flight execution of one schedule.
|
|
340
|
+
*
|
|
341
|
+
* Resolved through {@link runningByScheduleId} rather than the schedule
|
|
342
|
+
* map: a one-shot is retired as soon as it is dispatched, so its record is
|
|
343
|
+
* already gone while its execution is still running.
|
|
344
|
+
*/
|
|
313
345
|
abortSchedule(scheduleId) {
|
|
314
|
-
const
|
|
315
|
-
if (!
|
|
346
|
+
const controllers = this.runningByScheduleId.get(scheduleId);
|
|
347
|
+
if (!controllers)
|
|
316
348
|
return;
|
|
317
|
-
for (const controller of
|
|
349
|
+
for (const controller of [...controllers]) {
|
|
318
350
|
controller.abort(new Error("Schedule cancelled"));
|
|
319
351
|
}
|
|
320
352
|
}
|
|
@@ -343,7 +375,11 @@ export class Scheduler {
|
|
|
343
375
|
break;
|
|
344
376
|
}
|
|
345
377
|
this.dispatch(record);
|
|
346
|
-
|
|
378
|
+
// `dispatch` retires the schedule when its job is no longer
|
|
379
|
+
// registered; rescheduling a retired record would put a dead entry
|
|
380
|
+
// back on the queue.
|
|
381
|
+
if (this.schedules.has(record.schedule.id))
|
|
382
|
+
this.reschedule(record);
|
|
347
383
|
}
|
|
348
384
|
this.rearm();
|
|
349
385
|
}
|
|
@@ -394,8 +430,20 @@ export class Scheduler {
|
|
|
394
430
|
/** Starts one execution of a schedule and tracks it. */
|
|
395
431
|
dispatch(record) {
|
|
396
432
|
const job = this.jobs.get(record.schedule.jobId);
|
|
397
|
-
if (!job)
|
|
433
|
+
if (!job) {
|
|
434
|
+
// The job was unregistered under a live schedule. Returning silently
|
|
435
|
+
// left the schedule re-arming its timer forever, dispatching nothing
|
|
436
|
+
// and reporting nothing, with `handle.state` still "active".
|
|
437
|
+
const error = new SchedulerJobNotFoundError(record.schedule.jobId);
|
|
438
|
+
this.retire(record, "cancelled", { dropPending: true });
|
|
439
|
+
this.reportError({
|
|
440
|
+
scheduleId: record.schedule.id,
|
|
441
|
+
jobId: record.schedule.jobId,
|
|
442
|
+
executionId: "",
|
|
443
|
+
error,
|
|
444
|
+
});
|
|
398
445
|
return;
|
|
446
|
+
}
|
|
399
447
|
// A schedule cancelled or paused while a run was held back must not fire.
|
|
400
448
|
if (record.schedule.state === "cancelled" ||
|
|
401
449
|
record.schedule.state === "paused") {
|
|
@@ -432,6 +480,12 @@ export class Scheduler {
|
|
|
432
480
|
const controller = new AbortController();
|
|
433
481
|
record.running.add(controller);
|
|
434
482
|
this.runningControllers.add(controller);
|
|
483
|
+
let byScheduleId = this.runningByScheduleId.get(record.schedule.id);
|
|
484
|
+
if (!byScheduleId) {
|
|
485
|
+
byScheduleId = new Set();
|
|
486
|
+
this.runningByScheduleId.set(record.schedule.id, byScheduleId);
|
|
487
|
+
}
|
|
488
|
+
byScheduleId.add(controller);
|
|
435
489
|
this.runningByJob.set(job.id, (this.runningByJob.get(job.id) ?? 0) + 1);
|
|
436
490
|
const scheduleId = record.schedule.id;
|
|
437
491
|
const scheduledAt = record.schedule.nextRunAt;
|
|
@@ -465,6 +519,12 @@ export class Scheduler {
|
|
|
465
519
|
.finally(() => {
|
|
466
520
|
record.running.delete(controller);
|
|
467
521
|
this.runningControllers.delete(controller);
|
|
522
|
+
const live = this.runningByScheduleId.get(scheduleId);
|
|
523
|
+
if (live) {
|
|
524
|
+
live.delete(controller);
|
|
525
|
+
if (live.size === 0)
|
|
526
|
+
this.runningByScheduleId.delete(scheduleId);
|
|
527
|
+
}
|
|
468
528
|
this.inFlight.delete(execution);
|
|
469
529
|
const remaining = (this.runningByJob.get(job.id) ?? 1) - 1;
|
|
470
530
|
if (remaining <= 0)
|
|
@@ -88,6 +88,7 @@ function parseField(field, bounds, expression) {
|
|
|
88
88
|
throw new CronParseError(`Cron field "${bounds.name}" is empty`, expression);
|
|
89
89
|
}
|
|
90
90
|
const values = new Set();
|
|
91
|
+
const isDayOfWeek = bounds.name === "dayOfWeek";
|
|
91
92
|
for (const part of field.split(",")) {
|
|
92
93
|
const [rangePart, stepPart] = part.split("/");
|
|
93
94
|
if (stepPart !== undefined && !/^\d+$/.test(stepPart)) {
|
|
@@ -105,8 +106,16 @@ function parseField(field, bounds, expression) {
|
|
|
105
106
|
}
|
|
106
107
|
else if (rangePart.includes("-")) {
|
|
107
108
|
const [from, to] = rangePart.split("-");
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
// Range endpoints keep their raw day-of-week spelling: normalising 7
|
|
110
|
+
// to 0 before the endpoints are compared turned "0-7" into Sunday
|
|
111
|
+
// alone and made "1-7" an inverted range.
|
|
112
|
+
start = resolveValue(from ?? "", bounds, expression, isDayOfWeek);
|
|
113
|
+
end = resolveValue(to ?? "", bounds, expression, isDayOfWeek);
|
|
114
|
+
// "mon-sun" names Monday through Sunday: the end is the week's last
|
|
115
|
+
// day, not its first. "sun-sat" is left alone — it already ascends.
|
|
116
|
+
if (isDayOfWeek && end === 0 && start > end) {
|
|
117
|
+
end = 7;
|
|
118
|
+
}
|
|
110
119
|
if (start > end) {
|
|
111
120
|
throw new CronParseError(`Cron field "${bounds.name}" has an inverted range: "${rangePart}"`, expression);
|
|
112
121
|
}
|
|
@@ -117,7 +126,8 @@ function parseField(field, bounds, expression) {
|
|
|
117
126
|
end = stepPart === undefined ? start : bounds.max;
|
|
118
127
|
}
|
|
119
128
|
for (let v = start; v <= end; v += step) {
|
|
120
|
-
|
|
129
|
+
// Both 0 and 7 are Sunday; the set only ever holds 0.
|
|
130
|
+
values.add(isDayOfWeek && v === 7 ? 0 : v);
|
|
121
131
|
}
|
|
122
132
|
}
|
|
123
133
|
if (values.size === 0) {
|
|
@@ -125,13 +135,21 @@ function parseField(field, bounds, expression) {
|
|
|
125
135
|
}
|
|
126
136
|
return values;
|
|
127
137
|
}
|
|
128
|
-
/**
|
|
129
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Resolves a numeric or named field value, checking it against the bounds.
|
|
140
|
+
*
|
|
141
|
+
* @param raw - The literal from the expression.
|
|
142
|
+
* @param bounds - The field being parsed.
|
|
143
|
+
* @param expression - The whole expression, for error reporting.
|
|
144
|
+
* @param rawDayOfWeek - Keeps day-of-week `7` as 7 and accepts it as a bound,
|
|
145
|
+
* for a range endpoint that the caller expands and normalises itself.
|
|
146
|
+
*/
|
|
147
|
+
function resolveValue(raw, bounds, expression, rawDayOfWeek = false) {
|
|
130
148
|
let value;
|
|
131
149
|
if (/^\d+$/.test(raw)) {
|
|
132
150
|
value = Number(raw);
|
|
133
151
|
// Both 0 and 7 are Sunday in common cron dialects.
|
|
134
|
-
if (bounds.name === "dayOfWeek" && value === 7) {
|
|
152
|
+
if (bounds.name === "dayOfWeek" && value === 7 && !rawDayOfWeek) {
|
|
135
153
|
value = 0;
|
|
136
154
|
}
|
|
137
155
|
}
|
|
@@ -144,7 +162,8 @@ function resolveValue(raw, bounds, expression) {
|
|
|
144
162
|
else {
|
|
145
163
|
throw new CronParseError(`Cron field "${bounds.name}" has an invalid value: "${raw}"`, expression);
|
|
146
164
|
}
|
|
147
|
-
|
|
165
|
+
const max = rawDayOfWeek && bounds.name === "dayOfWeek" ? 7 : bounds.max;
|
|
166
|
+
if (value < bounds.min || value > max) {
|
|
148
167
|
throw new CronParseError(`Cron field "${bounds.name}" value ${value} is outside ${bounds.min}-${bounds.max}`, expression);
|
|
149
168
|
}
|
|
150
169
|
return value;
|
|
@@ -172,7 +191,10 @@ export function nextCronDate(parsed, after, utc = false) {
|
|
|
172
191
|
};
|
|
173
192
|
// Start at the next whole minute after `after`, with seconds cleared.
|
|
174
193
|
const candidate = new Date(after.getTime());
|
|
175
|
-
|
|
194
|
+
if (utc)
|
|
195
|
+
candidate.setUTCSeconds(0, 0);
|
|
196
|
+
else
|
|
197
|
+
candidate.setSeconds(0, 0);
|
|
176
198
|
candidate.setTime(candidate.getTime() + 60_000);
|
|
177
199
|
const limitYear = get.year(after) + MAX_SEARCH_YEARS;
|
|
178
200
|
while (get.year(candidate) <= limitYear) {
|
|
@@ -185,7 +207,7 @@ export function nextCronDate(parsed, after, utc = false) {
|
|
|
185
207
|
continue;
|
|
186
208
|
}
|
|
187
209
|
if (!parsed.hour.has(get.hour(candidate))) {
|
|
188
|
-
advanceHour(candidate);
|
|
210
|
+
advanceHour(candidate, utc);
|
|
189
211
|
continue;
|
|
190
212
|
}
|
|
191
213
|
if (!parsed.minute.has(get.minute(candidate))) {
|
|
@@ -246,9 +268,18 @@ function advanceDay(candidate, utc) {
|
|
|
246
268
|
* Moves to the top of the next hour.
|
|
247
269
|
*
|
|
248
270
|
* Uses wall-clock arithmetic rather than adding an hour of milliseconds, so a
|
|
249
|
-
* DST transition does not skip or repeat an hour of scheduling.
|
|
271
|
+
* DST transition does not skip or repeat an hour of scheduling. In UTC mode
|
|
272
|
+
* the top of the hour is taken in UTC: a host on a half-hour offset
|
|
273
|
+
* (Asia/Kolkata) would otherwise land every skip on :30 UTC and never visit
|
|
274
|
+
* minutes 0-29 of a restricted hour.
|
|
250
275
|
*/
|
|
251
|
-
function advanceHour(candidate) {
|
|
276
|
+
function advanceHour(candidate, utc) {
|
|
277
|
+
if (utc) {
|
|
278
|
+
candidate.setUTCMinutes(0, 0, 0);
|
|
279
|
+
candidate.setTime(candidate.getTime() + 3_600_000);
|
|
280
|
+
candidate.setUTCMinutes(0, 0, 0);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
252
283
|
candidate.setMinutes(0, 0, 0);
|
|
253
284
|
candidate.setTime(candidate.getTime() + 3_600_000);
|
|
254
285
|
candidate.setMinutes(0, 0, 0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/scheduler",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Scheduled task and job infrastructure with cron-like scheduling, persistence, and worker management.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"node": ">=24.0.0"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@zudojs/errors": "1.0
|
|
31
|
-
"@zudojs/constants": "1.
|
|
32
|
-
"@zudojs/types": "1.
|
|
30
|
+
"@zudojs/errors": "1.2.0",
|
|
31
|
+
"@zudojs/constants": "1.1.1",
|
|
32
|
+
"@zudojs/types": "1.1.1"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"typescript": "7.0.2",
|