@ecosy/schedule 0.1.0 → 0.2.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 +25 -1
- package/dist/cron.d.ts +44 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +2 -0
- package/dist/cron.js.map +1 -0
- package/dist/cron.mjs +2 -0
- package/dist/cron.mjs.map +1 -0
- package/dist/hook/index.d.ts +39 -0
- package/dist/hook/index.d.ts.map +1 -0
- package/dist/hook/index.js +2 -0
- package/dist/hook/index.js.map +1 -0
- package/dist/hook/index.mjs +2 -0
- package/dist/hook/index.mjs.map +1 -0
- package/{src/index.ts → dist/index.d.ts} +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +1 -0
- package/dist/registry.d.ts +39 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +2 -0
- package/dist/registry.js.map +1 -0
- package/dist/registry.mjs +2 -0
- package/dist/registry.mjs.map +1 -0
- package/dist/runner.d.ts +12 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +2 -0
- package/dist/runner.js.map +1 -0
- package/dist/runner.mjs +2 -0
- package/dist/runner.mjs.map +1 -0
- package/dist/schedule.d.ts +123 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +2 -0
- package/dist/schedule.js.map +1 -0
- package/dist/schedule.mjs +2 -0
- package/dist/schedule.mjs.map +1 -0
- package/dist/source/index.d.ts +37 -0
- package/dist/source/index.d.ts.map +1 -0
- package/dist/source/index.js +3 -0
- package/dist/source/index.js.map +1 -0
- package/dist/source/index.mjs +3 -0
- package/dist/source/index.mjs.map +1 -0
- package/dist/task.d.ts +60 -0
- package/dist/task.d.ts.map +1 -0
- package/dist/task.js +2 -0
- package/dist/task.js.map +1 -0
- package/dist/task.mjs +2 -0
- package/dist/task.mjs.map +1 -0
- package/dist/types.d.ts +155 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/types.mjs +2 -0
- package/dist/types.mjs.map +1 -0
- package/package.json +28 -9
- package/src/cron.ts +0 -368
- package/src/hook/index.ts +0 -101
- package/src/registry.ts +0 -100
- package/src/runner.ts +0 -150
- package/src/schedule.ts +0 -387
- package/src/source/index.ts +0 -126
- package/src/task.ts +0 -124
- package/src/types.ts +0 -147
- package/tsconfig.json +0 -23
package/src/runner.ts
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import type { IRegistry } from "./registry";
|
|
2
|
-
import type { NotFoundHandler, TaskDefinition, TaskFailure } from "./types";
|
|
3
|
-
|
|
4
|
-
export interface RunOutcome {
|
|
5
|
-
ok: boolean;
|
|
6
|
-
reason?: TaskFailure;
|
|
7
|
-
detail?: string;
|
|
8
|
-
raw?: unknown;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/** Milliseconds after which a run is abandoned. */
|
|
12
|
-
export const DEFAULT_TIMEOUT = 30_000;
|
|
13
|
-
|
|
14
|
-
function failure(reason: TaskFailure, detail: string, raw?: unknown): RunOutcome {
|
|
15
|
-
return { ok: false, reason, detail, raw };
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Races a promise against a deadline.
|
|
20
|
-
*
|
|
21
|
-
* The loser is not cancelled — nothing here can cancel a promise. Each target
|
|
22
|
-
* kind does what it can on top of this: an HTTP request is aborted, a child
|
|
23
|
-
* process is killed, and an in-process handler simply keeps running with
|
|
24
|
-
* nobody listening. That last one is a real limit, not an oversight: a
|
|
25
|
-
* function already executing cannot be interrupted in JavaScript.
|
|
26
|
-
*/
|
|
27
|
-
function withDeadline<T>(work: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
|
|
28
|
-
return new Promise((resolve, reject) => {
|
|
29
|
-
const timer = setTimeout(() => resolve(TIMED_OUT), ms);
|
|
30
|
-
work.then(
|
|
31
|
-
(value) => { clearTimeout(timer); resolve(value); },
|
|
32
|
-
(error) => { clearTimeout(timer); reject(error); },
|
|
33
|
-
);
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const TIMED_OUT = Symbol("timed-out");
|
|
38
|
-
|
|
39
|
-
async function runHandler(
|
|
40
|
-
task: TaskDefinition,
|
|
41
|
-
registry: IRegistry<unknown>,
|
|
42
|
-
context: unknown,
|
|
43
|
-
timeout: number,
|
|
44
|
-
notFound?: NotFoundHandler,
|
|
45
|
-
): Promise<RunOutcome> {
|
|
46
|
-
if (task.target.type !== "handler") return failure("unknown", "not a handler target");
|
|
47
|
-
|
|
48
|
-
const Entry = registry.get(task.target.key);
|
|
49
|
-
|
|
50
|
-
if (!Entry) {
|
|
51
|
-
// Loud, not silent: a row that is enabled but points nowhere would
|
|
52
|
-
// otherwise look like a task that simply never fires.
|
|
53
|
-
await notFound?.(task.target.key, task);
|
|
54
|
-
return failure("unknown", `no handler registered for "${task.target.key}"`);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
const result = await withDeadline(Promise.resolve(new Entry().run(context)), timeout);
|
|
59
|
-
|
|
60
|
-
if (result === TIMED_OUT) {
|
|
61
|
-
return failure("timeout", `handler exceeded ${timeout}ms (still running — a function cannot be interrupted)`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return { ok: true, raw: result };
|
|
65
|
-
} catch (error) {
|
|
66
|
-
return failure("throw", error instanceof Error ? error.message : String(error), error);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
async function runApi(task: TaskDefinition, timeout: number): Promise<RunOutcome> {
|
|
71
|
-
if (task.target.type !== "api") return failure("unknown", "not an api target");
|
|
72
|
-
|
|
73
|
-
const { url, method = "POST", headers, body } = task.target;
|
|
74
|
-
const controller = new AbortController();
|
|
75
|
-
const timer = setTimeout(() => controller.abort(), timeout);
|
|
76
|
-
|
|
77
|
-
try {
|
|
78
|
-
const response = await fetch(url, { method, headers, body, signal: controller.signal });
|
|
79
|
-
const text = await response.text();
|
|
80
|
-
|
|
81
|
-
// Only the status decides. A 200 carrying `{ ok: false }` is the caller's
|
|
82
|
-
// business — the scheduler has no way to know what a body means, and
|
|
83
|
-
// guessing would make it wrong for somebody.
|
|
84
|
-
return response.ok
|
|
85
|
-
? { ok: true, raw: text }
|
|
86
|
-
: failure("status", `${response.status} ${response.statusText}`, text);
|
|
87
|
-
} catch (error) {
|
|
88
|
-
const aborted = error instanceof Error && error.name === "AbortError";
|
|
89
|
-
|
|
90
|
-
return aborted
|
|
91
|
-
? failure("timeout", `request exceeded ${timeout}ms (abandoned — the server may still be working)`)
|
|
92
|
-
: failure("throw", error instanceof Error ? error.message : String(error), error);
|
|
93
|
-
} finally {
|
|
94
|
-
clearTimeout(timer);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
async function runFile(task: TaskDefinition, timeout: number): Promise<RunOutcome> {
|
|
99
|
-
if (task.target.type !== "file") return failure("unknown", "not a file target");
|
|
100
|
-
|
|
101
|
-
// Imported here rather than at the top so that a consumer using only
|
|
102
|
-
// handler and api targets is not forced onto Node.
|
|
103
|
-
const { spawn } = await import("node:child_process");
|
|
104
|
-
const { path, args = [] } = task.target;
|
|
105
|
-
|
|
106
|
-
return new Promise<RunOutcome>((resolve) => {
|
|
107
|
-
const child = spawn(process.execPath, [path, ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
|
108
|
-
|
|
109
|
-
let stdout = "";
|
|
110
|
-
let stderr = "";
|
|
111
|
-
let killed = false;
|
|
112
|
-
|
|
113
|
-
const timer = setTimeout(() => {
|
|
114
|
-
killed = true;
|
|
115
|
-
// The one target kind that can actually be stopped.
|
|
116
|
-
child.kill("SIGKILL");
|
|
117
|
-
}, timeout);
|
|
118
|
-
|
|
119
|
-
child.stdout?.on("data", (chunk) => { stdout += chunk; });
|
|
120
|
-
child.stderr?.on("data", (chunk) => { stderr += chunk; });
|
|
121
|
-
|
|
122
|
-
child.on("error", (error) => {
|
|
123
|
-
clearTimeout(timer);
|
|
124
|
-
resolve(failure("throw", error.message, error));
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
child.on("close", (code) => {
|
|
128
|
-
clearTimeout(timer);
|
|
129
|
-
|
|
130
|
-
if (killed) return resolve(failure("timeout", `killed after ${timeout}ms`, stderr));
|
|
131
|
-
if (code !== 0) return resolve(failure("exit", `exit ${code}: ${stderr.trim().slice(0, 500)}`, stderr));
|
|
132
|
-
|
|
133
|
-
resolve({ ok: true, raw: stdout });
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export function runTarget(
|
|
139
|
-
task: TaskDefinition,
|
|
140
|
-
registry: IRegistry<unknown>,
|
|
141
|
-
context: unknown,
|
|
142
|
-
timeout = DEFAULT_TIMEOUT,
|
|
143
|
-
notFound?: NotFoundHandler,
|
|
144
|
-
): Promise<RunOutcome> {
|
|
145
|
-
switch (task.target.type) {
|
|
146
|
-
case "handler": return runHandler(task, registry, context, timeout, notFound);
|
|
147
|
-
case "api": return runApi(task, timeout);
|
|
148
|
-
case "file": return runFile(task, timeout);
|
|
149
|
-
}
|
|
150
|
-
}
|
package/src/schedule.ts
DELETED
|
@@ -1,387 +0,0 @@
|
|
|
1
|
-
import { Registry, type IRegistry } from "./registry";
|
|
2
|
-
import { DEFAULT_TIMEOUT, runTarget } from "./runner";
|
|
3
|
-
import { Task, type TaskStatus } from "./task";
|
|
4
|
-
import type {
|
|
5
|
-
ClassType, Coordinator, ErrorHandler, Hook, InjectMap, Injected, NotFoundHandler,
|
|
6
|
-
Source, TaskDefinition, TaskEvent, TaskParser,
|
|
7
|
-
} from "./types";
|
|
8
|
-
|
|
9
|
-
/** How often due tasks are checked. One second, because expressions have a seconds field. */
|
|
10
|
-
const DEFAULT_TICK = 1_000;
|
|
11
|
-
const DEFAULT_SYNC = 60_000;
|
|
12
|
-
const DEFAULT_RETRY = 0;
|
|
13
|
-
|
|
14
|
-
/** What happens to a task that disappears from the source. */
|
|
15
|
-
export type CascadePolicy =
|
|
16
|
-
/** Stop scheduling, let the run in flight finish, then drop it. */
|
|
17
|
-
| "drain"
|
|
18
|
-
/** Stop scheduling now. A file target is killed; the other two are let go — see `runner`. */
|
|
19
|
-
| "stop"
|
|
20
|
-
/** Leave it running. For when the source is not the only authority. */
|
|
21
|
-
| "keep";
|
|
22
|
-
|
|
23
|
-
interface Descriptor<Entry, Context> {
|
|
24
|
-
injects: InjectMap;
|
|
25
|
-
source?: Source<Entry, Context>;
|
|
26
|
-
parser?: ClassType<TaskParser<Entry>>;
|
|
27
|
-
registry: IRegistry<Context>;
|
|
28
|
-
coordinator?: ClassType<Coordinator>;
|
|
29
|
-
hook?: ClassType<Hook>;
|
|
30
|
-
retry: number;
|
|
31
|
-
timeout: number;
|
|
32
|
-
syncMs: number | false;
|
|
33
|
-
tickMs: number;
|
|
34
|
-
cascade: CascadePolicy;
|
|
35
|
-
onError?: ErrorHandler;
|
|
36
|
-
notFound?: NotFoundHandler;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface ScheduleStatus {
|
|
40
|
-
running: boolean;
|
|
41
|
-
lastSyncAt: Date | null;
|
|
42
|
-
lastSyncOk: boolean | null;
|
|
43
|
-
tasks: TaskStatus[];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* A configured scheduler.
|
|
48
|
-
*
|
|
49
|
-
* A plain instance with no global state: constructing two gives two, and
|
|
50
|
-
* whether that should happen is the caller's business. Whatever runs this once
|
|
51
|
-
* — a bootstrap, a framework's init — already owns that question, and
|
|
52
|
-
* answering it here as well would only take the choice away.
|
|
53
|
-
*/
|
|
54
|
-
export class ScheduleRunner<Entry = string, Context = unknown> {
|
|
55
|
-
private readonly d: Descriptor<Entry, Context>;
|
|
56
|
-
private readonly tasks = new Map<string, Task>();
|
|
57
|
-
|
|
58
|
-
private context!: Injected<Context, InjectMap>;
|
|
59
|
-
private parser!: TaskParser<Entry>;
|
|
60
|
-
private coordinator?: Coordinator;
|
|
61
|
-
private hook?: Hook;
|
|
62
|
-
|
|
63
|
-
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
64
|
-
private syncTimer: ReturnType<typeof setInterval> | null = null;
|
|
65
|
-
private inFlight = new Set<Promise<void>>();
|
|
66
|
-
|
|
67
|
-
private started = false;
|
|
68
|
-
private lastSyncAt: Date | null = null;
|
|
69
|
-
private lastSyncOk: boolean | null = null;
|
|
70
|
-
|
|
71
|
-
constructor(descriptor: Descriptor<Entry, Context>) {
|
|
72
|
-
this.d = descriptor;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async start(): Promise<void> {
|
|
76
|
-
// Idempotent on a single instance: starting twice must not produce two
|
|
77
|
-
// sets of timers. This says nothing about two separate instances.
|
|
78
|
-
if (this.started) return;
|
|
79
|
-
if (!this.d.source) throw new Error("Schedule: a source is required — call .source(...)");
|
|
80
|
-
if (!this.d.parser) throw new Error("Schedule: a task parser is required — call .task(...)");
|
|
81
|
-
|
|
82
|
-
this.started = true;
|
|
83
|
-
|
|
84
|
-
const context = {} as Record<string, unknown>;
|
|
85
|
-
for (const [name, Token] of Object.entries(this.d.injects)) context[name] = new Token();
|
|
86
|
-
this.context = context as Injected<Context, InjectMap>;
|
|
87
|
-
|
|
88
|
-
this.parser = new this.d.parser();
|
|
89
|
-
this.coordinator = this.d.coordinator && new this.d.coordinator();
|
|
90
|
-
this.hook = this.d.hook && new this.d.hook();
|
|
91
|
-
|
|
92
|
-
await this.sync();
|
|
93
|
-
|
|
94
|
-
this.tickTimer = setInterval(() => this.tick(), this.d.tickMs);
|
|
95
|
-
|
|
96
|
-
/* `false` means the source is read once, at start, and never reconciled
|
|
97
|
-
again — right for a task list baked into the deployment, wrong for one an
|
|
98
|
-
operator edits. The reconcile still happened above, so the tasks are
|
|
99
|
-
loaded either way. */
|
|
100
|
-
if (this.d.syncMs !== false) {
|
|
101
|
-
this.syncTimer = setInterval(() => {
|
|
102
|
-
this.sync().catch((error) => {
|
|
103
|
-
console.warn("[schedule] sync failed:", error);
|
|
104
|
-
});
|
|
105
|
-
}, this.d.syncMs);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Clears both timers and waits for runs already in flight. */
|
|
110
|
-
async stop(): Promise<void> {
|
|
111
|
-
if (this.tickTimer) clearInterval(this.tickTimer);
|
|
112
|
-
if (this.syncTimer) clearInterval(this.syncTimer);
|
|
113
|
-
this.tickTimer = this.syncTimer = null;
|
|
114
|
-
this.started = false;
|
|
115
|
-
|
|
116
|
-
await Promise.allSettled([...this.inFlight]);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
status(): ScheduleStatus {
|
|
120
|
-
return {
|
|
121
|
-
running: this.started,
|
|
122
|
-
lastSyncAt: this.lastSyncAt,
|
|
123
|
-
lastSyncOk: this.lastSyncOk,
|
|
124
|
-
tasks: [...this.tasks.values()].map((task) => task.status()),
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/* ------------------------------------------------------------ syncing */
|
|
129
|
-
|
|
130
|
-
private async read(): Promise<Entry[]> {
|
|
131
|
-
return new this.d.source!().read(this.context as Context);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Re-reads the source and reconciles.
|
|
136
|
-
*
|
|
137
|
-
* Two failure modes are kept apart on purpose. A source that throws leaves
|
|
138
|
-
* the schedule exactly as it was — a database blip is not an instruction to
|
|
139
|
-
* cancel everything. A source that returns nothing while tasks exist is
|
|
140
|
-
* treated the same way: it is far more likely to be a partial read than a
|
|
141
|
-
* deliberate deletion of every job at once, and getting that wrong wipes a
|
|
142
|
-
* schedule during an incident, which is the worst possible moment.
|
|
143
|
-
*/
|
|
144
|
-
async sync(): Promise<void> {
|
|
145
|
-
let entries: Entry[];
|
|
146
|
-
|
|
147
|
-
try {
|
|
148
|
-
entries = await this.read();
|
|
149
|
-
|
|
150
|
-
/* A source that answers with something other than a list is a broken
|
|
151
|
-
source, not an empty schedule. Checked here so it goes down the same
|
|
152
|
-
path as a failed read — report it, keep the tasks already running —
|
|
153
|
-
rather than throwing past this try and out of a timer callback. */
|
|
154
|
-
if (!Array.isArray(entries)) {
|
|
155
|
-
throw new Error(`source returned ${typeof entries}, expected an array`);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
this.lastSyncOk = true;
|
|
159
|
-
} catch (error) {
|
|
160
|
-
this.lastSyncOk = false;
|
|
161
|
-
this.lastSyncAt = new Date();
|
|
162
|
-
await this.report(`source read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
this.lastSyncAt = new Date();
|
|
167
|
-
|
|
168
|
-
const seen = new Set<string>();
|
|
169
|
-
const now = new Date();
|
|
170
|
-
|
|
171
|
-
for (const entry of entries) {
|
|
172
|
-
let definition: TaskDefinition;
|
|
173
|
-
|
|
174
|
-
try {
|
|
175
|
-
definition = this.parser.parse(entry);
|
|
176
|
-
} catch (error) {
|
|
177
|
-
await this.report(`unparseable entry: ${error instanceof Error ? error.message : String(error)}`);
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
seen.add(definition.key);
|
|
182
|
-
const existing = this.tasks.get(definition.key);
|
|
183
|
-
|
|
184
|
-
if (existing) existing.update(definition, now);
|
|
185
|
-
else this.tasks.set(definition.key, new Task(definition, now));
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (entries.length === 0 && this.tasks.size > 0) {
|
|
189
|
-
await this.report(`source returned nothing while ${this.tasks.size} tasks are live — keeping them`);
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
for (const [key, task] of this.tasks) {
|
|
194
|
-
if (seen.has(key)) continue;
|
|
195
|
-
if (this.d.cascade === "keep") continue;
|
|
196
|
-
|
|
197
|
-
task.nextAt = null;
|
|
198
|
-
|
|
199
|
-
// `drain` leaves the entry until the run in flight settles; the tick
|
|
200
|
-
// loop drops it once `running` clears.
|
|
201
|
-
if (this.d.cascade === "stop" || !task.running) this.tasks.delete(key);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/* ------------------------------------------------------------ running */
|
|
206
|
-
|
|
207
|
-
private tick(): void {
|
|
208
|
-
const now = new Date();
|
|
209
|
-
|
|
210
|
-
for (const [key, task] of this.tasks) {
|
|
211
|
-
// A drained task keeps its slot only until its run settles.
|
|
212
|
-
if (task.nextAt === null && !task.running) {
|
|
213
|
-
this.tasks.delete(key);
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (!task.due(now)) continue;
|
|
218
|
-
|
|
219
|
-
const run = this.run(task).finally(() => this.inFlight.delete(run));
|
|
220
|
-
this.inFlight.add(run);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
private async run(task: Task): Promise<void> {
|
|
225
|
-
// Advanced before the run, so a slow task does not drag its own schedule
|
|
226
|
-
// along behind it. Overlap is held off by `running`, not by the clock.
|
|
227
|
-
const scheduledFor = task.advance();
|
|
228
|
-
task.running = true;
|
|
229
|
-
|
|
230
|
-
try {
|
|
231
|
-
if (this.coordinator && !(await this.coordinator.claim(task.key, scheduledFor))) {
|
|
232
|
-
return; // another instance owns this fire
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const definition = task.current;
|
|
236
|
-
const attempts = (definition.retry ?? this.d.retry) + 1;
|
|
237
|
-
const timeout = definition.timeout ?? this.d.timeout;
|
|
238
|
-
|
|
239
|
-
let event!: TaskEvent;
|
|
240
|
-
|
|
241
|
-
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
242
|
-
const startedAt = new Date();
|
|
243
|
-
const outcome = await runTarget(definition, this.d.registry, this.context, timeout, this.d.notFound);
|
|
244
|
-
|
|
245
|
-
event = {
|
|
246
|
-
key: task.key,
|
|
247
|
-
scheduledFor,
|
|
248
|
-
startedAt,
|
|
249
|
-
durationMs: Date.now() - startedAt.getTime(),
|
|
250
|
-
attempt,
|
|
251
|
-
ok: outcome.ok,
|
|
252
|
-
reason: outcome.reason,
|
|
253
|
-
detail: outcome.detail,
|
|
254
|
-
raw: outcome.raw,
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
if (outcome.ok) break;
|
|
258
|
-
|
|
259
|
-
await this.d.onError?.(event, definition);
|
|
260
|
-
|
|
261
|
-
// Backoff between attempts: retrying a failing endpoint three times in
|
|
262
|
-
// the same millisecond is three failures, not three chances.
|
|
263
|
-
if (attempt < attempts) {
|
|
264
|
-
await new Promise((r) => setTimeout(r, Math.min(2 ** attempt * 1000, 30_000)));
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
task.record(event.ok, event.startedAt);
|
|
269
|
-
|
|
270
|
-
// A hook that throws or hangs must not take the run down with it — the
|
|
271
|
-
// work already happened, and reporting is a separate concern.
|
|
272
|
-
await this.safely(() => this.hook?.notify(event));
|
|
273
|
-
await this.safely(() => this.coordinator?.release(task.key, scheduledFor, event));
|
|
274
|
-
} finally {
|
|
275
|
-
task.running = false;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
private async safely(work: () => unknown): Promise<void> {
|
|
280
|
-
try {
|
|
281
|
-
await work();
|
|
282
|
-
} catch (error) {
|
|
283
|
-
console.warn("[schedule] reporting failed:", error);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
private async report(detail: string): Promise<void> {
|
|
288
|
-
console.warn(`[schedule] ${detail}`);
|
|
289
|
-
|
|
290
|
-
await this.safely(() =>
|
|
291
|
-
this.d.onError?.(
|
|
292
|
-
{ key: "@schedule", scheduledFor: new Date(), startedAt: new Date(), durationMs: 0, attempt: 1, ok: false, reason: "unknown", detail },
|
|
293
|
-
{ key: "@schedule", expression: "", target: { type: "handler", key: "@schedule" } },
|
|
294
|
-
),
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/* -------------------------------------------------------------- the chain */
|
|
300
|
-
|
|
301
|
-
export interface IScheduleBuilder<Entry = string, Context = unknown>
|
|
302
|
-
extends ClassType<ScheduleRunner<Entry, Context>> {
|
|
303
|
-
source<E>(source: Source<E, Context>): IScheduleBuilder<E, Context>;
|
|
304
|
-
task(parser: ClassType<TaskParser<Entry>>): IScheduleBuilder<Entry, Context>;
|
|
305
|
-
registry(registry: IRegistry<Context>): IScheduleBuilder<Entry, Context>;
|
|
306
|
-
coordinator(coordinator: ClassType<Coordinator>): IScheduleBuilder<Entry, Context>;
|
|
307
|
-
hook(hook: ClassType<Hook>): IScheduleBuilder<Entry, Context>;
|
|
308
|
-
retry(times: number): IScheduleBuilder<Entry, Context>;
|
|
309
|
-
timeout(ms: number): IScheduleBuilder<Entry, Context>;
|
|
310
|
-
/**
|
|
311
|
-
* Whether, and how often, the source is re-read and reconciled against the
|
|
312
|
-
* tasks already running: a source entry that is new becomes a task, one that
|
|
313
|
-
* changed is updated, one that disappeared is handled by `cascade`.
|
|
314
|
-
*
|
|
315
|
-
* `false` turns the repeat off — read once at start and never again.
|
|
316
|
-
* `true` uses the default interval. A number sets it.
|
|
317
|
-
*/
|
|
318
|
-
sync(every: number | boolean): IScheduleBuilder<Entry, Context>;
|
|
319
|
-
tick(ms: number): IScheduleBuilder<Entry, Context>;
|
|
320
|
-
cascade(policy: CascadePolicy): IScheduleBuilder<Entry, Context>;
|
|
321
|
-
onError(handler: ErrorHandler): IScheduleBuilder<Entry, Context>;
|
|
322
|
-
notFound(handler: NotFoundHandler): IScheduleBuilder<Entry, Context>;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function chain<Entry, Context>(d: Descriptor<Entry, Context>): IScheduleBuilder<Entry, Context> {
|
|
326
|
-
// Each step returns a new class rather than mutating one, so a
|
|
327
|
-
// half-configured chain can be shared as a base and branched.
|
|
328
|
-
const step = <E>(patch: Partial<Descriptor<E, Context>>) =>
|
|
329
|
-
chain({ ...(d as unknown as Descriptor<E, Context>), ...patch });
|
|
330
|
-
|
|
331
|
-
return class extends ScheduleRunner<Entry, Context> {
|
|
332
|
-
constructor() {
|
|
333
|
-
super(d);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
static source<E>(source: Source<E, Context>) { return step<E>({ source }); }
|
|
337
|
-
static task(parser: ClassType<TaskParser<Entry>>) { return step<Entry>({ parser }); }
|
|
338
|
-
static registry(registry: IRegistry<Context>) { return step<Entry>({ registry }); }
|
|
339
|
-
static coordinator(coordinator: ClassType<Coordinator>) { return step<Entry>({ coordinator }); }
|
|
340
|
-
static hook(hook: ClassType<Hook>) { return step<Entry>({ hook }); }
|
|
341
|
-
static retry(times: number) { return step<Entry>({ retry: times }); }
|
|
342
|
-
static timeout(ms: number) { return step<Entry>({ timeout: ms }); }
|
|
343
|
-
static sync(every: number | boolean) {
|
|
344
|
-
const syncMs = every === true ? DEFAULT_SYNC : every;
|
|
345
|
-
return step<Entry>({ syncMs });
|
|
346
|
-
}
|
|
347
|
-
static tick(ms: number) { return step<Entry>({ tickMs: ms }); }
|
|
348
|
-
static cascade(policy: CascadePolicy) { return step<Entry>({ cascade: policy }); }
|
|
349
|
-
static onError(handler: ErrorHandler) { return step<Entry>({ onError: handler }); }
|
|
350
|
-
static notFound(handler: NotFoundHandler) { return step<Entry>({ notFound: handler }); }
|
|
351
|
-
} as unknown as IScheduleBuilder<Entry, Context>;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Chain-style configuration for a scheduler.
|
|
356
|
-
*
|
|
357
|
-
* ```ts
|
|
358
|
-
* export const AppSchedule = Schedule({ db: DataSource })
|
|
359
|
-
* .source((ctx) => ctx.db.query("select * from crons"))
|
|
360
|
-
* .task(RowParser)
|
|
361
|
-
* .registry(Registry.add(Registry("session.cleanup", (ctx) => ...)))
|
|
362
|
-
* .coordinator(PgCoordinator)
|
|
363
|
-
* .hook(Hook.combine(LoggerHook, TelegramHook))
|
|
364
|
-
* .retry(2)
|
|
365
|
-
* .sync(30_000)
|
|
366
|
-
* .cascade("drain");
|
|
367
|
-
*
|
|
368
|
-
* const schedule = new AppSchedule();
|
|
369
|
-
* await schedule.start();
|
|
370
|
-
* ```
|
|
371
|
-
*
|
|
372
|
-
* The chain is the class — there is no terminal to call, and whoever holds the
|
|
373
|
-
* instance decides when it starts and stops.
|
|
374
|
-
*/
|
|
375
|
-
export function Schedule<Injects extends InjectMap = Record<string, never>>(
|
|
376
|
-
injects: Injects = {} as Injects,
|
|
377
|
-
): IScheduleBuilder<string, Injected<unknown, Injects>> {
|
|
378
|
-
return chain({
|
|
379
|
-
injects,
|
|
380
|
-
registry: Registry.empty(),
|
|
381
|
-
retry: DEFAULT_RETRY,
|
|
382
|
-
timeout: DEFAULT_TIMEOUT,
|
|
383
|
-
syncMs: DEFAULT_SYNC,
|
|
384
|
-
tickMs: DEFAULT_TICK,
|
|
385
|
-
cascade: "drain",
|
|
386
|
-
});
|
|
387
|
-
}
|
package/src/source/index.ts
DELETED
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import type { ClassType, Promisable, SourceAdapter, TaskDefinition, TaskParser, TaskTarget } from "../types";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Built-in sources and the line format they produce.
|
|
5
|
-
*
|
|
6
|
-
* Only local files and HTTP: sharing a file between instances behind a load
|
|
7
|
-
* balancer is a mount, not a library concern.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Wraps a reader function into a source class.
|
|
12
|
-
*
|
|
13
|
-
* `.source()` takes a class, not a callback, so that nothing in the scheduler
|
|
14
|
-
* has to work out at runtime what it was handed. This keeps the one-line case
|
|
15
|
-
* one line without reintroducing that ambiguity.
|
|
16
|
-
*/
|
|
17
|
-
export function Source<Entry = string, Context = unknown>(
|
|
18
|
-
read: (context: Context) => Promisable<Entry[]>,
|
|
19
|
-
): ClassType<SourceAdapter<Entry, Context>> {
|
|
20
|
-
return class implements SourceAdapter<Entry, Context> {
|
|
21
|
-
read(context: Context) {
|
|
22
|
-
return read(context);
|
|
23
|
-
}
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Drops comments and blank lines. Everything else is a task. */
|
|
28
|
-
function lines(text: string): string[] {
|
|
29
|
-
return text
|
|
30
|
-
.split("\n")
|
|
31
|
-
.map((line) => line.trim())
|
|
32
|
-
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function FileSource(path: string): ClassType<SourceAdapter<string, unknown>> {
|
|
36
|
-
return class implements SourceAdapter<string, unknown> {
|
|
37
|
-
async read(): Promise<string[]> {
|
|
38
|
-
const { readFile } = await import("node:fs/promises");
|
|
39
|
-
return lines(await readFile(path, "utf-8"));
|
|
40
|
-
}
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function HttpSource(url: string, init?: RequestInit): ClassType<SourceAdapter<string, unknown>> {
|
|
45
|
-
return class implements SourceAdapter<string, unknown> {
|
|
46
|
-
async read(): Promise<string[]> {
|
|
47
|
-
const response = await fetch(url, init);
|
|
48
|
-
|
|
49
|
-
// Thrown, not swallowed: the scheduler treats a failed read as "keep
|
|
50
|
-
// what we have", and it can only do that if it hears about the failure.
|
|
51
|
-
if (!response.ok) throw new Error(`HttpSource: ${url} answered ${response.status}`);
|
|
52
|
-
|
|
53
|
-
return lines(await response.text());
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/* ------------------------------------------------------------ line format */
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Crontab-shaped, one task per line:
|
|
62
|
-
*
|
|
63
|
-
* ```
|
|
64
|
-
* # every five minutes
|
|
65
|
-
* 0 *\/5 * * * * handler:session.cleanup
|
|
66
|
-
* 0 0 3 * * * api:https://app/internal/report tz=Asia/Ho_Chi_Minh retry=2
|
|
67
|
-
* 0 0 4 * * * file:./scripts/rollup.js name=nightly-rollup
|
|
68
|
-
* ```
|
|
69
|
-
*
|
|
70
|
-
* The target doubles as the key, since one target on one schedule is the usual
|
|
71
|
-
* case. `name=` overrides it, which is what you need to run the same handler on
|
|
72
|
-
* two different expressions.
|
|
73
|
-
*/
|
|
74
|
-
export class LineParser implements TaskParser<string> {
|
|
75
|
-
parse(line: string): TaskDefinition {
|
|
76
|
-
const tokens = line.split(/\s+/);
|
|
77
|
-
|
|
78
|
-
// Five cron fields or six, then the target: count from the target
|
|
79
|
-
// backwards would be ambiguous, so find it by its `kind:` prefix.
|
|
80
|
-
const targetAt = tokens.findIndex((token) => /^(handler|api|file):/.test(token));
|
|
81
|
-
|
|
82
|
-
if (targetAt < 5 || targetAt > 6) {
|
|
83
|
-
throw new Error(`LineParser: expected a handler:/api:/file: target after 5 or 6 cron fields — "${line}"`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const expression = tokens.slice(0, targetAt).join(" ");
|
|
87
|
-
const target = this.target(tokens[targetAt], line);
|
|
88
|
-
const options = this.options(tokens.slice(targetAt + 1));
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
key: options.name ?? tokens[targetAt],
|
|
92
|
-
expression,
|
|
93
|
-
target,
|
|
94
|
-
timezone: options.tz,
|
|
95
|
-
enabled: options.enabled !== "false",
|
|
96
|
-
retry: options.retry === undefined ? undefined : Number(options.retry),
|
|
97
|
-
timeout: options.timeout === undefined ? undefined : Number(options.timeout),
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
private target(token: string, line: string): TaskTarget {
|
|
102
|
-
const at = token.indexOf(":");
|
|
103
|
-
const kind = token.slice(0, at);
|
|
104
|
-
const rest = token.slice(at + 1);
|
|
105
|
-
|
|
106
|
-
if (!rest) throw new Error(`LineParser: "${kind}:" has no value — "${line}"`);
|
|
107
|
-
|
|
108
|
-
switch (kind) {
|
|
109
|
-
case "handler": return { type: "handler", key: rest };
|
|
110
|
-
case "api": return { type: "api", url: rest };
|
|
111
|
-
case "file": return { type: "file", path: rest };
|
|
112
|
-
default: throw new Error(`LineParser: unknown target "${kind}" — "${line}"`);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
private options(tokens: string[]): Record<string, string | undefined> {
|
|
117
|
-
const options: Record<string, string> = {};
|
|
118
|
-
|
|
119
|
-
for (const token of tokens) {
|
|
120
|
-
const at = token.indexOf("=");
|
|
121
|
-
if (at > 0) options[token.slice(0, at)] = token.slice(at + 1);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return options;
|
|
125
|
-
}
|
|
126
|
-
}
|