@basaltkit/scheduler 1.2.1 → 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.
- package/README.md +132 -8
- package/dist/cron.js +46 -0
- package/dist/index.d.ts +48 -1
- package/dist/index.js +55 -1
- 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,6 +266,112 @@ 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
|
+
|
|
286
|
+
## Multiple replicas — `.onOneServer()`
|
|
287
|
+
|
|
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.
|
|
374
|
+
|
|
260
375
|
## Common errors and solutions (FAQ)
|
|
261
376
|
|
|
262
377
|
**My `daily().at('03:00')` task runs at the wrong time.**
|
|
@@ -266,7 +381,16 @@ Times are UTC by default. Add `.timezone('Europe/Lisbon')` (or your IANA timezon
|
|
|
266
381
|
`cron()` only accepts the classic 5-field format (`min hour day month day-of-week`). 6-field formats (with seconds) are not supported.
|
|
267
382
|
|
|
268
383
|
**The task runs twice (two servers).**
|
|
269
|
-
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.
|
|
270
394
|
|
|
271
395
|
**A task failed and I didn't see anything.**
|
|
272
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/dist/cron.js
CHANGED
|
@@ -4,12 +4,58 @@ export class CronParseError extends BasaltError {
|
|
|
4
4
|
super('CRON_INVALID', `Invalid cron expression "${expression}": ${detail}`);
|
|
5
5
|
}
|
|
6
6
|
}
|
|
7
|
+
const FIELD_BOUNDS = [
|
|
8
|
+
['minute', 0, 59],
|
|
9
|
+
['hour', 0, 23],
|
|
10
|
+
['day-of-month', 1, 31],
|
|
11
|
+
['month', 1, 12],
|
|
12
|
+
['day-of-week', 0, 6],
|
|
13
|
+
];
|
|
14
|
+
/**
|
|
15
|
+
* Validates one cron field against the syntax {@link fieldMatches} actually
|
|
16
|
+
* supports: asterisk, asterisk-slash-n steps, single values, `a-b` ranges and comma lists.
|
|
17
|
+
* Anything else (names like MON, out-of-range values, `5-1`) previously became
|
|
18
|
+
* NaN comparisons — a job that silently NEVER fires. Fail at parse time instead.
|
|
19
|
+
*/
|
|
20
|
+
function assertField(expression, field, name, min, max) {
|
|
21
|
+
const invalid = (detail) => {
|
|
22
|
+
throw new CronParseError(expression, `${name} field "${field}": ${detail}`);
|
|
23
|
+
};
|
|
24
|
+
if (field === '*')
|
|
25
|
+
return;
|
|
26
|
+
for (const part of field.split(',')) {
|
|
27
|
+
const step = /^\*\/(\d+)$/.exec(part);
|
|
28
|
+
if (step) {
|
|
29
|
+
if (Number(step[1]) < 1)
|
|
30
|
+
invalid('step must be >= 1');
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const range = /^(\d+)-(\d+)$/.exec(part);
|
|
34
|
+
if (range) {
|
|
35
|
+
const [from, to] = [Number(range[1]), Number(range[2])];
|
|
36
|
+
if (from > to)
|
|
37
|
+
invalid(`range ${from}-${to} is reversed`);
|
|
38
|
+
if (from < min || to > max)
|
|
39
|
+
invalid(`range ${from}-${to} outside ${min}-${max}`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (!/^\d+$/.test(part))
|
|
43
|
+
invalid(`"${part}" is not supported (use *, */n, n, a-b or comma lists; names like MON are not)`);
|
|
44
|
+
const value = Number(part);
|
|
45
|
+
if (value < min || value > max)
|
|
46
|
+
invalid(`${value} outside ${min}-${max}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
7
49
|
export function parseCron(expression) {
|
|
8
50
|
const parts = expression.trim().split(/\s+/);
|
|
9
51
|
if (parts.length !== 5) {
|
|
10
52
|
throw new CronParseError(expression, `expected 5 fields, received ${parts.length}`);
|
|
11
53
|
}
|
|
12
54
|
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
|
55
|
+
parts.forEach((field, i) => {
|
|
56
|
+
const [name, min, max] = FIELD_BOUNDS[i];
|
|
57
|
+
assertField(expression, field, name, min, max);
|
|
58
|
+
});
|
|
13
59
|
return { minute, hour, dayOfMonth, month, dayOfWeek };
|
|
14
60
|
}
|
|
15
61
|
export function cronToString(fields) {
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,24 @@ import type { JobDefinition } from '@basaltkit/queue';
|
|
|
2
2
|
export { CronParseError, cronMatches, parseCron, fieldMatches, zonedParts } from './cron.js';
|
|
3
3
|
export type { CronFields, ZonedParts } from './cron.js';
|
|
4
4
|
type Task = () => void | Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* Cross-replica mutex for `.onOneServer()` entries. `acquire` must be ATOMIC
|
|
7
|
+
* across processes (e.g. Redis `SET key value PX ttl NX`): it returns true for
|
|
8
|
+
* exactly one caller per key until the TTL expires. There is deliberately no
|
|
9
|
+
* `release` — the key covers the tick window, so a fast first run cannot be
|
|
10
|
+
* followed by a late replica re-acquiring and running the same minute again.
|
|
11
|
+
*
|
|
12
|
+
* ioredis example:
|
|
13
|
+
*
|
|
14
|
+
* const lock: ScheduleLock = {
|
|
15
|
+
* async acquire(key, ttlMs) {
|
|
16
|
+
* return (await redis.set(key, '1', 'PX', ttlMs, 'NX')) === 'OK'
|
|
17
|
+
* },
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
export interface ScheduleLock {
|
|
21
|
+
acquire(key: string, ttlMs: number): Promise<boolean>;
|
|
22
|
+
}
|
|
5
23
|
/**
|
|
6
24
|
* A scheduled entry, built fluently:
|
|
7
25
|
*
|
|
@@ -14,6 +32,7 @@ export declare class ScheduleEntry {
|
|
|
14
32
|
private fields;
|
|
15
33
|
private tz;
|
|
16
34
|
private noOverlap;
|
|
35
|
+
private oneServer;
|
|
17
36
|
private failureHandler;
|
|
18
37
|
private running;
|
|
19
38
|
/** count of executions skipped due to overlap — visible for observability/tests */
|
|
@@ -39,6 +58,16 @@ export declare class ScheduleEntry {
|
|
|
39
58
|
timezone(tz: string): this;
|
|
40
59
|
/** If the previous execution is still running, the new one is skipped. */
|
|
41
60
|
withoutOverlapping(): this;
|
|
61
|
+
/**
|
|
62
|
+
* On a horizontally-scaled deployment, run this entry on ONE replica per tick
|
|
63
|
+
* instead of on every pod. Requires a `lock` on the Scheduler (see
|
|
64
|
+
* {@link ScheduleLock}) — without one, boot fails loud rather than silently
|
|
65
|
+
* running the job N times. `runNow()`/`schedule:run` bypass the lock (a manual
|
|
66
|
+
* trigger is deliberate).
|
|
67
|
+
*/
|
|
68
|
+
onOneServer(): this;
|
|
69
|
+
/** @internal whether this entry asked for cross-replica locking. */
|
|
70
|
+
get wantsOneServer(): boolean;
|
|
42
71
|
onFailure(handler: (error: unknown) => void): this;
|
|
43
72
|
/** Entry description — consumed by `basalt schedule list`. */
|
|
44
73
|
describe(): {
|
|
@@ -51,10 +80,28 @@ export declare class ScheduleEntry {
|
|
|
51
80
|
run(): Promise<void>;
|
|
52
81
|
private onDayOfWeek;
|
|
53
82
|
}
|
|
83
|
+
export interface SchedulerOptions {
|
|
84
|
+
/** Cross-replica lock for `.onOneServer()` entries. */
|
|
85
|
+
lock?: ScheduleLock;
|
|
86
|
+
/**
|
|
87
|
+
* TTL for each per-entry, per-tick lock key. Default 60_000 (one tick window
|
|
88
|
+
* — the key embeds the minute, so it only needs to outlive clock skew).
|
|
89
|
+
*/
|
|
90
|
+
lockTtlMs?: number;
|
|
91
|
+
}
|
|
54
92
|
export declare class Scheduler {
|
|
55
93
|
private readonly entries;
|
|
56
94
|
private timer;
|
|
57
95
|
private interval;
|
|
96
|
+
private readonly lock;
|
|
97
|
+
private readonly lockTtlMs;
|
|
98
|
+
/** ticks skipped because another replica held the lock — for observability/tests */
|
|
99
|
+
skippedByLock: number;
|
|
100
|
+
constructor(options?: SchedulerOptions);
|
|
101
|
+
/** @internal true when any entry requested `.onOneServer()`. */
|
|
102
|
+
get needsLock(): boolean;
|
|
103
|
+
/** @internal whether a lock was configured. */
|
|
104
|
+
get hasLock(): boolean;
|
|
58
105
|
/** Schedules the dispatch of a @basaltkit/queue job. */
|
|
59
106
|
job<T>(job: JobDefinition<T>, ...payload: T extends void ? [] : [T]): ScheduleEntry;
|
|
60
107
|
/** Schedules a named function. */
|
|
@@ -85,7 +132,7 @@ export declare class Scheduler {
|
|
|
85
132
|
private add;
|
|
86
133
|
}
|
|
87
134
|
export declare const SCHEDULER: import("@basaltkit/core").Token<Scheduler>;
|
|
88
|
-
export interface SchedulerPluginOptions {
|
|
135
|
+
export interface SchedulerPluginOptions extends SchedulerOptions {
|
|
89
136
|
/** Callback that defines the schedules — receives the Scheduler at boot. */
|
|
90
137
|
define?: (schedule: Scheduler) => void;
|
|
91
138
|
/** Starts the timer at boot. Default: true (turn off in tests). */
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ export class ScheduleEntry {
|
|
|
19
19
|
};
|
|
20
20
|
tz = 'UTC';
|
|
21
21
|
noOverlap = false;
|
|
22
|
+
oneServer = false;
|
|
22
23
|
failureHandler;
|
|
23
24
|
running = false;
|
|
24
25
|
/** count of executions skipped due to overlap — visible for observability/tests */
|
|
@@ -84,6 +85,21 @@ export class ScheduleEntry {
|
|
|
84
85
|
this.noOverlap = true;
|
|
85
86
|
return this;
|
|
86
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* On a horizontally-scaled deployment, run this entry on ONE replica per tick
|
|
90
|
+
* instead of on every pod. Requires a `lock` on the Scheduler (see
|
|
91
|
+
* {@link ScheduleLock}) — without one, boot fails loud rather than silently
|
|
92
|
+
* running the job N times. `runNow()`/`schedule:run` bypass the lock (a manual
|
|
93
|
+
* trigger is deliberate).
|
|
94
|
+
*/
|
|
95
|
+
onOneServer() {
|
|
96
|
+
this.oneServer = true;
|
|
97
|
+
return this;
|
|
98
|
+
}
|
|
99
|
+
/** @internal whether this entry asked for cross-replica locking. */
|
|
100
|
+
get wantsOneServer() {
|
|
101
|
+
return this.oneServer;
|
|
102
|
+
}
|
|
87
103
|
onFailure(handler) {
|
|
88
104
|
this.failureHandler = handler;
|
|
89
105
|
return this;
|
|
@@ -123,6 +139,22 @@ export class Scheduler {
|
|
|
123
139
|
entries = [];
|
|
124
140
|
timer;
|
|
125
141
|
interval;
|
|
142
|
+
lock;
|
|
143
|
+
lockTtlMs;
|
|
144
|
+
/** ticks skipped because another replica held the lock — for observability/tests */
|
|
145
|
+
skippedByLock = 0;
|
|
146
|
+
constructor(options = {}) {
|
|
147
|
+
this.lock = options.lock;
|
|
148
|
+
this.lockTtlMs = options.lockTtlMs ?? 60_000;
|
|
149
|
+
}
|
|
150
|
+
/** @internal true when any entry requested `.onOneServer()`. */
|
|
151
|
+
get needsLock() {
|
|
152
|
+
return this.entries.some((entry) => entry.wantsOneServer);
|
|
153
|
+
}
|
|
154
|
+
/** @internal whether a lock was configured. */
|
|
155
|
+
get hasLock() {
|
|
156
|
+
return this.lock !== undefined;
|
|
157
|
+
}
|
|
126
158
|
/** Schedules the dispatch of a @basaltkit/queue job. */
|
|
127
159
|
job(job, ...payload) {
|
|
128
160
|
return this.add(new ScheduleEntry(job.name, () => job.dispatch(payload[0])));
|
|
@@ -160,6 +192,19 @@ export class Scheduler {
|
|
|
160
192
|
const errors = [];
|
|
161
193
|
await Promise.all(due.map(async (entry) => {
|
|
162
194
|
try {
|
|
195
|
+
if (entry.wantsOneServer && this.lock) {
|
|
196
|
+
// One key per entry per tick window: exactly one replica acquires
|
|
197
|
+
// it; the others skip this minute's run. A lock-store failure is
|
|
198
|
+
// treated as a task failure (visible), not as permission to run on
|
|
199
|
+
// every replica at once.
|
|
200
|
+
const minute = new Date(date);
|
|
201
|
+
minute.setSeconds(0, 0);
|
|
202
|
+
const key = `basalt:schedule:${entry.name}:${minute.toISOString()}`;
|
|
203
|
+
if (!(await this.lock.acquire(key, this.lockTtlMs))) {
|
|
204
|
+
this.skippedByLock++;
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
163
208
|
await entry.run();
|
|
164
209
|
}
|
|
165
210
|
catch (error) {
|
|
@@ -209,12 +254,21 @@ export function schedulerPlugin(options = {}) {
|
|
|
209
254
|
return definePlugin({
|
|
210
255
|
name: 'basalt:scheduler',
|
|
211
256
|
register({ container }) {
|
|
212
|
-
container.singleton(SCHEDULER, () => new Scheduler(
|
|
257
|
+
container.singleton(SCHEDULER, () => new Scheduler({
|
|
258
|
+
...(options.lock ? { lock: options.lock } : {}),
|
|
259
|
+
...(options.lockTtlMs !== undefined ? { lockTtlMs: options.lockTtlMs } : {}),
|
|
260
|
+
}));
|
|
213
261
|
registerScheduleRunCommand(container);
|
|
214
262
|
},
|
|
215
263
|
boot({ container }) {
|
|
216
264
|
const scheduler = container.get(SCHEDULER);
|
|
217
265
|
options.define?.(scheduler);
|
|
266
|
+
if (scheduler.needsLock && !scheduler.hasLock) {
|
|
267
|
+
// Fail closed at boot: silently running the entry on every replica is
|
|
268
|
+
// exactly the failure mode .onOneServer() exists to prevent.
|
|
269
|
+
throw new Error('schedulerPlugin: an entry uses .onOneServer() but no `lock` was configured. ' +
|
|
270
|
+
'Pass `schedulerPlugin({ lock })` with an atomic cross-replica lock (e.g. Redis SET NX PX) — see ScheduleLock.');
|
|
271
|
+
}
|
|
218
272
|
// Expose entries to tooling (CLI `basalt schedule:list`).
|
|
219
273
|
const metadata = ensureMetadata(container);
|
|
220
274
|
for (const entry of scheduler.list())
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/scheduler",
|
|
3
|
-
"version": "1.
|
|
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.1
|
|
18
|
-
"@basaltkit/queue": "^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",
|