@basaltkit/scheduler 1.2.1 → 1.3.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/README.md CHANGED
@@ -257,6 +257,15 @@ Exported for tooling and tests; you don't usually need them:
257
257
 
258
258
  - `SCHEDULER: Token<Scheduler>` — to get the Scheduler from the container: `app.container.get(SCHEDULER)`.
259
259
 
260
+ ## Multiple replicas — `.onOneServer()`
261
+
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).
268
+
260
269
  ## Common errors and solutions (FAQ)
261
270
 
262
271
  **My `daily().at('03:00')` task runs at the wrong time.**
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/scheduler",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Fluent Basalt scheduler: schedule.job(X).daily().at('03:00'), timezones, withoutOverlapping and @basaltkit/queue integration.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,8 +14,8 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "@basaltkit/core": "^1.1.2",
18
- "@basaltkit/queue": "^1.2.1"
17
+ "@basaltkit/core": "^1.3.0",
18
+ "@basaltkit/queue": "^1.3.1"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@types/node": "^26.3.0",