@zudojs/scheduler 1.1.1 → 1.2.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/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 +33 -1
- package/dist/scheduler/scheduler.core.js +80 -11
- package/dist/scheduler/trigger/cron.parser.js +35 -16
- 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
|
}
|
|
@@ -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
|
}
|
|
@@ -24,6 +28,12 @@ export interface SchedulerOptions {
|
|
|
24
28
|
readonly maxConcurrency?: number;
|
|
25
29
|
/** Called when a job execution fails. */
|
|
26
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;
|
|
27
37
|
}
|
|
28
38
|
/**
|
|
29
39
|
* Scheduler for time-based job execution.
|
|
@@ -46,6 +56,7 @@ export declare class Scheduler {
|
|
|
46
56
|
private readonly schedules;
|
|
47
57
|
private readonly maxConcurrency;
|
|
48
58
|
private readonly onError;
|
|
59
|
+
private readonly keepAlive;
|
|
49
60
|
/** Executions currently in flight, across all schedules. */
|
|
50
61
|
private readonly inFlight;
|
|
51
62
|
/**
|
|
@@ -57,6 +68,16 @@ export declare class Scheduler {
|
|
|
57
68
|
* to abort.
|
|
58
69
|
*/
|
|
59
70
|
private readonly runningControllers;
|
|
71
|
+
/**
|
|
72
|
+
* In-flight controllers per schedule id.
|
|
73
|
+
*
|
|
74
|
+
* Indexed by id for the same reason {@link runningControllers} exists: a
|
|
75
|
+
* one-shot is retired at dispatch time, so `cancel()` arrives after its
|
|
76
|
+
* record has already been deleted and the record's own `running` set is
|
|
77
|
+
* unreachable. Without this, `handle.cancel()` aborted a recurring
|
|
78
|
+
* schedule's run and silently did nothing for a one-shot's.
|
|
79
|
+
*/
|
|
80
|
+
private readonly runningByScheduleId;
|
|
60
81
|
/** Executions in flight per job id, for the per-job concurrency ceiling. */
|
|
61
82
|
private readonly runningByJob;
|
|
62
83
|
/** Records with `pendingRuns > 0`, drained as executions finish. */
|
|
@@ -87,6 +108,9 @@ export declare class Scheduler {
|
|
|
87
108
|
/**
|
|
88
109
|
* Stops the scheduler, aborting in-flight jobs and waiting for them to settle.
|
|
89
110
|
*
|
|
111
|
+
* Idempotent: on a scheduler that never started, or one already stopped, it
|
|
112
|
+
* only waits for any executions still settling.
|
|
113
|
+
*
|
|
90
114
|
* @param options - `drain` waits for running jobs to finish instead of
|
|
91
115
|
* aborting them; `timeoutMs` bounds the wait either way.
|
|
92
116
|
*/
|
|
@@ -137,7 +161,13 @@ export declare class Scheduler {
|
|
|
137
161
|
* misfire policy that says skip, discards them.
|
|
138
162
|
*/
|
|
139
163
|
private retire;
|
|
140
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Aborts every in-flight execution of one schedule.
|
|
166
|
+
*
|
|
167
|
+
* Resolved through {@link runningByScheduleId} rather than the schedule
|
|
168
|
+
* map: a one-shot is retired as soon as it is dispatched, so its record is
|
|
169
|
+
* already gone while its execution is still running.
|
|
170
|
+
*/
|
|
141
171
|
private abortSchedule;
|
|
142
172
|
/**
|
|
143
173
|
* Internal tick method for processing due jobs.
|
|
@@ -166,6 +196,8 @@ export declare class Scheduler {
|
|
|
166
196
|
private drainPending;
|
|
167
197
|
/** Records a started execution, evicting the oldest beyond the cap. */
|
|
168
198
|
private beginExecution;
|
|
199
|
+
/** Records the attempt an execution is on, if it is still in the history. */
|
|
200
|
+
private recordAttempt;
|
|
169
201
|
/** Completes the record for an execution, if it is still in the history. */
|
|
170
202
|
private finishExecution;
|
|
171
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
|
/**
|
|
@@ -46,6 +47,16 @@ export class Scheduler {
|
|
|
46
47
|
* to abort.
|
|
47
48
|
*/
|
|
48
49
|
runningControllers = new Set();
|
|
50
|
+
/**
|
|
51
|
+
* In-flight controllers per schedule id.
|
|
52
|
+
*
|
|
53
|
+
* Indexed by id for the same reason {@link runningControllers} exists: a
|
|
54
|
+
* one-shot is retired at dispatch time, so `cancel()` arrives after its
|
|
55
|
+
* record has already been deleted and the record's own `running` set is
|
|
56
|
+
* unreachable. Without this, `handle.cancel()` aborted a recurring
|
|
57
|
+
* schedule's run and silently did nothing for a one-shot's.
|
|
58
|
+
*/
|
|
59
|
+
runningByScheduleId = new Map();
|
|
49
60
|
/** Executions in flight per job id, for the per-job concurrency ceiling. */
|
|
50
61
|
runningByJob = new Map();
|
|
51
62
|
/** Records with `pendingRuns > 0`, drained as executions finish. */
|
|
@@ -76,6 +87,7 @@ export class Scheduler {
|
|
|
76
87
|
this.queue = opts.queue ?? new PriorityQueue();
|
|
77
88
|
this.maxConcurrency = opts.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
|
78
89
|
this.onError = opts.onError;
|
|
90
|
+
this.keepAlive = opts.keepAlive ?? true;
|
|
79
91
|
}
|
|
80
92
|
/** Whether the scheduler is currently running. */
|
|
81
93
|
get isRunning() {
|
|
@@ -98,12 +110,16 @@ export class Scheduler {
|
|
|
98
110
|
/**
|
|
99
111
|
* Stops the scheduler, aborting in-flight jobs and waiting for them to settle.
|
|
100
112
|
*
|
|
113
|
+
* Idempotent: on a scheduler that never started, or one already stopped, it
|
|
114
|
+
* only waits for any executions still settling.
|
|
115
|
+
*
|
|
101
116
|
* @param options - `drain` waits for running jobs to finish instead of
|
|
102
117
|
* aborting them; `timeoutMs` bounds the wait either way.
|
|
103
118
|
*/
|
|
104
119
|
async stop(options) {
|
|
105
120
|
if (!this.running) {
|
|
106
|
-
|
|
121
|
+
await this.settle(options?.timeoutMs);
|
|
122
|
+
return;
|
|
107
123
|
}
|
|
108
124
|
this.running = false;
|
|
109
125
|
if (this.timer) {
|
|
@@ -260,6 +276,7 @@ export class Scheduler {
|
|
|
260
276
|
const record = this.schedules.get(scheduleId);
|
|
261
277
|
if (!record)
|
|
262
278
|
return;
|
|
279
|
+
const previous = record.schedule.state;
|
|
263
280
|
record.schedule = { ...record.schedule, state };
|
|
264
281
|
if (state === "cancelled" || state === "completed") {
|
|
265
282
|
// Held-back runs belong to a schedule that no longer exists.
|
|
@@ -269,6 +286,11 @@ export class Scheduler {
|
|
|
269
286
|
this.queue.remove(scheduleId);
|
|
270
287
|
}
|
|
271
288
|
else if (state === "active") {
|
|
289
|
+
// Only a paused schedule resumes. `resume()` on a running one used to
|
|
290
|
+
// recompute its next fire time from now, so a supervisor calling it
|
|
291
|
+
// idempotently postponed the schedule indefinitely.
|
|
292
|
+
if (previous !== "paused")
|
|
293
|
+
return;
|
|
272
294
|
// Resuming: recompute from now so a schedule paused across its fire time
|
|
273
295
|
// does not immediately fire for every occurrence it missed.
|
|
274
296
|
const now = this.clock.now();
|
|
@@ -319,12 +341,18 @@ export class Scheduler {
|
|
|
319
341
|
this.retiredStates.delete(oldest);
|
|
320
342
|
}
|
|
321
343
|
}
|
|
322
|
-
/**
|
|
344
|
+
/**
|
|
345
|
+
* Aborts every in-flight execution of one schedule.
|
|
346
|
+
*
|
|
347
|
+
* Resolved through {@link runningByScheduleId} rather than the schedule
|
|
348
|
+
* map: a one-shot is retired as soon as it is dispatched, so its record is
|
|
349
|
+
* already gone while its execution is still running.
|
|
350
|
+
*/
|
|
323
351
|
abortSchedule(scheduleId) {
|
|
324
|
-
const
|
|
325
|
-
if (!
|
|
352
|
+
const controllers = this.runningByScheduleId.get(scheduleId);
|
|
353
|
+
if (!controllers)
|
|
326
354
|
return;
|
|
327
|
-
for (const controller of
|
|
355
|
+
for (const controller of [...controllers]) {
|
|
328
356
|
controller.abort(new Error("Schedule cancelled"));
|
|
329
357
|
}
|
|
330
358
|
}
|
|
@@ -353,7 +381,11 @@ export class Scheduler {
|
|
|
353
381
|
break;
|
|
354
382
|
}
|
|
355
383
|
this.dispatch(record);
|
|
356
|
-
|
|
384
|
+
// `dispatch` retires the schedule when its job is no longer
|
|
385
|
+
// registered; rescheduling a retired record would put a dead entry
|
|
386
|
+
// back on the queue.
|
|
387
|
+
if (this.schedules.has(record.schedule.id))
|
|
388
|
+
this.reschedule(record);
|
|
357
389
|
}
|
|
358
390
|
this.rearm();
|
|
359
391
|
}
|
|
@@ -365,8 +397,11 @@ export class Scheduler {
|
|
|
365
397
|
clearTimeout(this.timer);
|
|
366
398
|
const delay = this.calculateDelay();
|
|
367
399
|
this.timer = setTimeout(() => this.tick(), delay);
|
|
368
|
-
|
|
369
|
-
|
|
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?.();
|
|
370
405
|
}
|
|
371
406
|
/**
|
|
372
407
|
* Computes the next fire time and puts the schedule back on the queue.
|
|
@@ -404,8 +439,20 @@ export class Scheduler {
|
|
|
404
439
|
/** Starts one execution of a schedule and tracks it. */
|
|
405
440
|
dispatch(record) {
|
|
406
441
|
const job = this.jobs.get(record.schedule.jobId);
|
|
407
|
-
if (!job)
|
|
442
|
+
if (!job) {
|
|
443
|
+
// The job was unregistered under a live schedule. Returning silently
|
|
444
|
+
// left the schedule re-arming its timer forever, dispatching nothing
|
|
445
|
+
// and reporting nothing, with `handle.state` still "active".
|
|
446
|
+
const error = new SchedulerJobNotFoundError(record.schedule.jobId);
|
|
447
|
+
this.retire(record, "cancelled", { dropPending: true });
|
|
448
|
+
this.reportError({
|
|
449
|
+
scheduleId: record.schedule.id,
|
|
450
|
+
jobId: record.schedule.jobId,
|
|
451
|
+
executionId: "",
|
|
452
|
+
error,
|
|
453
|
+
});
|
|
408
454
|
return;
|
|
455
|
+
}
|
|
409
456
|
// A schedule cancelled or paused while a run was held back must not fire.
|
|
410
457
|
if (record.schedule.state === "cancelled" ||
|
|
411
458
|
record.schedule.state === "paused") {
|
|
@@ -442,6 +489,12 @@ export class Scheduler {
|
|
|
442
489
|
const controller = new AbortController();
|
|
443
490
|
record.running.add(controller);
|
|
444
491
|
this.runningControllers.add(controller);
|
|
492
|
+
let byScheduleId = this.runningByScheduleId.get(record.schedule.id);
|
|
493
|
+
if (!byScheduleId) {
|
|
494
|
+
byScheduleId = new Set();
|
|
495
|
+
this.runningByScheduleId.set(record.schedule.id, byScheduleId);
|
|
496
|
+
}
|
|
497
|
+
byScheduleId.add(controller);
|
|
445
498
|
this.runningByJob.set(job.id, (this.runningByJob.get(job.id) ?? 0) + 1);
|
|
446
499
|
const scheduleId = record.schedule.id;
|
|
447
500
|
const scheduledAt = record.schedule.nextRunAt;
|
|
@@ -456,7 +509,7 @@ export class Scheduler {
|
|
|
456
509
|
attempt: 1,
|
|
457
510
|
});
|
|
458
511
|
const execution = this.executor
|
|
459
|
-
.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) })
|
|
460
513
|
.then(() => {
|
|
461
514
|
this.finishExecution(executionId, "completed", startedAt);
|
|
462
515
|
})
|
|
@@ -475,6 +528,12 @@ export class Scheduler {
|
|
|
475
528
|
.finally(() => {
|
|
476
529
|
record.running.delete(controller);
|
|
477
530
|
this.runningControllers.delete(controller);
|
|
531
|
+
const live = this.runningByScheduleId.get(scheduleId);
|
|
532
|
+
if (live) {
|
|
533
|
+
live.delete(controller);
|
|
534
|
+
if (live.size === 0)
|
|
535
|
+
this.runningByScheduleId.delete(scheduleId);
|
|
536
|
+
}
|
|
478
537
|
this.inFlight.delete(execution);
|
|
479
538
|
const remaining = (this.runningByJob.get(job.id) ?? 1) - 1;
|
|
480
539
|
if (remaining <= 0)
|
|
@@ -526,6 +585,16 @@ export class Scheduler {
|
|
|
526
585
|
this.executionHistory.shift();
|
|
527
586
|
}
|
|
528
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
|
+
}
|
|
529
598
|
/** Completes the record for an execution, if it is still in the history. */
|
|
530
599
|
finishExecution(executionId, status, startedAt, error) {
|
|
531
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,17 +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
|
+
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)) {
|
|
94
|
-
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}"`);
|
|
95
96
|
}
|
|
96
97
|
const step = stepPart === undefined ? 1 : Number(stepPart);
|
|
97
98
|
if (step === 0) {
|
|
98
|
-
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}"`);
|
|
99
100
|
}
|
|
100
101
|
let start;
|
|
101
102
|
let end;
|
|
@@ -105,10 +106,18 @@ 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
|
-
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}"`);
|
|
112
121
|
}
|
|
113
122
|
}
|
|
114
123
|
else {
|
|
@@ -117,21 +126,30 @@ 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) {
|
|
124
|
-
throw new CronParseError(`Cron field "${bounds.name}" matches no values: "${field}"
|
|
134
|
+
throw new CronParseError(expression, `Cron field "${bounds.name}" matches no values: "${field}"`);
|
|
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
|
}
|
|
@@ -142,10 +160,11 @@ function resolveValue(raw, bounds, expression) {
|
|
|
142
160
|
value = DAY_NAMES[raw];
|
|
143
161
|
}
|
|
144
162
|
else {
|
|
145
|
-
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}"`);
|
|
146
164
|
}
|
|
147
|
-
|
|
148
|
-
|
|
165
|
+
const max = rawDayOfWeek && bounds.name === "dayOfWeek" ? 7 : bounds.max;
|
|
166
|
+
if (value < bounds.min || value > max) {
|
|
167
|
+
throw new CronParseError(expression, `Cron field "${bounds.name}" value ${value} is outside ${bounds.min}-${bounds.max}`);
|
|
149
168
|
}
|
|
150
169
|
return value;
|
|
151
170
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/scheduler",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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.0",
|
|
31
|
+
"@zudojs/constants": "1.1.2",
|
|
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
|
},
|