@warlock.js/scheduler 5.19.1 → 5.21.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 +12 -1
- 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
package/llms.txt
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
## Skills
|
|
8
8
|
|
|
9
|
-
- [configure-retry-and-overlap](@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md):
|
|
10
|
-
- [observe-scheduler](@warlock.js/scheduler/observe-scheduler/SKILL.md):
|
|
9
|
+
- [configure-retry-and-overlap](@warlock.js/scheduler/configure-retry-and-overlap/SKILL.md): (no description)
|
|
10
|
+
- [observe-scheduler](@warlock.js/scheduler/observe-scheduler/SKILL.md): (no description)
|
|
11
11
|
- [overview](@warlock.js/scheduler/overview/SKILL.md): @warlock.js/scheduler overview for orientation and choosing the focused skill for the task.
|
|
12
|
-
- [pin-schedule-timezone](@warlock.js/scheduler/pin-schedule-timezone/SKILL.md):
|
|
13
|
-
- [schedule-fluently](@warlock.js/scheduler/schedule-fluently/SKILL.md):
|
|
14
|
-
- [schedule-with-cron](@warlock.js/scheduler/schedule-with-cron/SKILL.md):
|
|
12
|
+
- [pin-schedule-timezone](@warlock.js/scheduler/pin-schedule-timezone/SKILL.md): (no description)
|
|
13
|
+
- [schedule-fluently](@warlock.js/scheduler/schedule-fluently/SKILL.md): (no description)
|
|
14
|
+
- [schedule-with-cron](@warlock.js/scheduler/schedule-with-cron/SKILL.md): (no description)
|
|
15
15
|
- [scheduler-basics](@warlock.js/scheduler/scheduler-basics/SKILL.md): Start here for @warlock.js/scheduler recurring jobs, schedules, worker configuration, and job lifecycle.
|
package/package.json
CHANGED
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"dayjs": "^1.11.19"
|
|
9
9
|
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"@warlock.js/cache": "5.21.0"
|
|
12
|
+
},
|
|
13
|
+
"peerDependenciesMeta": {
|
|
14
|
+
"@warlock.js/cache": {
|
|
15
|
+
"optional": true
|
|
16
|
+
}
|
|
17
|
+
},
|
|
10
18
|
"repository": {
|
|
11
19
|
"type": "git",
|
|
12
20
|
"url": "https://github.com/warlockjs/scheduler"
|
|
@@ -25,7 +33,7 @@
|
|
|
25
33
|
],
|
|
26
34
|
"author": "hassanzohdy",
|
|
27
35
|
"license": "MIT",
|
|
28
|
-
"version": "5.
|
|
36
|
+
"version": "5.21.0",
|
|
29
37
|
"main": "./cjs/index.cjs",
|
|
30
38
|
"module": "./esm/index.mjs",
|
|
31
39
|
"types": "./esm/index.d.mts",
|
|
@@ -1,137 +1,152 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: configure-retry-and-overlap
|
|
3
|
-
description: "
|
|
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
|
|
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.
|
package/skills/index.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "In-process job scheduler for Warlock.js with fluent and cron scheduling, retries, overlap prevention and timezones. Exports `Scheduler`, `scheduler`, `Job`, `job`, `CronParser`, `parseCron`, plus types `RetryConfig`, `JobResult`, `SchedulerEvents`. Use for: \"run this every day at 3am\", \"run a cron expression on weekdays\", \"retry a failing job 3 times\", \"stop a job overlapping itself\", \"run in New York time\", \"log job failures\". Not this package: durable queues and background workers are not scheduling; per-run locking across instances uses @warlock.js/cache."
|
|
3
|
+
---
|
|
4
|
+
# @warlock.js/scheduler
|
|
5
|
+
|
|
6
|
+
Build a `job(name, fn)`, describe when it runs with the fluent API (`.daily().at("03:00")`) or `.cron("...")`, add it to a `Scheduler`, then `start()`. Events (`job:error` and others) provide observability; `shutdown()` stops gracefully.
|
|
7
|
+
|
|
8
|
+
## The 80% path
|
|
9
|
+
1. Orient: `overview.md`, `scheduler-basics.md`.
|
|
10
|
+
2. Define the schedule fluently: `schedule-fluently.md`, or by cron: `schedule-with-cron.md`.
|
|
11
|
+
3. Make it resilient: `configure-retry-and-overlap.md`.
|
|
12
|
+
4. Fix wall-clock semantics: `pin-schedule-timezone.md`.
|
|
13
|
+
5. Watch it run: `observe-scheduler.md`.
|
|
14
|
+
|
|
15
|
+
## Conventions and pitfalls
|
|
16
|
+
- It is in-memory and per-process: each app instance runs its own copy. With multiple instances, guard jobs with a lock (for example `@warlock.js/cache`) or run the scheduler in one instance only.
|
|
17
|
+
- Missed runs while the process was down are not replayed.
|
|
18
|
+
- Set a timezone explicitly for jobs tied to business hours; otherwise the host's applies.
|
|
19
|
+
- Call `scheduler.shutdown()` on SIGTERM so running jobs finish.
|
|
20
|
+
- Use `preventOverlap()` for jobs that can outlast their interval.
|