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