@warlock.js/scheduler 5.30.0 → 5.31.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 +7 -0
- package/README.md +2 -4
- package/cjs/index.cjs.map +1 -1
- package/esm/cron-parser.d.mts.map +1 -1
- package/esm/cron-parser.mjs.map +1 -1
- package/esm/job.d.mts.map +1 -1
- package/esm/job.mjs.map +1 -1
- package/esm/scheduler.mjs.map +1 -1
- package/llms-full.txt +128 -143
- package/package.json +2 -2
- package/skills/configure-retry-and-overlap/SKILL.md +12 -22
- package/skills/index.md +4 -1
- package/skills/observe-scheduler/SKILL.md +31 -30
- package/skills/overview/SKILL.md +6 -0
- package/skills/pin-schedule-timezone/SKILL.md +21 -30
- package/skills/schedule-fluently/SKILL.md +40 -43
- package/skills/schedule-with-cron/SKILL.md +12 -12
- package/skills/scheduler-basics/SKILL.md +8 -8
package/llms-full.txt
CHANGED
|
@@ -18,18 +18,11 @@ Two independent execution-control concerns on a single `Job`. They compose clean
|
|
|
18
18
|
## `.retry(maxRetries, delay?, backoffMultiplier?)`
|
|
19
19
|
|
|
20
20
|
```ts
|
|
21
|
-
job("send-report", sendReport)
|
|
22
|
-
.daily()
|
|
23
|
-
.at("08:00")
|
|
24
|
-
.retry(3); // 3 retries, 1000ms apart (default delay)
|
|
21
|
+
job("send-report", sendReport).daily().at("08:00").retry(3); // 3 retries, 1000ms apart (default delay)
|
|
25
22
|
|
|
26
|
-
job("sync", syncInventory)
|
|
27
|
-
.everyHour()
|
|
28
|
-
.retry(5, 2000); // 5 retries, 2000ms each
|
|
23
|
+
job("sync", syncInventory).everyHour().retry(5, 2000); // 5 retries, 2000ms each
|
|
29
24
|
|
|
30
|
-
job("queue", processQueue)
|
|
31
|
-
.everyMinutes(10)
|
|
32
|
-
.retry(5, 1000, 2); // exponential: 1s → 2s → 4s → 8s → 16s
|
|
25
|
+
job("queue", processQueue).everyMinutes(10).retry(5, 1000, 2); // exponential: 1s → 2s → 4s → 8s → 16s
|
|
33
26
|
```
|
|
34
27
|
|
|
35
28
|
**Signature:** `retry(maxRetries: number, delay = 1000, backoffMultiplier?: number): this`
|
|
@@ -54,6 +47,7 @@ scheduler.on("job:error", (name, error) => {
|
|
|
54
47
|
```
|
|
55
48
|
|
|
56
49
|
`JobResult.retries`:
|
|
50
|
+
|
|
57
51
|
- On **success**: the number of failed attempts before the eventual success (0 if first attempt succeeded).
|
|
58
52
|
- On **failure**: equals the configured `maxRetries` (all of them were used).
|
|
59
53
|
|
|
@@ -76,7 +70,7 @@ To **stop** firing after N consecutive failures, do it in user code:
|
|
|
76
70
|
|
|
77
71
|
```ts
|
|
78
72
|
let consecutiveFailures = 0;
|
|
79
|
-
scheduler.on("job:error", name => {
|
|
73
|
+
scheduler.on("job:error", (name) => {
|
|
80
74
|
if (name !== "flaky") return;
|
|
81
75
|
consecutiveFailures++;
|
|
82
76
|
if (consecutiveFailures >= 5) {
|
|
@@ -84,7 +78,7 @@ scheduler.on("job:error", name => {
|
|
|
84
78
|
alert.critical("flaky disabled after 5 failures");
|
|
85
79
|
}
|
|
86
80
|
});
|
|
87
|
-
scheduler.on("job:complete", name => {
|
|
81
|
+
scheduler.on("job:complete", (name) => {
|
|
88
82
|
if (name === "flaky") consecutiveFailures = 0;
|
|
89
83
|
});
|
|
90
84
|
```
|
|
@@ -94,9 +88,7 @@ scheduler.on("job:complete", name => {
|
|
|
94
88
|
Tells the scheduler to skip a tick if the job is already running.
|
|
95
89
|
|
|
96
90
|
```ts
|
|
97
|
-
job("queue", processQueue)
|
|
98
|
-
.everyMinutes(5)
|
|
99
|
-
.preventOverlap();
|
|
91
|
+
job("queue", processQueue).everyMinutes(5).preventOverlap();
|
|
100
92
|
```
|
|
101
93
|
|
|
102
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.
|
|
@@ -107,7 +99,7 @@ Real-world shapes that benefit:
|
|
|
107
99
|
// 1) Boot-time recovery sweep before normal scheduling.
|
|
108
100
|
const queueJob = job("queue", processQueueOnce).everyMinutes(5).preventOverlap();
|
|
109
101
|
scheduler.addJob(queueJob);
|
|
110
|
-
queueJob.run().catch(err => log.error({ err }, "boot sweep failed"));
|
|
102
|
+
queueJob.run().catch((err) => log.error({ err }, "boot sweep failed"));
|
|
111
103
|
scheduler.start();
|
|
112
104
|
|
|
113
105
|
// 2) Admin-triggered manual re-run from a dashboard endpoint.
|
|
@@ -121,6 +113,7 @@ app.post("/admin/jobs/:name/run", async (req, res) => {
|
|
|
121
113
|
```
|
|
122
114
|
|
|
123
115
|
If a tick lands while one of these external runs is mid-flight:
|
|
116
|
+
|
|
124
117
|
- Scheduler emits `job:skip` with reason `"Job is already running"`
|
|
125
118
|
- The tick proceeds without re-entering the job
|
|
126
119
|
|
|
@@ -131,10 +124,7 @@ For a single-process app where the callback is only ever invoked via the schedul
|
|
|
131
124
|
They compose cleanly. Retry happens WITHIN one fire — if all retries fail, the tick ends and `nextRun` advances. `preventOverlap` matters only between fires.
|
|
132
125
|
|
|
133
126
|
```ts
|
|
134
|
-
job("long-flaky", processQueue)
|
|
135
|
-
.everyMinutes(5)
|
|
136
|
-
.preventOverlap()
|
|
137
|
-
.retry(3, 1000, 2); // up to 1 + 2 + 4 = 7 s of retry delay per fire
|
|
127
|
+
job("long-flaky", processQueue).everyMinutes(5).preventOverlap().retry(3, 1000, 2); // up to 1 + 2 + 4 = 7 s of retry delay per fire
|
|
138
128
|
```
|
|
139
129
|
|
|
140
130
|
If retries push the total work past the next interval, `preventOverlap()` ensures the next scheduled tick skips instead of stacking.
|
|
@@ -175,25 +165,25 @@ The `Scheduler` extends Node's `EventEmitter` with a fully-typed surface. Wire l
|
|
|
175
165
|
|
|
176
166
|
```ts
|
|
177
167
|
type SchedulerEvents = {
|
|
178
|
-
"job:start":
|
|
179
|
-
"job:complete":
|
|
180
|
-
"job:error":
|
|
181
|
-
"job:skip":
|
|
168
|
+
"job:start": [jobName: string];
|
|
169
|
+
"job:complete": [jobName: string, result: JobResult];
|
|
170
|
+
"job:error": [jobName: string, error: unknown];
|
|
171
|
+
"job:skip": [jobName: string, reason: string];
|
|
182
172
|
"scheduler:started": [];
|
|
183
173
|
"scheduler:stopped": [];
|
|
184
|
-
"scheduler:tick":
|
|
174
|
+
"scheduler:tick": [timestamp: Date];
|
|
185
175
|
};
|
|
186
176
|
```
|
|
187
177
|
|
|
188
|
-
| Event
|
|
189
|
-
|
|
|
190
|
-
| `scheduler:started`
|
|
191
|
-
| `scheduler:stopped`
|
|
192
|
-
| `scheduler:tick`
|
|
193
|
-
| `job:start`
|
|
194
|
-
| `job:complete`
|
|
195
|
-
| `job:error`
|
|
196
|
-
| `job:skip`
|
|
178
|
+
| Event | When it fires |
|
|
179
|
+
| ------------------- | -------------------------------------------------------------------------------------- |
|
|
180
|
+
| `scheduler:started` | Once, after `.start()` enters the tick loop |
|
|
181
|
+
| `scheduler:stopped` | When `.stop()` halts the loop. Not emitted if `.stop()` was a no-op (never started) |
|
|
182
|
+
| `scheduler:tick` | Every tick — once per `runEvery()` interval (default 1 s). Keep handlers lightweight. |
|
|
183
|
+
| `job:start` | Just before a job's callback is first invoked (NOT once per retry) |
|
|
184
|
+
| `job:complete` | When a job finishes successfully (possibly after retries) |
|
|
185
|
+
| `job:error` | When a job exhausts all retries and the final attempt still throws |
|
|
186
|
+
| `job:skip` | When a tick finds the job already running (see retry-and-overlap for when this occurs) |
|
|
197
187
|
|
|
198
188
|
**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.
|
|
199
189
|
|
|
@@ -202,9 +192,9 @@ type SchedulerEvents = {
|
|
|
202
192
|
```ts
|
|
203
193
|
type JobResult = {
|
|
204
194
|
success: boolean;
|
|
205
|
-
duration: number;
|
|
206
|
-
error?: unknown;
|
|
207
|
-
retries?: number;
|
|
195
|
+
duration: number; // milliseconds, end-to-end including retry waits
|
|
196
|
+
error?: unknown; // present when success === false
|
|
197
|
+
retries?: number; // attempts that failed before the final outcome
|
|
208
198
|
};
|
|
209
199
|
```
|
|
210
200
|
|
|
@@ -216,12 +206,12 @@ type JobResult = {
|
|
|
216
206
|
import type { JobResult } from "@warlock.js/scheduler";
|
|
217
207
|
import { scheduler } from "@warlock.js/scheduler";
|
|
218
208
|
|
|
219
|
-
scheduler.on("job:start",
|
|
209
|
+
scheduler.on("job:start", (name) => log.debug({ name }, "job start"));
|
|
220
210
|
scheduler.on("job:complete", (name, result: JobResult) => {
|
|
221
211
|
log.info({ name, duration: result.duration, retries: result.retries }, "job complete");
|
|
222
212
|
});
|
|
223
|
-
scheduler.on("job:error",
|
|
224
|
-
scheduler.on("job:skip",
|
|
213
|
+
scheduler.on("job:error", (name, error) => log.error({ name, error }, "job failed"));
|
|
214
|
+
scheduler.on("job:skip", (name, reason) => log.warn({ name, reason }, "job skipped"));
|
|
225
215
|
```
|
|
226
216
|
|
|
227
217
|
`.on()`, `.once()`, `.off()` are all type-narrowed via `SchedulerEvents`.
|
|
@@ -233,6 +223,7 @@ scheduler.on("job:skip", (name, reason) => log.warn({ name, reason }, "job s
|
|
|
233
223
|
Prepares every registered job (computes initial `nextRun`) and enters the tick loop. Emits `scheduler:started`.
|
|
234
224
|
|
|
235
225
|
Throws if:
|
|
226
|
+
|
|
236
227
|
- already running (`"Scheduler is already running."`)
|
|
237
228
|
- zero jobs registered (`"Cannot start scheduler with no jobs."`)
|
|
238
229
|
|
|
@@ -268,7 +259,7 @@ import { logger } from "./logger";
|
|
|
268
259
|
import { metrics } from "./metrics";
|
|
269
260
|
import { alerts } from "./alerts";
|
|
270
261
|
|
|
271
|
-
scheduler.on("job:start", name => metrics.increment(`job.start.${name}`));
|
|
262
|
+
scheduler.on("job:start", (name) => metrics.increment(`job.start.${name}`));
|
|
272
263
|
|
|
273
264
|
scheduler.on("job:complete", (name, result) => {
|
|
274
265
|
metrics.increment(`job.success.${name}`);
|
|
@@ -295,20 +286,20 @@ scheduler.start();
|
|
|
295
286
|
## Inspection at runtime
|
|
296
287
|
|
|
297
288
|
```ts
|
|
298
|
-
scheduler.isRunning;
|
|
299
|
-
scheduler.jobCount;
|
|
300
|
-
scheduler.list();
|
|
301
|
-
scheduler.getJob("name");
|
|
289
|
+
scheduler.isRunning; // boolean
|
|
290
|
+
scheduler.jobCount; // number of registered jobs
|
|
291
|
+
scheduler.list(); // readonly Job[]
|
|
292
|
+
scheduler.getJob("name"); // Job | undefined
|
|
302
293
|
```
|
|
303
294
|
|
|
304
295
|
And on each `Job`:
|
|
305
296
|
|
|
306
297
|
```ts
|
|
307
|
-
job.nextRun;
|
|
308
|
-
job.lastRun;
|
|
309
|
-
job.isRunning;
|
|
310
|
-
job.intervals;
|
|
311
|
-
job.cronExpression;
|
|
298
|
+
job.nextRun; // Dayjs | null
|
|
299
|
+
job.lastRun; // Dayjs | null — success OR failure
|
|
300
|
+
job.isRunning; // boolean
|
|
301
|
+
job.intervals; // readonly schedule config
|
|
302
|
+
job.cronExpression; // string | null
|
|
312
303
|
```
|
|
313
304
|
|
|
314
305
|
## See also
|
|
@@ -351,27 +342,33 @@ Six task skills cover the full surface. Most callers only need `scheduler-basics
|
|
|
351
342
|
### Foundations
|
|
352
343
|
|
|
353
344
|
#### `scheduler-basics`
|
|
345
|
+
|
|
354
346
|
Start here. The `job(name, fn)` factory, the `scheduler` singleton, the 2-primitive surface, UTC default, why the API is factory-first.
|
|
355
347
|
|
|
356
348
|
### Scheduling
|
|
357
349
|
|
|
358
350
|
#### `schedule-fluently`
|
|
351
|
+
|
|
359
352
|
Build schedules without writing cron: `.everyMinutes(N)`, `.daily().at("03:00")`, `.weekly().on("monday")`, `.monthly()`, `.beginOf("month")`, `.endOf("year")`, and friends. Reach for this 80% of the time.
|
|
360
353
|
|
|
361
354
|
#### `schedule-with-cron`
|
|
355
|
+
|
|
362
356
|
Drop down to `.cron("0 9 * * 1-5")` when the fluent API can't express it (Vixie OR semantics for DOM/DOW, complex multi-value lists). Includes `parseCron()` for previewing next-run times before deploy.
|
|
363
357
|
|
|
364
358
|
### Production concerns
|
|
365
359
|
|
|
366
360
|
#### `configure-retry-and-overlap`
|
|
361
|
+
|
|
367
362
|
`.retry(maxRetries, delay?, backoffMultiplier?)` for fixed or exponential backoff; `.preventOverlap()` so a slow run never collides with the next tick. Reach for both anytime a job calls external services.
|
|
368
363
|
|
|
369
364
|
#### `pin-schedule-timezone`
|
|
365
|
+
|
|
370
366
|
`.inTimezone("America/New_York")` pins wall-clock fire times across DST and across servers. The default is UTC — if your job description includes "9 AM," you almost certainly want this skill.
|
|
371
367
|
|
|
372
368
|
### Observability + shutdown
|
|
373
369
|
|
|
374
370
|
#### `observe-scheduler`
|
|
371
|
+
|
|
375
372
|
The seven typed `SchedulerEvents` (`job:start` / `complete` / `error` / `skip`, `scheduler:started` / `stopped` / `tick`), the `JobResult` payload, `start()` / `stop()` / `shutdown()`. Wire this for logging, metrics, alerting, and graceful SIGTERM handling.
|
|
376
373
|
|
|
377
374
|
## Quick taste
|
|
@@ -431,10 +428,7 @@ Every `Job` has its own timezone. The default is **UTC** — `.daily().at("09:00
|
|
|
431
428
|
## Basic usage
|
|
432
429
|
|
|
433
430
|
```ts
|
|
434
|
-
job("morning-digest", sendDailyDigest)
|
|
435
|
-
.daily()
|
|
436
|
-
.at("08:00")
|
|
437
|
-
.inTimezone("America/New_York"); // 8 AM ET, not 8 AM UTC
|
|
431
|
+
job("morning-digest", sendDailyDigest).daily().at("08:00").inTimezone("America/New_York"); // 8 AM ET, not 8 AM UTC
|
|
438
432
|
```
|
|
439
433
|
|
|
440
434
|
`.inTimezone()` is chainable — typically placed after the time/day methods.
|
|
@@ -451,9 +445,9 @@ Same logical task, three regions:
|
|
|
451
445
|
import { scheduler, job } from "@warlock.js/scheduler";
|
|
452
446
|
|
|
453
447
|
const regions = [
|
|
454
|
-
{ name: "us-east",
|
|
455
|
-
{ name: "eu-west",
|
|
456
|
-
{ name: "asia",
|
|
448
|
+
{ name: "us-east", tz: "America/New_York" },
|
|
449
|
+
{ name: "eu-west", tz: "Europe/Berlin" },
|
|
450
|
+
{ name: "asia", tz: "Asia/Tokyo" },
|
|
457
451
|
];
|
|
458
452
|
|
|
459
453
|
for (const { name, tz } of regions) {
|
|
@@ -461,7 +455,7 @@ for (const { name, tz } of regions) {
|
|
|
461
455
|
job(`morning-report-${name}`, () => sendReport(name))
|
|
462
456
|
.daily()
|
|
463
457
|
.at("09:00")
|
|
464
|
-
.inTimezone(tz)
|
|
458
|
+
.inTimezone(tz),
|
|
465
459
|
);
|
|
466
460
|
}
|
|
467
461
|
|
|
@@ -476,20 +470,20 @@ dayjs handles transitions correctly. A daily 09:00 job in `America/New_York` shi
|
|
|
476
470
|
|
|
477
471
|
## Common IANA strings
|
|
478
472
|
|
|
479
|
-
| Region
|
|
480
|
-
|
|
|
481
|
-
| UTC
|
|
482
|
-
| US Eastern
|
|
483
|
-
| US Central
|
|
484
|
-
| US Mountain
|
|
485
|
-
| US Pacific
|
|
486
|
-
| UK / Ireland
|
|
487
|
-
| Central Europe
|
|
488
|
-
| Eastern Europe
|
|
489
|
-
| India
|
|
490
|
-
| Japan
|
|
491
|
-
| Australia (Sydney)
|
|
492
|
-
| Egypt
|
|
473
|
+
| Region | TZ string |
|
|
474
|
+
| ------------------ | --------------------- |
|
|
475
|
+
| UTC | `UTC` |
|
|
476
|
+
| US Eastern | `America/New_York` |
|
|
477
|
+
| US Central | `America/Chicago` |
|
|
478
|
+
| US Mountain | `America/Denver` |
|
|
479
|
+
| US Pacific | `America/Los_Angeles` |
|
|
480
|
+
| UK / Ireland | `Europe/London` |
|
|
481
|
+
| Central Europe | `Europe/Berlin` |
|
|
482
|
+
| Eastern Europe | `Europe/Kyiv` |
|
|
483
|
+
| India | `Asia/Kolkata` |
|
|
484
|
+
| Japan | `Asia/Tokyo` |
|
|
485
|
+
| Australia (Sydney) | `Australia/Sydney` |
|
|
486
|
+
| Egypt | `Africa/Cairo` |
|
|
493
487
|
|
|
494
488
|
Full list: [IANA Time Zone Database](https://www.iana.org/time-zones).
|
|
495
489
|
|
|
@@ -498,17 +492,11 @@ Full list: [IANA Time Zone Database](https://www.iana.org/time-zones).
|
|
|
498
492
|
The timezone applies to every interpretation of "now" and every constraint:
|
|
499
493
|
|
|
500
494
|
```ts
|
|
501
|
-
job("monday-standup", task)
|
|
502
|
-
.weekly()
|
|
503
|
-
.on("monday")
|
|
504
|
-
.at("09:00")
|
|
505
|
-
.inTimezone("Europe/Berlin");
|
|
495
|
+
job("monday-standup", task).weekly().on("monday").at("09:00").inTimezone("Europe/Berlin");
|
|
506
496
|
// "Monday in Berlin local time" + "09:00 Berlin local time"
|
|
507
497
|
// — automatically shifts CET ↔ CEST as DST changes.
|
|
508
498
|
|
|
509
|
-
job("nightly-cron", task)
|
|
510
|
-
.cron("0 3 * * *")
|
|
511
|
-
.inTimezone("Asia/Tokyo");
|
|
499
|
+
job("nightly-cron", task).cron("0 3 * * *").inTimezone("Asia/Tokyo");
|
|
512
500
|
// "0 3 * * *" interpreted in Tokyo local time → 03:00 JST = 18:00 UTC.
|
|
513
501
|
```
|
|
514
502
|
|
|
@@ -548,24 +536,24 @@ Chainable methods on a `Job` instance. Each method returns `this` for chaining;
|
|
|
548
536
|
|
|
549
537
|
## Preset intervals
|
|
550
538
|
|
|
551
|
-
| Method
|
|
552
|
-
|
|
|
553
|
-
| `.everySecond()`
|
|
554
|
-
| `.everySeconds(N)`
|
|
555
|
-
| `.everyMinute()`
|
|
556
|
-
| `.everyMinutes(N)`
|
|
557
|
-
| `.everyHour()`
|
|
558
|
-
| `.everyHours(N)`
|
|
559
|
-
| `.everyDay()`
|
|
560
|
-
| `.daily()`
|
|
561
|
-
| `.twiceDaily()`
|
|
562
|
-
| `.everyWeek()`
|
|
563
|
-
| `.weekly()`
|
|
564
|
-
| `.everyMonth()`
|
|
565
|
-
| `.monthly()`
|
|
566
|
-
| `.everyYear()`
|
|
567
|
-
| `.yearly()`
|
|
568
|
-
| `.always()`
|
|
539
|
+
| Method | Equivalent of | Notes |
|
|
540
|
+
| ------------------ | --------------------- | ------------------------------ |
|
|
541
|
+
| `.everySecond()` | `.every(1, "second")` | High frequency — use sparingly |
|
|
542
|
+
| `.everySeconds(N)` | `.every(N, "second")` | |
|
|
543
|
+
| `.everyMinute()` | `.every(1, "minute")` | |
|
|
544
|
+
| `.everyMinutes(N)` | `.every(N, "minute")` | |
|
|
545
|
+
| `.everyHour()` | `.every(1, "hour")` | |
|
|
546
|
+
| `.everyHours(N)` | `.every(N, "hour")` | |
|
|
547
|
+
| `.everyDay()` | `.every(1, "day")` | Same as `.daily()` |
|
|
548
|
+
| `.daily()` | `.every(1, "day")` | |
|
|
549
|
+
| `.twiceDaily()` | `.every(12, "hour")` | Every 12 hours |
|
|
550
|
+
| `.everyWeek()` | `.every(1, "week")` | Same as `.weekly()` |
|
|
551
|
+
| `.weekly()` | `.every(1, "week")` | |
|
|
552
|
+
| `.everyMonth()` | `.every(1, "month")` | Same as `.monthly()` |
|
|
553
|
+
| `.monthly()` | `.every(1, "month")` | |
|
|
554
|
+
| `.everyYear()` | `.every(1, "year")` | Same as `.yearly()` |
|
|
555
|
+
| `.yearly()` | `.every(1, "year")` | |
|
|
556
|
+
| `.always()` | `.every(1, "minute")` | Continuous "tick" jobs |
|
|
569
557
|
|
|
570
558
|
## Custom intervals — `.every(value, unit)`
|
|
571
559
|
|
|
@@ -583,7 +571,7 @@ Units: `"second" | "minute" | "hour" | "day" | "week" | "month" | "year"`.
|
|
|
583
571
|
|
|
584
572
|
```ts
|
|
585
573
|
job("nightly", fn).daily().at("03:00");
|
|
586
|
-
job("midday",
|
|
574
|
+
job("midday", fn).daily().at("12:30:15");
|
|
587
575
|
```
|
|
588
576
|
|
|
589
577
|
**Validation.** `.at()` throws if the format is malformed (`"foo"`, `"9-30"`), or any component is out of range (hour > 23, minute > 59, second > 59).
|
|
@@ -595,7 +583,7 @@ Day-of-week (string) or day-of-month (number 1–31):
|
|
|
595
583
|
```ts
|
|
596
584
|
job("monday-standup", task).weekly().on("monday");
|
|
597
585
|
job("mid-month-sync", task).monthly().on(15);
|
|
598
|
-
job("end-of-month",
|
|
586
|
+
job("end-of-month", task).monthly().on(31); // see beginOf/endOf for the right way
|
|
599
587
|
```
|
|
600
588
|
|
|
601
589
|
Valid day-of-week strings: `"sunday"`, `"monday"`, `"tuesday"`, `"wednesday"`, `"thursday"`, `"friday"`, `"saturday"`.
|
|
@@ -608,14 +596,14 @@ Valid day-of-week strings: `"sunday"`, `"monday"`, `"tuesday"`, `"wednesday"`, `
|
|
|
608
596
|
|
|
609
597
|
Both accept `"day" | "month" | "year"`.
|
|
610
598
|
|
|
611
|
-
| Method
|
|
612
|
-
|
|
|
613
|
-
| `beginOf("day")`
|
|
614
|
-
| `endOf("day")`
|
|
615
|
-
| `beginOf("month")`
|
|
616
|
-
| `endOf("month")`
|
|
617
|
-
| `beginOf("year")`
|
|
618
|
-
| `endOf("year")`
|
|
599
|
+
| Method | Fires at |
|
|
600
|
+
| ------------------ | ---------------------------------------------- |
|
|
601
|
+
| `beginOf("day")` | 00:00 every day |
|
|
602
|
+
| `endOf("day")` | 23:59 every day |
|
|
603
|
+
| `beginOf("month")` | 1st of every month at 00:00 |
|
|
604
|
+
| `endOf("month")` | Last day of every month at 23:59 (**dynamic**) |
|
|
605
|
+
| `beginOf("year")` | January 1 at 00:00 every year |
|
|
606
|
+
| `endOf("year")` | December 31 at 23:59 every year |
|
|
619
607
|
|
|
620
608
|
**`endOf("month")` is dynamic** — recomputes per cycle, so a job defined in February (28 days) still fires on March 31, April 30, etc. Leap years pick Feb 29 correctly.
|
|
621
609
|
|
|
@@ -631,12 +619,12 @@ Every fluent method recomputes `nextRun` at the end. Putting `.at()` before `.da
|
|
|
631
619
|
|
|
632
620
|
```ts
|
|
633
621
|
job("good", fn)
|
|
634
|
-
.weekly()
|
|
635
|
-
.on("monday")
|
|
636
|
-
.at("09:00")
|
|
637
|
-
.inTimezone("UTC")
|
|
638
|
-
.preventOverlap()
|
|
639
|
-
.retry(3, 1000);
|
|
622
|
+
.weekly() // interval
|
|
623
|
+
.on("monday") // day
|
|
624
|
+
.at("09:00") // time
|
|
625
|
+
.inTimezone("UTC") // timezone
|
|
626
|
+
.preventOverlap() // execution
|
|
627
|
+
.retry(3, 1000); // execution
|
|
640
628
|
```
|
|
641
629
|
|
|
642
630
|
## Inline registration — `scheduler.newJob()`
|
|
@@ -644,10 +632,7 @@ job("good", fn)
|
|
|
644
632
|
For one-liners, the scheduler exposes a `newJob()` shortcut that creates, registers, and returns the job in one call:
|
|
645
633
|
|
|
646
634
|
```ts
|
|
647
|
-
scheduler
|
|
648
|
-
.newJob("cleanup", cleanupFn)
|
|
649
|
-
.daily()
|
|
650
|
-
.at("03:00");
|
|
635
|
+
scheduler.newJob("cleanup", cleanupFn).daily().at("03:00");
|
|
651
636
|
```
|
|
652
637
|
|
|
653
638
|
To register several pre-built jobs at once, `scheduler.addJobs([...])` is the batch counterpart to `addJob` (both are chainable and preserve insertion order):
|
|
@@ -666,11 +651,11 @@ If the scheduler is already running, every job added via `addJob` / `addJobs` is
|
|
|
666
651
|
```ts
|
|
667
652
|
const j = scheduler.getJob("nightly-cleanup");
|
|
668
653
|
|
|
669
|
-
j?.nextRun?.toISOString();
|
|
670
|
-
j?.lastRun?.toISOString();
|
|
671
|
-
j?.isRunning;
|
|
672
|
-
j?.intervals;
|
|
673
|
-
j?.cronExpression;
|
|
654
|
+
j?.nextRun?.toISOString(); // next scheduled run (Dayjs)
|
|
655
|
+
j?.lastRun?.toISOString(); // last attempt — success OR failure
|
|
656
|
+
j?.isRunning; // currently executing?
|
|
657
|
+
j?.intervals; // { every?, day?, dayOfMonthMode?, month?, time? } (readonly)
|
|
658
|
+
j?.cronExpression; // null if using fluent API
|
|
674
659
|
```
|
|
675
660
|
|
|
676
661
|
## See also
|
|
@@ -707,14 +692,14 @@ The escape hatch when the fluent API can't express a schedule. Activating `.cron
|
|
|
707
692
|
|
|
708
693
|
## Operators
|
|
709
694
|
|
|
710
|
-
| Syntax
|
|
711
|
-
|
|
|
712
|
-
| `*`
|
|
713
|
-
| `5`
|
|
714
|
-
| `1,3,5`
|
|
715
|
-
| `1-5`
|
|
716
|
-
| `*/N`
|
|
717
|
-
| `A-B/N`
|
|
695
|
+
| Syntax | Meaning | Example |
|
|
696
|
+
| ------- | ---------------------- | ---------------- |
|
|
697
|
+
| `*` | Any value (full range) | `* * * * *` |
|
|
698
|
+
| `5` | Single value | `30 14 * * *` |
|
|
699
|
+
| `1,3,5` | List | `0,30 * * * *` |
|
|
700
|
+
| `1-5` | Inclusive range | `0 9-17 * * *` |
|
|
701
|
+
| `*/N` | Step over the wildcard | `*/5 * * * *` |
|
|
702
|
+
| `A-B/N` | Step over a range | `0 1-10/2 * * *` |
|
|
718
703
|
|
|
719
704
|
**Not supported (yet):** named tokens (`MON-FRI`, `JAN-DEC`), special strings (`@daily`, `@hourly`, `@reboot`), seconds field (6-field), Quartz modifiers (`L`, `W`, `#`).
|
|
720
705
|
|
|
@@ -786,13 +771,13 @@ import dayjs from "dayjs";
|
|
|
786
771
|
|
|
787
772
|
const parser = parseCron("0 9 * * 1-5");
|
|
788
773
|
|
|
789
|
-
parser.nextRun().toISOString();
|
|
774
|
+
parser.nextRun().toISOString(); // next weekday at 9 AM
|
|
790
775
|
parser.nextRun(dayjs("2026-12-31")).format(); // from a specific anchor
|
|
791
776
|
|
|
792
|
-
parser.matches(dayjs());
|
|
777
|
+
parser.matches(dayjs()); // is "now" a fire moment?
|
|
793
778
|
|
|
794
|
-
parser.fields;
|
|
795
|
-
parser.expression;
|
|
779
|
+
parser.fields; // parsed numeric arrays
|
|
780
|
+
parser.expression; // original string
|
|
796
781
|
```
|
|
797
782
|
|
|
798
783
|
Impossible expressions are rejected eagerly at construction (see Validation), so `nextRun(from?)` always resolves a satisfiable expression within a year — its one-year scan bound is just a defensive backstop, never the path that catches a bad expression.
|
|
@@ -847,7 +832,7 @@ The 9 things that are true in every scheduler use:
|
|
|
847
832
|
6. **Validation throws at definition time.** `every(0)`, `at("24:00")`, `on(32)`, `retry(-1)`, malformed cron — all throw immediately when you wire the job, not at runtime.
|
|
848
833
|
7. **`Job.run()` returns a `JobResult`, never throws.** Errors funnel into `result.error`. The scheduler emits `job:error` after all retries are exhausted.
|
|
849
834
|
8. **Parallel mode is within a tick, not across.** Even with `runInParallel(true)`, the NEXT tick waits for the current tick's jobs to all settle.
|
|
850
|
-
9. **Tick interval is drift-compensated.** A tick that takes 200 ms is followed by an 800 ms delay so the cadence between tick
|
|
835
|
+
9. **Tick interval is drift-compensated.** A tick that takes 200 ms is followed by an 800 ms delay so the cadence between tick _starts_ averages `tickInterval`, not `tickInterval + work-time`.
|
|
851
836
|
|
|
852
837
|
## 30-second example
|
|
853
838
|
|
|
@@ -864,7 +849,7 @@ scheduler.addJob(
|
|
|
864
849
|
.at("03:00")
|
|
865
850
|
.inTimezone("America/New_York")
|
|
866
851
|
.preventOverlap()
|
|
867
|
-
.retry(3, 1000)
|
|
852
|
+
.retry(3, 1000),
|
|
868
853
|
);
|
|
869
854
|
|
|
870
855
|
scheduler.start();
|
|
@@ -890,13 +875,13 @@ scheduler.around((job, run) => storage.run({ job: job.name }, run));
|
|
|
890
875
|
|
|
891
876
|
## Pick a skill
|
|
892
877
|
|
|
893
|
-
| If the task is about…
|
|
894
|
-
|
|
|
895
|
-
| Building schedules via `every*`/`daily`/`weekly`/`monthly`/`at`/`on`/`beginOf`/`endOf`
|
|
896
|
-
| Writing `.cron("…")` expressions, debugging DOM/DOW behavior, using `parseCron()` for preview
|
|
878
|
+
| If the task is about… | Load |
|
|
879
|
+
| ----------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
|
880
|
+
| Building schedules via `every*`/`daily`/`weekly`/`monthly`/`at`/`on`/`beginOf`/`endOf` | the `schedule-fluently` topic |
|
|
881
|
+
| Writing `.cron("…")` expressions, debugging DOM/DOW behavior, using `parseCron()` for preview | the `schedule-with-cron` topic |
|
|
897
882
|
| Configuring `.retry()` / exponential backoff, `.preventOverlap()`, understanding failure rescheduling | the `configure-retry-and-overlap` topic |
|
|
898
|
-
| Subscribing to scheduler events, reading `JobResult`, lifecycle, graceful shutdown
|
|
899
|
-
| Per-job `.inTimezone()`, multi-region patterns, DST handling
|
|
883
|
+
| Subscribing to scheduler events, reading `JobResult`, lifecycle, graceful shutdown | the `observe-scheduler` topic |
|
|
884
|
+
| Per-job `.inTimezone()`, multi-region patterns, DST handling | the `pin-schedule-timezone` topic |
|
|
900
885
|
|
|
901
886
|
## When NOT to use this skill
|
|
902
887
|
|
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.31.0"
|
|
12
12
|
},
|
|
13
13
|
"peerDependenciesMeta": {
|
|
14
14
|
"@warlock.js/cache": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
],
|
|
35
35
|
"author": "hassanzohdy",
|
|
36
36
|
"license": "MIT",
|
|
37
|
-
"version": "5.
|
|
37
|
+
"version": "5.31.0",
|
|
38
38
|
"main": "./cjs/index.cjs",
|
|
39
39
|
"module": "./esm/index.mjs",
|
|
40
40
|
"types": "./esm/index.d.mts",
|
|
@@ -10,18 +10,11 @@ Two independent execution-control concerns on a single `Job`. They compose clean
|
|
|
10
10
|
## `.retry(maxRetries, delay?, backoffMultiplier?)`
|
|
11
11
|
|
|
12
12
|
```ts
|
|
13
|
-
job("send-report", sendReport)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
13
|
+
job("send-report", sendReport).daily().at("08:00").retry(3); // 3 retries, 1000ms apart (default delay)
|
|
14
|
+
|
|
15
|
+
job("sync", syncInventory).everyHour().retry(5, 2000); // 5 retries, 2000ms each
|
|
16
|
+
|
|
17
|
+
job("queue", processQueue).everyMinutes(10).retry(5, 1000, 2); // exponential: 1s → 2s → 4s → 8s → 16s
|
|
25
18
|
```
|
|
26
19
|
|
|
27
20
|
**Signature:** `retry(maxRetries: number, delay = 1000, backoffMultiplier?: number): this`
|
|
@@ -46,6 +39,7 @@ scheduler.on("job:error", (name, error) => {
|
|
|
46
39
|
```
|
|
47
40
|
|
|
48
41
|
`JobResult.retries`:
|
|
42
|
+
|
|
49
43
|
- On **success**: the number of failed attempts before the eventual success (0 if first attempt succeeded).
|
|
50
44
|
- On **failure**: equals the configured `maxRetries` (all of them were used).
|
|
51
45
|
|
|
@@ -68,7 +62,7 @@ To **stop** firing after N consecutive failures, do it in user code:
|
|
|
68
62
|
|
|
69
63
|
```ts
|
|
70
64
|
let consecutiveFailures = 0;
|
|
71
|
-
scheduler.on("job:error", name => {
|
|
65
|
+
scheduler.on("job:error", (name) => {
|
|
72
66
|
if (name !== "flaky") return;
|
|
73
67
|
consecutiveFailures++;
|
|
74
68
|
if (consecutiveFailures >= 5) {
|
|
@@ -76,7 +70,7 @@ scheduler.on("job:error", name => {
|
|
|
76
70
|
alert.critical("flaky disabled after 5 failures");
|
|
77
71
|
}
|
|
78
72
|
});
|
|
79
|
-
scheduler.on("job:complete", name => {
|
|
73
|
+
scheduler.on("job:complete", (name) => {
|
|
80
74
|
if (name === "flaky") consecutiveFailures = 0;
|
|
81
75
|
});
|
|
82
76
|
```
|
|
@@ -86,9 +80,7 @@ scheduler.on("job:complete", name => {
|
|
|
86
80
|
Tells the scheduler to skip a tick if the job is already running.
|
|
87
81
|
|
|
88
82
|
```ts
|
|
89
|
-
job("queue", processQueue)
|
|
90
|
-
.everyMinutes(5)
|
|
91
|
-
.preventOverlap();
|
|
83
|
+
job("queue", processQueue).everyMinutes(5).preventOverlap();
|
|
92
84
|
```
|
|
93
85
|
|
|
94
86
|
**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.
|
|
@@ -99,7 +91,7 @@ Real-world shapes that benefit:
|
|
|
99
91
|
// 1) Boot-time recovery sweep before normal scheduling.
|
|
100
92
|
const queueJob = job("queue", processQueueOnce).everyMinutes(5).preventOverlap();
|
|
101
93
|
scheduler.addJob(queueJob);
|
|
102
|
-
queueJob.run().catch(err => log.error({ err }, "boot sweep failed"));
|
|
94
|
+
queueJob.run().catch((err) => log.error({ err }, "boot sweep failed"));
|
|
103
95
|
scheduler.start();
|
|
104
96
|
|
|
105
97
|
// 2) Admin-triggered manual re-run from a dashboard endpoint.
|
|
@@ -113,6 +105,7 @@ app.post("/admin/jobs/:name/run", async (req, res) => {
|
|
|
113
105
|
```
|
|
114
106
|
|
|
115
107
|
If a tick lands while one of these external runs is mid-flight:
|
|
108
|
+
|
|
116
109
|
- Scheduler emits `job:skip` with reason `"Job is already running"`
|
|
117
110
|
- The tick proceeds without re-entering the job
|
|
118
111
|
|
|
@@ -123,10 +116,7 @@ For a single-process app where the callback is only ever invoked via the schedul
|
|
|
123
116
|
They compose cleanly. Retry happens WITHIN one fire — if all retries fail, the tick ends and `nextRun` advances. `preventOverlap` matters only between fires.
|
|
124
117
|
|
|
125
118
|
```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
|
|
119
|
+
job("long-flaky", processQueue).everyMinutes(5).preventOverlap().retry(3, 1000, 2); // up to 1 + 2 + 4 = 7 s of retry delay per fire
|
|
130
120
|
```
|
|
131
121
|
|
|
132
122
|
If retries push the total work past the next interval, `preventOverlap()` ensures the next scheduled tick skips instead of stacking.
|