@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
- /** Aborts every in-flight execution of one schedule. */
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
- /** Aborts every in-flight execution of one schedule. */
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 record = this.schedules.get(scheduleId);
315
- if (!record)
346
+ const controllers = this.runningByScheduleId.get(scheduleId);
347
+ if (!controllers)
316
348
  return;
317
- for (const controller of record.running) {
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
- this.reschedule(record);
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
- start = resolveValue(from ?? "", bounds, expression);
109
- end = resolveValue(to ?? "", bounds, expression);
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
- values.add(v);
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
- /** Resolves a numeric or named field value, checking it against the bounds. */
129
- function resolveValue(raw, bounds, expression) {
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
- if (value < bounds.min || value > bounds.max) {
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
- candidate.setSeconds(0, 0);
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.0",
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.1",
31
- "@zudojs/constants": "1.0.1",
32
- "@zudojs/types": "1.0.0"
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",