@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 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): Promise<JobExecutionResult>;
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
  }
@@ -8,6 +8,7 @@ export function createJobContext(jobId, executionId, scheduledAt, startedAt, att
8
8
  scheduledAt,
9
9
  startedAt,
10
10
  attempt,
11
+ attemptNumber: attempt,
11
12
  data,
12
13
  signal,
13
14
  });
@@ -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, SchedulerStoppedError, SchedulerJobCancelledError, SchedulerJobNotFoundError, SchedulerJobTimeoutError, InvalidScheduleError, InvalidJobError, } from "./errors/scheduler.errors.js";
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
- throw new SchedulerStoppedError();
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
- if (this.timer.unref)
395
- this.timer.unref();
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", expression);
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}`, expression);
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`, expression);
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}"`, expression);
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}"`, expression);
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}"`, expression);
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}"`, expression);
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}"`, expression);
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}`, expression);
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.2",
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.2.0",
31
- "@zudojs/constants": "1.1.1",
32
- "@zudojs/types": "1.1.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": "^4.1.11"
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://github.com/oyinlola-tech/zudo#readme",
47
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-scheduler",
48
48
  "bugs": {
49
49
  "url": "https://github.com/oyinlola-tech/zudo/issues"
50
50
  },