@zudojs/scheduler 1.1.2 → 1.2.1
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 +35 -0
- package/dist/index.d.ts +1 -0
- package/dist/scheduler/executor/jobExecutor.core.d.ts +5 -1
- package/dist/scheduler/executor/jobExecutor.core.js +4 -1
- package/dist/scheduler/job/jobContext.type.d.ts +9 -0
- package/dist/scheduler/job/jobContext.type.js +1 -0
- package/dist/scheduler/scheduler.core.d.ts +12 -0
- package/dist/scheduler/scheduler.core.js +24 -5
- package/dist/scheduler/trigger/cron.parser.js +9 -9
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -41,6 +41,28 @@ scheduler.start();
|
|
|
41
41
|
await scheduler.stop({ timeoutMs: 30_000 });
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
## Starting and stopping
|
|
45
|
+
|
|
46
|
+
A started scheduler keeps the Node.js process alive until `stop()`, so a
|
|
47
|
+
script whose only work is a scheduler runs its jobs instead of exiting
|
|
48
|
+
straight away. Pass `keepAlive: false` for a scheduler that should never hold
|
|
49
|
+
the process open by itself (the behaviour before 1.2.0).
|
|
50
|
+
|
|
51
|
+
`stop()` is idempotent: calling it on a scheduler that never started, or
|
|
52
|
+
calling it twice, resolves instead of throwing `SchedulerStoppedError`.
|
|
53
|
+
`stop({ drain: true })` lets running jobs finish instead of aborting them.
|
|
54
|
+
|
|
55
|
+
For deterministic tests, pass your own clock. `Clock` is exported from the
|
|
56
|
+
package root:
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { Scheduler } from "@zudojs/scheduler";
|
|
60
|
+
import type { Clock } from "@zudojs/scheduler";
|
|
61
|
+
|
|
62
|
+
const clock: Clock = { now: () => new Date(0), nowMs: () => 0 };
|
|
63
|
+
const scheduler = new Scheduler({ clock });
|
|
64
|
+
```
|
|
65
|
+
|
|
44
66
|
## Scheduling
|
|
45
67
|
|
|
46
68
|
Four entry points, each returning a `ScheduleHandle`:
|
|
@@ -104,6 +126,15 @@ A timeout raises `SchedulerJobTimeoutError` and **aborts the handler** via
|
|
|
104
126
|
`ctx.signal`; anything else raises `SchedulerJobExecutionError` carrying the
|
|
105
127
|
original error as `cause`.
|
|
106
128
|
|
|
129
|
+
`ctx.attempt` is the attempt in progress, **1-based**: `1` on the first run,
|
|
130
|
+
`2` on the first retry. `ctx.attemptNumber` is the same number under the name
|
|
131
|
+
`@zudojs/queue` uses for its processor context, so both packages count
|
|
132
|
+
attempts the same way.
|
|
133
|
+
|
|
134
|
+
An invalid cron expression throws `CronParseError` with the expression quoted
|
|
135
|
+
first and the reason after it:
|
|
136
|
+
`Invalid cron expression "99 0 * * *": Cron field "minute" value 99 is outside 0-59.`
|
|
137
|
+
|
|
107
138
|
## Concurrency and overlap
|
|
108
139
|
|
|
109
140
|
`maxConcurrency` (default 10) bounds executions in flight across all schedules;
|
|
@@ -176,6 +207,10 @@ scheduler.getExecutions("cleanup");
|
|
|
176
207
|
// duration, attempt, error? }]
|
|
177
208
|
```
|
|
178
209
|
|
|
210
|
+
`attempt` is the attempt that settled the run — `3` for a run whose retry
|
|
211
|
+
policy let it succeed on its third try — and, while the run is still going,
|
|
212
|
+
the attempt currently in progress.
|
|
213
|
+
|
|
179
214
|
## Features
|
|
180
215
|
|
|
181
216
|
- Delay, date, interval and cron triggers
|
package/dist/index.d.ts
CHANGED
|
@@ -35,6 +35,7 @@ export { DateTrigger, DelayTrigger, IntervalTrigger, CronTrigger, } from "./sche
|
|
|
35
35
|
export { parseCron, nextCronDate } from "./scheduler/trigger/index.js";
|
|
36
36
|
export type { ParsedCron } from "./scheduler/trigger/index.js";
|
|
37
37
|
export { SystemClock, createSystemClock } from "./scheduler/clock/index.js";
|
|
38
|
+
export type { Clock } from "./scheduler/clock/index.js";
|
|
38
39
|
export { JobRegistry } from "./scheduler/registry/index.js";
|
|
39
40
|
export { JobExecutor, retryDelay } from "./scheduler/executor/index.js";
|
|
40
41
|
export { PriorityQueue } from "./scheduler/priorityQueue/index.js";
|
|
@@ -17,9 +17,13 @@ export declare class JobExecutor {
|
|
|
17
17
|
* @param attempt - The attempt number to start from (1-based).
|
|
18
18
|
* @param signal - Signal that aborts the job and stops further retries.
|
|
19
19
|
* @param data - Optional payload handed to the handler.
|
|
20
|
+
* @param hooks - `onAttempt` is told the 1-based number of each attempt as
|
|
21
|
+
* it starts, so a caller can record the attempt that settled the run.
|
|
20
22
|
* @returns The execution result.
|
|
21
23
|
*/
|
|
22
|
-
execute(job: JobDefinition, executionId: string, scheduledAt: Date, attempt: number, signal: AbortSignal, data?: unknown
|
|
24
|
+
execute(job: JobDefinition, executionId: string, scheduledAt: Date, attempt: number, signal: AbortSignal, data?: unknown, hooks?: {
|
|
25
|
+
readonly onAttempt?: (attempt: number) => void;
|
|
26
|
+
}): Promise<JobExecutionResult>;
|
|
23
27
|
/** Runs the handler once, under a timeout that also aborts it. */
|
|
24
28
|
private runOnce;
|
|
25
29
|
/** Maps a thrown value onto the scheduler's error taxonomy. */
|
|
@@ -20,9 +20,11 @@ export class JobExecutor {
|
|
|
20
20
|
* @param attempt - The attempt number to start from (1-based).
|
|
21
21
|
* @param signal - Signal that aborts the job and stops further retries.
|
|
22
22
|
* @param data - Optional payload handed to the handler.
|
|
23
|
+
* @param hooks - `onAttempt` is told the 1-based number of each attempt as
|
|
24
|
+
* it starts, so a caller can record the attempt that settled the run.
|
|
23
25
|
* @returns The execution result.
|
|
24
26
|
*/
|
|
25
|
-
async execute(job, executionId, scheduledAt, attempt, signal, data) {
|
|
27
|
+
async execute(job, executionId, scheduledAt, attempt, signal, data, hooks) {
|
|
26
28
|
const retry = job.options?.retry;
|
|
27
29
|
const maxAttempts = Math.max(1, retry?.attempts ?? 1);
|
|
28
30
|
let currentAttempt = attempt;
|
|
@@ -31,6 +33,7 @@ export class JobExecutor {
|
|
|
31
33
|
if (signal.aborted) {
|
|
32
34
|
throw new SchedulerJobCancelledError("Job was cancelled via signal.", job.id);
|
|
33
35
|
}
|
|
36
|
+
hooks?.onAttempt?.(currentAttempt);
|
|
34
37
|
try {
|
|
35
38
|
await this.runOnce(job, executionId, scheduledAt, currentAttempt, signal, data);
|
|
36
39
|
return { success: true };
|
|
@@ -6,7 +6,16 @@ export interface JobContext<T = unknown> {
|
|
|
6
6
|
readonly executionId: string;
|
|
7
7
|
readonly scheduledAt: Date;
|
|
8
8
|
readonly startedAt: Date;
|
|
9
|
+
/**
|
|
10
|
+
* The attempt in progress, 1-based: `1` on the first run, `2` on the first
|
|
11
|
+
* retry. Counts attempts within one execution, per the job's retry policy.
|
|
12
|
+
*/
|
|
9
13
|
readonly attempt: number;
|
|
14
|
+
/**
|
|
15
|
+
* Alias of {@link JobContext.attempt}, named as in `@zudojs/queue`, whose
|
|
16
|
+
* processor context carries the same 1-based `attemptNumber`.
|
|
17
|
+
*/
|
|
18
|
+
readonly attemptNumber: number;
|
|
10
19
|
readonly data: T;
|
|
11
20
|
readonly signal: AbortSignal;
|
|
12
21
|
}
|
|
@@ -28,6 +28,12 @@ export interface SchedulerOptions {
|
|
|
28
28
|
readonly maxConcurrency?: number;
|
|
29
29
|
/** Called when a job execution fails. */
|
|
30
30
|
readonly onError?: (event: SchedulerErrorEvent) => void;
|
|
31
|
+
/**
|
|
32
|
+
* Whether a started scheduler keeps the Node.js process alive until
|
|
33
|
+
* `stop()` (default: true). Pass `false` for a scheduler that should never
|
|
34
|
+
* by itself hold the process open.
|
|
35
|
+
*/
|
|
36
|
+
readonly keepAlive?: boolean;
|
|
31
37
|
}
|
|
32
38
|
/**
|
|
33
39
|
* Scheduler for time-based job execution.
|
|
@@ -50,6 +56,7 @@ export declare class Scheduler {
|
|
|
50
56
|
private readonly schedules;
|
|
51
57
|
private readonly maxConcurrency;
|
|
52
58
|
private readonly onError;
|
|
59
|
+
private readonly keepAlive;
|
|
53
60
|
/** Executions currently in flight, across all schedules. */
|
|
54
61
|
private readonly inFlight;
|
|
55
62
|
/**
|
|
@@ -101,6 +108,9 @@ export declare class Scheduler {
|
|
|
101
108
|
/**
|
|
102
109
|
* Stops the scheduler, aborting in-flight jobs and waiting for them to settle.
|
|
103
110
|
*
|
|
111
|
+
* Idempotent: on a scheduler that never started, or one already stopped, it
|
|
112
|
+
* only waits for any executions still settling.
|
|
113
|
+
*
|
|
104
114
|
* @param options - `drain` waits for running jobs to finish instead of
|
|
105
115
|
* aborting them; `timeoutMs` bounds the wait either way.
|
|
106
116
|
*/
|
|
@@ -186,6 +196,8 @@ export declare class Scheduler {
|
|
|
186
196
|
private drainPending;
|
|
187
197
|
/** Records a started execution, evicting the oldest beyond the cap. */
|
|
188
198
|
private beginExecution;
|
|
199
|
+
/** Records the attempt an execution is on, if it is still in the history. */
|
|
200
|
+
private recordAttempt;
|
|
189
201
|
/** Completes the record for an execution, if it is still in the history. */
|
|
190
202
|
private finishExecution;
|
|
191
203
|
/**
|
|
@@ -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,
|
|
3
|
+
import { SchedulerAlreadyStartedError, 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";
|
|
@@ -35,6 +35,7 @@ export class Scheduler {
|
|
|
35
35
|
schedules = new Map();
|
|
36
36
|
maxConcurrency;
|
|
37
37
|
onError;
|
|
38
|
+
keepAlive;
|
|
38
39
|
/** Executions currently in flight, across all schedules. */
|
|
39
40
|
inFlight = new Set();
|
|
40
41
|
/**
|
|
@@ -86,6 +87,7 @@ export class Scheduler {
|
|
|
86
87
|
this.queue = opts.queue ?? new PriorityQueue();
|
|
87
88
|
this.maxConcurrency = opts.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
|
88
89
|
this.onError = opts.onError;
|
|
90
|
+
this.keepAlive = opts.keepAlive ?? true;
|
|
89
91
|
}
|
|
90
92
|
/** Whether the scheduler is currently running. */
|
|
91
93
|
get isRunning() {
|
|
@@ -108,12 +110,16 @@ export class Scheduler {
|
|
|
108
110
|
/**
|
|
109
111
|
* Stops the scheduler, aborting in-flight jobs and waiting for them to settle.
|
|
110
112
|
*
|
|
113
|
+
* Idempotent: on a scheduler that never started, or one already stopped, it
|
|
114
|
+
* only waits for any executions still settling.
|
|
115
|
+
*
|
|
111
116
|
* @param options - `drain` waits for running jobs to finish instead of
|
|
112
117
|
* aborting them; `timeoutMs` bounds the wait either way.
|
|
113
118
|
*/
|
|
114
119
|
async stop(options) {
|
|
115
120
|
if (!this.running) {
|
|
116
|
-
|
|
121
|
+
await this.settle(options?.timeoutMs);
|
|
122
|
+
return;
|
|
117
123
|
}
|
|
118
124
|
this.running = false;
|
|
119
125
|
if (this.timer) {
|
|
@@ -391,8 +397,11 @@ export class Scheduler {
|
|
|
391
397
|
clearTimeout(this.timer);
|
|
392
398
|
const delay = this.calculateDelay();
|
|
393
399
|
this.timer = setTimeout(() => this.tick(), delay);
|
|
394
|
-
|
|
395
|
-
|
|
400
|
+
// Referenced by default: a started scheduler is the process's reason to
|
|
401
|
+
// stay alive until stop(). An unreferenced timer let a script that only
|
|
402
|
+
// ran a scheduler exit 0 before anything fired.
|
|
403
|
+
if (!this.keepAlive)
|
|
404
|
+
this.timer.unref?.();
|
|
396
405
|
}
|
|
397
406
|
/**
|
|
398
407
|
* Computes the next fire time and puts the schedule back on the queue.
|
|
@@ -500,7 +509,7 @@ export class Scheduler {
|
|
|
500
509
|
attempt: 1,
|
|
501
510
|
});
|
|
502
511
|
const execution = this.executor
|
|
503
|
-
.execute(job, executionId, scheduledAt, 1, controller.signal, record.options.data)
|
|
512
|
+
.execute(job, executionId, scheduledAt, 1, controller.signal, record.options.data, { onAttempt: (attempt) => this.recordAttempt(executionId, attempt) })
|
|
504
513
|
.then(() => {
|
|
505
514
|
this.finishExecution(executionId, "completed", startedAt);
|
|
506
515
|
})
|
|
@@ -576,6 +585,16 @@ export class Scheduler {
|
|
|
576
585
|
this.executionHistory.shift();
|
|
577
586
|
}
|
|
578
587
|
}
|
|
588
|
+
/** Records the attempt an execution is on, if it is still in the history. */
|
|
589
|
+
recordAttempt(executionId, attempt) {
|
|
590
|
+
const index = this.executionHistory.findIndex((entry) => entry.id === executionId);
|
|
591
|
+
if (index === -1)
|
|
592
|
+
return;
|
|
593
|
+
this.executionHistory[index] = {
|
|
594
|
+
...this.executionHistory[index],
|
|
595
|
+
attempt,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
579
598
|
/** Completes the record for an execution, if it is still in the history. */
|
|
580
599
|
finishExecution(executionId, status, startedAt, error) {
|
|
581
600
|
const index = this.executionHistory.findIndex((entry) => entry.id === executionId);
|
|
@@ -64,12 +64,12 @@ const MAX_SEARCH_YEARS = 5;
|
|
|
64
64
|
export function parseCron(expression) {
|
|
65
65
|
const trimmed = expression.trim().toLowerCase();
|
|
66
66
|
if (trimmed.length === 0) {
|
|
67
|
-
throw new CronParseError("Cron expression cannot be empty"
|
|
67
|
+
throw new CronParseError(expression, "Cron expression cannot be empty");
|
|
68
68
|
}
|
|
69
69
|
const expanded = MACROS[trimmed] ?? trimmed;
|
|
70
70
|
const fields = expanded.split(/\s+/);
|
|
71
71
|
if (fields.length !== 5) {
|
|
72
|
-
throw new CronParseError(`Cron expression must have 5 fields (minute hour day-of-month month day-of-week), got ${fields.length}
|
|
72
|
+
throw new CronParseError(expression, `Cron expression must have 5 fields (minute hour day-of-month month day-of-week), got ${fields.length}`);
|
|
73
73
|
}
|
|
74
74
|
const sets = FIELD_BOUNDS.map((bounds, index) => parseField(fields[index] ?? "", bounds, expression));
|
|
75
75
|
return {
|
|
@@ -85,18 +85,18 @@ export function parseCron(expression) {
|
|
|
85
85
|
/** Parses a single cron field into the set of values it permits. */
|
|
86
86
|
function parseField(field, bounds, expression) {
|
|
87
87
|
if (field.length === 0) {
|
|
88
|
-
throw new CronParseError(`Cron field "${bounds.name}" is empty
|
|
88
|
+
throw new CronParseError(expression, `Cron field "${bounds.name}" is empty`);
|
|
89
89
|
}
|
|
90
90
|
const values = new Set();
|
|
91
91
|
const isDayOfWeek = bounds.name === "dayOfWeek";
|
|
92
92
|
for (const part of field.split(",")) {
|
|
93
93
|
const [rangePart, stepPart] = part.split("/");
|
|
94
94
|
if (stepPart !== undefined && !/^\d+$/.test(stepPart)) {
|
|
95
|
-
throw new CronParseError(`Cron field "${bounds.name}" has an invalid step: "${part}"
|
|
95
|
+
throw new CronParseError(expression, `Cron field "${bounds.name}" has an invalid step: "${part}"`);
|
|
96
96
|
}
|
|
97
97
|
const step = stepPart === undefined ? 1 : Number(stepPart);
|
|
98
98
|
if (step === 0) {
|
|
99
|
-
throw new CronParseError(`Cron field "${bounds.name}" has a zero step: "${part}"
|
|
99
|
+
throw new CronParseError(expression, `Cron field "${bounds.name}" has a zero step: "${part}"`);
|
|
100
100
|
}
|
|
101
101
|
let start;
|
|
102
102
|
let end;
|
|
@@ -117,7 +117,7 @@ function parseField(field, bounds, expression) {
|
|
|
117
117
|
end = 7;
|
|
118
118
|
}
|
|
119
119
|
if (start > end) {
|
|
120
|
-
throw new CronParseError(`Cron field "${bounds.name}" has an inverted range: "${rangePart}"
|
|
120
|
+
throw new CronParseError(expression, `Cron field "${bounds.name}" has an inverted range: "${rangePart}"`);
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
else {
|
|
@@ -131,7 +131,7 @@ function parseField(field, bounds, expression) {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
if (values.size === 0) {
|
|
134
|
-
throw new CronParseError(`Cron field "${bounds.name}" matches no values: "${field}"
|
|
134
|
+
throw new CronParseError(expression, `Cron field "${bounds.name}" matches no values: "${field}"`);
|
|
135
135
|
}
|
|
136
136
|
return values;
|
|
137
137
|
}
|
|
@@ -160,11 +160,11 @@ function resolveValue(raw, bounds, expression, rawDayOfWeek = false) {
|
|
|
160
160
|
value = DAY_NAMES[raw];
|
|
161
161
|
}
|
|
162
162
|
else {
|
|
163
|
-
throw new CronParseError(`Cron field "${bounds.name}" has an invalid value: "${raw}"
|
|
163
|
+
throw new CronParseError(expression, `Cron field "${bounds.name}" has an invalid value: "${raw}"`);
|
|
164
164
|
}
|
|
165
165
|
const max = rawDayOfWeek && bounds.name === "dayOfWeek" ? 7 : bounds.max;
|
|
166
166
|
if (value < bounds.min || value > max) {
|
|
167
|
-
throw new CronParseError(`Cron field "${bounds.name}" value ${value} is outside ${bounds.min}-${bounds.max}
|
|
167
|
+
throw new CronParseError(expression, `Cron field "${bounds.name}" value ${value} is outside ${bounds.min}-${bounds.max}`);
|
|
168
168
|
}
|
|
169
169
|
return value;
|
|
170
170
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/scheduler",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Scheduled task and job infrastructure with cron-like scheduling, persistence, and worker management.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
"node": ">=24.0.0"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@zudojs/errors": "1.
|
|
31
|
-
"@zudojs/constants": "1.1.
|
|
32
|
-
"@zudojs/types": "1.
|
|
30
|
+
"@zudojs/errors": "1.3.1",
|
|
31
|
+
"@zudojs/constants": "1.1.3",
|
|
32
|
+
"@zudojs/types": "1.2.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"typescript": "7.0.2",
|
|
36
|
-
"vitest": "^
|
|
36
|
+
"vitest": "^5.0.1"
|
|
37
37
|
},
|
|
38
38
|
"publishConfig": {
|
|
39
39
|
"access": "public"
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"cron",
|
|
45
45
|
"jobs"
|
|
46
46
|
],
|
|
47
|
-
"homepage": "https://
|
|
47
|
+
"homepage": "https://zudojs.oyinlola.site/docs/packages-scheduler",
|
|
48
48
|
"bugs": {
|
|
49
49
|
"url": "https://github.com/oyinlola-tech/zudo/issues"
|
|
50
50
|
},
|