@warlock.js/scheduler 5.24.0 → 5.25.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/cjs/index.cjs +49 -3
- package/cjs/index.cjs.map +1 -1
- package/esm/process-roles.mjs +37 -0
- package/esm/process-roles.mjs.map +1 -0
- package/esm/scheduler.d.mts +9 -2
- package/esm/scheduler.d.mts.map +1 -1
- package/esm/scheduler.mjs +15 -3
- package/esm/scheduler.mjs.map +1 -1
- package/llms-full.txt +683 -683
- package/package.json +2 -2
- package/skills/configure-retry-and-overlap/SKILL.md +152 -152
- 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
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"dayjs": "^1.11.19"
|
|
9
9
|
},
|
|
10
10
|
"peerDependencies": {
|
|
11
|
-
"@warlock.js/cache": "5.
|
|
11
|
+
"@warlock.js/cache": "5.25.0"
|
|
12
12
|
},
|
|
13
13
|
"peerDependenciesMeta": {
|
|
14
14
|
"@warlock.js/cache": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
],
|
|
34
34
|
"author": "hassanzohdy",
|
|
35
35
|
"license": "MIT",
|
|
36
|
-
"version": "5.
|
|
36
|
+
"version": "5.25.0",
|
|
37
37
|
"main": "./cjs/index.cjs",
|
|
38
38
|
"module": "./esm/index.mjs",
|
|
39
39
|
"types": "./esm/index.d.mts",
|
|
@@ -1,152 +1,152 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: configure-retry-and-overlap
|
|
3
|
-
description: 'Configure `.retry(maxRetries, delay?, backoffMultiplier?)` for fixed / exponential backoff and `.preventOverlap()` for external-invocation safety. Triggers: `.retry`, `.preventOverlap`, `maxRetries`, `backoffMultiplier`, `JobResult.retries`, `job:error`, `job:skip`; "how do I retry a failed job", "exponential backoff for scheduled job", "prevent concurrent job runs", "stop firing after N consecutive failures"; typical import `import { job, scheduler } from "@warlock.js/scheduler"`. Skip: events / shutdown — `@warlock.js/scheduler/observe-scheduler/SKILL.md`; building the schedule itself — `@warlock.js/scheduler/schedule-fluently/SKILL.md`; competing libs `p-retry`, `async-retry`, `bullmq`.'
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Retry, backoff, and overlap
|
|
7
|
-
|
|
8
|
-
Two independent execution-control concerns on a single `Job`. They compose cleanly.
|
|
9
|
-
|
|
10
|
-
## `.retry(maxRetries, delay?, backoffMultiplier?)`
|
|
11
|
-
|
|
12
|
-
```ts
|
|
13
|
-
job("send-report", sendReport)
|
|
14
|
-
.daily()
|
|
15
|
-
.at("08:00")
|
|
16
|
-
.retry(3); // 3 retries, 1000ms apart (default delay)
|
|
17
|
-
|
|
18
|
-
job("sync", syncInventory)
|
|
19
|
-
.everyHour()
|
|
20
|
-
.retry(5, 2000); // 5 retries, 2000ms each
|
|
21
|
-
|
|
22
|
-
job("queue", processQueue)
|
|
23
|
-
.everyMinutes(10)
|
|
24
|
-
.retry(5, 1000, 2); // exponential: 1s → 2s → 4s → 8s → 16s
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
**Signature:** `retry(maxRetries: number, delay = 1000, backoffMultiplier?: number): this`
|
|
28
|
-
|
|
29
|
-
**Formula:** delay before attempt `N+1` = `delay × backoffMultiplier^(N-1)`. Without a multiplier, all retries wait `delay` ms.
|
|
30
|
-
|
|
31
|
-
**Validation.** Throws at definition time on negative `maxRetries`, negative `delay`, or zero/negative `backoffMultiplier`. `retry(0)` is valid — means "no retries, single attempt."
|
|
32
|
-
|
|
33
|
-
## Retry count surfaces in `JobResult`
|
|
34
|
-
|
|
35
|
-
```ts
|
|
36
|
-
scheduler.on("job:complete", (name, result) => {
|
|
37
|
-
if (result.retries && result.retries > 0) {
|
|
38
|
-
log.warn({ name, retries: result.retries }, "succeeded after retries");
|
|
39
|
-
}
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
scheduler.on("job:error", (name, error) => {
|
|
43
|
-
// Fires once, AFTER all retries are exhausted.
|
|
44
|
-
log.error({ name, error }, "failed permanently");
|
|
45
|
-
});
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
`JobResult.retries`:
|
|
49
|
-
- On **success**: the number of failed attempts before the eventual success (0 if first attempt succeeded).
|
|
50
|
-
- On **failure**: equals the configured `maxRetries` (all of them were used).
|
|
51
|
-
|
|
52
|
-
## After permanent failure — `nextRun` advances normally
|
|
53
|
-
|
|
54
|
-
This is load-bearing: a job that exhausts every retry and still throws does **not** re-fire on the next tick. The scheduler:
|
|
55
|
-
|
|
56
|
-
1. Emits `job:error` once with the final error.
|
|
57
|
-
2. Advances `nextRun` by the job's interval, same as success.
|
|
58
|
-
|
|
59
|
-
Both branches go through the same `finally`-block path in `Job.run()`. There is no "stuck on retry" mode.
|
|
60
|
-
|
|
61
|
-
```ts
|
|
62
|
-
// Fires every 10 minutes, retries 3 times per fire.
|
|
63
|
-
// If the 10:00 run exhausts all retries, next run is at 10:10. NOT at 10:00:00.001.
|
|
64
|
-
job("flaky", fn).everyMinutes(10).retry(3, 1000);
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
To **stop** firing after N consecutive failures, do it in user code:
|
|
68
|
-
|
|
69
|
-
```ts
|
|
70
|
-
let consecutiveFailures = 0;
|
|
71
|
-
scheduler.on("job:error", name => {
|
|
72
|
-
if (name !== "flaky") return;
|
|
73
|
-
consecutiveFailures++;
|
|
74
|
-
if (consecutiveFailures >= 5) {
|
|
75
|
-
scheduler.removeJob("flaky");
|
|
76
|
-
alert.critical("flaky disabled after 5 failures");
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
scheduler.on("job:complete", name => {
|
|
80
|
-
if (name === "flaky") consecutiveFailures = 0;
|
|
81
|
-
});
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
## `.preventOverlap(skip = true)`
|
|
85
|
-
|
|
86
|
-
Tells the scheduler to skip a tick if the job is already running.
|
|
87
|
-
|
|
88
|
-
```ts
|
|
89
|
-
job("queue", processQueue)
|
|
90
|
-
.everyMinutes(5)
|
|
91
|
-
.preventOverlap();
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
**Important nuance.** The scheduler awaits each tick's jobs before scheduling the next tick — so **concurrent same-job runs from the scheduler's own loop are structurally impossible**, regardless of `preventOverlap()`. Where it actually matters: jobs whose callbacks ALSO get invoked outside the scheduler.
|
|
95
|
-
|
|
96
|
-
Real-world shapes that benefit:
|
|
97
|
-
|
|
98
|
-
```ts
|
|
99
|
-
// 1) Boot-time recovery sweep before normal scheduling.
|
|
100
|
-
const queueJob = job("queue", processQueueOnce).everyMinutes(5).preventOverlap();
|
|
101
|
-
scheduler.addJob(queueJob);
|
|
102
|
-
queueJob.run().catch(err => log.error({ err }, "boot sweep failed"));
|
|
103
|
-
scheduler.start();
|
|
104
|
-
|
|
105
|
-
// 2) Admin-triggered manual re-run from a dashboard endpoint.
|
|
106
|
-
app.post("/admin/jobs/:name/run", async (req, res) => {
|
|
107
|
-
const j = scheduler.getJob(req.params.name);
|
|
108
|
-
await j?.run();
|
|
109
|
-
res.sendStatus(204);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
// 3) Multiple scheduler instances pointed at the same Job (rare).
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
If a tick lands while one of these external runs is mid-flight:
|
|
116
|
-
- Scheduler emits `job:skip` with reason `"Job is already running"`
|
|
117
|
-
- The tick proceeds without re-entering the job
|
|
118
|
-
|
|
119
|
-
For a single-process app where the callback is only ever invoked via the scheduler, `preventOverlap()` is a no-op — but it's free, defensive documentation. Keep it on for any job that touches shared state.
|
|
120
|
-
|
|
121
|
-
## Combining retry + preventOverlap
|
|
122
|
-
|
|
123
|
-
They compose cleanly. Retry happens WITHIN one fire — if all retries fail, the tick ends and `nextRun` advances. `preventOverlap` matters only between fires.
|
|
124
|
-
|
|
125
|
-
```ts
|
|
126
|
-
job("long-flaky", processQueue)
|
|
127
|
-
.everyMinutes(5)
|
|
128
|
-
.preventOverlap()
|
|
129
|
-
.retry(3, 1000, 2); // up to 1 + 2 + 4 = 7 s of retry delay per fire
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
If retries push the total work past the next interval, `preventOverlap()` ensures the next scheduled tick skips instead of stacking.
|
|
133
|
-
|
|
134
|
-
## See also
|
|
135
|
-
|
|
136
|
-
- [`@warlock.js/scheduler/observe-scheduler/SKILL.md`](@warlock.js/scheduler/observe-scheduler/SKILL.md) — full event reference, `JobResult` shape, lifecycle
|
|
137
|
-
- [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the scheduling methods these compose with
|
|
138
|
-
|
|
139
|
-
## Running on one server only: `onOneServer()`
|
|
140
|
-
|
|
141
|
-
`preventOverlap()` is in-process. With several app instances, add `onOneServer()` so only one instance runs each tick:
|
|
142
|
-
|
|
143
|
-
```ts
|
|
144
|
-
job("nightly-report", buildReport).daily().at("02:00").onOneServer().preventOverlap();
|
|
145
|
-
job("sync", sync).everyMinutes(5).onOneServer({ lockTtl: "10m", key: "sync-v2" });
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
- Claim key: `scheduler.<key ?? name>.<scheduledTickEpochMs>` (scheduled tick time truncated to the second, so every server derives the same key). Losers skip the tick and the scheduler emits `job:skip`.
|
|
149
|
-
- It is a **claim, not a lock**: a create-only, TTL-bounded cache entry that is never released, only expires. That guarantees one run per tick even if another server's timer fires late, after the winner finished.
|
|
150
|
-
- Default claim TTL: `min(interval, 1h)` with a 60s floor (1h for cron jobs). Override with `lockTtl`.
|
|
151
|
-
- Needs the optional `@warlock.js/cache` peer and a shared driver (redis/pg). On the memory driver it only dedupes within one process.
|
|
152
|
-
- Throws at call time if the job has no name/key.
|
|
1
|
+
---
|
|
2
|
+
name: configure-retry-and-overlap
|
|
3
|
+
description: 'Configure `.retry(maxRetries, delay?, backoffMultiplier?)` for fixed / exponential backoff and `.preventOverlap()` for external-invocation safety. Triggers: `.retry`, `.preventOverlap`, `maxRetries`, `backoffMultiplier`, `JobResult.retries`, `job:error`, `job:skip`; "how do I retry a failed job", "exponential backoff for scheduled job", "prevent concurrent job runs", "stop firing after N consecutive failures"; typical import `import { job, scheduler } from "@warlock.js/scheduler"`. Skip: events / shutdown — `@warlock.js/scheduler/observe-scheduler/SKILL.md`; building the schedule itself — `@warlock.js/scheduler/schedule-fluently/SKILL.md`; competing libs `p-retry`, `async-retry`, `bullmq`.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retry, backoff, and overlap
|
|
7
|
+
|
|
8
|
+
Two independent execution-control concerns on a single `Job`. They compose cleanly.
|
|
9
|
+
|
|
10
|
+
## `.retry(maxRetries, delay?, backoffMultiplier?)`
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
job("send-report", sendReport)
|
|
14
|
+
.daily()
|
|
15
|
+
.at("08:00")
|
|
16
|
+
.retry(3); // 3 retries, 1000ms apart (default delay)
|
|
17
|
+
|
|
18
|
+
job("sync", syncInventory)
|
|
19
|
+
.everyHour()
|
|
20
|
+
.retry(5, 2000); // 5 retries, 2000ms each
|
|
21
|
+
|
|
22
|
+
job("queue", processQueue)
|
|
23
|
+
.everyMinutes(10)
|
|
24
|
+
.retry(5, 1000, 2); // exponential: 1s → 2s → 4s → 8s → 16s
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Signature:** `retry(maxRetries: number, delay = 1000, backoffMultiplier?: number): this`
|
|
28
|
+
|
|
29
|
+
**Formula:** delay before attempt `N+1` = `delay × backoffMultiplier^(N-1)`. Without a multiplier, all retries wait `delay` ms.
|
|
30
|
+
|
|
31
|
+
**Validation.** Throws at definition time on negative `maxRetries`, negative `delay`, or zero/negative `backoffMultiplier`. `retry(0)` is valid — means "no retries, single attempt."
|
|
32
|
+
|
|
33
|
+
## Retry count surfaces in `JobResult`
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
scheduler.on("job:complete", (name, result) => {
|
|
37
|
+
if (result.retries && result.retries > 0) {
|
|
38
|
+
log.warn({ name, retries: result.retries }, "succeeded after retries");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
scheduler.on("job:error", (name, error) => {
|
|
43
|
+
// Fires once, AFTER all retries are exhausted.
|
|
44
|
+
log.error({ name, error }, "failed permanently");
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`JobResult.retries`:
|
|
49
|
+
- On **success**: the number of failed attempts before the eventual success (0 if first attempt succeeded).
|
|
50
|
+
- On **failure**: equals the configured `maxRetries` (all of them were used).
|
|
51
|
+
|
|
52
|
+
## After permanent failure — `nextRun` advances normally
|
|
53
|
+
|
|
54
|
+
This is load-bearing: a job that exhausts every retry and still throws does **not** re-fire on the next tick. The scheduler:
|
|
55
|
+
|
|
56
|
+
1. Emits `job:error` once with the final error.
|
|
57
|
+
2. Advances `nextRun` by the job's interval, same as success.
|
|
58
|
+
|
|
59
|
+
Both branches go through the same `finally`-block path in `Job.run()`. There is no "stuck on retry" mode.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// Fires every 10 minutes, retries 3 times per fire.
|
|
63
|
+
// If the 10:00 run exhausts all retries, next run is at 10:10. NOT at 10:00:00.001.
|
|
64
|
+
job("flaky", fn).everyMinutes(10).retry(3, 1000);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
To **stop** firing after N consecutive failures, do it in user code:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
let consecutiveFailures = 0;
|
|
71
|
+
scheduler.on("job:error", name => {
|
|
72
|
+
if (name !== "flaky") return;
|
|
73
|
+
consecutiveFailures++;
|
|
74
|
+
if (consecutiveFailures >= 5) {
|
|
75
|
+
scheduler.removeJob("flaky");
|
|
76
|
+
alert.critical("flaky disabled after 5 failures");
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
scheduler.on("job:complete", name => {
|
|
80
|
+
if (name === "flaky") consecutiveFailures = 0;
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## `.preventOverlap(skip = true)`
|
|
85
|
+
|
|
86
|
+
Tells the scheduler to skip a tick if the job is already running.
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
job("queue", processQueue)
|
|
90
|
+
.everyMinutes(5)
|
|
91
|
+
.preventOverlap();
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Important nuance.** The scheduler awaits each tick's jobs before scheduling the next tick — so **concurrent same-job runs from the scheduler's own loop are structurally impossible**, regardless of `preventOverlap()`. Where it actually matters: jobs whose callbacks ALSO get invoked outside the scheduler.
|
|
95
|
+
|
|
96
|
+
Real-world shapes that benefit:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
// 1) Boot-time recovery sweep before normal scheduling.
|
|
100
|
+
const queueJob = job("queue", processQueueOnce).everyMinutes(5).preventOverlap();
|
|
101
|
+
scheduler.addJob(queueJob);
|
|
102
|
+
queueJob.run().catch(err => log.error({ err }, "boot sweep failed"));
|
|
103
|
+
scheduler.start();
|
|
104
|
+
|
|
105
|
+
// 2) Admin-triggered manual re-run from a dashboard endpoint.
|
|
106
|
+
app.post("/admin/jobs/:name/run", async (req, res) => {
|
|
107
|
+
const j = scheduler.getJob(req.params.name);
|
|
108
|
+
await j?.run();
|
|
109
|
+
res.sendStatus(204);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// 3) Multiple scheduler instances pointed at the same Job (rare).
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
If a tick lands while one of these external runs is mid-flight:
|
|
116
|
+
- Scheduler emits `job:skip` with reason `"Job is already running"`
|
|
117
|
+
- The tick proceeds without re-entering the job
|
|
118
|
+
|
|
119
|
+
For a single-process app where the callback is only ever invoked via the scheduler, `preventOverlap()` is a no-op — but it's free, defensive documentation. Keep it on for any job that touches shared state.
|
|
120
|
+
|
|
121
|
+
## Combining retry + preventOverlap
|
|
122
|
+
|
|
123
|
+
They compose cleanly. Retry happens WITHIN one fire — if all retries fail, the tick ends and `nextRun` advances. `preventOverlap` matters only between fires.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
job("long-flaky", processQueue)
|
|
127
|
+
.everyMinutes(5)
|
|
128
|
+
.preventOverlap()
|
|
129
|
+
.retry(3, 1000, 2); // up to 1 + 2 + 4 = 7 s of retry delay per fire
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
If retries push the total work past the next interval, `preventOverlap()` ensures the next scheduled tick skips instead of stacking.
|
|
133
|
+
|
|
134
|
+
## See also
|
|
135
|
+
|
|
136
|
+
- [`@warlock.js/scheduler/observe-scheduler/SKILL.md`](@warlock.js/scheduler/observe-scheduler/SKILL.md) — full event reference, `JobResult` shape, lifecycle
|
|
137
|
+
- [`@warlock.js/scheduler/schedule-fluently/SKILL.md`](@warlock.js/scheduler/schedule-fluently/SKILL.md) — the scheduling methods these compose with
|
|
138
|
+
|
|
139
|
+
## Running on one server only: `onOneServer()`
|
|
140
|
+
|
|
141
|
+
`preventOverlap()` is in-process. With several app instances, add `onOneServer()` so only one instance runs each tick:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
job("nightly-report", buildReport).daily().at("02:00").onOneServer().preventOverlap();
|
|
145
|
+
job("sync", sync).everyMinutes(5).onOneServer({ lockTtl: "10m", key: "sync-v2" });
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
- Claim key: `scheduler.<key ?? name>.<scheduledTickEpochMs>` (scheduled tick time truncated to the second, so every server derives the same key). Losers skip the tick and the scheduler emits `job:skip`.
|
|
149
|
+
- It is a **claim, not a lock**: a create-only, TTL-bounded cache entry that is never released, only expires. That guarantees one run per tick even if another server's timer fires late, after the winner finished.
|
|
150
|
+
- Default claim TTL: `min(interval, 1h)` with a 60s floor (1h for cron jobs). Override with `lockTtl`.
|
|
151
|
+
- Needs the optional `@warlock.js/cache` peer and a shared driver (redis/pg). On the memory driver it only dedupes within one process.
|
|
152
|
+
- Throws at call time if the job has no name/key.
|
|
@@ -1,153 +1,153 @@
|
|
|
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
|
+
---
|
|
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
|