@warlock.js/scheduler 5.19.1 → 5.20.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 +6 -0
- package/README.md +10 -0
- package/cjs/index.cjs +75 -3
- package/cjs/index.cjs.map +1 -1
- package/esm/job.d.mts +32 -0
- package/esm/job.d.mts.map +1 -1
- package/esm/job.mjs +73 -2
- package/esm/job.mjs.map +1 -1
- package/esm/scheduler.d.mts.map +1 -1
- package/esm/scheduler.mjs +2 -1
- package/esm/scheduler.mjs.map +1 -1
- package/esm/types.d.mts +2 -1
- package/esm/types.d.mts.map +1 -1
- package/llms-full.txt +683 -668
- package/llms.txt +5 -5
- package/package.json +9 -1
- package/skills/configure-retry-and-overlap/SKILL.md +152 -137
- package/skills/index.md +20 -0
- package/skills/observe-scheduler/SKILL.md +153 -153
- package/skills/pin-schedule-timezone/SKILL.md +114 -114
- package/skills/schedule-fluently/SKILL.md +141 -141
- package/skills/schedule-with-cron/SKILL.md +123 -123
|
@@ -1,153 +1,153 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: observe-scheduler
|
|
3
|
-
description: "
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Observability and lifecycle
|
|
7
|
-
|
|
8
|
-
The `Scheduler` extends Node's `EventEmitter` with a fully-typed surface. Wire listeners before calling `.start()`.
|
|
9
|
-
|
|
10
|
-
## All seven events
|
|
11
|
-
|
|
12
|
-
```ts
|
|
13
|
-
type SchedulerEvents = {
|
|
14
|
-
"job:start": [jobName: string];
|
|
15
|
-
"job:complete": [jobName: string, result: JobResult];
|
|
16
|
-
"job:error": [jobName: string, error: unknown];
|
|
17
|
-
"job:skip": [jobName: string, reason: string];
|
|
18
|
-
"scheduler:started": [];
|
|
19
|
-
"scheduler:stopped": [];
|
|
20
|
-
"scheduler:tick": [timestamp: Date];
|
|
21
|
-
};
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
| Event | When it fires |
|
|
25
|
-
| -------------------- | -------------------------------------------------------------------------------------- |
|
|
26
|
-
| `scheduler:started` | Once, after `.start()` enters the tick loop |
|
|
27
|
-
| `scheduler:stopped` | When `.stop()` halts the loop. Not emitted if `.stop()` was a no-op (never started) |
|
|
28
|
-
| `scheduler:tick` | Every tick — once per `runEvery()` interval (default 1 s). Keep handlers lightweight. |
|
|
29
|
-
| `job:start` | Just before a job's callback is first invoked (NOT once per retry) |
|
|
30
|
-
| `job:complete` | When a job finishes successfully (possibly after retries) |
|
|
31
|
-
| `job:error` | When a job exhausts all retries and the final attempt still throws |
|
|
32
|
-
| `job:skip` | When a tick finds the job already running (see retry-and-overlap for when this occurs) |
|
|
33
|
-
|
|
34
|
-
**Contract for retries.** `job:start` and `job:complete`/`job:error` fire **once per fire**, not once per retry attempt. The retry count surfaces inside the `JobResult` passed to `job:complete`, or as `result.retries === maxRetries` on the failure path.
|
|
35
|
-
|
|
36
|
-
## `JobResult` shape
|
|
37
|
-
|
|
38
|
-
```ts
|
|
39
|
-
type JobResult = {
|
|
40
|
-
success: boolean;
|
|
41
|
-
duration: number; // milliseconds, end-to-end including retry waits
|
|
42
|
-
error?: unknown; // present when success === false
|
|
43
|
-
retries?: number; // attempts that failed before the final outcome
|
|
44
|
-
};
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
`duration` is wall-clock from the first attempt's start to the final settlement — useful for both p50 metrics and capacity planning.
|
|
48
|
-
|
|
49
|
-
## Subscribing
|
|
50
|
-
|
|
51
|
-
```ts
|
|
52
|
-
import type { JobResult } from "@warlock.js/scheduler";
|
|
53
|
-
import { scheduler } from "@warlock.js/scheduler";
|
|
54
|
-
|
|
55
|
-
scheduler.on("job:start", name => log.debug({ name }, "job start"));
|
|
56
|
-
scheduler.on("job:complete", (name, result: JobResult) => {
|
|
57
|
-
log.info({ name, duration: result.duration, retries: result.retries }, "job complete");
|
|
58
|
-
});
|
|
59
|
-
scheduler.on("job:error", (name, error) => log.error({ name, error }, "job failed"));
|
|
60
|
-
scheduler.on("job:skip", (name, reason) => log.warn({ name, reason }, "job skipped"));
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
`.on()`, `.once()`, `.off()` are all type-narrowed via `SchedulerEvents`.
|
|
64
|
-
|
|
65
|
-
## Lifecycle methods
|
|
66
|
-
|
|
67
|
-
### `.start()`
|
|
68
|
-
|
|
69
|
-
Prepares every registered job (computes initial `nextRun`) and enters the tick loop. Emits `scheduler:started`.
|
|
70
|
-
|
|
71
|
-
Throws if:
|
|
72
|
-
- already running (`"Scheduler is already running."`)
|
|
73
|
-
- zero jobs registered (`"Cannot start scheduler with no jobs."`)
|
|
74
|
-
|
|
75
|
-
### `.stop()`
|
|
76
|
-
|
|
77
|
-
Immediately clears the next-tick timer. Does NOT wait for jobs currently mid-execution. Emits `scheduler:stopped`. **No-op if not running** — calling `.stop()` on a never-started or already-stopped scheduler is safe and silent.
|
|
78
|
-
|
|
79
|
-
### `.shutdown(timeout = 30000)`
|
|
80
|
-
|
|
81
|
-
Graceful equivalent of `.stop()`:
|
|
82
|
-
|
|
83
|
-
1. Marks the scheduler as shutting down (no new ticks scheduled).
|
|
84
|
-
2. Calls `.stop()` internally (so `scheduler:stopped` fires).
|
|
85
|
-
3. Awaits every currently-running job's `waitForCompletion()`, capped by `timeout`.
|
|
86
|
-
4. Resolves either when all jobs finish or when the timeout elapses.
|
|
87
|
-
|
|
88
|
-
```ts
|
|
89
|
-
process.on("SIGTERM", async () => {
|
|
90
|
-
await scheduler.shutdown(30_000);
|
|
91
|
-
process.exit(0);
|
|
92
|
-
});
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
The timeout is a HARD cap — jobs still running after it are abandoned (their promises keep resolving in the background, but the scheduler doesn't await them). The package does not currently support per-job timeouts or `AbortSignal` cancellation.
|
|
96
|
-
|
|
97
|
-
## Production observer pattern
|
|
98
|
-
|
|
99
|
-
Wire all observers before `start()`, in one place:
|
|
100
|
-
|
|
101
|
-
```ts
|
|
102
|
-
import { scheduler } from "@warlock.js/scheduler";
|
|
103
|
-
import { logger } from "./logger";
|
|
104
|
-
import { metrics } from "./metrics";
|
|
105
|
-
import { alerts } from "./alerts";
|
|
106
|
-
|
|
107
|
-
scheduler.on("job:start", name => metrics.increment(`job.start.${name}`));
|
|
108
|
-
|
|
109
|
-
scheduler.on("job:complete", (name, result) => {
|
|
110
|
-
metrics.increment(`job.success.${name}`);
|
|
111
|
-
metrics.timing(`job.duration.${name}`, result.duration);
|
|
112
|
-
if (result.retries) metrics.increment(`job.retries.${name}`, result.retries);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
scheduler.on("job:error", (name, error) => {
|
|
116
|
-
logger.error({ name, error }, "Job failed permanently");
|
|
117
|
-
alerts.critical(`Scheduler job "${name}" failed`, error);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
scheduler.on("job:skip", (name, reason) => {
|
|
121
|
-
logger.warn({ name, reason }, "Job skipped — likely external invocation in flight");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
scheduler.on("scheduler:started", () => logger.info("scheduler up"));
|
|
125
|
-
scheduler.on("scheduler:stopped", () => logger.info("scheduler down"));
|
|
126
|
-
|
|
127
|
-
// Register jobs, then:
|
|
128
|
-
scheduler.start();
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
## Inspection at runtime
|
|
132
|
-
|
|
133
|
-
```ts
|
|
134
|
-
scheduler.isRunning; // boolean
|
|
135
|
-
scheduler.jobCount; // number of registered jobs
|
|
136
|
-
scheduler.list(); // readonly Job[]
|
|
137
|
-
scheduler.getJob("name"); // Job | undefined
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
And on each `Job`:
|
|
141
|
-
|
|
142
|
-
```ts
|
|
143
|
-
job.nextRun; // Dayjs | null
|
|
144
|
-
job.lastRun; // Dayjs | null — success OR failure
|
|
145
|
-
job.isRunning; // boolean
|
|
146
|
-
job.intervals; // readonly schedule config
|
|
147
|
-
job.cronExpression; // string | null
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
## See also
|
|
151
|
-
|
|
152
|
-
- [`@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md`](@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md) — what triggers each event in detail
|
|
153
|
-
- [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the methods that mutate the state these getters expose
|
|
1
|
+
---
|
|
2
|
+
name: observe-scheduler
|
|
3
|
+
description: 'Subscribe to scheduler lifecycle and job events for logging / metrics / alerting / graceful shutdown — seven typed `SchedulerEvents`, `JobResult` payload, `start()` / `stop()` / `shutdown()`. Triggers: `scheduler.on`, `scheduler.shutdown`, `JobResult`, `job:complete`, `job:error`, `scheduler:started`, `scheduler.getJob`; "graceful SIGTERM shutdown", "did this job run", "scheduler metrics and alerts", "subscribe to job events"; typical import `import { scheduler, type JobResult } from "@warlock.js/scheduler"`. Skip: retry / overlap — `@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md`; building schedules — `@warlock.js/scheduler/schedule-fluently/SKILL.md`; native `EventEmitter`, `process.on`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Observability and lifecycle
|
|
7
|
+
|
|
8
|
+
The `Scheduler` extends Node's `EventEmitter` with a fully-typed surface. Wire listeners before calling `.start()`.
|
|
9
|
+
|
|
10
|
+
## All seven events
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
type SchedulerEvents = {
|
|
14
|
+
"job:start": [jobName: string];
|
|
15
|
+
"job:complete": [jobName: string, result: JobResult];
|
|
16
|
+
"job:error": [jobName: string, error: unknown];
|
|
17
|
+
"job:skip": [jobName: string, reason: string];
|
|
18
|
+
"scheduler:started": [];
|
|
19
|
+
"scheduler:stopped": [];
|
|
20
|
+
"scheduler:tick": [timestamp: Date];
|
|
21
|
+
};
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| Event | When it fires |
|
|
25
|
+
| -------------------- | -------------------------------------------------------------------------------------- |
|
|
26
|
+
| `scheduler:started` | Once, after `.start()` enters the tick loop |
|
|
27
|
+
| `scheduler:stopped` | When `.stop()` halts the loop. Not emitted if `.stop()` was a no-op (never started) |
|
|
28
|
+
| `scheduler:tick` | Every tick — once per `runEvery()` interval (default 1 s). Keep handlers lightweight. |
|
|
29
|
+
| `job:start` | Just before a job's callback is first invoked (NOT once per retry) |
|
|
30
|
+
| `job:complete` | When a job finishes successfully (possibly after retries) |
|
|
31
|
+
| `job:error` | When a job exhausts all retries and the final attempt still throws |
|
|
32
|
+
| `job:skip` | When a tick finds the job already running (see retry-and-overlap for when this occurs) |
|
|
33
|
+
|
|
34
|
+
**Contract for retries.** `job:start` and `job:complete`/`job:error` fire **once per fire**, not once per retry attempt. The retry count surfaces inside the `JobResult` passed to `job:complete`, or as `result.retries === maxRetries` on the failure path.
|
|
35
|
+
|
|
36
|
+
## `JobResult` shape
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
type JobResult = {
|
|
40
|
+
success: boolean;
|
|
41
|
+
duration: number; // milliseconds, end-to-end including retry waits
|
|
42
|
+
error?: unknown; // present when success === false
|
|
43
|
+
retries?: number; // attempts that failed before the final outcome
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`duration` is wall-clock from the first attempt's start to the final settlement — useful for both p50 metrics and capacity planning.
|
|
48
|
+
|
|
49
|
+
## Subscribing
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import type { JobResult } from "@warlock.js/scheduler";
|
|
53
|
+
import { scheduler } from "@warlock.js/scheduler";
|
|
54
|
+
|
|
55
|
+
scheduler.on("job:start", name => log.debug({ name }, "job start"));
|
|
56
|
+
scheduler.on("job:complete", (name, result: JobResult) => {
|
|
57
|
+
log.info({ name, duration: result.duration, retries: result.retries }, "job complete");
|
|
58
|
+
});
|
|
59
|
+
scheduler.on("job:error", (name, error) => log.error({ name, error }, "job failed"));
|
|
60
|
+
scheduler.on("job:skip", (name, reason) => log.warn({ name, reason }, "job skipped"));
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`.on()`, `.once()`, `.off()` are all type-narrowed via `SchedulerEvents`.
|
|
64
|
+
|
|
65
|
+
## Lifecycle methods
|
|
66
|
+
|
|
67
|
+
### `.start()`
|
|
68
|
+
|
|
69
|
+
Prepares every registered job (computes initial `nextRun`) and enters the tick loop. Emits `scheduler:started`.
|
|
70
|
+
|
|
71
|
+
Throws if:
|
|
72
|
+
- already running (`"Scheduler is already running."`)
|
|
73
|
+
- zero jobs registered (`"Cannot start scheduler with no jobs."`)
|
|
74
|
+
|
|
75
|
+
### `.stop()`
|
|
76
|
+
|
|
77
|
+
Immediately clears the next-tick timer. Does NOT wait for jobs currently mid-execution. Emits `scheduler:stopped`. **No-op if not running** — calling `.stop()` on a never-started or already-stopped scheduler is safe and silent.
|
|
78
|
+
|
|
79
|
+
### `.shutdown(timeout = 30000)`
|
|
80
|
+
|
|
81
|
+
Graceful equivalent of `.stop()`:
|
|
82
|
+
|
|
83
|
+
1. Marks the scheduler as shutting down (no new ticks scheduled).
|
|
84
|
+
2. Calls `.stop()` internally (so `scheduler:stopped` fires).
|
|
85
|
+
3. Awaits every currently-running job's `waitForCompletion()`, capped by `timeout`.
|
|
86
|
+
4. Resolves either when all jobs finish or when the timeout elapses.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
process.on("SIGTERM", async () => {
|
|
90
|
+
await scheduler.shutdown(30_000);
|
|
91
|
+
process.exit(0);
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The timeout is a HARD cap — jobs still running after it are abandoned (their promises keep resolving in the background, but the scheduler doesn't await them). The package does not currently support per-job timeouts or `AbortSignal` cancellation.
|
|
96
|
+
|
|
97
|
+
## Production observer pattern
|
|
98
|
+
|
|
99
|
+
Wire all observers before `start()`, in one place:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import { scheduler } from "@warlock.js/scheduler";
|
|
103
|
+
import { logger } from "./logger";
|
|
104
|
+
import { metrics } from "./metrics";
|
|
105
|
+
import { alerts } from "./alerts";
|
|
106
|
+
|
|
107
|
+
scheduler.on("job:start", name => metrics.increment(`job.start.${name}`));
|
|
108
|
+
|
|
109
|
+
scheduler.on("job:complete", (name, result) => {
|
|
110
|
+
metrics.increment(`job.success.${name}`);
|
|
111
|
+
metrics.timing(`job.duration.${name}`, result.duration);
|
|
112
|
+
if (result.retries) metrics.increment(`job.retries.${name}`, result.retries);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
scheduler.on("job:error", (name, error) => {
|
|
116
|
+
logger.error({ name, error }, "Job failed permanently");
|
|
117
|
+
alerts.critical(`Scheduler job "${name}" failed`, error);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
scheduler.on("job:skip", (name, reason) => {
|
|
121
|
+
logger.warn({ name, reason }, "Job skipped — likely external invocation in flight");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
scheduler.on("scheduler:started", () => logger.info("scheduler up"));
|
|
125
|
+
scheduler.on("scheduler:stopped", () => logger.info("scheduler down"));
|
|
126
|
+
|
|
127
|
+
// Register jobs, then:
|
|
128
|
+
scheduler.start();
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Inspection at runtime
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
scheduler.isRunning; // boolean
|
|
135
|
+
scheduler.jobCount; // number of registered jobs
|
|
136
|
+
scheduler.list(); // readonly Job[]
|
|
137
|
+
scheduler.getJob("name"); // Job | undefined
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
And on each `Job`:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
job.nextRun; // Dayjs | null
|
|
144
|
+
job.lastRun; // Dayjs | null — success OR failure
|
|
145
|
+
job.isRunning; // boolean
|
|
146
|
+
job.intervals; // readonly schedule config
|
|
147
|
+
job.cronExpression; // string | null
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## See also
|
|
151
|
+
|
|
152
|
+
- [`@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md`](@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md) — what triggers each event in detail
|
|
153
|
+
- [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the methods that mutate the state these getters expose
|
|
@@ -1,114 +1,114 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: pin-schedule-timezone
|
|
3
|
-
description: "
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Per-job timezones
|
|
7
|
-
|
|
8
|
-
Every `Job` has its own timezone. The default is **UTC** — `.daily().at("09:00")` fires at 09:00 UTC, regardless of the server's locale or system timezone. Pin to wall-clock time with `.inTimezone(IANA string)`.
|
|
9
|
-
|
|
10
|
-
## Basic usage
|
|
11
|
-
|
|
12
|
-
```ts
|
|
13
|
-
job("morning-digest", sendDailyDigest)
|
|
14
|
-
.daily()
|
|
15
|
-
.at("08:00")
|
|
16
|
-
.inTimezone("America/New_York"); // 8 AM ET, not 8 AM UTC
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
`.inTimezone()` is chainable — typically placed after the time/day methods.
|
|
20
|
-
|
|
21
|
-
## Why the default is UTC
|
|
22
|
-
|
|
23
|
-
A server runs wherever ops put it (Frankfurt, Virginia, GCP us-central1, …). System-local time is unpredictable; UTC is stable. The framework picks the stable default so that `daily().at("09:00")` without further config is reproducible across deployments. Pin to wall-clock time when business hours actually matter.
|
|
24
|
-
|
|
25
|
-
## Multi-region fan-out
|
|
26
|
-
|
|
27
|
-
Same logical task, three regions:
|
|
28
|
-
|
|
29
|
-
```ts
|
|
30
|
-
import { scheduler, job } from "@warlock.js/scheduler";
|
|
31
|
-
|
|
32
|
-
const regions = [
|
|
33
|
-
{ name: "us-east", tz: "America/New_York" },
|
|
34
|
-
{ name: "eu-west", tz: "Europe/Berlin" },
|
|
35
|
-
{ name: "asia", tz: "Asia/Tokyo" },
|
|
36
|
-
];
|
|
37
|
-
|
|
38
|
-
for (const { name, tz } of regions) {
|
|
39
|
-
scheduler.addJob(
|
|
40
|
-
job(`morning-report-${name}`, () => sendReport(name))
|
|
41
|
-
.daily()
|
|
42
|
-
.at("09:00")
|
|
43
|
-
.inTimezone(tz)
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
scheduler.start();
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
Three separate `Job` instances, three separate `nextRun` calculations. Each fires once per day at its region's 09:00.
|
|
51
|
-
|
|
52
|
-
## DST is automatic
|
|
53
|
-
|
|
54
|
-
dayjs handles transitions correctly. A daily 09:00 job in `America/New_York` shifts between 13:00 UTC (EST winter) and 14:00 UTC (EDT summer) without intervention. The same job in a fixed-offset region (`Asia/Tokyo`, `Africa/Algiers`) stays at a constant UTC offset.
|
|
55
|
-
|
|
56
|
-
## Common IANA strings
|
|
57
|
-
|
|
58
|
-
| Region | TZ string |
|
|
59
|
-
| ------------------- | --------------------------- |
|
|
60
|
-
| UTC | `UTC` |
|
|
61
|
-
| US Eastern | `America/New_York` |
|
|
62
|
-
| US Central | `America/Chicago` |
|
|
63
|
-
| US Mountain | `America/Denver` |
|
|
64
|
-
| US Pacific | `America/Los_Angeles` |
|
|
65
|
-
| UK / Ireland | `Europe/London` |
|
|
66
|
-
| Central Europe | `Europe/Berlin` |
|
|
67
|
-
| Eastern Europe | `Europe/Kyiv` |
|
|
68
|
-
| India | `Asia/Kolkata` |
|
|
69
|
-
| Japan | `Asia/Tokyo` |
|
|
70
|
-
| Australia (Sydney) | `Australia/Sydney` |
|
|
71
|
-
| Egypt | `Africa/Cairo` |
|
|
72
|
-
|
|
73
|
-
Full list: [IANA Time Zone Database](https://www.iana.org/time-zones).
|
|
74
|
-
|
|
75
|
-
## Interaction with `at()`, `on()`, cron
|
|
76
|
-
|
|
77
|
-
The timezone applies to every interpretation of "now" and every constraint:
|
|
78
|
-
|
|
79
|
-
```ts
|
|
80
|
-
job("monday-standup", task)
|
|
81
|
-
.weekly()
|
|
82
|
-
.on("monday")
|
|
83
|
-
.at("09:00")
|
|
84
|
-
.inTimezone("Europe/Berlin");
|
|
85
|
-
// "Monday in Berlin local time" + "09:00 Berlin local time"
|
|
86
|
-
// — automatically shifts CET ↔ CEST as DST changes.
|
|
87
|
-
|
|
88
|
-
job("nightly-cron", task)
|
|
89
|
-
.cron("0 3 * * *")
|
|
90
|
-
.inTimezone("Asia/Tokyo");
|
|
91
|
-
// "0 3 * * *" interpreted in Tokyo local time → 03:00 JST = 18:00 UTC.
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## Validation
|
|
95
|
-
|
|
96
|
-
`.inTimezone()` stores the string as-is, then immediately recomputes `nextRun` — and that recompute calls dayjs with the zone. An invalid IANA string makes dayjs throw a `RangeError: Invalid time zone specified` **synchronously, at that point in the chain**:
|
|
97
|
-
|
|
98
|
-
```ts
|
|
99
|
-
// Throws right here — the .inTimezone() call recomputes nextRun, which
|
|
100
|
-
// hits dayjs().tz("Asia/Whatever") and raises RangeError immediately.
|
|
101
|
-
const j = job("t", task).daily().at("09:00").inTimezone("Asia/Whatever");
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
A valid zone computes `nextRun` on the spot (non-null after the chain). So a typo fails fast at definition time, not at the first tick — no special unit test needed to surface it.
|
|
105
|
-
|
|
106
|
-
## Server-side caveats
|
|
107
|
-
|
|
108
|
-
Node ships full ICU on most platforms, so all IANA zones work out of the box. If you're running a small-ICU build (alpine without full ICU, or old Node versions with `--icu=small`), only a limited set of zones is recognized. Set the `NODE_ICU_DATA` env var or use the `full-icu` package in that case.
|
|
109
|
-
|
|
110
|
-
## See also
|
|
111
|
-
|
|
112
|
-
- [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — `at()`, `on()`, `daily()`, etc.
|
|
113
|
-
- [`@warlock.js/scheduler/schedule-with-cron/SKILL.md`](@warlock.js/scheduler/schedule-with-cron/SKILL.md) — cron schedules are timezone-aware too
|
|
114
|
-
- [`@warlock.js/scheduler/observe-scheduler/SKILL.md`](@warlock.js/scheduler/observe-scheduler/SKILL.md) — `job.nextRun.toISOString()` always renders in UTC; format with `.tz(zone)` to see local time
|
|
1
|
+
---
|
|
2
|
+
name: pin-schedule-timezone
|
|
3
|
+
description: 'Pin a job to a specific IANA timezone via `.inTimezone(zone)` so its wall-clock fire time stays correct regardless of server location / DST. Triggers: `.inTimezone`, `America/New_York`, `Europe/Berlin`, `Asia/Tokyo`, `NODE_ICU_DATA`; "schedule job in a timezone", "DST drift on non-UTC server", "multi-region fan-out scheduling", "fire at 9am ET regardless of server"; typical import `import { job, scheduler } from "@warlock.js/scheduler"`. Skip: fluent interval methods — `@warlock.js/scheduler/schedule-fluently/SKILL.md`; cron syntax — `@warlock.js/scheduler/schedule-with-cron/SKILL.md`; competing libs `dayjs-timezone`, `luxon`, `date-fns-tz`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Per-job timezones
|
|
7
|
+
|
|
8
|
+
Every `Job` has its own timezone. The default is **UTC** — `.daily().at("09:00")` fires at 09:00 UTC, regardless of the server's locale or system timezone. Pin to wall-clock time with `.inTimezone(IANA string)`.
|
|
9
|
+
|
|
10
|
+
## Basic usage
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
job("morning-digest", sendDailyDigest)
|
|
14
|
+
.daily()
|
|
15
|
+
.at("08:00")
|
|
16
|
+
.inTimezone("America/New_York"); // 8 AM ET, not 8 AM UTC
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`.inTimezone()` is chainable — typically placed after the time/day methods.
|
|
20
|
+
|
|
21
|
+
## Why the default is UTC
|
|
22
|
+
|
|
23
|
+
A server runs wherever ops put it (Frankfurt, Virginia, GCP us-central1, …). System-local time is unpredictable; UTC is stable. The framework picks the stable default so that `daily().at("09:00")` without further config is reproducible across deployments. Pin to wall-clock time when business hours actually matter.
|
|
24
|
+
|
|
25
|
+
## Multi-region fan-out
|
|
26
|
+
|
|
27
|
+
Same logical task, three regions:
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { scheduler, job } from "@warlock.js/scheduler";
|
|
31
|
+
|
|
32
|
+
const regions = [
|
|
33
|
+
{ name: "us-east", tz: "America/New_York" },
|
|
34
|
+
{ name: "eu-west", tz: "Europe/Berlin" },
|
|
35
|
+
{ name: "asia", tz: "Asia/Tokyo" },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
for (const { name, tz } of regions) {
|
|
39
|
+
scheduler.addJob(
|
|
40
|
+
job(`morning-report-${name}`, () => sendReport(name))
|
|
41
|
+
.daily()
|
|
42
|
+
.at("09:00")
|
|
43
|
+
.inTimezone(tz)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
scheduler.start();
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Three separate `Job` instances, three separate `nextRun` calculations. Each fires once per day at its region's 09:00.
|
|
51
|
+
|
|
52
|
+
## DST is automatic
|
|
53
|
+
|
|
54
|
+
dayjs handles transitions correctly. A daily 09:00 job in `America/New_York` shifts between 13:00 UTC (EST winter) and 14:00 UTC (EDT summer) without intervention. The same job in a fixed-offset region (`Asia/Tokyo`, `Africa/Algiers`) stays at a constant UTC offset.
|
|
55
|
+
|
|
56
|
+
## Common IANA strings
|
|
57
|
+
|
|
58
|
+
| Region | TZ string |
|
|
59
|
+
| ------------------- | --------------------------- |
|
|
60
|
+
| UTC | `UTC` |
|
|
61
|
+
| US Eastern | `America/New_York` |
|
|
62
|
+
| US Central | `America/Chicago` |
|
|
63
|
+
| US Mountain | `America/Denver` |
|
|
64
|
+
| US Pacific | `America/Los_Angeles` |
|
|
65
|
+
| UK / Ireland | `Europe/London` |
|
|
66
|
+
| Central Europe | `Europe/Berlin` |
|
|
67
|
+
| Eastern Europe | `Europe/Kyiv` |
|
|
68
|
+
| India | `Asia/Kolkata` |
|
|
69
|
+
| Japan | `Asia/Tokyo` |
|
|
70
|
+
| Australia (Sydney) | `Australia/Sydney` |
|
|
71
|
+
| Egypt | `Africa/Cairo` |
|
|
72
|
+
|
|
73
|
+
Full list: [IANA Time Zone Database](https://www.iana.org/time-zones).
|
|
74
|
+
|
|
75
|
+
## Interaction with `at()`, `on()`, cron
|
|
76
|
+
|
|
77
|
+
The timezone applies to every interpretation of "now" and every constraint:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
job("monday-standup", task)
|
|
81
|
+
.weekly()
|
|
82
|
+
.on("monday")
|
|
83
|
+
.at("09:00")
|
|
84
|
+
.inTimezone("Europe/Berlin");
|
|
85
|
+
// "Monday in Berlin local time" + "09:00 Berlin local time"
|
|
86
|
+
// — automatically shifts CET ↔ CEST as DST changes.
|
|
87
|
+
|
|
88
|
+
job("nightly-cron", task)
|
|
89
|
+
.cron("0 3 * * *")
|
|
90
|
+
.inTimezone("Asia/Tokyo");
|
|
91
|
+
// "0 3 * * *" interpreted in Tokyo local time → 03:00 JST = 18:00 UTC.
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Validation
|
|
95
|
+
|
|
96
|
+
`.inTimezone()` stores the string as-is, then immediately recomputes `nextRun` — and that recompute calls dayjs with the zone. An invalid IANA string makes dayjs throw a `RangeError: Invalid time zone specified` **synchronously, at that point in the chain**:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// Throws right here — the .inTimezone() call recomputes nextRun, which
|
|
100
|
+
// hits dayjs().tz("Asia/Whatever") and raises RangeError immediately.
|
|
101
|
+
const j = job("t", task).daily().at("09:00").inTimezone("Asia/Whatever");
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A valid zone computes `nextRun` on the spot (non-null after the chain). So a typo fails fast at definition time, not at the first tick — no special unit test needed to surface it.
|
|
105
|
+
|
|
106
|
+
## Server-side caveats
|
|
107
|
+
|
|
108
|
+
Node ships full ICU on most platforms, so all IANA zones work out of the box. If you're running a small-ICU build (alpine without full ICU, or old Node versions with `--icu=small`), only a limited set of zones is recognized. Set the `NODE_ICU_DATA` env var or use the `full-icu` package in that case.
|
|
109
|
+
|
|
110
|
+
## See also
|
|
111
|
+
|
|
112
|
+
- [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — `at()`, `on()`, `daily()`, etc.
|
|
113
|
+
- [`@warlock.js/scheduler/schedule-with-cron/SKILL.md`](@warlock.js/scheduler/schedule-with-cron/SKILL.md) — cron schedules are timezone-aware too
|
|
114
|
+
- [`@warlock.js/scheduler/observe-scheduler/SKILL.md`](@warlock.js/scheduler/observe-scheduler/SKILL.md) — `job.nextRun.toISOString()` always renders in UTC; format with `.tz(zone)` to see local time
|