@procedures/schedule 0.0.0-stage → 0.2.3
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/LICENSE +21 -0
- package/README.md +98 -2
- package/dist/.tsbuildinfo +1 -0
- package/dist/cron.d.ts +47 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +197 -0
- package/dist/cron.js.map +1 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/module.d.ts +42 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +174 -0
- package/dist/module.js.map +1 -0
- package/dist/procedures/schedule/add.d.ts +17 -0
- package/dist/procedures/schedule/add.d.ts.map +1 -0
- package/dist/procedures/schedule/add.js +66 -0
- package/dist/procedures/schedule/add.js.map +1 -0
- package/dist/procedures/schedule/index.d.ts +5 -0
- package/dist/procedures/schedule/index.d.ts.map +1 -0
- package/dist/procedures/schedule/index.js +5 -0
- package/dist/procedures/schedule/index.js.map +1 -0
- package/dist/procedures/schedule/list.d.ts +12 -0
- package/dist/procedures/schedule/list.d.ts.map +1 -0
- package/dist/procedures/schedule/list.js +18 -0
- package/dist/procedures/schedule/list.js.map +1 -0
- package/dist/procedures/schedule/next.d.ts +13 -0
- package/dist/procedures/schedule/next.d.ts.map +1 -0
- package/dist/procedures/schedule/next.js +24 -0
- package/dist/procedures/schedule/next.js.map +1 -0
- package/dist/procedures/schedule/remove.d.ts +12 -0
- package/dist/procedures/schedule/remove.d.ts.map +1 -0
- package/dist/procedures/schedule/remove.js +13 -0
- package/dist/procedures/schedule/remove.js.map +1 -0
- package/dist/programs.d.ts +26 -0
- package/dist/programs.d.ts.map +1 -0
- package/dist/programs.js +34 -0
- package/dist/programs.js.map +1 -0
- package/dist/register.d.ts +6 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +8 -0
- package/dist/register.js.map +1 -0
- package/dist/scheduler.d.ts +106 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +274 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/store.d.ts +62 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +188 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +100 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +78 -3
- package/src/cron.ts +230 -0
- package/src/errors.ts +27 -0
- package/src/index.ts +10 -0
- package/src/module.ts +227 -0
- package/src/procedures/schedule/add.ts +66 -0
- package/src/procedures/schedule/index.ts +4 -0
- package/src/procedures/schedule/list.ts +20 -0
- package/src/procedures/schedule/next.ts +25 -0
- package/src/procedures/schedule/remove.ts +15 -0
- package/src/programs.ts +43 -0
- package/src/register.ts +10 -0
- package/src/scheduler.ts +331 -0
- package/src/store.ts +213 -0
- package/src/types.ts +122 -0
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scheduler: a host starts it, and it starts the stored programs of the schedules at their
|
|
3
|
+
* times (roadmap R46).
|
|
4
|
+
*
|
|
5
|
+
* - It wakes at each minute (the unit of cron), reads the schedules of its store, and starts each
|
|
6
|
+
* schedule whose time came. A schedule that a procedure adds or removes counts at the next minute.
|
|
7
|
+
* - A run goes through the registry, with the capability of the host narrowed by the capability
|
|
8
|
+
* of the caller that added the schedule. The expose rule, the call rule, the interceptors and
|
|
9
|
+
* the resources of the host policy apply too.
|
|
10
|
+
* - A schedule has at most one run at a time. A time that comes during a run does not start a
|
|
11
|
+
* second run: the store counts it as skipped.
|
|
12
|
+
* - The store records the last run of each schedule: its start, its time and its outcome.
|
|
13
|
+
* - A run that passes `runTimeoutMs` gets the abort of its signal and the outcome `TIMEOUT`. The
|
|
14
|
+
* schedule stays busy until the handler ends: a handler that does not stop blocks only its own
|
|
15
|
+
* schedule.
|
|
16
|
+
* - The scheduler does not run the times that it missed while it was stopped. Times that it
|
|
17
|
+
* missed during a run or a slow minute give one run, not one for each time.
|
|
18
|
+
* - The timers use an injectable `Clock` (`after` and `now`), and the calendar uses an injectable
|
|
19
|
+
* wall clock (`now`, default `Date.now`). A test gives `fakeClock` of `@mark1russell7/service/testing`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
InvocationError,
|
|
24
|
+
PROCEDURE_REGISTRY,
|
|
25
|
+
invokePath,
|
|
26
|
+
invokeProcedure,
|
|
27
|
+
isRuntimeDefined,
|
|
28
|
+
keyToPath,
|
|
29
|
+
narrowCapability,
|
|
30
|
+
outputValue,
|
|
31
|
+
realClock,
|
|
32
|
+
type Cancel,
|
|
33
|
+
type Capability,
|
|
34
|
+
type Clock,
|
|
35
|
+
type HostPolicy,
|
|
36
|
+
type InvokeOptions,
|
|
37
|
+
type ProcedureRegistry,
|
|
38
|
+
} from "@procedures/client";
|
|
39
|
+
import { nextTime, parseCron } from "./cron.js";
|
|
40
|
+
import { ScheduleError } from "./errors.js";
|
|
41
|
+
import { findProgram } from "./programs.js";
|
|
42
|
+
import { defaultScheduleStore, type ScheduleStore } from "./store.js";
|
|
43
|
+
import type { Schedule, ScheduleRun } from "./types.js";
|
|
44
|
+
|
|
45
|
+
/** The default time limit of a run: 10 minutes. */
|
|
46
|
+
export const DEFAULT_RUN_TIMEOUT_MS = 600_000;
|
|
47
|
+
|
|
48
|
+
/** What a host gets after each run and each skipped time. */
|
|
49
|
+
export interface ScheduleRunEvent {
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly program: string;
|
|
52
|
+
readonly outcome: "success" | "failure" | "skipped";
|
|
53
|
+
/** The wall time of the start, in milliseconds since 1970. */
|
|
54
|
+
readonly startedAt: number;
|
|
55
|
+
readonly durationMs: number;
|
|
56
|
+
readonly error?: { readonly code?: string | undefined; readonly message: string } | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The options of a `Scheduler`. */
|
|
60
|
+
export interface SchedulerOptions {
|
|
61
|
+
/** The registry of the programs. The default is the global registry. */
|
|
62
|
+
readonly registry?: ProcedureRegistry | undefined;
|
|
63
|
+
/** The store of the schedules: the store of the module of the procedures. The default is `defaultScheduleStore`. */
|
|
64
|
+
readonly store?: ScheduleStore | undefined;
|
|
65
|
+
/** The capability of the host for each run. The default is the capability of `policy`, else no limits. */
|
|
66
|
+
readonly capability?: Capability | undefined;
|
|
67
|
+
/** The policy of the host: its expose rule, call rule, interceptors, clearance order, resources and capability. */
|
|
68
|
+
readonly policy?: HostPolicy | undefined;
|
|
69
|
+
/** The clock of the timers and of the durations. The default is `realClock`. */
|
|
70
|
+
readonly clock?: Clock | undefined;
|
|
71
|
+
/** The wall time, in milliseconds since 1970. The default is `Date.now`. */
|
|
72
|
+
readonly now?: (() => number) | undefined;
|
|
73
|
+
/** The time limit of a run, in milliseconds. 0: no limit. The default is 10 minutes. */
|
|
74
|
+
readonly runTimeoutMs?: number | undefined;
|
|
75
|
+
/** This function gets each run and each skipped time. */
|
|
76
|
+
readonly onRun?: ((event: ScheduleRunEvent) => void) | undefined;
|
|
77
|
+
/** This function gets an error of the scheduler itself: a store that cannot be read, for example. */
|
|
78
|
+
readonly onError?: ((error: unknown) => void) | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The next time of one schedule, for the version of the schedule that it came from. */
|
|
82
|
+
interface Due {
|
|
83
|
+
readonly version: string;
|
|
84
|
+
at: number | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const versionOf = (schedule: Schedule): string => `${schedule.createdAt}|${schedule.cron}|${schedule.timeZone}`;
|
|
88
|
+
|
|
89
|
+
/** The code and the message of an error. */
|
|
90
|
+
function errorInfo(error: unknown): { code?: string; message: string } {
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
93
|
+
return typeof code === "string" ? { code, message } : { message };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The next time of a schedule after a time, or undefined for an expression that is not correct. */
|
|
97
|
+
function nextOf(schedule: Schedule, after: number): number | undefined {
|
|
98
|
+
try {
|
|
99
|
+
return nextTime(parseCron(schedule.cron), after, schedule.timeZone);
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A scheduler of the stored programs. See the comment of the file. */
|
|
106
|
+
export class Scheduler {
|
|
107
|
+
private readonly registry: ProcedureRegistry;
|
|
108
|
+
private readonly store: ScheduleStore;
|
|
109
|
+
private readonly clock: Clock;
|
|
110
|
+
private readonly now: () => number;
|
|
111
|
+
private readonly runTimeoutMs: number;
|
|
112
|
+
private readonly options: SchedulerOptions;
|
|
113
|
+
private readonly due = new Map<string, Due>();
|
|
114
|
+
private readonly runs = new Map<string, { promise: Promise<void>; controller: AbortController }>();
|
|
115
|
+
private startedAt: number | undefined;
|
|
116
|
+
private cancel: Cancel | undefined;
|
|
117
|
+
private ticking: Promise<void> | undefined;
|
|
118
|
+
|
|
119
|
+
constructor(options: SchedulerOptions = {}) {
|
|
120
|
+
this.options = options;
|
|
121
|
+
this.registry = options.registry ?? PROCEDURE_REGISTRY;
|
|
122
|
+
this.store = options.store ?? defaultScheduleStore;
|
|
123
|
+
this.clock = options.clock ?? realClock;
|
|
124
|
+
this.now = options.now ?? (() => Date.now());
|
|
125
|
+
this.runTimeoutMs = options.runTimeoutMs ?? DEFAULT_RUN_TIMEOUT_MS;
|
|
126
|
+
if (!(this.runTimeoutMs >= 0)) throw new TypeError("Scheduler: runTimeoutMs must be 0 or more");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** True after `start()` and before `stop()`. */
|
|
130
|
+
get started(): boolean {
|
|
131
|
+
return this.startedAt !== undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The names of the schedules with a run that has not ended. */
|
|
135
|
+
get active(): string[] {
|
|
136
|
+
return [...this.runs.keys()].sort();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** This method starts the scheduler: the first times come after now. It gives the end of the first examination. */
|
|
140
|
+
async start(): Promise<void> {
|
|
141
|
+
if (this.startedAt !== undefined) return;
|
|
142
|
+
this.startedAt = this.now();
|
|
143
|
+
await this.tick();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** This method stops the timer, aborts the runs, and gives the end of the runs. */
|
|
147
|
+
async stop(): Promise<void> {
|
|
148
|
+
this.startedAt = undefined;
|
|
149
|
+
this.cancel?.();
|
|
150
|
+
this.cancel = undefined;
|
|
151
|
+
this.due.clear();
|
|
152
|
+
for (const run of this.runs.values()) run.controller.abort(new Error("The scheduler stopped"));
|
|
153
|
+
await this.settled();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** This method gives the end of the examination in progress and of the runs in progress. */
|
|
157
|
+
async settled(): Promise<void> {
|
|
158
|
+
await this.ticking;
|
|
159
|
+
await Promise.all([...this.runs.values()].map((run) => run.promise));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* This method examines the schedules now and starts each one whose time came. It does not wait
|
|
164
|
+
* for the runs. A second call during an examination gives the same examination.
|
|
165
|
+
*/
|
|
166
|
+
tick(): Promise<void> {
|
|
167
|
+
this.ticking ??= this.examine().finally(() => {
|
|
168
|
+
this.ticking = undefined;
|
|
169
|
+
this.arm();
|
|
170
|
+
});
|
|
171
|
+
return this.ticking;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** This method starts one run of a schedule now, unless it has a run in progress. It gives the event of the run. */
|
|
175
|
+
async runNow(id: string): Promise<ScheduleRunEvent> {
|
|
176
|
+
const schedule = await this.store.get(id);
|
|
177
|
+
if (schedule === undefined) throw new ScheduleError("NOT_FOUND", `No schedule is named ${id}`);
|
|
178
|
+
return new Promise<ScheduleRunEvent>((resolve) => {
|
|
179
|
+
if (!this.launch(schedule, this.now(), resolve)) resolve(this.skip(schedule, this.now()));
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async examine(): Promise<void> {
|
|
184
|
+
if (this.startedAt === undefined) return;
|
|
185
|
+
const now = this.now();
|
|
186
|
+
let schedules: Schedule[];
|
|
187
|
+
try {
|
|
188
|
+
schedules = await this.store.list();
|
|
189
|
+
} catch (error) {
|
|
190
|
+
this.options.onError?.(error);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const ids = new Set(schedules.map((schedule) => schedule.id));
|
|
194
|
+
for (const id of this.due.keys()) if (!ids.has(id)) this.due.delete(id);
|
|
195
|
+
for (const schedule of schedules) {
|
|
196
|
+
// A stop during the read of the store starts nothing
|
|
197
|
+
if (this.startedAt === undefined) return;
|
|
198
|
+
const version = versionOf(schedule);
|
|
199
|
+
let due = this.due.get(schedule.id);
|
|
200
|
+
if (due === undefined || due.version !== version) {
|
|
201
|
+
// A new or a changed schedule: its first time is after it was added, and after the start
|
|
202
|
+
due = { version, at: nextOf(schedule, Math.max(schedule.createdAt, this.startedAt) - 1) };
|
|
203
|
+
this.due.set(schedule.id, due);
|
|
204
|
+
}
|
|
205
|
+
if (due.at === undefined || due.at > now) continue;
|
|
206
|
+
// The times that passed give one run: the next time comes after now
|
|
207
|
+
due.at = nextOf(schedule, now);
|
|
208
|
+
if (!this.launch(schedule, now)) void this.skip(schedule, now);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** This method arms the timer for the next minute, or for an earlier time that is due. */
|
|
213
|
+
private arm(): void {
|
|
214
|
+
if (this.startedAt === undefined) return;
|
|
215
|
+
this.cancel?.();
|
|
216
|
+
const now = this.now();
|
|
217
|
+
let wake = Math.floor(now / 60_000) * 60_000 + 60_000;
|
|
218
|
+
for (const due of this.due.values()) if (due.at !== undefined && due.at < wake) wake = due.at;
|
|
219
|
+
this.cancel = this.clock.after(Math.max(0, wake - now), () => {
|
|
220
|
+
this.cancel = undefined;
|
|
221
|
+
void this.tick();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** This method gives an event to `onRun`. An error of `onRun` goes to `onError`, not to the scheduler. */
|
|
226
|
+
private report(event: ScheduleRunEvent): void {
|
|
227
|
+
try {
|
|
228
|
+
this.options.onRun?.(event);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
this.options.onError?.(error);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** This method records a time that came during a run of the same schedule. */
|
|
235
|
+
private skip(schedule: Schedule, now: number): ScheduleRunEvent {
|
|
236
|
+
const event: ScheduleRunEvent = { id: schedule.id, program: schedule.program, outcome: "skipped", startedAt: now, durationMs: 0 };
|
|
237
|
+
void this.store
|
|
238
|
+
.update(schedule.id, (current) => (current.createdAt === schedule.createdAt ? { ...current, skipped: current.skipped + 1 } : undefined))
|
|
239
|
+
.catch((error: unknown) => this.options.onError?.(error));
|
|
240
|
+
this.report(event);
|
|
241
|
+
return event;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** This method starts a run, unless the schedule has one. It gives false for a schedule with a run. */
|
|
245
|
+
private launch(schedule: Schedule, startedAt: number, done?: (event: ScheduleRunEvent) => void): boolean {
|
|
246
|
+
if (this.runs.has(schedule.id)) return false;
|
|
247
|
+
const controller = new AbortController();
|
|
248
|
+
const promise = this.run(schedule, startedAt, controller).then((event) => {
|
|
249
|
+
this.runs.delete(schedule.id);
|
|
250
|
+
this.report(event);
|
|
251
|
+
done?.(event);
|
|
252
|
+
});
|
|
253
|
+
this.runs.set(schedule.id, { promise, controller });
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** One run: the program through the registry, then its record. */
|
|
258
|
+
private async run(schedule: Schedule, startedAt: number, controller: AbortController): Promise<ScheduleRunEvent> {
|
|
259
|
+
const start = this.clock.now();
|
|
260
|
+
let timedOut = false;
|
|
261
|
+
const stopTimer =
|
|
262
|
+
this.runTimeoutMs > 0
|
|
263
|
+
? this.clock.after(this.runTimeoutMs, () => {
|
|
264
|
+
timedOut = true;
|
|
265
|
+
controller.abort(new Error(`The run of ${schedule.id} passed its time limit of ${this.runTimeoutMs} ms`));
|
|
266
|
+
})
|
|
267
|
+
: undefined;
|
|
268
|
+
let error: { code?: string; message: string } | undefined;
|
|
269
|
+
try {
|
|
270
|
+
await this.invoke(schedule, controller.signal);
|
|
271
|
+
} catch (caught) {
|
|
272
|
+
error = errorInfo(caught);
|
|
273
|
+
} finally {
|
|
274
|
+
stopTimer?.();
|
|
275
|
+
}
|
|
276
|
+
if (timedOut) error = { code: "TIMEOUT", message: `The run passed its time limit of ${this.runTimeoutMs} ms${error ? `: ${error.message}` : ""}` };
|
|
277
|
+
const run: ScheduleRun = {
|
|
278
|
+
startedAt,
|
|
279
|
+
durationMs: Math.max(0, this.clock.now() - start),
|
|
280
|
+
outcome: error === undefined ? "success" : "failure",
|
|
281
|
+
...(error !== undefined ? { error } : {}),
|
|
282
|
+
};
|
|
283
|
+
try {
|
|
284
|
+
await this.store.update(schedule.id, (current) =>
|
|
285
|
+
current.createdAt === schedule.createdAt
|
|
286
|
+
? { ...current, lastRun: run, runs: current.runs + 1, failures: current.failures + (error === undefined ? 0 : 1) }
|
|
287
|
+
: undefined,
|
|
288
|
+
);
|
|
289
|
+
} catch (caught) {
|
|
290
|
+
this.options.onError?.(caught);
|
|
291
|
+
}
|
|
292
|
+
return { id: schedule.id, program: schedule.program, outcome: run.outcome, startedAt, durationMs: run.durationMs, ...(error !== undefined ? { error } : {}) };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The options of an invocation: the policy of the host, a capability and the signal of the run. */
|
|
296
|
+
private invokeOptions(capability: Capability | undefined, signal: AbortSignal): InvokeOptions {
|
|
297
|
+
const policy = this.options.policy;
|
|
298
|
+
return {
|
|
299
|
+
registry: this.registry,
|
|
300
|
+
signal,
|
|
301
|
+
...(capability !== undefined ? { capability } : {}),
|
|
302
|
+
...(policy?.expose ? { expose: policy.expose } : {}),
|
|
303
|
+
...(policy?.strictCalls ? { strictCalls: true } : {}),
|
|
304
|
+
...(policy && policy.interceptors.length > 0 ? { interceptors: policy.interceptors } : {}),
|
|
305
|
+
...(policy?.clearanceOrder ? { clearanceOrder: policy.clearanceOrder } : {}),
|
|
306
|
+
...(policy?.resources ? { resources: policy.resources } : {}),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The program of a schedule through the registry, after a load from the program store when the registry does not have it. */
|
|
311
|
+
private async invoke(schedule: Schedule, signal: AbortSignal): Promise<void> {
|
|
312
|
+
const path = keyToPath(schedule.program);
|
|
313
|
+
const host = this.options.capability ?? this.options.policy?.capabilityOf?.();
|
|
314
|
+
if ((await findProgram(this.registry, schedule.program)) === "store") {
|
|
315
|
+
// The load defines the program: a step of the host, with the capability of the host
|
|
316
|
+
await outputValue(await invokePath(["procedure", "load"], { path }, this.invokeOptions(host, signal)), ["procedure", "load"]);
|
|
317
|
+
}
|
|
318
|
+
const procedure = this.registry.get(path);
|
|
319
|
+
if (procedure === undefined || !isRuntimeDefined(procedure)) {
|
|
320
|
+
throw new ScheduleError("NOT_FOUND", `The program ${schedule.program} is not in the registry after its load`);
|
|
321
|
+
}
|
|
322
|
+
const capability = schedule.capability === undefined ? host : narrowCapability(host, schedule.capability, this.options.policy?.clearanceOrder);
|
|
323
|
+
if (signal.aborted) throw new InvocationError("ABORTED", `The run of ${schedule.id} was aborted before its start`, path);
|
|
324
|
+
const output = await invokeProcedure(procedure, schedule.input ?? {}, this.invokeOptions(capability, signal));
|
|
325
|
+
if (output.kind === "stream") {
|
|
326
|
+
for await (const _item of output.items) {
|
|
327
|
+
// A program that gives a stream runs to its end
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stores of the schedules: in memory, or in one JSON file.
|
|
3
|
+
*
|
|
4
|
+
* A host gives one store to the module of the procedures (`createScheduleModule({ store })`) and
|
|
5
|
+
* the same store to its `Scheduler`. `update()` changes one schedule in one step, so the record of
|
|
6
|
+
* a run does not lose a change of `schedule.add` that came at the same time.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname } from "node:path";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { ScheduleError } from "./errors.js";
|
|
13
|
+
import type { Schedule } from "./types.js";
|
|
14
|
+
|
|
15
|
+
/** A store of schedules. Each method gives a Promise: a caller cannot tell a local store from a remote one. */
|
|
16
|
+
export interface ScheduleStore {
|
|
17
|
+
/** The kind of the backend: "memory", "file" or another name. */
|
|
18
|
+
readonly kind: string;
|
|
19
|
+
get(id: string): Promise<Schedule | undefined>;
|
|
20
|
+
/** The schedules, sorted by name. */
|
|
21
|
+
list(): Promise<Schedule[]>;
|
|
22
|
+
/** Write a schedule. A schedule of the same name is replaced. */
|
|
23
|
+
put(schedule: Schedule): Promise<void>;
|
|
24
|
+
/** Delete a schedule. True when it existed. */
|
|
25
|
+
delete(id: string): Promise<boolean>;
|
|
26
|
+
/**
|
|
27
|
+
* Change one schedule in one step. `change` gets a copy and gives the new schedule, or
|
|
28
|
+
* undefined for no change. The result is the stored schedule, or undefined when none exists.
|
|
29
|
+
*/
|
|
30
|
+
update(id: string, change: (schedule: Schedule) => Schedule | undefined): Promise<Schedule | undefined>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const byId = (a: Schedule, b: Schedule): number => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
34
|
+
|
|
35
|
+
const isCount = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
36
|
+
|
|
37
|
+
/** The schedule of a record that came from a file: its shape is not trusted. */
|
|
38
|
+
export function checkSchedule(value: unknown): Schedule {
|
|
39
|
+
const record = value as Partial<Schedule> | null;
|
|
40
|
+
if (record === null || typeof record !== "object") throw new ScheduleError("BAD_STORE", "A stored schedule must be an object");
|
|
41
|
+
if (typeof record.id !== "string" || record.id === "") throw new ScheduleError("BAD_STORE", "A stored schedule needs an id");
|
|
42
|
+
if (typeof record.cron !== "string" || typeof record.program !== "string" || record.program === "") {
|
|
43
|
+
throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs a cron expression and a program`);
|
|
44
|
+
}
|
|
45
|
+
if (record.timeZone !== "utc" && record.timeZone !== "local") throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs the time zone utc or local`);
|
|
46
|
+
if (typeof record.createdAt !== "number" || !Number.isFinite(record.createdAt)) throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs createdAt`);
|
|
47
|
+
if (!isCount(record.runs) || !isCount(record.failures) || !isCount(record.skipped)) throw new ScheduleError("BAD_STORE", `The schedule ${record.id} needs the counts of its runs`);
|
|
48
|
+
return record as Schedule;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A store in memory: for tests, and for a host that keeps its schedules only while it runs. */
|
|
52
|
+
export class MemoryScheduleStore implements ScheduleStore {
|
|
53
|
+
readonly kind = "memory";
|
|
54
|
+
private readonly schedules = new Map<string, Schedule>();
|
|
55
|
+
|
|
56
|
+
async get(id: string): Promise<Schedule | undefined> {
|
|
57
|
+
const schedule = this.schedules.get(id);
|
|
58
|
+
return schedule ? structuredClone(schedule) : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async list(): Promise<Schedule[]> {
|
|
62
|
+
return [...this.schedules.values()].map((schedule) => structuredClone(schedule)).sort(byId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async put(schedule: Schedule): Promise<void> {
|
|
66
|
+
const checked = checkSchedule(structuredClone(schedule));
|
|
67
|
+
this.schedules.set(checked.id, checked);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async delete(id: string): Promise<boolean> {
|
|
71
|
+
return this.schedules.delete(id);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async update(id: string, change: (schedule: Schedule) => Schedule | undefined): Promise<Schedule | undefined> {
|
|
75
|
+
const current = this.schedules.get(id);
|
|
76
|
+
if (current === undefined) return undefined;
|
|
77
|
+
const next = change(structuredClone(current));
|
|
78
|
+
if (next === undefined) return structuredClone(current);
|
|
79
|
+
const checked = checkSchedule(structuredClone(next));
|
|
80
|
+
if (checked.id !== id) throw new ScheduleError("BAD_STORE", "An update cannot change the id of a schedule");
|
|
81
|
+
this.schedules.set(id, checked);
|
|
82
|
+
return structuredClone(checked);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface ScheduleFile {
|
|
87
|
+
schemaVersion: 1;
|
|
88
|
+
schedules: Record<string, Schedule>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** True when Windows holds a file for a moment. */
|
|
92
|
+
function isContention(error: unknown): boolean {
|
|
93
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
94
|
+
return process.platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBUSY");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const pause = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A store in one JSON file: `{ "schemaVersion": 1, "schedules": { "<id>": <Schedule> } }`. Each
|
|
101
|
+
* operation reads the file again. A write goes through a temporary file and a rename, so a reader
|
|
102
|
+
* never sees a part of a write. The writes of this process go one at a time. The store is for
|
|
103
|
+
* one process: two processes on one file can lose a write.
|
|
104
|
+
*/
|
|
105
|
+
export class FileScheduleStore implements ScheduleStore {
|
|
106
|
+
readonly kind = "file";
|
|
107
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
108
|
+
|
|
109
|
+
/** @param path - The JSON file. The store makes it, and its folder, at the first write. */
|
|
110
|
+
constructor(readonly path: string) {}
|
|
111
|
+
|
|
112
|
+
async get(id: string): Promise<Schedule | undefined> {
|
|
113
|
+
return (await this.read()).schedules[id];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async list(): Promise<Schedule[]> {
|
|
117
|
+
return Object.values((await this.read()).schedules).sort(byId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async put(schedule: Schedule): Promise<void> {
|
|
121
|
+
const checked = checkSchedule(structuredClone(schedule));
|
|
122
|
+
await this.change((file) => {
|
|
123
|
+
file.schedules[checked.id] = checked;
|
|
124
|
+
return true;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async delete(id: string): Promise<boolean> {
|
|
129
|
+
let deleted = false;
|
|
130
|
+
await this.change((file) => {
|
|
131
|
+
deleted = Object.hasOwn(file.schedules, id);
|
|
132
|
+
if (deleted) delete file.schedules[id];
|
|
133
|
+
return deleted;
|
|
134
|
+
});
|
|
135
|
+
return deleted;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async update(id: string, change: (schedule: Schedule) => Schedule | undefined): Promise<Schedule | undefined> {
|
|
139
|
+
let result: Schedule | undefined;
|
|
140
|
+
await this.change((file) => {
|
|
141
|
+
const current = file.schedules[id];
|
|
142
|
+
if (current === undefined) return false;
|
|
143
|
+
const next = change(structuredClone(current));
|
|
144
|
+
if (next === undefined) {
|
|
145
|
+
result = current;
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const checked = checkSchedule(structuredClone(next));
|
|
149
|
+
if (checked.id !== id) throw new ScheduleError("BAD_STORE", "An update cannot change the id of a schedule");
|
|
150
|
+
file.schedules[id] = checked;
|
|
151
|
+
result = checked;
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The content of the file. A missing file is an empty store. */
|
|
158
|
+
private async read(): Promise<ScheduleFile> {
|
|
159
|
+
let text: string;
|
|
160
|
+
try {
|
|
161
|
+
text = await readFile(this.path, "utf8");
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { schemaVersion: 1, schedules: {} };
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
let parsed: unknown;
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(text);
|
|
169
|
+
} catch {
|
|
170
|
+
throw new ScheduleError("BAD_STORE", `The schedule file ${this.path} is not JSON. The store does not write over it.`);
|
|
171
|
+
}
|
|
172
|
+
const file = parsed as Partial<ScheduleFile> | null;
|
|
173
|
+
if (file === null || typeof file !== "object" || file.schemaVersion !== 1 || file.schedules === null || typeof file.schedules !== "object") {
|
|
174
|
+
throw new ScheduleError("BAD_STORE", `The schedule file ${this.path} does not have schemaVersion 1 and schedules`);
|
|
175
|
+
}
|
|
176
|
+
const schedules: Record<string, Schedule> = {};
|
|
177
|
+
for (const [id, value] of Object.entries(file.schedules)) {
|
|
178
|
+
const schedule = checkSchedule(value);
|
|
179
|
+
if (schedule.id !== id) throw new ScheduleError("BAD_STORE", `The schedule file ${this.path} has the schedule ${schedule.id} under the key ${id}`);
|
|
180
|
+
schedules[id] = schedule;
|
|
181
|
+
}
|
|
182
|
+
return { schemaVersion: 1, schedules };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** One change of the file, after the changes before it. `edit` gives false for no change. */
|
|
186
|
+
private change(edit: (file: ScheduleFile) => boolean): Promise<void> {
|
|
187
|
+
const run = this.queue.then(async () => {
|
|
188
|
+
const file = await this.read();
|
|
189
|
+
if (!edit(file)) return;
|
|
190
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
191
|
+
const temporary = `${this.path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
192
|
+
await writeFile(temporary, JSON.stringify(file, null, 2) + "\n", "utf8");
|
|
193
|
+
for (let attempt = 1; ; attempt++) {
|
|
194
|
+
try {
|
|
195
|
+
await rename(temporary, this.path);
|
|
196
|
+
return;
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (attempt >= 20 || !isContention(error)) {
|
|
199
|
+
await rm(temporary, { force: true });
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
await pause(10 * attempt);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
// A failed change does not stop the next one
|
|
207
|
+
this.queue = run.catch(() => undefined);
|
|
208
|
+
return run;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The store of the default module and of a `Scheduler` without a store: in memory, one for the process. */
|
|
213
|
+
export const defaultScheduleStore: ScheduleStore = new MemoryScheduleStore();
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions of the procedures of this package
|
|
3
|
+
*
|
|
4
|
+
* mark procedure new adds the types of each new procedure here. The zod schemas are in
|
|
5
|
+
* `module.ts`: an exported schema needs a written type (isolatedDeclarations).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PermittedEffects } from "@procedures/client";
|
|
9
|
+
|
|
10
|
+
/** The time zone of a cron expression: the UTC time, or the local time of the host. */
|
|
11
|
+
export type ScheduleTimeZone = "utc" | "local";
|
|
12
|
+
|
|
13
|
+
/** The last run of a schedule. */
|
|
14
|
+
export interface ScheduleRun {
|
|
15
|
+
/** The wall time of the start, in milliseconds since 1970. */
|
|
16
|
+
startedAt: number;
|
|
17
|
+
/** The time of the run, in milliseconds. */
|
|
18
|
+
durationMs: number;
|
|
19
|
+
outcome: "success" | "failure";
|
|
20
|
+
/** The error of a failure: its code, when it has one, and its message. */
|
|
21
|
+
error?: { code?: string | undefined; message: string } | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One stored schedule: a cron expression that starts a stored program with an input. */
|
|
25
|
+
export interface Schedule {
|
|
26
|
+
/** The name of the schedule. */
|
|
27
|
+
id: string;
|
|
28
|
+
/** The cron expression: 5 fields, or a name such as `@daily`. */
|
|
29
|
+
cron: string;
|
|
30
|
+
/** The dotted key of the stored program: `reports.weekly`. */
|
|
31
|
+
program: string;
|
|
32
|
+
/** The input of the program. */
|
|
33
|
+
input?: unknown;
|
|
34
|
+
timeZone: ScheduleTimeZone;
|
|
35
|
+
/** When the schedule was added, in milliseconds since 1970. A schedule starts no run for a time before it. */
|
|
36
|
+
createdAt: number;
|
|
37
|
+
/**
|
|
38
|
+
* The capability of the caller that added the schedule, without its deadline. A run gets the
|
|
39
|
+
* capability of the host, narrowed by this one: a caller cannot start more than it can do.
|
|
40
|
+
*/
|
|
41
|
+
capability?: { clearance?: unknown; effects?: PermittedEffects } | undefined;
|
|
42
|
+
lastRun?: ScheduleRun | undefined;
|
|
43
|
+
/** The number of runs. */
|
|
44
|
+
runs: number;
|
|
45
|
+
/** The number of runs that failed. */
|
|
46
|
+
failures: number;
|
|
47
|
+
/** The number of times that came while a run of the schedule was not finished: no second run started. */
|
|
48
|
+
skipped: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A schedule as the procedures give it: with its next time. */
|
|
52
|
+
export interface ScheduleView extends Schedule {
|
|
53
|
+
/** The next time, as an ISO text in the time zone of the schedule, or null when the expression has no next time. */
|
|
54
|
+
nextRunAt: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// =============================================================================
|
|
58
|
+
// schedule.add
|
|
59
|
+
// =============================================================================
|
|
60
|
+
|
|
61
|
+
export interface ScheduleAddInput {
|
|
62
|
+
/** The name of the schedule. The default is the key of the program. */
|
|
63
|
+
id?: string | undefined;
|
|
64
|
+
cron: string;
|
|
65
|
+
/** The dotted key of the stored program. */
|
|
66
|
+
program: string;
|
|
67
|
+
input?: unknown;
|
|
68
|
+
/** The time zone of the expression. The default is `local`. */
|
|
69
|
+
timeZone?: ScheduleTimeZone | undefined;
|
|
70
|
+
/** True: replace a schedule of the same name. The default is false: a second schedule of a name is an error. */
|
|
71
|
+
replace?: boolean | undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ScheduleAddOutput {
|
|
75
|
+
schedule: ScheduleView;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// =============================================================================
|
|
79
|
+
// schedule.list
|
|
80
|
+
// =============================================================================
|
|
81
|
+
|
|
82
|
+
export interface ScheduleListInput {
|
|
83
|
+
/** Only the schedules of this program. */
|
|
84
|
+
program?: string | undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ScheduleListOutput {
|
|
88
|
+
/** The schedules, sorted by name. */
|
|
89
|
+
schedules: ScheduleView[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// =============================================================================
|
|
93
|
+
// schedule.remove
|
|
94
|
+
// =============================================================================
|
|
95
|
+
|
|
96
|
+
export interface ScheduleRemoveInput {
|
|
97
|
+
id: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ScheduleRemoveOutput {
|
|
101
|
+
/** True when the schedule existed. */
|
|
102
|
+
removed: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// =============================================================================
|
|
106
|
+
// schedule.next
|
|
107
|
+
// =============================================================================
|
|
108
|
+
|
|
109
|
+
export interface ScheduleNextInput {
|
|
110
|
+
cron: string;
|
|
111
|
+
/** The number of times: 1 to 100. The default is 5. */
|
|
112
|
+
count?: number | undefined;
|
|
113
|
+
/** The times come after this time: an ISO text. The default is now. */
|
|
114
|
+
after?: string | undefined;
|
|
115
|
+
/** The time zone of the expression. The default is `local`. */
|
|
116
|
+
timeZone?: ScheduleTimeZone | undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ScheduleNextOutput {
|
|
120
|
+
/** The next times, as ISO texts in the time zone of the expression. Fewer than `count` when the expression has fewer times in the next 9 years. */
|
|
121
|
+
times: string[];
|
|
122
|
+
}
|