@zudojs/scheduler 0.0.1 → 1.0.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.
Files changed (104) hide show
  1. package/README.md +170 -16
  2. package/dist/index.d.ts +7 -3
  3. package/dist/index.js +4 -3
  4. package/dist/scheduler/constants/index.d.ts +1 -1
  5. package/dist/scheduler/constants/index.js +1 -1
  6. package/dist/scheduler/constants/schedulerConstants.core.d.ts +0 -4
  7. package/dist/scheduler/constants/schedulerConstants.core.js +0 -4
  8. package/dist/scheduler/duration/duration.parser.d.ts +13 -5
  9. package/dist/scheduler/duration/duration.parser.js +54 -19
  10. package/dist/scheduler/errors/index.d.ts +1 -1
  11. package/dist/scheduler/errors/index.js +1 -1
  12. package/dist/scheduler/errors/scheduler.errors.d.ts +7 -1
  13. package/dist/scheduler/errors/scheduler.errors.js +7 -1
  14. package/dist/scheduler/executor/index.d.ts +1 -1
  15. package/dist/scheduler/executor/index.js +1 -1
  16. package/dist/scheduler/executor/jobExecutor.core.d.ts +24 -4
  17. package/dist/scheduler/executor/jobExecutor.core.js +151 -28
  18. package/dist/scheduler/job/jobOptions.type.d.ts +11 -0
  19. package/dist/scheduler/priorityQueue/schedulerPriorityQueue.core.d.ts +12 -0
  20. package/dist/scheduler/priorityQueue/schedulerPriorityQueue.core.js +32 -5
  21. package/dist/scheduler/registry/jobRegistry.core.d.ts +8 -0
  22. package/dist/scheduler/registry/jobRegistry.core.js +14 -0
  23. package/dist/scheduler/schedule/schedule.type.d.ts +11 -6
  24. package/dist/scheduler/schedule/schedule.type.js +10 -1
  25. package/dist/scheduler/schedule/scheduleOptions.type.d.ts +24 -1
  26. package/dist/scheduler/scheduleHandle/index.d.ts +1 -0
  27. package/dist/scheduler/scheduleHandle/scheduleHandle.type.d.ts +26 -3
  28. package/dist/scheduler/scheduleHandle/scheduleHandle.type.js +23 -7
  29. package/dist/scheduler/scheduler.core.d.ts +120 -11
  30. package/dist/scheduler/scheduler.core.js +424 -53
  31. package/dist/scheduler/trigger/cron.parser.d.ts +44 -0
  32. package/dist/scheduler/trigger/cron.parser.js +256 -0
  33. package/dist/scheduler/trigger/index.d.ts +2 -0
  34. package/dist/scheduler/trigger/index.js +1 -0
  35. package/dist/scheduler/trigger/schedulerTrigger.core.d.ts +22 -1
  36. package/dist/scheduler/trigger/schedulerTrigger.core.js +69 -6
  37. package/dist/scheduler/types/index.d.ts +12 -1
  38. package/dist/scheduler/types/index.js +7 -0
  39. package/dist/scheduler/types/schedulerTypes.core.d.ts +7 -87
  40. package/dist/scheduler/types/schedulerTypes.core.js +7 -1
  41. package/package.json +15 -8
  42. package/dist/.tsbuildinfo +0 -1
  43. package/dist/index.d.ts.map +0 -1
  44. package/dist/index.js.map +0 -1
  45. package/dist/scheduler/clock/index.d.ts.map +0 -1
  46. package/dist/scheduler/clock/index.js.map +0 -1
  47. package/dist/scheduler/clock/schedulerClock.type.d.ts.map +0 -1
  48. package/dist/scheduler/clock/schedulerClock.type.js.map +0 -1
  49. package/dist/scheduler/constants/index.d.ts.map +0 -1
  50. package/dist/scheduler/constants/index.js.map +0 -1
  51. package/dist/scheduler/constants/schedulerConstants.core.d.ts.map +0 -1
  52. package/dist/scheduler/constants/schedulerConstants.core.js.map +0 -1
  53. package/dist/scheduler/duration/duration.parser.d.ts.map +0 -1
  54. package/dist/scheduler/duration/duration.parser.js.map +0 -1
  55. package/dist/scheduler/duration/index.d.ts.map +0 -1
  56. package/dist/scheduler/duration/index.js.map +0 -1
  57. package/dist/scheduler/errors/index.d.ts.map +0 -1
  58. package/dist/scheduler/errors/index.js.map +0 -1
  59. package/dist/scheduler/errors/scheduler.errors.d.ts.map +0 -1
  60. package/dist/scheduler/errors/scheduler.errors.js.map +0 -1
  61. package/dist/scheduler/executor/index.d.ts.map +0 -1
  62. package/dist/scheduler/executor/index.js.map +0 -1
  63. package/dist/scheduler/executor/jobExecutor.core.d.ts.map +0 -1
  64. package/dist/scheduler/executor/jobExecutor.core.js.map +0 -1
  65. package/dist/scheduler/job/index.d.ts.map +0 -1
  66. package/dist/scheduler/job/index.js.map +0 -1
  67. package/dist/scheduler/job/jobContext.type.d.ts.map +0 -1
  68. package/dist/scheduler/job/jobContext.type.js.map +0 -1
  69. package/dist/scheduler/job/jobDefinition.type.d.ts.map +0 -1
  70. package/dist/scheduler/job/jobDefinition.type.js.map +0 -1
  71. package/dist/scheduler/job/jobHandler.type.d.ts.map +0 -1
  72. package/dist/scheduler/job/jobHandler.type.js.map +0 -1
  73. package/dist/scheduler/job/jobOptions.type.d.ts.map +0 -1
  74. package/dist/scheduler/job/jobOptions.type.js.map +0 -1
  75. package/dist/scheduler/priorityQueue/index.d.ts.map +0 -1
  76. package/dist/scheduler/priorityQueue/index.js.map +0 -1
  77. package/dist/scheduler/priorityQueue/schedulerPriorityQueue.core.d.ts.map +0 -1
  78. package/dist/scheduler/priorityQueue/schedulerPriorityQueue.core.js.map +0 -1
  79. package/dist/scheduler/registry/index.d.ts.map +0 -1
  80. package/dist/scheduler/registry/index.js.map +0 -1
  81. package/dist/scheduler/registry/jobRegistry.core.d.ts.map +0 -1
  82. package/dist/scheduler/registry/jobRegistry.core.js.map +0 -1
  83. package/dist/scheduler/schedule/index.d.ts.map +0 -1
  84. package/dist/scheduler/schedule/index.js.map +0 -1
  85. package/dist/scheduler/schedule/schedule.type.d.ts.map +0 -1
  86. package/dist/scheduler/schedule/schedule.type.js.map +0 -1
  87. package/dist/scheduler/schedule/scheduleOptions.type.d.ts.map +0 -1
  88. package/dist/scheduler/schedule/scheduleOptions.type.js.map +0 -1
  89. package/dist/scheduler/scheduleHandle/index.d.ts.map +0 -1
  90. package/dist/scheduler/scheduleHandle/index.js.map +0 -1
  91. package/dist/scheduler/scheduleHandle/scheduleHandle.type.d.ts.map +0 -1
  92. package/dist/scheduler/scheduleHandle/scheduleHandle.type.js.map +0 -1
  93. package/dist/scheduler/scheduler.core.d.ts.map +0 -1
  94. package/dist/scheduler/scheduler.core.js.map +0 -1
  95. package/dist/scheduler/trigger/index.d.ts.map +0 -1
  96. package/dist/scheduler/trigger/index.js.map +0 -1
  97. package/dist/scheduler/trigger/schedulerTrigger.core.d.ts.map +0 -1
  98. package/dist/scheduler/trigger/schedulerTrigger.core.js.map +0 -1
  99. package/dist/scheduler/trigger/trigger.type.d.ts.map +0 -1
  100. package/dist/scheduler/trigger/trigger.type.js.map +0 -1
  101. package/dist/scheduler/types/index.d.ts.map +0 -1
  102. package/dist/scheduler/types/index.js.map +0 -1
  103. package/dist/scheduler/types/schedulerTypes.core.d.ts.map +0 -1
  104. package/dist/scheduler/types/schedulerTypes.core.js.map +0 -1
@@ -1,14 +1,31 @@
1
1
  import { ScheduleHandleImpl } from "./scheduleHandle/scheduleHandle.type.js";
2
- import { SchedulerError, SchedulerNotStartedError, SchedulerAlreadyStartedError, SchedulerStoppedError, InvalidScheduleError, InvalidJobError, } from "./errors/scheduler.errors.js";
2
+ import { createSchedule } from "./schedule/schedule.type.js";
3
+ import { SchedulerAlreadyStartedError, SchedulerStoppedError, SchedulerJobCancelledError, SchedulerJobTimeoutError, InvalidScheduleError, InvalidJobError, } from "./errors/scheduler.errors.js";
3
4
  import { DateTrigger, DelayTrigger, IntervalTrigger, CronTrigger, } from "./trigger/schedulerTrigger.core.js";
4
5
  import { SystemClock } from "./clock/schedulerClock.type.js";
5
6
  import { JobRegistry } from "./registry/jobRegistry.core.js";
6
7
  import { JobExecutor } from "./executor/jobExecutor.core.js";
7
8
  import { PriorityQueue } from "./priorityQueue/schedulerPriorityQueue.core.js";
8
9
  import { parseDuration } from "./duration/duration.parser.js";
9
- import { MAX_SCHEDULES, MAX_TIMER_DELAY, } from "./constants/schedulerConstants.core.js";
10
+ import { MAX_SCHEDULES, MAX_TIMER_DELAY, MAX_EXECUTION_HISTORY, DEFAULT_MAX_CONCURRENCY, DEFAULT_MISFIRE_POLICY, DEFAULT_OVERLAP_POLICY, } from "./constants/schedulerConstants.core.js";
11
+ /**
12
+ * Ceiling on fire times held back by `overlap: "queue"` or a job concurrency
13
+ * limit. Without one, a schedule whose job never keeps up would grow an
14
+ * unbounded backlog of runs nobody wants any more.
15
+ */
16
+ const MAX_PENDING_RUNS = 1024;
10
17
  /**
11
18
  * Scheduler for time-based job execution.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * const scheduler = new Scheduler({ onError: (e) => log.error(e) });
23
+ * scheduler.define({ id: "cleanup", name: "Cleanup", handler: run });
24
+ * scheduler.every("5m", "cleanup");
25
+ * scheduler.start();
26
+ * // …later
27
+ * await scheduler.stop();
28
+ * ```
12
29
  */
13
30
  export class Scheduler {
14
31
  jobs;
@@ -16,13 +33,51 @@ export class Scheduler {
16
33
  queue;
17
34
  clock;
18
35
  schedules = new Map();
36
+ maxConcurrency;
37
+ onError;
38
+ /** Executions currently in flight, across all schedules. */
39
+ inFlight = new Set();
40
+ /**
41
+ * Abort controllers for every in-flight execution.
42
+ *
43
+ * Tracked here as well as on the schedule record: a one-shot schedule is
44
+ * retired as soon as it is dispatched, so by the time `stop()` runs its
45
+ * record is already gone and a per-record set alone would have nothing left
46
+ * to abort.
47
+ */
48
+ runningControllers = new Set();
49
+ /** Executions in flight per job id, for the per-job concurrency ceiling. */
50
+ runningByJob = new Map();
51
+ /** Records with `pendingRuns > 0`, drained as executions finish. */
52
+ pending = new Set();
53
+ /** Bounded ring of execution records, newest last. */
54
+ executionHistory = [];
19
55
  running = false;
20
56
  timer;
21
- constructor(jobs, executor, queue, clock) {
22
- this.jobs = jobs ?? new JobRegistry();
23
- this.executor = executor ?? new JobExecutor(clock ?? new SystemClock());
24
- this.queue = queue ?? new PriorityQueue();
25
- this.clock = clock ?? new SystemClock();
57
+ /**
58
+ * @param options - Scheduler options, or a {@link JobRegistry} for the
59
+ * positional form kept for backwards compatibility.
60
+ */
61
+ constructor(options, executor, queue, clock) {
62
+ const opts = options instanceof JobRegistry
63
+ ? { jobs: options, executor, queue, clock }
64
+ : (options ?? {});
65
+ // Resolve the clock once and share it. Building a second SystemClock for
66
+ // the executor meant a custom clock reached only one of the two.
67
+ this.clock = opts.clock ?? new SystemClock();
68
+ this.jobs = opts.jobs ?? new JobRegistry();
69
+ this.executor = opts.executor ?? new JobExecutor(this.clock);
70
+ this.queue = opts.queue ?? new PriorityQueue();
71
+ this.maxConcurrency = opts.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
72
+ this.onError = opts.onError;
73
+ }
74
+ /** Whether the scheduler is currently running. */
75
+ get isRunning() {
76
+ return this.running;
77
+ }
78
+ /** Number of live schedules. */
79
+ get scheduleCount() {
80
+ return this.schedules.size;
26
81
  }
27
82
  /**
28
83
  * Starts the scheduler.
@@ -35,9 +90,12 @@ export class Scheduler {
35
90
  this.tick();
36
91
  }
37
92
  /**
38
- * Stops the scheduler.
93
+ * Stops the scheduler, aborting in-flight jobs and waiting for them to settle.
94
+ *
95
+ * @param options - `drain` waits for running jobs to finish instead of
96
+ * aborting them; `timeoutMs` bounds the wait either way.
39
97
  */
40
- stop() {
98
+ async stop(options) {
41
99
  if (!this.running) {
42
100
  throw new SchedulerStoppedError();
43
101
  }
@@ -46,66 +104,170 @@ export class Scheduler {
46
104
  clearTimeout(this.timer);
47
105
  this.timer = undefined;
48
106
  }
107
+ if (options?.drain !== true) {
108
+ // Abort every in-flight execution. Previously the AbortController was
109
+ // created and immediately discarded, so nothing could ever be cancelled.
110
+ for (const controller of this.runningControllers) {
111
+ controller.abort(new Error("Scheduler stopped"));
112
+ }
113
+ // Runs held back by overlap or a concurrency ceiling are discarded with
114
+ // them; keeping them would fire on a later start for a time long past.
115
+ for (const record of this.pending)
116
+ record.pendingRuns = 0;
117
+ this.pending.clear();
118
+ }
119
+ await this.settle(options?.timeoutMs);
120
+ }
121
+ /** Waits for in-flight executions to finish, up to an optional timeout. */
122
+ async settle(timeoutMs) {
123
+ if (this.inFlight.size === 0)
124
+ return;
125
+ const all = Promise.allSettled([...this.inFlight]).then(() => undefined);
126
+ if (timeoutMs === undefined) {
127
+ await all;
128
+ return;
129
+ }
130
+ await Promise.race([
131
+ all,
132
+ new Promise((resolve) => {
133
+ const timer = setTimeout(resolve, timeoutMs);
134
+ if (timer.unref)
135
+ timer.unref();
136
+ }),
137
+ ]);
49
138
  }
50
139
  /**
51
140
  * Defines a new job.
52
141
  */
53
142
  define(job) {
143
+ if (typeof job?.id !== "string" || job.id.trim().length === 0) {
144
+ throw new InvalidJobError("Job definition requires a non-empty string id.", String(job?.id));
145
+ }
146
+ if (typeof job.handler !== "function") {
147
+ throw new InvalidJobError(`Job "${job.id}" requires a handler function.`, job.id);
148
+ }
149
+ if (job.options?.timeout !== undefined && job.options.timeout <= 0) {
150
+ throw new InvalidJobError(`Job "${job.id}" timeout must be positive, got ${job.options.timeout}.`, job.id);
151
+ }
54
152
  this.jobs.register(job);
55
153
  }
56
154
  /**
57
155
  * Schedules a job to run once after a delay.
58
156
  */
59
- after(delay, jobId) {
157
+ after(delay, jobId, options) {
60
158
  const delayMs = parseDuration(delay);
61
- const trigger = new DelayTrigger(delayMs);
62
- return this.scheduleJob(jobId, trigger, "delay");
159
+ return this.scheduleJob(jobId, new DelayTrigger(delayMs), "delay", options);
63
160
  }
64
161
  /**
65
162
  * Schedules a job to run at a specific date.
66
163
  */
67
- at(date, jobId) {
68
- const trigger = new DateTrigger(date);
69
- return this.scheduleJob(jobId, trigger, "once");
164
+ at(date, jobId, options) {
165
+ return this.scheduleJob(jobId, new DateTrigger(date), "once", options);
70
166
  }
71
167
  /**
72
168
  * Schedules a job to run at a fixed interval.
73
169
  */
74
- every(interval, jobId) {
170
+ every(interval, jobId, options) {
75
171
  const intervalMs = parseDuration(interval);
76
- const trigger = new IntervalTrigger(intervalMs);
77
- return this.scheduleJob(jobId, trigger, "interval");
172
+ return this.scheduleJob(jobId, new IntervalTrigger(intervalMs), "interval", options);
78
173
  }
79
174
  /**
80
175
  * Schedules a job using a cron expression.
81
176
  */
82
- cron(expression, jobId) {
83
- const trigger = new CronTrigger(expression);
84
- return this.scheduleJob(jobId, trigger, "cron", { expression });
177
+ cron(expression, jobId, options) {
178
+ const trigger = new CronTrigger(expression, options?.timezone);
179
+ return this.scheduleJob(jobId, trigger, "cron", options, expression);
180
+ }
181
+ /** Returns a snapshot of a schedule, or undefined. */
182
+ getSchedule(scheduleId) {
183
+ return this.schedules.get(scheduleId)?.schedule;
184
+ }
185
+ /** Returns snapshots of every live schedule. */
186
+ listSchedules() {
187
+ return [...this.schedules.values()].map((record) => record.schedule);
85
188
  }
86
189
  /**
87
190
  * Schedules a job with a trigger.
88
191
  */
89
- scheduleJob(jobId, trigger, type, options) {
192
+ scheduleJob(jobId, trigger, type, options, expression) {
90
193
  if (!this.jobs.has(jobId)) {
91
194
  throw new InvalidJobError(`Job "${jobId}" is not registered.`, jobId);
92
195
  }
196
+ if (this.schedules.size >= MAX_SCHEDULES) {
197
+ throw new InvalidScheduleError(`Maximum number of schedules (${MAX_SCHEDULES}) exceeded.`, jobId);
198
+ }
93
199
  const scheduleId = crypto.randomUUID();
94
- const nextRunAt = trigger.next(this.clock.now());
200
+ const now = this.clock.now();
201
+ let nextRunAt = trigger.next(now);
95
202
  if (nextRunAt === null) {
96
- throw new InvalidScheduleError("Trigger returned null for next run.", scheduleId);
97
- }
98
- const schedule = {
99
- id: scheduleId,
100
- jobId,
101
- type,
102
- expression: options?.expression,
103
- nextRunAt,
104
- state: "active",
203
+ // The fire time has already passed. That is the misfire case, not an
204
+ // error — `at(pastDate)` and a schedule restored after a restart both
205
+ // land here.
206
+ const misfire = options?.misfire ?? DEFAULT_MISFIRE_POLICY;
207
+ if (misfire === "skip") {
208
+ throw new InvalidScheduleError("Trigger has no future fire time and the misfire policy is 'skip'.", scheduleId);
209
+ }
210
+ // "run-once" and "catch-up" both start by running immediately.
211
+ nextRunAt = now;
212
+ }
213
+ if (Number.isNaN(nextRunAt.getTime())) {
214
+ throw new InvalidScheduleError("Trigger produced an invalid date.", scheduleId);
215
+ }
216
+ const schedule = createSchedule(scheduleId, jobId, type, nextRunAt, options ?? {}, expression);
217
+ const record = {
218
+ schedule,
219
+ trigger,
220
+ options: options ?? {},
221
+ running: new Set(),
222
+ pendingRuns: 0,
105
223
  };
106
- this.schedules.set(scheduleId, schedule);
224
+ this.schedules.set(scheduleId, record);
107
225
  this.queue.enqueue(schedule);
108
- return new ScheduleHandleImpl(scheduleId, "active");
226
+ // The handle holds a reference to this scheduler, so pause, resume and
227
+ // cancel actually reach the queue instead of mutating a detached copy.
228
+ return new ScheduleHandleImpl(scheduleId, "active", {
229
+ setState: (state) => this.setScheduleState(scheduleId, state),
230
+ getState: () => this.schedules.get(scheduleId)?.schedule.state,
231
+ getNextRun: () => this.schedules.get(scheduleId)?.schedule.nextRunAt,
232
+ abortRunning: () => this.abortSchedule(scheduleId),
233
+ });
234
+ }
235
+ /** Applies a state change to a live schedule. */
236
+ setScheduleState(scheduleId, state) {
237
+ const record = this.schedules.get(scheduleId);
238
+ if (!record)
239
+ return;
240
+ record.schedule = { ...record.schedule, state };
241
+ if (state === "cancelled" || state === "completed") {
242
+ // Held-back runs belong to a schedule that no longer exists.
243
+ record.pendingRuns = 0;
244
+ this.pending.delete(record);
245
+ this.queue.remove(scheduleId);
246
+ this.schedules.delete(scheduleId);
247
+ }
248
+ else if (state === "paused") {
249
+ this.queue.remove(scheduleId);
250
+ }
251
+ else if (state === "active") {
252
+ // Resuming: recompute from now so a schedule paused across its fire time
253
+ // does not immediately fire for every occurrence it missed.
254
+ const next = record.trigger.next(this.clock.now());
255
+ if (next && !Number.isNaN(next.getTime())) {
256
+ record.schedule = { ...record.schedule, nextRunAt: next };
257
+ this.queue.remove(scheduleId);
258
+ this.queue.enqueue(record.schedule);
259
+ this.rearm();
260
+ }
261
+ }
262
+ }
263
+ /** Aborts every in-flight execution of one schedule. */
264
+ abortSchedule(scheduleId) {
265
+ const record = this.schedules.get(scheduleId);
266
+ if (!record)
267
+ return;
268
+ for (const controller of record.running) {
269
+ controller.abort(new Error("Schedule cancelled"));
270
+ }
109
271
  }
110
272
  /**
111
273
  * Internal tick method for processing due jobs.
@@ -121,13 +283,231 @@ export class Scheduler {
121
283
  break;
122
284
  }
123
285
  this.queue.dequeue();
124
- if (schedule.state !== "active") {
286
+ const record = this.schedules.get(schedule.id);
287
+ if (!record || record.schedule.state !== "active") {
125
288
  continue;
126
289
  }
127
- this.executeSchedule(schedule);
290
+ // Concurrency ceiling: without one, N due schedules start N executions
291
+ // at once. Anything over the ceiling is left for the next tick.
292
+ if (this.inFlight.size >= this.maxConcurrency) {
293
+ this.queue.enqueue(record.schedule);
294
+ break;
295
+ }
296
+ this.dispatch(record);
297
+ this.reschedule(record);
128
298
  }
299
+ this.rearm();
300
+ }
301
+ /** (Re)arms the tick timer for the next due schedule. */
302
+ rearm() {
303
+ if (!this.running)
304
+ return;
305
+ if (this.timer)
306
+ clearTimeout(this.timer);
129
307
  const delay = this.calculateDelay();
130
308
  this.timer = setTimeout(() => this.tick(), delay);
309
+ if (this.timer.unref)
310
+ this.timer.unref();
311
+ }
312
+ /**
313
+ * Computes the next fire time and puts the schedule back on the queue.
314
+ *
315
+ * Nothing did this before, so `every()` and `cron()` fired exactly once and
316
+ * then sat in the map forever.
317
+ */
318
+ reschedule(record) {
319
+ const isRecurring = record.schedule.type === "interval" || record.schedule.type === "cron";
320
+ if (!isRecurring) {
321
+ // One-shot: retire it rather than leaking the entry.
322
+ record.schedule = {
323
+ ...record.schedule,
324
+ state: "completed",
325
+ lastRunAt: this.clock.now(),
326
+ };
327
+ this.schedules.delete(record.schedule.id);
328
+ return;
329
+ }
330
+ // "catch-up" advances from the fire time that just ran, so a schedule
331
+ // that fell behind replays each missed occurrence instead of silently
332
+ // skipping to the next future one. Every other policy resumes from now.
333
+ const misfire = record.options.misfire ?? DEFAULT_MISFIRE_POLICY;
334
+ const now = this.clock.now();
335
+ const from = misfire === "catch-up" ? record.schedule.nextRunAt : now;
336
+ const next = record.trigger.next(from);
337
+ if (!next || Number.isNaN(next.getTime())) {
338
+ // A trigger with no further fire time is finished.
339
+ record.schedule = { ...record.schedule, state: "completed" };
340
+ this.schedules.delete(record.schedule.id);
341
+ return;
342
+ }
343
+ record.schedule = {
344
+ ...record.schedule,
345
+ nextRunAt: next,
346
+ lastRunAt: now,
347
+ };
348
+ this.queue.enqueue(record.schedule);
349
+ }
350
+ /** Starts one execution of a schedule and tracks it. */
351
+ dispatch(record) {
352
+ const job = this.jobs.get(record.schedule.jobId);
353
+ if (!job)
354
+ return;
355
+ // A schedule cancelled or paused while a run was held back must not fire.
356
+ if (record.schedule.state === "cancelled" ||
357
+ record.schedule.state === "paused") {
358
+ return;
359
+ }
360
+ // A schedule's own policy wins over the job's default.
361
+ const overlap = record.options.overlap ?? job.options?.overlap ?? DEFAULT_OVERLAP_POLICY;
362
+ if (record.running.size > 0) {
363
+ if (overlap === "skip")
364
+ return;
365
+ if (overlap === "queue") {
366
+ // Hold the fire time rather than dropping it; it is dispatched when
367
+ // the running execution finishes. "queue" used to fall through to
368
+ // "allow" and start a concurrent run.
369
+ this.defer(record);
370
+ return;
371
+ }
372
+ if (overlap === "replace") {
373
+ for (const controller of record.running) {
374
+ controller.abort(new Error("Superseded by a newer execution"));
375
+ }
376
+ }
377
+ }
378
+ // Per-job concurrency ceiling. `JobOptions.concurrency` was accepted and
379
+ // read by nothing, so a job declaring `concurrency: 1` still ran as many
380
+ // executions at once as it had due schedules.
381
+ const concurrency = job.options?.concurrency;
382
+ if (concurrency !== undefined &&
383
+ (this.runningByJob.get(job.id) ?? 0) >= Math.max(1, concurrency)) {
384
+ this.defer(record);
385
+ return;
386
+ }
387
+ const executionId = crypto.randomUUID();
388
+ const controller = new AbortController();
389
+ record.running.add(controller);
390
+ this.runningControllers.add(controller);
391
+ this.runningByJob.set(job.id, (this.runningByJob.get(job.id) ?? 0) + 1);
392
+ const scheduleId = record.schedule.id;
393
+ const scheduledAt = record.schedule.nextRunAt;
394
+ const startedAt = this.clock.now();
395
+ this.beginExecution({
396
+ id: executionId,
397
+ jobId: job.id,
398
+ scheduleId,
399
+ status: "running",
400
+ scheduledAt,
401
+ startedAt,
402
+ attempt: 1,
403
+ });
404
+ const execution = this.executor
405
+ .execute(job, executionId, scheduledAt, 1, controller.signal, record.options.data)
406
+ .then(() => {
407
+ this.finishExecution(executionId, "completed", startedAt);
408
+ })
409
+ .catch((error) => {
410
+ this.finishExecution(executionId, statusFor(error), startedAt, error);
411
+ // A failure used to vanish into an empty catch block with a "retry
412
+ // logic would go here" comment. Retries now live in the executor, and
413
+ // whatever survives them is reported.
414
+ this.reportError({
415
+ scheduleId,
416
+ jobId: job.id,
417
+ executionId,
418
+ error,
419
+ });
420
+ })
421
+ .finally(() => {
422
+ record.running.delete(controller);
423
+ this.runningControllers.delete(controller);
424
+ this.inFlight.delete(execution);
425
+ const remaining = (this.runningByJob.get(job.id) ?? 1) - 1;
426
+ if (remaining <= 0)
427
+ this.runningByJob.delete(job.id);
428
+ else
429
+ this.runningByJob.set(job.id, remaining);
430
+ this.drainPending();
431
+ });
432
+ this.inFlight.add(execution);
433
+ }
434
+ /** Holds a fire time back until capacity frees up. */
435
+ defer(record) {
436
+ if (record.pendingRuns >= MAX_PENDING_RUNS)
437
+ return;
438
+ record.pendingRuns += 1;
439
+ this.pending.add(record);
440
+ }
441
+ /**
442
+ * Dispatches held-back runs that can now proceed.
443
+ *
444
+ * A record that is still blocked re-defers itself, which shows up as its
445
+ * counter returning to where it started; that is the loop's exit condition,
446
+ * so a blocked record cannot spin.
447
+ */
448
+ drainPending() {
449
+ if (!this.running || this.pending.size === 0)
450
+ return;
451
+ for (const record of [...this.pending]) {
452
+ while (record.pendingRuns > 0) {
453
+ if (this.inFlight.size >= this.maxConcurrency)
454
+ return;
455
+ const before = record.pendingRuns;
456
+ record.pendingRuns -= 1;
457
+ this.dispatch(record);
458
+ if (record.pendingRuns >= before)
459
+ break;
460
+ }
461
+ if (record.pendingRuns === 0)
462
+ this.pending.delete(record);
463
+ }
464
+ }
465
+ /** Records a started execution, evicting the oldest beyond the cap. */
466
+ beginExecution(execution) {
467
+ this.executionHistory.push(execution);
468
+ while (this.executionHistory.length > MAX_EXECUTION_HISTORY) {
469
+ this.executionHistory.shift();
470
+ }
471
+ }
472
+ /** Completes the record for an execution, if it is still in the history. */
473
+ finishExecution(executionId, status, startedAt, error) {
474
+ const index = this.executionHistory.findIndex((entry) => entry.id === executionId);
475
+ if (index === -1)
476
+ return;
477
+ const completedAt = this.clock.now();
478
+ this.executionHistory[index] = {
479
+ ...this.executionHistory[index],
480
+ status,
481
+ completedAt,
482
+ duration: completedAt.getTime() - startedAt.getTime(),
483
+ ...(error === undefined ? {} : { error }),
484
+ };
485
+ }
486
+ /**
487
+ * Returns the recorded executions, oldest first.
488
+ *
489
+ * At most {@link MAX_EXECUTION_HISTORY} are kept. `JobExecution` and that
490
+ * constant were both exported from the beginning and nothing produced or
491
+ * read either.
492
+ *
493
+ * @param jobId - Restrict to one job's executions.
494
+ */
495
+ getExecutions(jobId) {
496
+ const all = [...this.executionHistory];
497
+ return jobId === undefined
498
+ ? all
499
+ : all.filter((execution) => execution.jobId === jobId);
500
+ }
501
+ /** Hands an execution failure to the error listener. */
502
+ reportError(event) {
503
+ if (!this.onError)
504
+ return;
505
+ try {
506
+ this.onError(event);
507
+ }
508
+ catch {
509
+ // A throwing error listener must not take the scheduler down with it.
510
+ }
131
511
  }
132
512
  /**
133
513
  * Calculates the delay until the next tick.
@@ -143,22 +523,13 @@ export class Scheduler {
143
523
  const delay = next.nextRunAt.getTime() - this.clock.nowMs();
144
524
  return Math.max(0, Math.min(delay, MAX_TIMER_DELAY));
145
525
  }
146
- /**
147
- * Executes a schedule.
148
- */
149
- async executeSchedule(schedule) {
150
- const job = this.jobs.get(schedule.jobId);
151
- if (!job) {
152
- return;
153
- }
154
- const executionId = crypto.randomUUID();
155
- const signal = new AbortController().signal;
156
- try {
157
- await this.executor.execute(job, executionId, schedule.nextRunAt, 1, signal);
158
- }
159
- catch (error) {
160
- // Job execution failed - retry logic would go here
161
- }
162
- }
526
+ }
527
+ /** Maps a failed execution onto the job state that describes it. */
528
+ function statusFor(error) {
529
+ if (error instanceof SchedulerJobTimeoutError)
530
+ return "timed_out";
531
+ if (error instanceof SchedulerJobCancelledError)
532
+ return "cancelled";
533
+ return "failed";
163
534
  }
164
535
  //# sourceMappingURL=scheduler.core.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @zudojs/scheduler/trigger/cron
3
+ *
4
+ * Standard five-field cron expression parsing and next-fire computation.
5
+ *
6
+ * Fields, in order: minute, hour, day-of-month, month, day-of-week.
7
+ * Each supports `*`, a value, a `a-b` range, a `a-b/n` or `*\/n` step, and a
8
+ * comma-separated list of any of those. Month and day-of-week also accept the
9
+ * usual three-letter names. A leading `@yearly`-style macro is expanded first.
10
+ */
11
+ /** A parsed cron expression: the permitted values for each field. */
12
+ export interface ParsedCron {
13
+ readonly minute: ReadonlySet<number>;
14
+ readonly hour: ReadonlySet<number>;
15
+ readonly dayOfMonth: ReadonlySet<number>;
16
+ readonly month: ReadonlySet<number>;
17
+ readonly dayOfWeek: ReadonlySet<number>;
18
+ /** True when day-of-month was `*`, which changes how the two day fields combine. */
19
+ readonly dayOfMonthUnrestricted: boolean;
20
+ /** True when day-of-week was `*`. */
21
+ readonly dayOfWeekUnrestricted: boolean;
22
+ }
23
+ /**
24
+ * Parses a cron expression into the set of values each field permits.
25
+ *
26
+ * @param expression - A five-field cron expression or a supported macro.
27
+ * @returns The parsed expression.
28
+ * @throws {CronParseError} when the expression is not valid.
29
+ */
30
+ export declare function parseCron(expression: string): ParsedCron;
31
+ /**
32
+ * Computes the next time a parsed cron expression fires, strictly after `after`.
33
+ *
34
+ * Search is minute-by-minute with whole-field skips, bounded by
35
+ * {@link MAX_SEARCH_YEARS} so an unsatisfiable expression (30 February) fails
36
+ * rather than looping.
37
+ *
38
+ * @param parsed - The parsed expression.
39
+ * @param after - The instant to search forward from (exclusive).
40
+ * @param utc - Interpret the fields in UTC rather than local time.
41
+ * @returns The next fire time, or null when none exists within the horizon.
42
+ */
43
+ export declare function nextCronDate(parsed: ParsedCron, after: Date, utc?: boolean): Date | null;
44
+ //# sourceMappingURL=cron.parser.d.ts.map