@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 +9 -0
- package/dist/cron.js +46 -0
- package/dist/index.d.ts +48 -1
- package/dist/index.js +55 -1
- package/package.json +3 -3
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.
|
|
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.
|
|
18
|
-
"@basaltkit/queue": "^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",
|