@zudojs/scheduler 1.0.0 → 1.1.1
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 +13 -0
- package/dist/scheduler/duration/duration.parser.js +4 -1
- package/dist/scheduler/executor/jobExecutor.core.js +5 -2
- package/dist/scheduler/scheduler.core.d.ts +15 -0
- package/dist/scheduler/scheduler.core.js +85 -21
- package/dist/scheduler/trigger/cron.parser.js +16 -4
- package/package.json +8 -4
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 &&
|
|
150
|
-
|
|
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
|
}
|
|
@@ -199,6 +211,12 @@ export class Scheduler {
|
|
|
199
211
|
const scheduleId = crypto.randomUUID();
|
|
200
212
|
const now = this.clock.now();
|
|
201
213
|
let nextRunAt = trigger.next(now);
|
|
214
|
+
if (nextRunAt === null && (type === "cron" || type === "interval")) {
|
|
215
|
+
// A recurring trigger with no next fire time is unsatisfiable (30
|
|
216
|
+
// February) — not a misfire. Running it "once now" fired a job at an
|
|
217
|
+
// arbitrary moment and then retired it silently.
|
|
218
|
+
throw new InvalidScheduleError(`Recurring trigger has no future fire time${expression === undefined ? "" : ` ("${expression}")`}.`, jobId);
|
|
219
|
+
}
|
|
202
220
|
if (nextRunAt === null) {
|
|
203
221
|
// The fire time has already passed. That is the misfire case, not an
|
|
204
222
|
// error — `at(pastDate)` and a schedule restored after a restart both
|
|
@@ -223,11 +241,16 @@ export class Scheduler {
|
|
|
223
241
|
};
|
|
224
242
|
this.schedules.set(scheduleId, record);
|
|
225
243
|
this.queue.enqueue(schedule);
|
|
244
|
+
// A schedule added after start() must re-arm the timer: it may be due
|
|
245
|
+
// sooner than whatever the timer is currently armed for (up to ~24.8
|
|
246
|
+
// days on an empty scheduler).
|
|
247
|
+
this.rearm();
|
|
226
248
|
// The handle holds a reference to this scheduler, so pause, resume and
|
|
227
249
|
// cancel actually reach the queue instead of mutating a detached copy.
|
|
228
250
|
return new ScheduleHandleImpl(scheduleId, "active", {
|
|
229
251
|
setState: (state) => this.setScheduleState(scheduleId, state),
|
|
230
|
-
getState: () => this.schedules.get(scheduleId)?.schedule.state
|
|
252
|
+
getState: () => this.schedules.get(scheduleId)?.schedule.state ??
|
|
253
|
+
this.retiredStates.get(scheduleId),
|
|
231
254
|
getNextRun: () => this.schedules.get(scheduleId)?.schedule.nextRunAt,
|
|
232
255
|
abortRunning: () => this.abortSchedule(scheduleId),
|
|
233
256
|
});
|
|
@@ -240,10 +263,7 @@ export class Scheduler {
|
|
|
240
263
|
record.schedule = { ...record.schedule, state };
|
|
241
264
|
if (state === "cancelled" || state === "completed") {
|
|
242
265
|
// Held-back runs belong to a schedule that no longer exists.
|
|
243
|
-
record
|
|
244
|
-
this.pending.delete(record);
|
|
245
|
-
this.queue.remove(scheduleId);
|
|
246
|
-
this.schedules.delete(scheduleId);
|
|
266
|
+
this.retire(record, state, { dropPending: true });
|
|
247
267
|
}
|
|
248
268
|
else if (state === "paused") {
|
|
249
269
|
this.queue.remove(scheduleId);
|
|
@@ -251,13 +271,52 @@ export class Scheduler {
|
|
|
251
271
|
else if (state === "active") {
|
|
252
272
|
// Resuming: recompute from now so a schedule paused across its fire time
|
|
253
273
|
// does not immediately fire for every occurrence it missed.
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
274
|
+
const now = this.clock.now();
|
|
275
|
+
let next = record.trigger.next(now);
|
|
276
|
+
if (next === null) {
|
|
277
|
+
// The fire time passed while paused. A one-shot used to stay "active"
|
|
278
|
+
// here with nothing ever able to dispatch it; apply the misfire
|
|
279
|
+
// policy exactly as `scheduleJob` does for a fire time already past.
|
|
280
|
+
const misfire = record.options.misfire ?? DEFAULT_MISFIRE_POLICY;
|
|
281
|
+
const isRecurring = record.schedule.type === "interval" ||
|
|
282
|
+
record.schedule.type === "cron";
|
|
283
|
+
if (misfire === "skip" || isRecurring) {
|
|
284
|
+
// Nothing left to fire: retire it rather than leaking an entry.
|
|
285
|
+
this.retire(record, "completed", { dropPending: true });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
next = now;
|
|
260
289
|
}
|
|
290
|
+
if (Number.isNaN(next.getTime()))
|
|
291
|
+
return;
|
|
292
|
+
record.schedule = { ...record.schedule, nextRunAt: next };
|
|
293
|
+
this.queue.remove(scheduleId);
|
|
294
|
+
this.queue.enqueue(record.schedule);
|
|
295
|
+
this.rearm();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Drops a schedule that will never fire again, remembering why.
|
|
300
|
+
*
|
|
301
|
+
* Runs already held back by `overlap: "queue"` or a concurrency ceiling
|
|
302
|
+
* are kept by default: they are fire times that have arrived, and a
|
|
303
|
+
* one-shot retired at dispatch still owes them. Only a cancel, or a
|
|
304
|
+
* misfire policy that says skip, discards them.
|
|
305
|
+
*/
|
|
306
|
+
retire(record, state, options) {
|
|
307
|
+
record.schedule = { ...record.schedule, state };
|
|
308
|
+
if (options?.dropPending === true) {
|
|
309
|
+
record.pendingRuns = 0;
|
|
310
|
+
this.pending.delete(record);
|
|
311
|
+
}
|
|
312
|
+
this.queue.remove(record.schedule.id);
|
|
313
|
+
this.schedules.delete(record.schedule.id);
|
|
314
|
+
this.retiredStates.set(record.schedule.id, state);
|
|
315
|
+
while (this.retiredStates.size > MAX_SCHEDULES) {
|
|
316
|
+
const oldest = this.retiredStates.keys().next().value;
|
|
317
|
+
if (oldest === undefined)
|
|
318
|
+
break;
|
|
319
|
+
this.retiredStates.delete(oldest);
|
|
261
320
|
}
|
|
262
321
|
}
|
|
263
322
|
/** Aborts every in-flight execution of one schedule. */
|
|
@@ -319,12 +378,8 @@ export class Scheduler {
|
|
|
319
378
|
const isRecurring = record.schedule.type === "interval" || record.schedule.type === "cron";
|
|
320
379
|
if (!isRecurring) {
|
|
321
380
|
// One-shot: retire it rather than leaking the entry.
|
|
322
|
-
record.schedule = {
|
|
323
|
-
|
|
324
|
-
state: "completed",
|
|
325
|
-
lastRunAt: this.clock.now(),
|
|
326
|
-
};
|
|
327
|
-
this.schedules.delete(record.schedule.id);
|
|
381
|
+
record.schedule = { ...record.schedule, lastRunAt: this.clock.now() };
|
|
382
|
+
this.retire(record, "completed");
|
|
328
383
|
return;
|
|
329
384
|
}
|
|
330
385
|
// "catch-up" advances from the fire time that just ran, so a schedule
|
|
@@ -336,8 +391,7 @@ export class Scheduler {
|
|
|
336
391
|
const next = record.trigger.next(from);
|
|
337
392
|
if (!next || Number.isNaN(next.getTime())) {
|
|
338
393
|
// A trigger with no further fire time is finished.
|
|
339
|
-
|
|
340
|
-
this.schedules.delete(record.schedule.id);
|
|
394
|
+
this.retire(record, "completed");
|
|
341
395
|
return;
|
|
342
396
|
}
|
|
343
397
|
record.schedule = {
|
|
@@ -428,6 +482,9 @@ export class Scheduler {
|
|
|
428
482
|
else
|
|
429
483
|
this.runningByJob.set(job.id, remaining);
|
|
430
484
|
this.drainPending();
|
|
485
|
+
// Capacity freed: a schedule held back by the ceiling is waiting for
|
|
486
|
+
// exactly this moment, and no timer is armed for it.
|
|
487
|
+
this.rearm();
|
|
431
488
|
});
|
|
432
489
|
this.inFlight.add(execution);
|
|
433
490
|
}
|
|
@@ -516,6 +573,13 @@ export class Scheduler {
|
|
|
516
573
|
if (this.queue.isEmpty) {
|
|
517
574
|
return MAX_TIMER_DELAY;
|
|
518
575
|
}
|
|
576
|
+
// At the ceiling a due schedule cannot be dispatched, and arming a
|
|
577
|
+
// zero-delay timer for it spun the event loop — about a tick per
|
|
578
|
+
// millisecond — until an execution finished. The finishing execution
|
|
579
|
+
// re-arms the timer, so waiting here loses nothing.
|
|
580
|
+
if (this.inFlight.size >= this.maxConcurrency) {
|
|
581
|
+
return MAX_TIMER_DELAY;
|
|
582
|
+
}
|
|
519
583
|
const next = this.queue.peek();
|
|
520
584
|
if (!next) {
|
|
521
585
|
return MAX_TIMER_DELAY;
|
|
@@ -172,7 +172,10 @@ export function nextCronDate(parsed, after, utc = false) {
|
|
|
172
172
|
};
|
|
173
173
|
// Start at the next whole minute after `after`, with seconds cleared.
|
|
174
174
|
const candidate = new Date(after.getTime());
|
|
175
|
-
|
|
175
|
+
if (utc)
|
|
176
|
+
candidate.setUTCSeconds(0, 0);
|
|
177
|
+
else
|
|
178
|
+
candidate.setSeconds(0, 0);
|
|
176
179
|
candidate.setTime(candidate.getTime() + 60_000);
|
|
177
180
|
const limitYear = get.year(after) + MAX_SEARCH_YEARS;
|
|
178
181
|
while (get.year(candidate) <= limitYear) {
|
|
@@ -185,7 +188,7 @@ export function nextCronDate(parsed, after, utc = false) {
|
|
|
185
188
|
continue;
|
|
186
189
|
}
|
|
187
190
|
if (!parsed.hour.has(get.hour(candidate))) {
|
|
188
|
-
advanceHour(candidate);
|
|
191
|
+
advanceHour(candidate, utc);
|
|
189
192
|
continue;
|
|
190
193
|
}
|
|
191
194
|
if (!parsed.minute.has(get.minute(candidate))) {
|
|
@@ -246,9 +249,18 @@ function advanceDay(candidate, utc) {
|
|
|
246
249
|
* Moves to the top of the next hour.
|
|
247
250
|
*
|
|
248
251
|
* 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.
|
|
252
|
+
* DST transition does not skip or repeat an hour of scheduling. In UTC mode
|
|
253
|
+
* the top of the hour is taken in UTC: a host on a half-hour offset
|
|
254
|
+
* (Asia/Kolkata) would otherwise land every skip on :30 UTC and never visit
|
|
255
|
+
* minutes 0-29 of a restricted hour.
|
|
250
256
|
*/
|
|
251
|
-
function advanceHour(candidate) {
|
|
257
|
+
function advanceHour(candidate, utc) {
|
|
258
|
+
if (utc) {
|
|
259
|
+
candidate.setUTCMinutes(0, 0, 0);
|
|
260
|
+
candidate.setTime(candidate.getTime() + 3_600_000);
|
|
261
|
+
candidate.setUTCMinutes(0, 0, 0);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
252
264
|
candidate.setMinutes(0, 0, 0);
|
|
253
265
|
candidate.setTime(candidate.getTime() + 3_600_000);
|
|
254
266
|
candidate.setMinutes(0, 0, 0);
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/scheduler",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
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,9 +27,9 @@
|
|
|
23
27
|
"node": ">=24.0.0"
|
|
24
28
|
},
|
|
25
29
|
"dependencies": {
|
|
26
|
-
"@zudojs/errors": "1.
|
|
27
|
-
"@zudojs/constants": "1.
|
|
28
|
-
"@zudojs/types": "1.
|
|
30
|
+
"@zudojs/errors": "1.1.0",
|
|
31
|
+
"@zudojs/constants": "1.1.0",
|
|
32
|
+
"@zudojs/types": "1.1.0"
|
|
29
33
|
},
|
|
30
34
|
"devDependencies": {
|
|
31
35
|
"typescript": "7.0.2",
|