@basaltkit/scheduler 1.3.0 → 1.3.1

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.
Files changed (2) hide show
  1. package/README.md +129 -14
  2. 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 | Required? | Default | Description |
212
- |---|---|---|---|---|
213
- | `define` | `(schedule: Scheduler) => void` | No | — | Callback where you declare the schedules (receives the Scheduler at boot). |
214
- | `autostart` | `boolean` | No | `true` | Starts the timer at boot. Turn off in tests. |
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
- | `tick` | `(date?: Date) => Promise<void>` | Runs the entries due at that instant (default: now). Aggregates failures without `onFailure` into an `AggregateError`. |
224
- | `start` | `() => void` | Aligns to the next minute and then `tick()`s every 60s. Idempotent. |
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 only. On N replicas, mark an entry
263
- `.onOneServer()` and pass `schedulerPlugin({ lock })` an atomic cross-replica
264
- lock (`ScheduleLock`: `acquire(key, ttlMs)` e.g. Redis `SET key v PX ttl NX`).
265
- Exactly one replica runs the entry per tick; using `.onOneServer()` without a
266
- `lock` fails loud at boot. Cron expressions are validated at definition time
267
- (`CronParseError` on unsupported syntax like `MON` or out-of-range values).
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. If you have multiple replicas, enable the scheduler on only one (e.g. via an environment variable) or schedule `schedule.job(...)` with idempotent jobs.
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.0",
3
+ "version": "1.3.1",
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.0",
18
- "@basaltkit/queue": "^1.3.1"
21
+ "@basaltkit/core": "^1.3.1",
22
+ "@basaltkit/queue": "^1.4.1"
19
23
  },
20
24
  "devDependencies": {
21
25
  "@types/node": "^26.3.0",