@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
- /** 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. */
@@ -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
- /** 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
+ */
323
345
  abortSchedule(scheduleId) {
324
- const record = this.schedules.get(scheduleId);
325
- if (!record)
346
+ const controllers = this.runningByScheduleId.get(scheduleId);
347
+ if (!controllers)
326
348
  return;
327
- for (const controller of record.running) {
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
- 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);
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
- 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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/scheduler",
3
- "version": "1.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.1.0",
31
- "@zudojs/constants": "1.1.0",
32
- "@zudojs/types": "1.1.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",