@zudojs/scheduler 1.1.1 → 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.
|
@@ -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. */
|
|
@@ -260,6 +270,7 @@ export class Scheduler {
|
|
|
260
270
|
const record = this.schedules.get(scheduleId);
|
|
261
271
|
if (!record)
|
|
262
272
|
return;
|
|
273
|
+
const previous = record.schedule.state;
|
|
263
274
|
record.schedule = { ...record.schedule, state };
|
|
264
275
|
if (state === "cancelled" || state === "completed") {
|
|
265
276
|
// Held-back runs belong to a schedule that no longer exists.
|
|
@@ -269,6 +280,11 @@ export class Scheduler {
|
|
|
269
280
|
this.queue.remove(scheduleId);
|
|
270
281
|
}
|
|
271
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;
|
|
272
288
|
// Resuming: recompute from now so a schedule paused across its fire time
|
|
273
289
|
// does not immediately fire for every occurrence it missed.
|
|
274
290
|
const now = this.clock.now();
|
|
@@ -319,12 +335,18 @@ export class Scheduler {
|
|
|
319
335
|
this.retiredStates.delete(oldest);
|
|
320
336
|
}
|
|
321
337
|
}
|
|
322
|
-
/**
|
|
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
|
+
*/
|
|
323
345
|
abortSchedule(scheduleId) {
|
|
324
|
-
const
|
|
325
|
-
if (!
|
|
346
|
+
const controllers = this.runningByScheduleId.get(scheduleId);
|
|
347
|
+
if (!controllers)
|
|
326
348
|
return;
|
|
327
|
-
for (const controller of
|
|
349
|
+
for (const controller of [...controllers]) {
|
|
328
350
|
controller.abort(new Error("Schedule cancelled"));
|
|
329
351
|
}
|
|
330
352
|
}
|
|
@@ -353,7 +375,11 @@ export class Scheduler {
|
|
|
353
375
|
break;
|
|
354
376
|
}
|
|
355
377
|
this.dispatch(record);
|
|
356
|
-
|
|
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);
|
|
357
383
|
}
|
|
358
384
|
this.rearm();
|
|
359
385
|
}
|
|
@@ -404,8 +430,20 @@ export class Scheduler {
|
|
|
404
430
|
/** Starts one execution of a schedule and tracks it. */
|
|
405
431
|
dispatch(record) {
|
|
406
432
|
const job = this.jobs.get(record.schedule.jobId);
|
|
407
|
-
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
|
+
});
|
|
408
445
|
return;
|
|
446
|
+
}
|
|
409
447
|
// A schedule cancelled or paused while a run was held back must not fire.
|
|
410
448
|
if (record.schedule.state === "cancelled" ||
|
|
411
449
|
record.schedule.state === "paused") {
|
|
@@ -442,6 +480,12 @@ export class Scheduler {
|
|
|
442
480
|
const controller = new AbortController();
|
|
443
481
|
record.running.add(controller);
|
|
444
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);
|
|
445
489
|
this.runningByJob.set(job.id, (this.runningByJob.get(job.id) ?? 0) + 1);
|
|
446
490
|
const scheduleId = record.schedule.id;
|
|
447
491
|
const scheduledAt = record.schedule.nextRunAt;
|
|
@@ -475,6 +519,12 @@ export class Scheduler {
|
|
|
475
519
|
.finally(() => {
|
|
476
520
|
record.running.delete(controller);
|
|
477
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
|
+
}
|
|
478
528
|
this.inFlight.delete(execution);
|
|
479
529
|
const remaining = (this.runningByJob.get(job.id) ?? 1) - 1;
|
|
480
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;
|
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.
|
|
31
|
-
"@zudojs/constants": "1.1.
|
|
32
|
-
"@zudojs/types": "1.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",
|