@voyant-travel/workflows-orchestrator 0.107.10
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 +201 -0
- package/NOTICE +52 -0
- package/README.md +76 -0
- package/dist/abort-registry.d.ts +6 -0
- package/dist/abort-registry.d.ts.map +1 -0
- package/dist/abort-registry.js +37 -0
- package/dist/concurrency.d.ts +31 -0
- package/dist/concurrency.d.ts.map +1 -0
- package/dist/concurrency.js +145 -0
- package/dist/drive.d.ts +67 -0
- package/dist/drive.d.ts.map +1 -0
- package/dist/drive.js +373 -0
- package/dist/driver-inmemory.d.ts +30 -0
- package/dist/driver-inmemory.d.ts.map +1 -0
- package/dist/driver-inmemory.js +394 -0
- package/dist/event-router.d.ts +51 -0
- package/dist/event-router.d.ts.map +1 -0
- package/dist/event-router.js +68 -0
- package/dist/http-step-handler.d.ts +25 -0
- package/dist/http-step-handler.d.ts.map +1 -0
- package/dist/http-step-handler.js +78 -0
- package/dist/in-memory-store.d.ts +5 -0
- package/dist/in-memory-store.d.ts.map +1 -0
- package/dist/in-memory-store.js +41 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/journal-helpers.d.ts +3 -0
- package/dist/journal-helpers.d.ts.map +1 -0
- package/dist/journal-helpers.js +9 -0
- package/dist/orchestrator.d.ts +116 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +411 -0
- package/dist/resume-run.d.ts +40 -0
- package/dist/resume-run.d.ts.map +1 -0
- package/dist/resume-run.js +119 -0
- package/dist/schedule.d.ts +51 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +243 -0
- package/dist/testing/driver-compliance.d.ts +58 -0
- package/dist/testing/driver-compliance.d.ts.map +1 -0
- package/dist/testing/driver-compliance.js +667 -0
- package/dist/types.d.ts +182 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/package.json +51 -0
- package/src/__tests__/orchestrator-test-support.ts +18 -0
- package/src/abort-registry.ts +41 -0
- package/src/concurrency.ts +217 -0
- package/src/drive.ts +477 -0
- package/src/driver-inmemory.ts +511 -0
- package/src/event-router.ts +120 -0
- package/src/http-step-handler.ts +112 -0
- package/src/in-memory-store.ts +44 -0
- package/src/index.ts +73 -0
- package/src/journal-helpers.ts +11 -0
- package/src/orchestrator.ts +527 -0
- package/src/resume-run.ts +162 -0
- package/src/schedule.ts +310 -0
- package/src/testing/driver-compliance.ts +800 -0
- package/src/types.ts +201 -0
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import type { Duration, EnvironmentName, ScheduleDeclaration } from "@voyant-travel/workflows"
|
|
2
|
+
import type { ManifestSchedule, WorkflowManifest } from "@voyant-travel/workflows/protocol"
|
|
3
|
+
|
|
4
|
+
export type SchedulableDeclaration = ScheduleDeclaration | ManifestSchedule
|
|
5
|
+
|
|
6
|
+
export interface ScheduleSource {
|
|
7
|
+
id?: string
|
|
8
|
+
workflowId: string
|
|
9
|
+
decl: SchedulableDeclaration
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SchedulerDeps {
|
|
13
|
+
sources: readonly ScheduleSource[]
|
|
14
|
+
onFire: (args: {
|
|
15
|
+
workflowId: string
|
|
16
|
+
input: unknown
|
|
17
|
+
scheduleId: string
|
|
18
|
+
scheduleName?: string
|
|
19
|
+
fireAt: number
|
|
20
|
+
}) => Promise<void>
|
|
21
|
+
now?: () => number
|
|
22
|
+
environment?: EnvironmentName
|
|
23
|
+
tickMs?: number
|
|
24
|
+
setInterval?: typeof setInterval
|
|
25
|
+
clearInterval?: typeof clearInterval
|
|
26
|
+
logger?: (level: "info" | "warn" | "error", msg: string, data?: object) => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SchedulerHandle {
|
|
30
|
+
start: () => void
|
|
31
|
+
stop: () => void
|
|
32
|
+
tick: () => Promise<void>
|
|
33
|
+
nextFirings: () => {
|
|
34
|
+
workflowId: string
|
|
35
|
+
scheduleId: string
|
|
36
|
+
name?: string
|
|
37
|
+
nextAt: number
|
|
38
|
+
done: boolean
|
|
39
|
+
}[]
|
|
40
|
+
sourceCount: () => number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function unrefTimer(timer: unknown): void {
|
|
44
|
+
if (
|
|
45
|
+
typeof timer === "object" &&
|
|
46
|
+
timer !== null &&
|
|
47
|
+
"unref" in timer &&
|
|
48
|
+
typeof timer.unref === "function"
|
|
49
|
+
) {
|
|
50
|
+
timer.unref()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface SourceState {
|
|
55
|
+
source: ScheduleSource
|
|
56
|
+
scheduleId: string
|
|
57
|
+
nextAt: number
|
|
58
|
+
done: boolean
|
|
59
|
+
inFlight: boolean
|
|
60
|
+
queued: QueuedFire[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface QueuedFire {
|
|
64
|
+
fireAt: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function manifestScheduleSources(manifest: WorkflowManifest): ScheduleSource[] {
|
|
68
|
+
const sources: ScheduleSource[] = []
|
|
69
|
+
for (const workflow of manifest.workflows) {
|
|
70
|
+
workflow.schedules.forEach((decl, index) => {
|
|
71
|
+
sources.push({
|
|
72
|
+
id: `${manifest.versionId}:${workflow.id}:${decl.name ?? index}`,
|
|
73
|
+
workflowId: workflow.id,
|
|
74
|
+
decl,
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
return sources
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createScheduler(deps: SchedulerDeps): SchedulerHandle {
|
|
82
|
+
const now = deps.now ?? (() => Date.now())
|
|
83
|
+
const tickMs = deps.tickMs ?? 1_000
|
|
84
|
+
const setInt = deps.setInterval ?? setInterval
|
|
85
|
+
const clearInt = deps.clearInterval ?? clearInterval
|
|
86
|
+
const env = deps.environment ?? "development"
|
|
87
|
+
const log = deps.logger ?? (() => {})
|
|
88
|
+
|
|
89
|
+
const states: SourceState[] = []
|
|
90
|
+
for (const [index, source] of deps.sources.entries()) {
|
|
91
|
+
if (source.decl.enabled === false) continue
|
|
92
|
+
if (source.decl.environments && !source.decl.environments.includes(env)) continue
|
|
93
|
+
let firstAt: number
|
|
94
|
+
try {
|
|
95
|
+
firstAt = computeNextFire(source.decl, now())
|
|
96
|
+
} catch (err) {
|
|
97
|
+
log("warn", `scheduler: skipping source for workflow "${source.workflowId}": ${String(err)}`)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
states.push({
|
|
101
|
+
source,
|
|
102
|
+
scheduleId: source.id ?? `${source.workflowId}:${source.decl.name ?? index}`,
|
|
103
|
+
nextAt: firstAt,
|
|
104
|
+
done: false,
|
|
105
|
+
inFlight: false,
|
|
106
|
+
queued: [],
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
111
|
+
|
|
112
|
+
const advanceAfterFire = (state: SourceState, firedAt: number): void => {
|
|
113
|
+
if ("at" in state.source.decl) {
|
|
114
|
+
state.done = true
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
state.nextAt = computeNextFire(state.source.decl, firedAt)
|
|
119
|
+
} catch (err) {
|
|
120
|
+
log(
|
|
121
|
+
"error",
|
|
122
|
+
`scheduler: cannot compute next fire for "${state.source.workflowId}": ${String(err)}`,
|
|
123
|
+
)
|
|
124
|
+
state.done = true
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const fire = async (state: SourceState, fireAt: number): Promise<void> => {
|
|
129
|
+
try {
|
|
130
|
+
const input = await resolveInput(state.source.decl.input)
|
|
131
|
+
await deps.onFire({
|
|
132
|
+
workflowId: state.source.workflowId,
|
|
133
|
+
input,
|
|
134
|
+
scheduleId: state.scheduleId,
|
|
135
|
+
scheduleName: state.source.decl.name,
|
|
136
|
+
fireAt,
|
|
137
|
+
})
|
|
138
|
+
} catch (err) {
|
|
139
|
+
log("error", `scheduler: onFire threw for "${state.source.workflowId}": ${String(err)}`)
|
|
140
|
+
} finally {
|
|
141
|
+
const next = state.queued.shift()
|
|
142
|
+
if (next) {
|
|
143
|
+
void fire(state, next.fireAt)
|
|
144
|
+
} else {
|
|
145
|
+
state.inFlight = false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const doTick = async (): Promise<void> => {
|
|
151
|
+
const t = now()
|
|
152
|
+
const ready = states.filter((state) => !state.done && state.nextAt <= t)
|
|
153
|
+
for (const state of ready) {
|
|
154
|
+
const overlap = state.source.decl.overlap ?? "skip"
|
|
155
|
+
if (state.inFlight && overlap === "skip") continue
|
|
156
|
+
const fireAt = state.nextAt
|
|
157
|
+
if (state.inFlight && overlap === "queue") {
|
|
158
|
+
state.queued.push({ fireAt })
|
|
159
|
+
advanceAfterFire(state, t)
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
state.inFlight = true
|
|
163
|
+
const firePromise = fire(state, fireAt)
|
|
164
|
+
advanceAfterFire(state, t)
|
|
165
|
+
if (overlap !== "allow") await firePromise
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
start() {
|
|
171
|
+
if (timer) return
|
|
172
|
+
timer = setInt(() => {
|
|
173
|
+
doTick().catch(() => {})
|
|
174
|
+
}, tickMs)
|
|
175
|
+
unrefTimer(timer)
|
|
176
|
+
},
|
|
177
|
+
stop() {
|
|
178
|
+
if (!timer) return
|
|
179
|
+
clearInt(timer)
|
|
180
|
+
timer = undefined
|
|
181
|
+
},
|
|
182
|
+
tick: doTick,
|
|
183
|
+
nextFirings() {
|
|
184
|
+
return states.map((state) => ({
|
|
185
|
+
workflowId: state.source.workflowId,
|
|
186
|
+
scheduleId: state.scheduleId,
|
|
187
|
+
name: state.source.decl.name,
|
|
188
|
+
nextAt: state.nextAt,
|
|
189
|
+
done: state.done,
|
|
190
|
+
}))
|
|
191
|
+
},
|
|
192
|
+
sourceCount() {
|
|
193
|
+
return states.length
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function computeNextFire(decl: SchedulableDeclaration, fromMs: number): number {
|
|
199
|
+
if ("cron" in decl && decl.cron !== undefined) return nextCronFire(parseCron(decl.cron), fromMs)
|
|
200
|
+
if ("every" in decl && decl.every !== undefined) return fromMs + toMs(decl.every)
|
|
201
|
+
if ("at" in decl && decl.at !== undefined) {
|
|
202
|
+
const at = typeof decl.at === "string" ? Date.parse(decl.at) : decl.at.getTime()
|
|
203
|
+
if (!Number.isFinite(at)) throw new Error(`invalid "at" value: ${String(decl.at)}`)
|
|
204
|
+
return at < fromMs ? Number.POSITIVE_INFINITY : at
|
|
205
|
+
}
|
|
206
|
+
throw new Error(`schedule declaration missing one of cron/every/at`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface CronSpec {
|
|
210
|
+
minute: number[]
|
|
211
|
+
hour: number[]
|
|
212
|
+
day: number[]
|
|
213
|
+
month: number[]
|
|
214
|
+
dow: number[]
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function parseCron(expr: string): CronSpec {
|
|
218
|
+
const parts = expr.trim().split(/\s+/)
|
|
219
|
+
if (parts.length !== 5) {
|
|
220
|
+
throw new Error(`invalid cron "${expr}" - expected 5 fields (minute hour day month dow)`)
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
minute: parseField(parts[0]!, 0, 59, "minute"),
|
|
224
|
+
hour: parseField(parts[1]!, 0, 23, "hour"),
|
|
225
|
+
day: parseField(parts[2]!, 1, 31, "day"),
|
|
226
|
+
month: parseField(parts[3]!, 1, 12, "month"),
|
|
227
|
+
dow: parseField(parts[4]!, 0, 6, "dow"),
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseField(f: string, min: number, max: number, label: string): number[] {
|
|
232
|
+
const out = new Set<number>()
|
|
233
|
+
for (const part of f.split(",")) {
|
|
234
|
+
const stepMatch = /^(.+)\/(\d+)$/.exec(part)
|
|
235
|
+
const body = stepMatch ? stepMatch[1]! : part
|
|
236
|
+
const step = stepMatch ? Number(stepMatch[2]) : 1
|
|
237
|
+
if (!(step >= 1)) throw new Error(`cron ${label} step must be >=1 in "${f}"`)
|
|
238
|
+
let lo: number
|
|
239
|
+
let hi: number
|
|
240
|
+
if (body === "*") {
|
|
241
|
+
lo = min
|
|
242
|
+
hi = max
|
|
243
|
+
} else if (body.includes("-")) {
|
|
244
|
+
const [a, b] = body.split("-")
|
|
245
|
+
lo = Number(a)
|
|
246
|
+
hi = Number(b)
|
|
247
|
+
} else {
|
|
248
|
+
lo = Number(body)
|
|
249
|
+
hi = lo
|
|
250
|
+
}
|
|
251
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo < min || hi > max || lo > hi) {
|
|
252
|
+
throw new Error(`cron ${label} out of range [${min}..${max}] in "${f}"`)
|
|
253
|
+
}
|
|
254
|
+
for (let i = lo; i <= hi; i += step) out.add(i)
|
|
255
|
+
}
|
|
256
|
+
return [...out].sort((a, b) => a - b)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function nextCronFire(spec: CronSpec, fromMs: number): number {
|
|
260
|
+
const date = new Date(fromMs)
|
|
261
|
+
date.setUTCSeconds(0, 0)
|
|
262
|
+
date.setUTCMinutes(date.getUTCMinutes() + 1)
|
|
263
|
+
|
|
264
|
+
const maxIterations = 60 * 24 * 366 * 5
|
|
265
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
266
|
+
if (
|
|
267
|
+
spec.minute.includes(date.getUTCMinutes()) &&
|
|
268
|
+
spec.hour.includes(date.getUTCHours()) &&
|
|
269
|
+
spec.day.includes(date.getUTCDate()) &&
|
|
270
|
+
spec.month.includes(date.getUTCMonth() + 1) &&
|
|
271
|
+
spec.dow.includes(date.getUTCDay())
|
|
272
|
+
) {
|
|
273
|
+
return date.getTime()
|
|
274
|
+
}
|
|
275
|
+
date.setUTCMinutes(date.getUTCMinutes() + 1)
|
|
276
|
+
}
|
|
277
|
+
throw new Error("cron search exceeded 5 years without finding a match")
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function toMs(duration: Duration | string | number): number {
|
|
281
|
+
if (typeof duration === "number") return duration
|
|
282
|
+
const m = /^(\d+)(ms|s|m|h|d|w)$/.exec(duration)
|
|
283
|
+
if (!m) throw new Error(`invalid duration "${duration}"`)
|
|
284
|
+
const n = Number(m[1])
|
|
285
|
+
switch (m[2]) {
|
|
286
|
+
case "ms":
|
|
287
|
+
return n
|
|
288
|
+
case "s":
|
|
289
|
+
return n * 1_000
|
|
290
|
+
case "m":
|
|
291
|
+
return n * 60_000
|
|
292
|
+
case "h":
|
|
293
|
+
return n * 3_600_000
|
|
294
|
+
case "d":
|
|
295
|
+
return n * 86_400_000
|
|
296
|
+
case "w":
|
|
297
|
+
return n * 604_800_000
|
|
298
|
+
default:
|
|
299
|
+
throw new Error(`invalid duration "${duration}"`)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function resolveInput(
|
|
304
|
+
input: unknown | (() => unknown | Promise<unknown>) | undefined,
|
|
305
|
+
): Promise<unknown> {
|
|
306
|
+
if (typeof input === "function") {
|
|
307
|
+
return await (input as () => unknown | Promise<unknown>)()
|
|
308
|
+
}
|
|
309
|
+
return input
|
|
310
|
+
}
|