@basaltkit/scheduler 1.3.0 → 1.3.2
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/README.md +129 -14
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -208,10 +208,15 @@ respects each entry's overlap guard and `onFailure` handler.
|
|
|
208
208
|
|
|
209
209
|
Registers a `Scheduler` (singleton) under the `SCHEDULER` token; on `boot` it calls `define`, publishes the entries to the container's metadata (key `schedule:entries`, consumed by `basalt schedule:list`), registers the `schedule:run` command, and starts the timer; on `shutdown` it calls `stop()`.
|
|
210
210
|
|
|
211
|
-
| Option | Type |
|
|
212
|
-
|
|
213
|
-
| `define` | `(schedule: Scheduler) => void` |
|
|
214
|
-
| `autostart` | `boolean` |
|
|
211
|
+
| Option | Type | Default | Purpose |
|
|
212
|
+
|---|---|---|---|
|
|
213
|
+
| `define` | `(schedule: Scheduler) => void` | — | Callback where you declare the schedules (receives the Scheduler at boot). |
|
|
214
|
+
| `autostart` | `boolean` | `true` | Starts the timer at boot. Turn it off in tests so the process isn't holding a 60 s interval. |
|
|
215
|
+
| `lock` | `ScheduleLock` | — | Cross-replica mutex, **required** as soon as any entry calls `.onOneServer()`. Boot throws without it. See [`ScheduleLock`](#the-schedulelock-contract). |
|
|
216
|
+
| `lockTtlMs` | `number` | `60_000` | TTL of each per-entry, per-tick lock key. The key already embeds the minute, so this only has to outlive clock skew between replicas — one tick window is the right default. |
|
|
217
|
+
|
|
218
|
+
`SchedulerOptions` (`lock`, `lockTtlMs`) are the same two the `Scheduler` constructor takes, so a
|
|
219
|
+
hand-built `new Scheduler({ lock })` behaves identically.
|
|
215
220
|
|
|
216
221
|
### `class Scheduler`
|
|
217
222
|
|
|
@@ -220,9 +225,12 @@ Registers a `Scheduler` (singleton) under the `SCHEDULER` token; on `boot` it ca
|
|
|
220
225
|
| `call` | `(name: string, task: () => void \| Promise<void>) => ScheduleEntry` | Schedules a function with a name. |
|
|
221
226
|
| `job` | `<T>(job: JobDefinition<T>, payload?) => ScheduleEntry` | Schedules the `dispatch` of an `@basaltkit/queue` job (payload required if the job needs one). |
|
|
222
227
|
| `list` | `() => { name, cron, timezone }[]` | Describes all entries. |
|
|
223
|
-
| `
|
|
224
|
-
| `
|
|
228
|
+
| `names` | `() => string[]` | Names of every entry — used by the CLI to validate/suggest. |
|
|
229
|
+
| `runNow` | `(name: string) => Promise<boolean>` | Runs one entry on demand, **ignoring its cron and its `.onOneServer()` lock** (a manual trigger is deliberate). `false` if no entry has that name. The overlap guard and `onFailure` still apply. |
|
|
230
|
+
| `tick` | `(date?: Date) => Promise<void>` | Runs the entries due at that instant (default: now), concurrently. Aggregates failures without `onFailure` into an `AggregateError`. |
|
|
231
|
+
| `start` | `() => void` | Aligns to the next minute and then `tick()`s every 60s. Idempotent; the timers are `unref`'d. |
|
|
225
232
|
| `stop` | `() => void` | Stops the timers. |
|
|
233
|
+
| `skippedByLock` | `number` | Ticks skipped because another replica held the lock — for observability and tests. |
|
|
226
234
|
|
|
227
235
|
### `class ScheduleEntry` (returned by `call`/`job`)
|
|
228
236
|
|
|
@@ -231,7 +239,8 @@ Frequency methods (all return `this`): `everyMinute()`, `everyMinutes(n)`, `hour
|
|
|
231
239
|
| Method/property | Type | Default | Description |
|
|
232
240
|
|---|---|---|---|
|
|
233
241
|
| `timezone(tz)` | `(tz: string) => this` | `'UTC'` | IANA timezone in which the time is interpreted. |
|
|
234
|
-
| `withoutOverlapping()` | `() => this` | off | Skips the run if the previous one is still in progress
|
|
242
|
+
| `withoutOverlapping()` | `() => this` | off | Skips the run if the previous one is still in progress — **in this process only**. |
|
|
243
|
+
| `onOneServer()` | `() => this` | off | Runs the entry on exactly ONE replica per tick instead of on every pod. Requires `schedulerPlugin({ lock })`; boot throws without one. |
|
|
235
244
|
| `onFailure(handler)` | `((error: unknown) => void) => this` | — | Receives the error instead of propagating it. |
|
|
236
245
|
| `describe()` | `() => { name, cron, timezone }` | — | Description of the entry. |
|
|
237
246
|
| `isDue(date)` | `(date: Date) => boolean` | — | Is the entry due at this instant? |
|
|
@@ -257,14 +266,111 @@ Exported for tooling and tests; you don't usually need them:
|
|
|
257
266
|
|
|
258
267
|
- `SCHEDULER: Token<Scheduler>` — to get the Scheduler from the container: `app.container.get(SCHEDULER)`.
|
|
259
268
|
|
|
269
|
+
### Errors
|
|
270
|
+
|
|
271
|
+
| Error | Code | When |
|
|
272
|
+
|---|---|---|
|
|
273
|
+
| `CronParseError` | `CRON_INVALID` | An expression passed to `.cron()` (or built internally) isn't 5 fields, uses unsupported syntax (`MON`, `@daily`, `?`, `L`), has a reversed range (`5-1`), a step below 1, or a value outside its field's bounds. Extends `BasaltError`; raised at **definition** time, so a typo fails at boot rather than becoming a task that silently never fires. |
|
|
274
|
+
| `Error` (plain) | — | At `boot`, when an entry uses `.onOneServer()` but no `lock` was configured. See [Boot-time enforcement](#boot-time-enforcement). |
|
|
275
|
+
| `AggregateError` | — (built-in) | From `tick()`, when one or more due entries failed without an `onFailure` handler — or when a `.onOneServer()` entry's `lock.acquire` rejected. All due entries still ran; `error.errors` holds each failure. Swallowed by the automatic timer path so a failing task can't kill the process. |
|
|
276
|
+
|
|
277
|
+
### Hooks & callbacks
|
|
278
|
+
|
|
279
|
+
The scheduler has no hook bus. Two per-entry callbacks and one injected collaborator:
|
|
280
|
+
|
|
281
|
+
| Callback | Where | Receives | Default when unset |
|
|
282
|
+
|---|---|---|---|
|
|
283
|
+
| `onFailure(handler)` | `ScheduleEntry` | `(error: unknown)` | The error propagates — aggregated into the tick's `AggregateError`, then **swallowed** by the automatic timer. Set it, or a failure in production is invisible. |
|
|
284
|
+
| `lock.acquire` | `SchedulerOptions` | `(key: string, ttlMs: number) => Promise<boolean>` | No lock. Boot throws if any entry needs one. |
|
|
285
|
+
|
|
260
286
|
## Multiple replicas — `.onOneServer()`
|
|
261
287
|
|
|
262
|
-
`withoutOverlapping()` guards one process
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
288
|
+
`withoutOverlapping()` guards one process. It does nothing about the real production problem:
|
|
289
|
+
you run four pods, every pod boots the scheduler, and at 03:00 the nightly billing job runs
|
|
290
|
+
**four times**. Mark the entry `.onOneServer()` and give the plugin a lock:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
import Redis from 'ioredis'
|
|
294
|
+
import { schedulerPlugin, type ScheduleLock } from '@basaltkit/scheduler'
|
|
295
|
+
|
|
296
|
+
const redis = new Redis(process.env.REDIS_URL!)
|
|
297
|
+
|
|
298
|
+
const lock: ScheduleLock = {
|
|
299
|
+
async acquire(key, ttlMs) {
|
|
300
|
+
return (await redis.set(key, '1', 'PX', ttlMs, 'NX')) === 'OK'
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
schedulerPlugin({
|
|
305
|
+
lock,
|
|
306
|
+
define: (schedule) => {
|
|
307
|
+
schedule.job(ReconcileBilling, { mode: 'full' }).daily().at('03:00').onOneServer()
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### The `ScheduleLock` contract
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
interface ScheduleLock {
|
|
316
|
+
acquire(key: string, ttlMs: number): Promise<boolean>
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
One method, and the guarantees it must provide are the whole point:
|
|
321
|
+
|
|
322
|
+
- **`acquire` must be atomic across processes.** For a given `key`, exactly one caller anywhere
|
|
323
|
+
in the fleet may get `true` until the TTL expires. Redis `SET key value PX ttl NX` is the
|
|
324
|
+
canonical implementation. A check-then-set (`GET` then `SET`) is **not** atomic and silently
|
|
325
|
+
reintroduces the duplicate runs you added the lock to prevent.
|
|
326
|
+
- **The key must actually expire.** The scheduler never deletes it. A store without TTL support
|
|
327
|
+
would let the first tick's key block that entry forever.
|
|
328
|
+
- **There is deliberately no `release`.** The key covers the whole tick window, so a fast first
|
|
329
|
+
run cannot be followed by a late replica acquiring the freed key and running the same minute a
|
|
330
|
+
second time. The TTL is the release.
|
|
331
|
+
|
|
332
|
+
The key the scheduler builds is `basalt:schedule:<entry name>:<minute, ISO, seconds zeroed>` —
|
|
333
|
+
one key per entry per minute. `lockTtlMs` (default `60_000`) only has to outlive clock skew
|
|
334
|
+
between replicas.
|
|
335
|
+
|
|
336
|
+
### What happens when acquisition fails
|
|
337
|
+
|
|
338
|
+
Two distinct cases, and they are treated very differently:
|
|
339
|
+
|
|
340
|
+
| Case | Behaviour |
|
|
341
|
+
|---|---|
|
|
342
|
+
| `acquire` resolves `false` — another replica won this tick | The entry is skipped on this replica and `scheduler.skippedByLock` increments. This is the normal path on N−1 of your N pods, every tick. Not an error. |
|
|
343
|
+
| `acquire` **rejects** — the lock store is down, times out, or the credentials expired | Treated as a **task failure**: the rejection is collected into the tick's `AggregateError` exactly like a thrown task, so it is visible. The entry does **not** run. It is *not* treated as permission to proceed. |
|
|
344
|
+
|
|
345
|
+
That last row is the deliberate design choice. Failing open — running everywhere when the lock
|
|
346
|
+
store is unreachable — would mean a Redis outage silently turns one nightly billing run into
|
|
347
|
+
four. Failing closed means a Redis outage skips a tick, which a subsequent tick or a manual
|
|
348
|
+
`basalt schedule:run <name>` can recover.
|
|
349
|
+
|
|
350
|
+
### Boot-time enforcement
|
|
351
|
+
|
|
352
|
+
If any entry calls `.onOneServer()` and no `lock` was configured, `boot` **throws**:
|
|
353
|
+
|
|
354
|
+
```
|
|
355
|
+
schedulerPlugin: an entry uses .onOneServer() but no `lock` was configured.
|
|
356
|
+
Pass `schedulerPlugin({ lock })` with an atomic cross-replica lock (e.g. Redis SET NX PX) — see ScheduleLock.
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Failing the deploy is the point: silently running the entry on every replica is exactly the
|
|
360
|
+
failure mode `.onOneServer()` exists to prevent, and it is invisible until the duplicate charges
|
|
361
|
+
land.
|
|
362
|
+
|
|
363
|
+
### What the lock does not cover
|
|
364
|
+
|
|
365
|
+
- **`runNow()` / `basalt schedule:run <name>`** bypass the lock entirely — a manual trigger is
|
|
366
|
+
deliberate, and you asked *this* process to run it.
|
|
367
|
+
- **Entries without `.onOneServer()`** are untouched and still run on every replica.
|
|
368
|
+
- **The lock is per tick, not per run.** If a task overruns its minute, the next minute's key is
|
|
369
|
+
a different key. Combine with `.withoutOverlapping()` if the same replica must not stack runs,
|
|
370
|
+
and prefer `schedule.job(...)` so a queue worker (with its own idempotency) does the work.
|
|
371
|
+
|
|
372
|
+
Cron expressions are validated at definition time — `CronParseError` on unsupported syntax like
|
|
373
|
+
`MON` or out-of-range values, so a typo fails at boot instead of becoming a job that never fires.
|
|
268
374
|
|
|
269
375
|
## Common errors and solutions (FAQ)
|
|
270
376
|
|
|
@@ -275,7 +381,16 @@ Times are UTC by default. Add `.timezone('Europe/Lisbon')` (or your IANA timezon
|
|
|
275
381
|
`cron()` only accepts the classic 5-field format (`min hour day month day-of-week`). 6-field formats (with seconds) are not supported.
|
|
276
382
|
|
|
277
383
|
**The task runs twice (two servers).**
|
|
278
|
-
The scheduler runs in every process where the plugin starts.
|
|
384
|
+
The scheduler runs in every process where the plugin starts. Mark the entry `.onOneServer()` and pass `schedulerPlugin({ lock })` an atomic cross-replica lock — see the *Multiple replicas* section above. `withoutOverlapping()` will not help: it guards one process only.
|
|
385
|
+
|
|
386
|
+
**Boot fails with "an entry uses .onOneServer() but no `lock` was configured".**
|
|
387
|
+
Exactly as intended — booting without the lock would run that entry on every replica. Supply a `ScheduleLock` (Redis `SET key v PX ttl NX`), or drop `.onOneServer()`.
|
|
388
|
+
|
|
389
|
+
**An `.onOneServer()` entry stopped running everywhere at once.**
|
|
390
|
+
Its lock store is unreachable. A rejecting `acquire` is treated as a task failure (visible in the tick's `AggregateError`), never as permission to run on every replica. Fix the store; a later tick or `basalt schedule:run <name>` recovers the missed run.
|
|
391
|
+
|
|
392
|
+
**`skippedByLock` keeps climbing.**
|
|
393
|
+
Normal. On N replicas, N−1 of them skip each `.onOneServer()` tick. Only worry if it climbs on *every* replica — that means nobody is acquiring, i.e. a stale key without a TTL.
|
|
279
394
|
|
|
280
395
|
**A task failed and I didn't see anything.**
|
|
281
396
|
In automatic mode, failures without `onFailure` are silenced so as not to crash the process. Set `onFailure` on each entry (or log inside it).
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/scheduler",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.2",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": ">=22.5.0"
|
|
6
|
+
},
|
|
4
7
|
"description": "Fluent Basalt scheduler: schedule.job(X).daily().at('03:00'), timezones, withoutOverlapping and @basaltkit/queue integration.",
|
|
5
8
|
"license": "MIT",
|
|
6
9
|
"type": "module",
|
|
10
|
+
"sideEffects": false,
|
|
7
11
|
"exports": {
|
|
8
12
|
".": {
|
|
9
13
|
"types": "./dist/index.d.ts",
|
|
@@ -14,8 +18,8 @@
|
|
|
14
18
|
"dist"
|
|
15
19
|
],
|
|
16
20
|
"dependencies": {
|
|
17
|
-
"@basaltkit/core": "^1.3.
|
|
18
|
-
"@basaltkit/queue": "^
|
|
21
|
+
"@basaltkit/core": "^1.3.1",
|
|
22
|
+
"@basaltkit/queue": "^2.0.0"
|
|
19
23
|
},
|
|
20
24
|
"devDependencies": {
|
|
21
25
|
"@types/node": "^26.3.0",
|