@zerotal/scheduler 1.9.0 → 1.11.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.
- package/CHANGELOG.md +47 -0
- package/api-surface.md +23 -3
- package/package.json +2 -2
- package/src/CronExpression.ts +122 -0
- package/src/ScheduledTask.ts +83 -17
- package/src/SchedulerManager.ts +19 -1
- package/src/config.ts +13 -3
- package/src/errors.ts +32 -0
- package/src/index.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,53 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.10.0] — 2026-08-30
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **A task with a `timezone` no longer takes the whole scheduler down.** The code
|
|
16
|
+
passed croner's options form — `Bun.cron(schedule, { run, timezone })` — on the
|
|
17
|
+
belief that Bun accepted it at runtime and only the types disagreed. It does not:
|
|
18
|
+
it throws, and it throws during _registration_, so the worker died on boot and
|
|
19
|
+
restart-looped. One task with a timezone stopped **every** task in the app,
|
|
20
|
+
including the ones that release inventory holds and chase deposits. Setting a
|
|
21
|
+
timezone is the obvious thing to do for a business that operates in one country and
|
|
22
|
+
is not on UTC, which is most of them.
|
|
23
|
+
|
|
24
|
+
Zerotal evaluates the zone itself now. A zoned task registers a minute tick and runs
|
|
25
|
+
on the ticks where its expression matches the wall clock in its own zone — minute
|
|
26
|
+
granularity being the finest `Bun.cron` accepts anyway, so nothing is given up, and
|
|
27
|
+
comparing against the zone's local time rather than an offset computed once is what
|
|
28
|
+
keeps it right across a daylight-saving change. `nextRunAt()` reads the same zone,
|
|
29
|
+
so `schedule:list` and the monitor agree with when the task will actually run.
|
|
30
|
+
|
|
31
|
+
The test that covered this asserted the broken behaviour against a mock that
|
|
32
|
+
accepted anything. It now runs against the real `Bun.cron`.
|
|
33
|
+
|
|
34
|
+
- **A task that cannot register takes only itself out.** Registration throws for
|
|
35
|
+
exactly the reasons that are one task's problem — an expression the runtime
|
|
36
|
+
rejects, a zone it does not know — and letting one propagate meant a typo in one
|
|
37
|
+
schedule stopped every schedule in the app, from a crash loop that named none of
|
|
38
|
+
them. The others start now, and the log names the one that did not.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- **`scheduler.timezone` does something.** It was documented as informational and read
|
|
43
|
+
by nothing. It is now the zone every schedule is evaluated in unless the task sets
|
|
44
|
+
its own — one line instead of a `.timezone()` on every task. Its default changed
|
|
45
|
+
from the literal `"UTC"` to **the system zone**, so an app that never set the key
|
|
46
|
+
keeps doing exactly what it did; only an app that explicitly asked for a zone gets
|
|
47
|
+
a change, which is the change it asked for.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- `UnknownTimeZoneError` (and the `SchedulerError` base it extends) — raised at
|
|
52
|
+
registration and naming the task, rather than a `RangeError` thrown once a minute
|
|
53
|
+
from inside a cron tick with nothing to say which schedule it came from.
|
|
54
|
+
- `wallClockIn`, `isValidTimeZone`, `CronExpression.matchesIn` and
|
|
55
|
+
`CronExpression.nextRunAfterIn` — the zone arithmetic the scheduler now uses,
|
|
56
|
+
exported because an app doing its own time-window logic needs the same answers.
|
|
57
|
+
|
|
11
58
|
## [1.9.0] — 2026-08-29
|
|
12
59
|
|
|
13
60
|
### Documented
|
package/api-surface.md
CHANGED
|
@@ -10,10 +10,12 @@ class CronExpression = {
|
|
|
10
10
|
static describe: (expression: string) => string
|
|
11
11
|
static isValid: (expression: string) => boolean
|
|
12
12
|
static nextRunAfter: (expression: string, from?: Date) => Date | null
|
|
13
|
+
static nextRunAfterIn: (expression: string, from: Date, timeZone: string) => Date | null
|
|
13
14
|
static readonly SEARCH_HORIZON_DAYS: number
|
|
14
15
|
dayOfMonth: (v: number | string) => CronExpression
|
|
15
16
|
hour: (v: number | string) => CronExpression
|
|
16
17
|
matches: (date?: Date) => boolean
|
|
18
|
+
matchesIn: (date: Date, timeZone: string) => boolean
|
|
17
19
|
minute: (v: number | string) => CronExpression
|
|
18
20
|
month: (v: number | string) => CronExpression
|
|
19
21
|
nextRun: (from?: Date) => Date | null
|
|
@@ -121,6 +123,13 @@ class SchedulerBuilder = {
|
|
|
121
123
|
yearlyOn: (month?: number, dayOfMonth?: number, time?: string) => ScheduledTask
|
|
122
124
|
}
|
|
123
125
|
|
|
126
|
+
class SchedulerError = {
|
|
127
|
+
new (message: string, code?: string, status?: number, context?: Record<string, unknown>): SchedulerError
|
|
128
|
+
readonly code: string
|
|
129
|
+
readonly context?: Record<string, unknown> | undefined
|
|
130
|
+
readonly status: number
|
|
131
|
+
}
|
|
132
|
+
|
|
124
133
|
class SchedulerManager = {
|
|
125
134
|
new (): SchedulerManager
|
|
126
135
|
add: (name: string, cronExpression: string, callback: TaskCallback) => ScheduledTask
|
|
@@ -172,14 +181,25 @@ class TaskSkipped = {
|
|
|
172
181
|
readonly reason: 'lock' | 'window' | 'env' | 'when' | 'skip' | 'overlap'
|
|
173
182
|
}
|
|
174
183
|
|
|
184
|
+
class UnknownTimeZoneError = {
|
|
185
|
+
new (task: string, timezone: string): UnknownTimeZoneError
|
|
186
|
+
readonly code: string
|
|
187
|
+
readonly context?: Record<string, unknown> | undefined
|
|
188
|
+
readonly status: number
|
|
189
|
+
}
|
|
190
|
+
|
|
175
191
|
const Scheduler = SchedulerManager
|
|
176
192
|
|
|
193
|
+
function isValidTimeZone = (timeZone: string) => boolean
|
|
194
|
+
|
|
177
195
|
function SchedulerConfig = (options?: Partial<SchedulerConfigShape>) => SchedulerConfigShape
|
|
178
196
|
|
|
197
|
+
function wallClockIn = (date: Date, timeZone: string) => Date
|
|
198
|
+
|
|
179
199
|
interface OverlapLockOptions = {
|
|
180
|
-
crossProcess?: boolean
|
|
181
|
-
expiresAfterMinutes?: number
|
|
182
|
-
refresh?: boolean
|
|
200
|
+
crossProcess?: boolean | undefined
|
|
201
|
+
expiresAfterMinutes?: number | undefined
|
|
202
|
+
refresh?: boolean | undefined
|
|
183
203
|
}
|
|
184
204
|
|
|
185
205
|
interface RunLogConfig = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/scheduler",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"typecheck": "tsc --noEmit"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@zerotal/core": "1.
|
|
33
|
+
"@zerotal/core": "1.11.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "^5.8.0"
|
package/src/CronExpression.ts
CHANGED
|
@@ -6,6 +6,73 @@
|
|
|
6
6
|
* Field syntax: star, n, a-b, a-b with step, star with step, and
|
|
7
7
|
* comma-separated lists of any of those (e.g. `1,15,30` or `1-5`).
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* The same instant, shifted so a `Date`'s **local** getters read `timeZone`'s wall
|
|
11
|
+
* clock.
|
|
12
|
+
*
|
|
13
|
+
* Cron is a wall-clock spec: `0 3 * * *` means three in the morning where the
|
|
14
|
+
* business is, not three UTC. Every matcher below reads `getHours()`,
|
|
15
|
+
* `getMinutes()` and friends, which answer in the *process's* zone — so evaluating
|
|
16
|
+
* a schedule in another zone is a question of which Date you hand them.
|
|
17
|
+
*
|
|
18
|
+
* The returned Date is a lie about the instant and true about the clock face: its
|
|
19
|
+
* epoch value is wrong by the zone offset, and it is only ever passed to the field
|
|
20
|
+
* matchers, never returned to a caller or compared against a real time.
|
|
21
|
+
*
|
|
22
|
+
* @param date - The instant to read.
|
|
23
|
+
* @param timeZone - An IANA zone name, e.g. `"Africa/Johannesburg"`.
|
|
24
|
+
* @returns A Date whose local fields are that zone's wall clock at `date`.
|
|
25
|
+
* @throws {@link RangeError} When `timeZone` is not a zone this runtime knows.
|
|
26
|
+
*/
|
|
27
|
+
export function wallClockIn(date: Date, timeZone: string): Date {
|
|
28
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
29
|
+
timeZone,
|
|
30
|
+
hourCycle: "h23",
|
|
31
|
+
year: "numeric",
|
|
32
|
+
month: "2-digit",
|
|
33
|
+
day: "2-digit",
|
|
34
|
+
hour: "2-digit",
|
|
35
|
+
minute: "2-digit",
|
|
36
|
+
second: "2-digit",
|
|
37
|
+
}).formatToParts(date);
|
|
38
|
+
|
|
39
|
+
const field = (type: string): number => {
|
|
40
|
+
const found = parts.find((p) => p.type === type);
|
|
41
|
+
return found ? Number(found.value) : 0;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return new Date(
|
|
45
|
+
field("year"),
|
|
46
|
+
field("month") - 1,
|
|
47
|
+
field("day"),
|
|
48
|
+
field("hour"),
|
|
49
|
+
field("minute"),
|
|
50
|
+
field("second"),
|
|
51
|
+
0,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether this runtime knows `timeZone`.
|
|
57
|
+
*
|
|
58
|
+
* Worth checking separately from using it: a typo in a schedule's zone would
|
|
59
|
+
* otherwise surface as a `RangeError` thrown once a minute from inside a cron
|
|
60
|
+
* tick, with nothing naming the task it came from.
|
|
61
|
+
*
|
|
62
|
+
* @param timeZone - An IANA zone name.
|
|
63
|
+
*/
|
|
64
|
+
export function isValidTimeZone(timeZone: string): boolean {
|
|
65
|
+
try {
|
|
66
|
+
new Intl.DateTimeFormat("en-US", { timeZone });
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Minutes in a day, for the day-skip in the next-run scans. */
|
|
74
|
+
const MINUTES_PER_DAY = 24 * 60;
|
|
75
|
+
|
|
9
76
|
export class CronExpression {
|
|
10
77
|
private fields: [string, string, string, string, string];
|
|
11
78
|
|
|
@@ -148,6 +215,17 @@ export class CronExpression {
|
|
|
148
215
|
);
|
|
149
216
|
}
|
|
150
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Whether the expression matches `date` **as read in `timeZone`**.
|
|
220
|
+
*
|
|
221
|
+
* @param date - The instant to test.
|
|
222
|
+
* @param timeZone - An IANA zone name the expression is written against.
|
|
223
|
+
* @throws {@link RangeError} When `timeZone` is not a zone this runtime knows.
|
|
224
|
+
*/
|
|
225
|
+
matchesIn(date: Date, timeZone: string): boolean {
|
|
226
|
+
return this.matches(wallClockIn(date, timeZone));
|
|
227
|
+
}
|
|
228
|
+
|
|
151
229
|
nextRun(from: Date = new Date()): Date | null {
|
|
152
230
|
return CronExpression.nextRunAfter(this.toString(), from);
|
|
153
231
|
}
|
|
@@ -226,6 +304,50 @@ export class CronExpression {
|
|
|
226
304
|
return null;
|
|
227
305
|
}
|
|
228
306
|
|
|
307
|
+
/**
|
|
308
|
+
* The first instant at or after `from` that `expression` fires, reading the clock
|
|
309
|
+
* in `timeZone`.
|
|
310
|
+
*
|
|
311
|
+
* The returned Date is a real instant, not a shifted one — the shifting happens
|
|
312
|
+
* inside the scan, where the expression is compared against the zone's wall clock
|
|
313
|
+
* a minute at a time. That is what keeps it right across a DST change: nothing
|
|
314
|
+
* here computes an offset once and adds it.
|
|
315
|
+
*
|
|
316
|
+
* @param expression - A 5- or 6-field cron expression.
|
|
317
|
+
* @param from - Search start; the result is strictly after this minute.
|
|
318
|
+
* @param timeZone - An IANA zone name the expression is written against.
|
|
319
|
+
* @returns The next firing instant, or `null` for an invalid expression, an unknown
|
|
320
|
+
* zone, or no occurrence within {@link SEARCH_HORIZON_DAYS}.
|
|
321
|
+
*/
|
|
322
|
+
static nextRunAfterIn(expression: string, from: Date, timeZone: string): Date | null {
|
|
323
|
+
if (!CronExpression.isValid(expression)) return null;
|
|
324
|
+
if (!isValidTimeZone(timeZone)) return null;
|
|
325
|
+
|
|
326
|
+
const cron = new CronExpression(expression);
|
|
327
|
+
const cursor = new Date(from);
|
|
328
|
+
cursor.setSeconds(0, 0);
|
|
329
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
330
|
+
|
|
331
|
+
const deadline = new Date(from);
|
|
332
|
+
deadline.setDate(deadline.getDate() + CronExpression.SEARCH_HORIZON_DAYS);
|
|
333
|
+
|
|
334
|
+
while (cursor <= deadline) {
|
|
335
|
+
const local = wallClockIn(cursor, timeZone);
|
|
336
|
+
if (cron._dayMatches(local)) {
|
|
337
|
+
if (cron.matches(local)) return new Date(cursor);
|
|
338
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
339
|
+
} else {
|
|
340
|
+
// Nothing on this local day can match — jump to the zone's next midnight
|
|
341
|
+
// rather than probing 1,440 minutes. Zone offsets are whole minutes, so
|
|
342
|
+
// this lands on it exactly; a DST change costs an hour of extra probes and
|
|
343
|
+
// nothing else.
|
|
344
|
+
const elapsed = local.getHours() * 60 + local.getMinutes();
|
|
345
|
+
cursor.setMinutes(cursor.getMinutes() + (MINUTES_PER_DAY - elapsed));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
229
351
|
/**
|
|
230
352
|
* How far ahead {@link nextRunAfter} looks. Four years and change, so a Feb-29 expression
|
|
231
353
|
* resolves to the next leap day instead of reporting that it never runs.
|
package/src/ScheduledTask.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
-
import { FrameworkEvents } from "@zerotal/core";
|
|
2
|
+
import { config, FrameworkEvents } from "@zerotal/core";
|
|
3
3
|
import { deployEnv } from "@zerotal/core";
|
|
4
4
|
import { TaskRan, TaskFailed, TaskSkipped } from "./events.ts";
|
|
5
5
|
import type { LockManager, ManagedLock } from "@zerotal/core/lock";
|
|
6
|
-
import { CronExpression } from "./CronExpression.ts";
|
|
6
|
+
import { CronExpression, isValidTimeZone } from "./CronExpression.ts";
|
|
7
|
+
import { UnknownTimeZoneError } from "./errors.ts";
|
|
7
8
|
import { frameworkLog } from "@zerotal/core/logger";
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The tick a timezoned task rides on.
|
|
12
|
+
*
|
|
13
|
+
* `Bun.cron` evaluates a schedule in the system zone and offers no way to change
|
|
14
|
+
* that, so a task with its own zone registers this instead and does the matching
|
|
15
|
+
* itself. Minute granularity is not a compromise: five fields is all `Bun.cron`
|
|
16
|
+
* accepts, so a minute is the finest a Zerotal schedule can express anyway.
|
|
17
|
+
*/
|
|
18
|
+
const EVERY_MINUTE = "* * * * *";
|
|
19
|
+
|
|
9
20
|
// ── Output capture (shared, reference-counted) ────────────────────────────────
|
|
10
21
|
// A single `console.log` patch is installed while ANY task is capturing, and the
|
|
11
22
|
// captured output is routed to the running task's own buffer via AsyncLocalStorage.
|
|
@@ -47,7 +58,7 @@ export interface OverlapLockOptions {
|
|
|
47
58
|
* Take a cross-process lock (via the configured lock driver) in addition to the
|
|
48
59
|
* in-process guard. Default: `true`. Set `false` to guard within this process only.
|
|
49
60
|
*/
|
|
50
|
-
crossProcess?: boolean;
|
|
61
|
+
crossProcess?: boolean | undefined;
|
|
51
62
|
|
|
52
63
|
/**
|
|
53
64
|
* **How long after a crash before another host may take the task over**, in
|
|
@@ -64,7 +75,7 @@ export interface OverlapLockOptions {
|
|
|
64
75
|
* want a crash to be *slower* to recover from, which is rarely what anyone
|
|
65
76
|
* wants.
|
|
66
77
|
*/
|
|
67
|
-
expiresAfterMinutes?: number;
|
|
78
|
+
expiresAfterMinutes?: number | undefined;
|
|
68
79
|
|
|
69
80
|
/**
|
|
70
81
|
* Heartbeat the lock for as long as the task runs. Default: `true`.
|
|
@@ -73,7 +84,7 @@ export interface OverlapLockOptions {
|
|
|
73
84
|
* inside {@link expiresAfterMinutes} or lose its lock — and where you must
|
|
74
85
|
* therefore size that value for the worst case.
|
|
75
86
|
*/
|
|
76
|
-
refresh?: boolean;
|
|
87
|
+
refresh?: boolean | undefined;
|
|
77
88
|
}
|
|
78
89
|
|
|
79
90
|
export class ScheduledTask {
|
|
@@ -134,6 +145,14 @@ export class ScheduledTask {
|
|
|
134
145
|
this._callback = callback;
|
|
135
146
|
}
|
|
136
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Evaluate this task's schedule in `tz` rather than the server's zone.
|
|
150
|
+
*
|
|
151
|
+
* @param tz - An IANA zone name, e.g. `"Africa/Johannesburg"`.
|
|
152
|
+
* @throws {@link UnknownTimeZoneError} At {@link start}, when the runtime does not
|
|
153
|
+
* know the zone — named there rather than here so a schedule declared as data
|
|
154
|
+
* fails where the failure can name the task.
|
|
155
|
+
*/
|
|
137
156
|
timezone(tz: string): this {
|
|
138
157
|
this._timezone = tz;
|
|
139
158
|
return this;
|
|
@@ -250,9 +269,18 @@ export class ScheduledTask {
|
|
|
250
269
|
get lastDurationMs(): number | undefined {
|
|
251
270
|
return this._lastDurationMs;
|
|
252
271
|
}
|
|
253
|
-
/**
|
|
272
|
+
/**
|
|
273
|
+
* Next scheduled fire time from `from` (null if the expression never matches).
|
|
274
|
+
*
|
|
275
|
+
* Read in the task's own timezone when it has one, so `schedule:list` and the
|
|
276
|
+
* monitor agree with when the task will actually run rather than with when the
|
|
277
|
+
* same expression would fire in the server's zone.
|
|
278
|
+
*/
|
|
254
279
|
nextRunAt(from: Date = new Date()): Date | null {
|
|
255
|
-
|
|
280
|
+
const timezone = this._effectiveTimezone();
|
|
281
|
+
return timezone
|
|
282
|
+
? CronExpression.nextRunAfterIn(this._schedule, from, timezone)
|
|
283
|
+
: new CronExpression(this._schedule).nextRun(from);
|
|
256
284
|
}
|
|
257
285
|
/** Run the task body immediately, bypassing schedule/time-window guards. */
|
|
258
286
|
async runNow(): Promise<void> {
|
|
@@ -497,19 +525,57 @@ export class ScheduledTask {
|
|
|
497
525
|
};
|
|
498
526
|
}
|
|
499
527
|
|
|
528
|
+
/**
|
|
529
|
+
* The zone this task's schedule is read in: its own, else `scheduler.timezone`.
|
|
530
|
+
*
|
|
531
|
+
* `undefined` when that resolves to the zone the process is already in, because
|
|
532
|
+
* `Bun.cron` evaluates a schedule in the system zone and its own tick is both
|
|
533
|
+
* cheaper and better tested than a minute tick with a match on top.
|
|
534
|
+
*
|
|
535
|
+
* @returns The zone to evaluate in, or `undefined` to let `Bun.cron` do it.
|
|
536
|
+
*/
|
|
537
|
+
private _effectiveTimezone(): string | undefined {
|
|
538
|
+
const timezone = this._timezone ?? config.safe("scheduler.timezone", "");
|
|
539
|
+
if (!timezone) return undefined;
|
|
540
|
+
return timezone === Intl.DateTimeFormat().resolvedOptions().timeZone ? undefined : timezone;
|
|
541
|
+
}
|
|
542
|
+
|
|
500
543
|
start(): void {
|
|
501
544
|
if (this._handle) return;
|
|
502
545
|
const handler = this._buildHandler();
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
546
|
+
const timezone = this._effectiveTimezone();
|
|
547
|
+
|
|
548
|
+
if (!timezone) {
|
|
549
|
+
this._handle = Bun.cron(this._schedule, handler);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!isValidTimeZone(timezone)) {
|
|
554
|
+
throw new UnknownTimeZoneError(this._name, timezone);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// `Bun.cron` takes `(schedule, handler)` and nothing else. The croner options
|
|
558
|
+
// form — `(schedule, { run, timezone })` — throws, and it throws during
|
|
559
|
+
// *registration*, so one task with a timezone took every other task in the app
|
|
560
|
+
// with it and the worker restart-looped. Setting a timezone is the obvious
|
|
561
|
+
// thing to do for a business that operates in one country and is not on UTC,
|
|
562
|
+
// which is most of them.
|
|
563
|
+
//
|
|
564
|
+
// So the schedule is evaluated here rather than by Bun. Bun ticks once a
|
|
565
|
+
// minute — the finest granularity it has, so nothing is given up — and the task
|
|
566
|
+
// runs on the ticks where the expression matches the wall clock in its own
|
|
567
|
+
// zone. Comparing against the zone's local time rather than an offset computed
|
|
568
|
+
// at registration is also what keeps it right across a DST change.
|
|
569
|
+
//
|
|
570
|
+
// A wall clock that skips an hour skips the schedules inside it, and one that
|
|
571
|
+
// repeats an hour runs them twice. That is what every cron does, and the
|
|
572
|
+
// alternative — inventing a firing time the clock never showed — is worse.
|
|
573
|
+
const expression = new CronExpression(this._schedule);
|
|
574
|
+
|
|
575
|
+
this._handle = Bun.cron(EVERY_MINUTE, async () => {
|
|
576
|
+
if (!expression.matchesIn(new Date(), timezone)) return;
|
|
577
|
+
await handler();
|
|
578
|
+
});
|
|
513
579
|
}
|
|
514
580
|
|
|
515
581
|
stop(): void {
|
package/src/SchedulerManager.ts
CHANGED
|
@@ -24,10 +24,28 @@ export class SchedulerManager {
|
|
|
24
24
|
return new SchedulerBuilder(this, name, callback);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Register every task with the runtime's cron.
|
|
29
|
+
*
|
|
30
|
+
* A task that cannot be registered takes itself out and nothing else. Registration
|
|
31
|
+
* throws for exactly the reasons that are one task's problem — an expression the
|
|
32
|
+
* runtime rejects, a timezone it does not know — and letting one of them propagate
|
|
33
|
+
* meant the worker died during boot and restart-looped, so a typo in one schedule
|
|
34
|
+
* stopped every schedule in the app. Which ones those are is not a thing you can
|
|
35
|
+
* see from a crash loop.
|
|
36
|
+
*/
|
|
27
37
|
start(): void {
|
|
28
38
|
this._started = true;
|
|
29
39
|
for (const task of this._tasks.values()) {
|
|
30
|
-
|
|
40
|
+
try {
|
|
41
|
+
task.start();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
frameworkLog("scheduler").error(
|
|
44
|
+
`Task "${task.name}" did not register and will not run: ${(err as Error).message ?? String(err)}`,
|
|
45
|
+
{ task: task.name, schedule: task.schedule },
|
|
46
|
+
);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
31
49
|
frameworkLog("scheduler").info(`Started "${task.name}" — ${task.schedule}`);
|
|
32
50
|
}
|
|
33
51
|
}
|
package/src/config.ts
CHANGED
|
@@ -2,8 +2,15 @@ import { deepMerge } from "@zerotal/core";
|
|
|
2
2
|
import { DEFAULT_RUN_LOG_KEEP, DEFAULT_RUN_LOG_PATH } from "./runLog.ts";
|
|
3
3
|
|
|
4
4
|
export interface SchedulerConfigShape {
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
5
|
+
/**
|
|
6
|
+
* IANA timezone every schedule's cron expression is read in, unless the task
|
|
7
|
+
* sets its own with `.timezone(tz)`.
|
|
8
|
+
*
|
|
9
|
+
* Defaults to the system zone, so an app that does not set this keeps whatever
|
|
10
|
+
* its server does. Setting it is the usual case for a business that operates in
|
|
11
|
+
* one country and is not on UTC — one line instead of a `.timezone()` on every
|
|
12
|
+
* task.
|
|
13
|
+
*/
|
|
7
14
|
timezone: string;
|
|
8
15
|
/** Durable run history — read with `schedule:runs` (see runLog.ts). */
|
|
9
16
|
runLog: {
|
|
@@ -17,7 +24,10 @@ export interface SchedulerConfigShape {
|
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
const defaults: SchedulerConfigShape = {
|
|
20
|
-
|
|
27
|
+
// The system zone rather than a hardcoded "UTC": the default has to be what the
|
|
28
|
+
// app already does, or turning this key from decoration into behaviour would have
|
|
29
|
+
// moved every schedule in every app that never set it.
|
|
30
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
21
31
|
runLog: {
|
|
22
32
|
path: DEFAULT_RUN_LOG_PATH,
|
|
23
33
|
keep: DEFAULT_RUN_LOG_KEEP,
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ZerotalError } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/** Base class for all @zerotal/scheduler errors. */
|
|
4
|
+
export class SchedulerError extends ZerotalError {
|
|
5
|
+
constructor(
|
|
6
|
+
message: string,
|
|
7
|
+
code = "E_SCHEDULER",
|
|
8
|
+
status = 500,
|
|
9
|
+
context?: Record<string, unknown>,
|
|
10
|
+
) {
|
|
11
|
+
super(message, code, status, context);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Thrown when a task declares a timezone this runtime does not know.
|
|
17
|
+
*
|
|
18
|
+
* Raised at registration rather than at the first tick, because a `RangeError`
|
|
19
|
+
* thrown once a minute from inside a cron callback names the formatter and not the
|
|
20
|
+
* task — and a typo'd zone would otherwise be a task that silently never runs.
|
|
21
|
+
*/
|
|
22
|
+
export class UnknownTimeZoneError extends SchedulerError {
|
|
23
|
+
constructor(task: string, timezone: string) {
|
|
24
|
+
super(
|
|
25
|
+
`[Zerotal Scheduler] Task "${task}" declares timezone "${timezone}", which this runtime ` +
|
|
26
|
+
`does not know. Use an IANA name, e.g. "Africa/Johannesburg" or "UTC".`,
|
|
27
|
+
"E_SCHEDULER_UNKNOWN_TIMEZONE",
|
|
28
|
+
500,
|
|
29
|
+
{ task, timezone },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { SchedulerManager, SchedulerBuilder } from "./SchedulerManager.ts";
|
|
2
2
|
export { ScheduledTask } from "./ScheduledTask.ts";
|
|
3
|
-
export { CronExpression } from "./CronExpression.ts";
|
|
3
|
+
export { CronExpression, wallClockIn, isValidTimeZone } from "./CronExpression.ts";
|
|
4
|
+
export { SchedulerError, UnknownTimeZoneError } from "./errors.ts";
|
|
4
5
|
export { SchedulerProvider } from "./provider/SchedulerProvider.ts";
|
|
5
6
|
|
|
6
7
|
// Convention-based scheduling: extend Schedule, drop it in app/schedules/.
|