@zudojs/scheduler 1.0.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.
@@ -38,7 +38,10 @@ export function parseDuration(duration) {
38
38
  throw new InvalidDurationError(duration);
39
39
  }
40
40
  // Each component is digits followed by a unit. `ms` is matched before `m`.
41
- const pattern = /(\d+)(ms|s|m|h|d|w)/gy;
41
+ // Sticky, not global: a failed component ends the loop instead of rescanning
42
+ // from the next offset, so a long digit run cannot be re-matched per offset.
43
+ // (`g` alongside `y` was redundant — sticky already wins for `exec`.)
44
+ const pattern = /(\d+)(ms|s|m|h|d|w)/y; // codeql[js/polynomial-redos]
42
45
  let total = 0;
43
46
  let matched = 0;
44
47
  let match;
@@ -1,6 +1,6 @@
1
1
  import { createJobContext } from "../job/jobContext.type.js";
2
2
  import { SchedulerJobExecutionError, SchedulerJobCancelledError, SchedulerJobTimeoutError, } from "../errors/scheduler.errors.js";
3
- import { DEFAULT_JOB_TIMEOUT, DEFAULT_MAX_RETRIES, DEFAULT_RETRY_DELAY, } from "../constants/schedulerConstants.core.js";
3
+ import { DEFAULT_JOB_TIMEOUT, DEFAULT_MAX_RETRIES, DEFAULT_RETRY_DELAY, MAX_TIMER_DELAY, } from "../constants/schedulerConstants.core.js";
4
4
  /** Marker attached to the rejection a timeout produces, carrying its budget. */
5
5
  const TIMEOUT = Symbol("scheduler.timeout");
6
6
  /**
@@ -54,7 +54,10 @@ export class JobExecutor {
54
54
  }
55
55
  /** Runs the handler once, under a timeout that also aborts it. */
56
56
  async runOnce(job, executionId, scheduledAt, attempt, signal, data) {
57
- const timeout = job.options?.timeout ?? DEFAULT_JOB_TIMEOUT;
57
+ // A job registered straight into a `JobRegistry` skips `define()`'s
58
+ // validation; clamp so an oversized budget waits, rather than being
59
+ // silently reduced to 1ms by the timer subsystem.
60
+ const timeout = Math.min(job.options?.timeout ?? DEFAULT_JOB_TIMEOUT, MAX_TIMER_DELAY);
58
61
  // A dedicated controller per attempt, chained to the caller's signal, so a
59
62
  // timeout actually aborts the handler instead of only rejecting the
60
63
  // wrapper while the work carries on.
@@ -63,6 +63,12 @@ export declare class Scheduler {
63
63
  private readonly pending;
64
64
  /** Bounded ring of execution records, newest last. */
65
65
  private readonly executionHistory;
66
+ /**
67
+ * Final state of schedules that have been retired, so a handle to a
68
+ * one-shot that already fired reports "completed" instead of the "active"
69
+ * it was created with. Bounded to {@link MAX_SCHEDULES} entries.
70
+ */
71
+ private readonly retiredStates;
66
72
  private running;
67
73
  private timer?;
68
74
  /**
@@ -122,6 +128,15 @@ export declare class Scheduler {
122
128
  private scheduleJob;
123
129
  /** Applies a state change to a live schedule. */
124
130
  private setScheduleState;
131
+ /**
132
+ * Drops a schedule that will never fire again, remembering why.
133
+ *
134
+ * Runs already held back by `overlap: "queue"` or a concurrency ceiling
135
+ * are kept by default: they are fire times that have arrived, and a
136
+ * one-shot retired at dispatch still owes them. Only a cancel, or a
137
+ * misfire policy that says skip, discards them.
138
+ */
139
+ private retire;
125
140
  /** Aborts every in-flight execution of one schedule. */
126
141
  private abortSchedule;
127
142
  /**
@@ -52,6 +52,12 @@ export class Scheduler {
52
52
  pending = new Set();
53
53
  /** Bounded ring of execution records, newest last. */
54
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();
55
61
  running = false;
56
62
  timer;
57
63
  /**
@@ -146,8 +152,14 @@ export class Scheduler {
146
152
  if (typeof job.handler !== "function") {
147
153
  throw new InvalidJobError(`Job "${job.id}" requires a handler function.`, job.id);
148
154
  }
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);
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);
151
163
  }
152
164
  this.jobs.register(job);
153
165
  }
@@ -227,7 +239,8 @@ export class Scheduler {
227
239
  // cancel actually reach the queue instead of mutating a detached copy.
228
240
  return new ScheduleHandleImpl(scheduleId, "active", {
229
241
  setState: (state) => this.setScheduleState(scheduleId, state),
230
- getState: () => this.schedules.get(scheduleId)?.schedule.state,
242
+ getState: () => this.schedules.get(scheduleId)?.schedule.state ??
243
+ this.retiredStates.get(scheduleId),
231
244
  getNextRun: () => this.schedules.get(scheduleId)?.schedule.nextRunAt,
232
245
  abortRunning: () => this.abortSchedule(scheduleId),
233
246
  });
@@ -240,10 +253,7 @@ export class Scheduler {
240
253
  record.schedule = { ...record.schedule, state };
241
254
  if (state === "cancelled" || state === "completed") {
242
255
  // 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);
256
+ this.retire(record, state, { dropPending: true });
247
257
  }
248
258
  else if (state === "paused") {
249
259
  this.queue.remove(scheduleId);
@@ -251,13 +261,52 @@ export class Scheduler {
251
261
  else if (state === "active") {
252
262
  // Resuming: recompute from now so a schedule paused across its fire time
253
263
  // 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();
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;
260
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);
261
310
  }
262
311
  }
263
312
  /** Aborts every in-flight execution of one schedule. */
@@ -319,12 +368,8 @@ export class Scheduler {
319
368
  const isRecurring = record.schedule.type === "interval" || record.schedule.type === "cron";
320
369
  if (!isRecurring) {
321
370
  // 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);
371
+ record.schedule = { ...record.schedule, lastRunAt: this.clock.now() };
372
+ this.retire(record, "completed");
328
373
  return;
329
374
  }
330
375
  // "catch-up" advances from the fire time that just ran, so a schedule
@@ -336,8 +381,7 @@ export class Scheduler {
336
381
  const next = record.trigger.next(from);
337
382
  if (!next || Number.isNaN(next.getTime())) {
338
383
  // A trigger with no further fire time is finished.
339
- record.schedule = { ...record.schedule, state: "completed" };
340
- this.schedules.delete(record.schedule.id);
384
+ this.retire(record, "completed");
341
385
  return;
342
386
  }
343
387
  record.schedule = {
@@ -428,6 +472,9 @@ export class Scheduler {
428
472
  else
429
473
  this.runningByJob.set(job.id, remaining);
430
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();
431
478
  });
432
479
  this.inFlight.add(execution);
433
480
  }
@@ -516,6 +563,13 @@ export class Scheduler {
516
563
  if (this.queue.isEmpty) {
517
564
  return MAX_TIMER_DELAY;
518
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
+ }
519
573
  const next = this.queue.peek();
520
574
  if (!next) {
521
575
  return MAX_TIMER_DELAY;
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/scheduler",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Scheduled task and job infrastructure with cron-like scheduling, persistence, and worker management.",
5
5
  "license": "MIT",
6
+ "author": {
7
+ "name": "Oluwayemi Oyinlola",
8
+ "url": "https://github.com/oyinlola-tech"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.js",
8
12
  "module": "./dist/index.js",
@@ -23,8 +27,8 @@
23
27
  "node": ">=24.0.0"
24
28
  },
25
29
  "dependencies": {
26
- "@zudojs/errors": "1.0.0",
27
- "@zudojs/constants": "1.0.0",
30
+ "@zudojs/errors": "1.0.1",
31
+ "@zudojs/constants": "1.0.1",
28
32
  "@zudojs/types": "1.0.0"
29
33
  },
30
34
  "devDependencies": {