@ecosy/schedule 0.1.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 +1 -0
- package/package.json +41 -0
- package/src/cron.ts +368 -0
- package/src/hook/index.ts +101 -0
- package/src/index.ts +6 -0
- package/src/registry.ts +100 -0
- package/src/runner.ts +150 -0
- package/src/schedule.ts +387 -0
- package/src/source/index.ts +126 -0
- package/src/task.ts +124 -0
- package/src/types.ts +147 -0
- package/tsconfig.json +23 -0
package/src/task.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { nextFire, parseCron, type CronExpression } from "./cron";
|
|
2
|
+
import type { TaskDefinition } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface TaskStatus {
|
|
5
|
+
key: string;
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
expression: string;
|
|
8
|
+
timezone: string;
|
|
9
|
+
nextAt: Date | null;
|
|
10
|
+
running: boolean;
|
|
11
|
+
lastRunAt: Date | null;
|
|
12
|
+
lastOk: boolean | null;
|
|
13
|
+
runs: number;
|
|
14
|
+
failures: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One scheduled task: its definition, when it fires next, and how it has been
|
|
19
|
+
* doing.
|
|
20
|
+
*
|
|
21
|
+
* The split between the two matters at sync time. A definition that changed
|
|
22
|
+
* gets a freshly computed schedule — the new expression decides the next fire,
|
|
23
|
+
* not an adjustment of the old one — while the history carries over, because
|
|
24
|
+
* "when did this last succeed" is the question people ask after an edit, not
|
|
25
|
+
* before it.
|
|
26
|
+
*/
|
|
27
|
+
export class Task {
|
|
28
|
+
readonly key: string;
|
|
29
|
+
|
|
30
|
+
private definition: TaskDefinition;
|
|
31
|
+
private cron: CronExpression;
|
|
32
|
+
|
|
33
|
+
nextAt: Date | null = null;
|
|
34
|
+
running = false;
|
|
35
|
+
|
|
36
|
+
lastRunAt: Date | null = null;
|
|
37
|
+
lastOk: boolean | null = null;
|
|
38
|
+
runs = 0;
|
|
39
|
+
failures = 0;
|
|
40
|
+
|
|
41
|
+
constructor(definition: TaskDefinition, from: Date = new Date()) {
|
|
42
|
+
this.key = definition.key;
|
|
43
|
+
this.definition = definition;
|
|
44
|
+
this.cron = parseCron(definition.expression);
|
|
45
|
+
this.schedule(from);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get enabled(): boolean {
|
|
49
|
+
return this.definition.enabled !== false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get timezone(): string {
|
|
53
|
+
return this.definition.timezone ?? "UTC";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get current(): TaskDefinition {
|
|
57
|
+
return this.definition;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Recomputes the next fire from scratch. History is untouched. */
|
|
61
|
+
private schedule(from: Date): void {
|
|
62
|
+
this.nextAt = this.enabled ? nextFire(this.cron, from, this.timezone) : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Applies a changed definition.
|
|
67
|
+
*
|
|
68
|
+
* Only reparses when the expression or zone actually moved: reparsing on
|
|
69
|
+
* every sync would reset `nextAt` each time and, for anything firing less
|
|
70
|
+
* often than the sync interval, push the fire permanently into the future.
|
|
71
|
+
*/
|
|
72
|
+
update(definition: TaskDefinition, from: Date = new Date()): void {
|
|
73
|
+
const rescheduled =
|
|
74
|
+
definition.expression !== this.definition.expression ||
|
|
75
|
+
definition.timezone !== this.definition.timezone ||
|
|
76
|
+
(definition.enabled !== false) !== this.enabled;
|
|
77
|
+
|
|
78
|
+
this.definition = definition;
|
|
79
|
+
|
|
80
|
+
if (rescheduled) {
|
|
81
|
+
this.cron = parseCron(definition.expression);
|
|
82
|
+
this.schedule(from);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
due(now: Date): boolean {
|
|
87
|
+
return this.enabled && !this.running && this.nextAt !== null && this.nextAt <= now;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Moves to the fire after this one.
|
|
92
|
+
*
|
|
93
|
+
* Called the moment a run is picked up, before it finishes, so a task that
|
|
94
|
+
* takes longer than its interval does not push its own schedule along behind
|
|
95
|
+
* it. Overlap is prevented by `running`, not by delaying the clock.
|
|
96
|
+
*/
|
|
97
|
+
advance(): Date {
|
|
98
|
+
const fired = this.nextAt ?? new Date();
|
|
99
|
+
this.nextAt = nextFire(this.cron, fired, this.timezone);
|
|
100
|
+
return fired;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
record(ok: boolean, at: Date): void {
|
|
104
|
+
this.runs++;
|
|
105
|
+
if (!ok) this.failures++;
|
|
106
|
+
this.lastRunAt = at;
|
|
107
|
+
this.lastOk = ok;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
status(): TaskStatus {
|
|
111
|
+
return {
|
|
112
|
+
key: this.key,
|
|
113
|
+
enabled: this.enabled,
|
|
114
|
+
expression: this.definition.expression,
|
|
115
|
+
timezone: this.timezone,
|
|
116
|
+
nextAt: this.nextAt,
|
|
117
|
+
running: this.running,
|
|
118
|
+
lastRunAt: this.lastRunAt,
|
|
119
|
+
lastOk: this.lastOk,
|
|
120
|
+
runs: this.runs,
|
|
121
|
+
failures: this.failures,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the scheduler needs from the world.
|
|
3
|
+
*
|
|
4
|
+
* One thing is required — a source, because nothing can guess where task
|
|
5
|
+
* definitions live. Everything else is optional and has a defined behaviour
|
|
6
|
+
* when absent, so a scheduler with just a source and a registry is a complete,
|
|
7
|
+
* working single-instance scheduler.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A class constructible with no arguments.
|
|
12
|
+
*
|
|
13
|
+
* The single currency of this package: anything the scheduler builds for you —
|
|
14
|
+
* a source, a parser, a coordinator, a hook, a handler — arrives as one of
|
|
15
|
+
* these. Configuration that a constructor would need is captured by a factory
|
|
16
|
+
* that hands back a class, the way `DiskCache(dir)` and `Source(fn)` do.
|
|
17
|
+
*
|
|
18
|
+
* One currency is why there is no `typeof x === "function"` check anywhere in
|
|
19
|
+
* here: nothing has to work out at runtime what it was given.
|
|
20
|
+
*/
|
|
21
|
+
export type ClassType<Instance = unknown> = new () => Instance;
|
|
22
|
+
|
|
23
|
+
export type InjectMap = Record<string, ClassType>;
|
|
24
|
+
|
|
25
|
+
export type Injected<Context, Injects extends InjectMap> = Context & {
|
|
26
|
+
[K in keyof Injects]: Injects[K] extends ClassType<infer Instance> ? Instance : never;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type Promisable<T> = T | Promise<T>;
|
|
30
|
+
|
|
31
|
+
/* ------------------------------------------------------------ task shape */
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What a task does when it fires.
|
|
35
|
+
*
|
|
36
|
+
* Three kinds, and they fail in genuinely different ways — see `TaskFailure`.
|
|
37
|
+
*/
|
|
38
|
+
export type TaskTarget =
|
|
39
|
+
/** A function registered in code. Fast and typed; cannot be interrupted. */
|
|
40
|
+
| { type: "handler"; key: string }
|
|
41
|
+
/** An HTTP call. Works across instances; the request can be abandoned but the server keeps going. */
|
|
42
|
+
| { type: "api"; url: string; method?: string; headers?: Record<string, string>; body?: string }
|
|
43
|
+
/** A child process. The only kind that can genuinely be killed. */
|
|
44
|
+
| { type: "file"; path: string; args?: string[] };
|
|
45
|
+
|
|
46
|
+
/** One task, after a parser has read it out of whatever the source returned. */
|
|
47
|
+
export interface TaskDefinition {
|
|
48
|
+
/** Unique. Used for the registry lookup, for coordination, and as the sync identity. */
|
|
49
|
+
key: string;
|
|
50
|
+
/** Six-field cron, or five for standard crontab. */
|
|
51
|
+
expression: string;
|
|
52
|
+
target: TaskTarget;
|
|
53
|
+
/** IANA zone. Defaults to UTC — never the host zone, which differs between machines. */
|
|
54
|
+
timezone?: string;
|
|
55
|
+
/** False keeps the task known but unscheduled, so its history survives a pause. */
|
|
56
|
+
enabled?: boolean;
|
|
57
|
+
/** Overrides the schedule-level default. */
|
|
58
|
+
retry?: number;
|
|
59
|
+
/** Milliseconds. Overrides the schedule-level default. */
|
|
60
|
+
timeout?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Turns one entry from a source into a task. Implement this to accept your own format. */
|
|
64
|
+
export interface TaskParser<Entry = string> {
|
|
65
|
+
parse(entry: Entry): TaskDefinition;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* -------------------------------------------------------------- outcomes */
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Why a run failed, normalised across the three target kinds.
|
|
72
|
+
*
|
|
73
|
+
* Without this every consumer of a hook would branch on target type to find
|
|
74
|
+
* out what went wrong. `raw` keeps the original for anyone who needs it.
|
|
75
|
+
*/
|
|
76
|
+
export type TaskFailure =
|
|
77
|
+
| "throw" // handler threw
|
|
78
|
+
| "status" // api answered outside 2xx
|
|
79
|
+
| "timeout" // deadline passed
|
|
80
|
+
| "exit" // file exited non-zero, or was killed
|
|
81
|
+
| "unknown";
|
|
82
|
+
|
|
83
|
+
export interface TaskEvent {
|
|
84
|
+
key: string;
|
|
85
|
+
/** The fire time this run belongs to, computed from the expression — not `now`. */
|
|
86
|
+
scheduledFor: Date;
|
|
87
|
+
startedAt: Date;
|
|
88
|
+
durationMs: number;
|
|
89
|
+
/** 1 for the first try. */
|
|
90
|
+
attempt: number;
|
|
91
|
+
ok: boolean;
|
|
92
|
+
reason?: TaskFailure;
|
|
93
|
+
detail?: string;
|
|
94
|
+
/** Whatever the target produced: return value, response body, stdout. */
|
|
95
|
+
raw?: unknown;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ----------------------------------------------------------------- ports */
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Where task definitions come from. Required.
|
|
102
|
+
*
|
|
103
|
+
* A class, not a callback — everything the scheduler constructs is a class, so
|
|
104
|
+
* there is one currency and nothing has to guess at runtime what it was handed.
|
|
105
|
+
* `Source(fn)` wraps a one-line reader into one, so brevity costs nothing.
|
|
106
|
+
*/
|
|
107
|
+
export interface SourceAdapter<Entry = string, Context = unknown> {
|
|
108
|
+
read(context: Context): Promisable<Entry[]>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type Source<Entry = string, Context = unknown> = ClassType<SourceAdapter<Entry, Context>>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Decides which instance runs a given fire. Optional.
|
|
115
|
+
*
|
|
116
|
+
* Without one every instance runs everything, which is correct for a single
|
|
117
|
+
* process and wrong the moment there are two. Deliberately separate from the
|
|
118
|
+
* source: a file-backed schedule behind a load balancer still needs
|
|
119
|
+
* coordination, and a database-backed one on a single box does not.
|
|
120
|
+
*
|
|
121
|
+
* `claim` must be atomic across instances. A unique constraint on
|
|
122
|
+
* `(key, scheduledFor)` is a stronger guarantee than a distributed lock, and
|
|
123
|
+
* it leaves a run history behind for free.
|
|
124
|
+
*/
|
|
125
|
+
export interface Coordinator {
|
|
126
|
+
/** True when this instance won the right to run. False when another already has it. */
|
|
127
|
+
claim(key: string, scheduledFor: Date): Promisable<boolean>;
|
|
128
|
+
/** Called once the run ends, so an abandoned claim can be told from a live one. */
|
|
129
|
+
release(key: string, scheduledFor: Date, event: TaskEvent): Promisable<void>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Where run outcomes go. Optional — without one, a finished task reports
|
|
134
|
+
* nowhere.
|
|
135
|
+
*
|
|
136
|
+
* One hook, not a list: `combine` turns several into one, so nothing
|
|
137
|
+
* downstream ever branches on how many there are.
|
|
138
|
+
*/
|
|
139
|
+
export interface Hook {
|
|
140
|
+
notify(event: TaskEvent): Promisable<void>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Called when a source entry names a handler that was never registered. */
|
|
144
|
+
export type NotFoundHandler = (key: string, task: TaskDefinition) => Promisable<void>;
|
|
145
|
+
|
|
146
|
+
/** Called when a run fails, before any retry decision. */
|
|
147
|
+
export type ErrorHandler = (event: TaskEvent, task: TaskDefinition) => Promisable<void>;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "ESNext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"declaration": true,
|
|
16
|
+
"declarationMap": true,
|
|
17
|
+
"sourceMap": true,
|
|
18
|
+
"rootDir": "./src",
|
|
19
|
+
"outDir": "./dist"
|
|
20
|
+
},
|
|
21
|
+
"include": ["src/**/*"],
|
|
22
|
+
"exclude": ["node_modules", "dist"]
|
|
23
|
+
}
|