@smithers-orchestrator/scheduler 0.16.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/LICENSE +21 -0
- package/package.json +190 -0
- package/src/ApprovalResolution.ts +7 -0
- package/src/CachePolicy.ts +8 -0
- package/src/ContinuationRequest.ts +3 -0
- package/src/ContinueAsNewTransition.ts +9 -0
- package/src/EngineDecision.ts +19 -0
- package/src/PlanNode.ts +32 -0
- package/src/RalphMeta.ts +7 -0
- package/src/RalphState.ts +4 -0
- package/src/RalphStateMap.ts +3 -0
- package/src/ReadonlyTaskStateMap.ts +3 -0
- package/src/RenderContext.ts +14 -0
- package/src/RetryPolicy.ts +6 -0
- package/src/RetryWaitMap.ts +1 -0
- package/src/RunResult.ts +15 -0
- package/src/ScheduleResult.ts +15 -0
- package/src/ScheduleSnapshot.ts +8 -0
- package/src/Scheduler.js +28 -0
- package/src/SchedulerLive.js +8 -0
- package/src/SmithersWorkflowOptions.ts +43 -0
- package/src/TaskFailure.ts +5 -0
- package/src/TaskOutput.ts +9 -0
- package/src/TaskRecord.ts +10 -0
- package/src/TaskState.ts +10 -0
- package/src/TaskStateMap.ts +3 -0
- package/src/TokenUsage.ts +9 -0
- package/src/WaitReason.ts +8 -0
- package/src/WorkflowSession.js +10 -0
- package/src/WorkflowSessionLive.js +6 -0
- package/src/WorkflowSessionOptions.ts +10 -0
- package/src/WorkflowSessionService.ts +52 -0
- package/src/buildPlanTree.js +273 -0
- package/src/buildStateKey.js +8 -0
- package/src/cloneTaskStateMap.js +10 -0
- package/src/computeRetryDelayMs.js +14 -0
- package/src/index.d.ts +437 -0
- package/src/index.js +53 -0
- package/src/isTerminalState.js +15 -0
- package/src/makeWorkflowSession.js +723 -0
- package/src/nowMs.js +6 -0
- package/src/parseStateKey.js +15 -0
- package/src/retryPolicyToSchedule.js +26 -0
- package/src/retryScheduleDelayMs.js +23 -0
- package/src/scheduleTasks.js +330 -0
package/src/nowMs.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {string} key
|
|
3
|
+
* @returns {{ readonly nodeId: string; readonly iteration: number; }}
|
|
4
|
+
*/
|
|
5
|
+
export function parseStateKey(key) {
|
|
6
|
+
const separator = key.lastIndexOf("::");
|
|
7
|
+
if (separator < 0) {
|
|
8
|
+
return { nodeId: key, iteration: 0 };
|
|
9
|
+
}
|
|
10
|
+
const iteration = Number(key.slice(separator + 2));
|
|
11
|
+
return {
|
|
12
|
+
nodeId: key.slice(0, separator),
|
|
13
|
+
iteration: Number.isFinite(iteration) ? iteration : 0,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Duration, Schedule } from "effect";
|
|
2
|
+
/** @typedef {import("./RetryPolicy.ts").RetryPolicy} RetryPolicy */
|
|
3
|
+
const MAX_RETRY_DELAY_MS = 5 * 60 * 1000;
|
|
4
|
+
/**
|
|
5
|
+
* Convert a RetryPolicy to an Effect Schedule for use with Effect.retry.
|
|
6
|
+
*
|
|
7
|
+
* @param {RetryPolicy} policy
|
|
8
|
+
* @returns {Schedule.Schedule<unknown>}
|
|
9
|
+
*/
|
|
10
|
+
export function retryPolicyToSchedule(policy) {
|
|
11
|
+
const base = typeof policy.initialDelayMs === "number"
|
|
12
|
+
? Math.max(0, Math.floor(policy.initialDelayMs))
|
|
13
|
+
: 0;
|
|
14
|
+
if (base <= 0)
|
|
15
|
+
return Schedule.stop;
|
|
16
|
+
const backoff = policy.backoff ?? "fixed";
|
|
17
|
+
const capDelay = Schedule.modifyDelay((_out, delay) => Duration.min(delay, Duration.millis(MAX_RETRY_DELAY_MS)));
|
|
18
|
+
switch (backoff) {
|
|
19
|
+
case "fixed":
|
|
20
|
+
return capDelay(Schedule.fixed(Duration.millis(base)));
|
|
21
|
+
case "linear":
|
|
22
|
+
return capDelay(Schedule.linear(Duration.millis(base)));
|
|
23
|
+
case "exponential":
|
|
24
|
+
return capDelay(Schedule.exponential(Duration.millis(base)));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect, Schedule, ScheduleDecision, ScheduleIntervals } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* @param {Schedule.Schedule<unknown>} schedule
|
|
4
|
+
* @param {number} attempt
|
|
5
|
+
* @returns {number}
|
|
6
|
+
*/
|
|
7
|
+
export function retryScheduleDelayMs(schedule, attempt) {
|
|
8
|
+
const safeAttempt = Math.max(1, Math.floor(attempt));
|
|
9
|
+
let state = schedule.initial;
|
|
10
|
+
let now = 0;
|
|
11
|
+
let delayMs = 0;
|
|
12
|
+
for (let index = 0; index < safeAttempt; index++) {
|
|
13
|
+
const [nextState, , decision] = Effect.runSync(schedule.step(now, undefined, state));
|
|
14
|
+
if (ScheduleDecision.isDone(decision)) {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
const nextNow = ScheduleIntervals.start(decision.intervals);
|
|
18
|
+
delayMs = Math.max(0, nextNow - now);
|
|
19
|
+
state = nextState;
|
|
20
|
+
now = nextNow;
|
|
21
|
+
}
|
|
22
|
+
return delayMs;
|
|
23
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { buildStateKey } from "./buildStateKey.js";
|
|
2
|
+
/** @typedef {import("./TaskState.ts").TaskState} TaskState */
|
|
3
|
+
|
|
4
|
+
/** @typedef {import("./PlanNode.ts").PlanNode} PlanNode */
|
|
5
|
+
/** @typedef {import("./RalphStateMap.ts").RalphStateMap} RalphStateMap */
|
|
6
|
+
/** @typedef {import("./RetryWaitMap.ts").RetryWaitMap} RetryWaitMap */
|
|
7
|
+
/** @typedef {import("./ScheduleResult.ts").ScheduleResult} ScheduleResult */
|
|
8
|
+
/** @typedef {import("@smithers-orchestrator/graph").TaskDescriptor} TaskDescriptor */
|
|
9
|
+
/** @typedef {import("./TaskStateMap.ts").TaskStateMap} TaskStateMap */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {TaskState} state
|
|
13
|
+
* @param {TaskDescriptor} descriptor
|
|
14
|
+
* @returns {boolean}
|
|
15
|
+
*/
|
|
16
|
+
function isTerminal(state, descriptor) {
|
|
17
|
+
if (state === "finished" || state === "skipped")
|
|
18
|
+
return true;
|
|
19
|
+
if (state === "failed")
|
|
20
|
+
return descriptor.continueOnFail;
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* @param {TaskState} state
|
|
25
|
+
* @param {TaskDescriptor} descriptor
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
function isTraversalTerminal(state, descriptor) {
|
|
29
|
+
if (isTerminal(state, descriptor))
|
|
30
|
+
return true;
|
|
31
|
+
return Boolean(descriptor.waitAsync &&
|
|
32
|
+
(state === "waiting-approval" || state === "waiting-event"));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* @param {TaskDescriptor} descriptor
|
|
36
|
+
* @param {TaskStateMap} states
|
|
37
|
+
* @param {Map<string, TaskDescriptor>} descriptors
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
function dependenciesSatisfied(descriptor, states, descriptors) {
|
|
41
|
+
if (!descriptor.dependsOn || descriptor.dependsOn.length === 0)
|
|
42
|
+
return true;
|
|
43
|
+
for (const dependencyId of descriptor.dependsOn) {
|
|
44
|
+
const dependency = descriptors.get(dependencyId);
|
|
45
|
+
if (!dependency)
|
|
46
|
+
return false;
|
|
47
|
+
const state = states.get(buildStateKey(dependency.nodeId, dependency.iteration));
|
|
48
|
+
if (!state || !isTerminal(state, dependency)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* @param {PlanNode | null} plan
|
|
56
|
+
* @param {TaskStateMap} states
|
|
57
|
+
* @param {Map<string, TaskDescriptor>} descriptors
|
|
58
|
+
* @param {RalphStateMap} ralphState
|
|
59
|
+
* @param {RetryWaitMap} retryWait
|
|
60
|
+
* @param {number} nowMs
|
|
61
|
+
* @returns {ScheduleResult}
|
|
62
|
+
*/
|
|
63
|
+
export function scheduleTasks(plan, states, descriptors, ralphState, retryWait, nowMs) {
|
|
64
|
+
const runnable = [];
|
|
65
|
+
let pendingExists = false;
|
|
66
|
+
let waitingApprovalExists = false;
|
|
67
|
+
let waitingEventExists = false;
|
|
68
|
+
let waitingTimerExists = false;
|
|
69
|
+
const readyRalphs = [];
|
|
70
|
+
let continuation;
|
|
71
|
+
let nextRetryAtMs;
|
|
72
|
+
let fatalError;
|
|
73
|
+
const groupUsage = new Map();
|
|
74
|
+
for (const [stateKey, state] of states) {
|
|
75
|
+
if (state !== "in-progress")
|
|
76
|
+
continue;
|
|
77
|
+
const separator = stateKey.lastIndexOf("::");
|
|
78
|
+
const nodeId = separator >= 0 ? stateKey.slice(0, separator) : stateKey;
|
|
79
|
+
const descriptor = descriptors.get(nodeId);
|
|
80
|
+
if (!descriptor)
|
|
81
|
+
continue;
|
|
82
|
+
const groupId = descriptor.parallelGroupId;
|
|
83
|
+
const cap = descriptor.parallelMaxConcurrency;
|
|
84
|
+
if (groupId && cap != null) {
|
|
85
|
+
groupUsage.set(groupId, (groupUsage.get(groupId) ?? 0) + 1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* @param {PlanNode} node
|
|
90
|
+
* @returns {{ readonly terminal: boolean; readonly failed: boolean }}
|
|
91
|
+
*/
|
|
92
|
+
function inspect(node) {
|
|
93
|
+
switch (node.kind) {
|
|
94
|
+
case "task": {
|
|
95
|
+
const descriptor = descriptors.get(node.nodeId);
|
|
96
|
+
if (!descriptor)
|
|
97
|
+
return { terminal: true, failed: false };
|
|
98
|
+
const state = states.get(buildStateKey(descriptor.nodeId, descriptor.iteration)) ??
|
|
99
|
+
"pending";
|
|
100
|
+
const terminal = state === "finished" ||
|
|
101
|
+
state === "skipped" ||
|
|
102
|
+
state === "failed" ||
|
|
103
|
+
Boolean(descriptor.waitAsync &&
|
|
104
|
+
(state === "waiting-approval" || state === "waiting-event"));
|
|
105
|
+
return { terminal, failed: state === "failed" };
|
|
106
|
+
}
|
|
107
|
+
case "sequence":
|
|
108
|
+
case "group": {
|
|
109
|
+
for (const child of node.children) {
|
|
110
|
+
const result = inspect(child);
|
|
111
|
+
if (!result.terminal)
|
|
112
|
+
return { terminal: false, failed: false };
|
|
113
|
+
if (result.failed)
|
|
114
|
+
return { terminal: true, failed: true };
|
|
115
|
+
}
|
|
116
|
+
return { terminal: true, failed: false };
|
|
117
|
+
}
|
|
118
|
+
case "parallel": {
|
|
119
|
+
let terminal = true;
|
|
120
|
+
let failed = false;
|
|
121
|
+
for (const child of node.children) {
|
|
122
|
+
const result = inspect(child);
|
|
123
|
+
if (!result.terminal)
|
|
124
|
+
terminal = false;
|
|
125
|
+
if (result.failed)
|
|
126
|
+
failed = true;
|
|
127
|
+
}
|
|
128
|
+
return { terminal, failed: terminal && failed };
|
|
129
|
+
}
|
|
130
|
+
case "saga": {
|
|
131
|
+
for (const child of node.actionChildren) {
|
|
132
|
+
const result = inspect(child);
|
|
133
|
+
if (!result.terminal)
|
|
134
|
+
return { terminal: false, failed: false };
|
|
135
|
+
if (result.failed)
|
|
136
|
+
return { terminal: true, failed: true };
|
|
137
|
+
}
|
|
138
|
+
return { terminal: true, failed: false };
|
|
139
|
+
}
|
|
140
|
+
case "try-catch-finally": {
|
|
141
|
+
for (const child of node.tryChildren) {
|
|
142
|
+
const result = inspect(child);
|
|
143
|
+
if (!result.terminal)
|
|
144
|
+
return { terminal: false, failed: false };
|
|
145
|
+
if (result.failed)
|
|
146
|
+
return { terminal: true, failed: true };
|
|
147
|
+
}
|
|
148
|
+
return { terminal: true, failed: false };
|
|
149
|
+
}
|
|
150
|
+
default:
|
|
151
|
+
return { terminal: true, failed: false };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* @param {readonly PlanNode[]} children
|
|
156
|
+
*/
|
|
157
|
+
function walkSequence(children) {
|
|
158
|
+
for (const child of children) {
|
|
159
|
+
const result = walk(child);
|
|
160
|
+
if (!result.terminal)
|
|
161
|
+
return { terminal: false };
|
|
162
|
+
}
|
|
163
|
+
return { terminal: true };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* @param {PlanNode} node
|
|
167
|
+
* @returns {{ readonly terminal: boolean }}
|
|
168
|
+
*/
|
|
169
|
+
function walk(node) {
|
|
170
|
+
switch (node.kind) {
|
|
171
|
+
case "task": {
|
|
172
|
+
const descriptor = descriptors.get(node.nodeId);
|
|
173
|
+
if (!descriptor)
|
|
174
|
+
return { terminal: true };
|
|
175
|
+
const state = states.get(buildStateKey(descriptor.nodeId, descriptor.iteration)) ??
|
|
176
|
+
"pending";
|
|
177
|
+
if (state === "waiting-approval")
|
|
178
|
+
waitingApprovalExists = true;
|
|
179
|
+
if (state === "waiting-event")
|
|
180
|
+
waitingEventExists = true;
|
|
181
|
+
if (state === "waiting-timer")
|
|
182
|
+
waitingTimerExists = true;
|
|
183
|
+
if (state === "pending" || state === "cancelled")
|
|
184
|
+
pendingExists = true;
|
|
185
|
+
const terminal = isTraversalTerminal(state, descriptor);
|
|
186
|
+
if (!terminal && (state === "pending" || state === "cancelled")) {
|
|
187
|
+
if (!dependenciesSatisfied(descriptor, states, descriptors)) {
|
|
188
|
+
return { terminal };
|
|
189
|
+
}
|
|
190
|
+
const retryAt = retryWait.get(buildStateKey(descriptor.nodeId, descriptor.iteration));
|
|
191
|
+
if (retryAt && retryAt > nowMs) {
|
|
192
|
+
pendingExists = true;
|
|
193
|
+
nextRetryAtMs =
|
|
194
|
+
nextRetryAtMs == null ? retryAt : Math.min(nextRetryAtMs, retryAt);
|
|
195
|
+
return { terminal };
|
|
196
|
+
}
|
|
197
|
+
const groupId = descriptor.parallelGroupId;
|
|
198
|
+
const cap = descriptor.parallelMaxConcurrency;
|
|
199
|
+
if (groupId && cap != null) {
|
|
200
|
+
const used = groupUsage.get(groupId) ?? 0;
|
|
201
|
+
if (used >= cap) {
|
|
202
|
+
return { terminal };
|
|
203
|
+
}
|
|
204
|
+
groupUsage.set(groupId, used + 1);
|
|
205
|
+
}
|
|
206
|
+
runnable.push(descriptor);
|
|
207
|
+
}
|
|
208
|
+
return { terminal };
|
|
209
|
+
}
|
|
210
|
+
case "sequence":
|
|
211
|
+
return walkSequence(node.children);
|
|
212
|
+
case "parallel": {
|
|
213
|
+
let terminal = true;
|
|
214
|
+
for (const child of node.children) {
|
|
215
|
+
const result = walk(child);
|
|
216
|
+
if (!result.terminal)
|
|
217
|
+
terminal = false;
|
|
218
|
+
}
|
|
219
|
+
return { terminal };
|
|
220
|
+
}
|
|
221
|
+
case "ralph": {
|
|
222
|
+
const state = ralphState.get(node.id);
|
|
223
|
+
const done = node.until || state?.done;
|
|
224
|
+
if (done)
|
|
225
|
+
return { terminal: true };
|
|
226
|
+
let terminal = true;
|
|
227
|
+
for (const child of node.children) {
|
|
228
|
+
const result = walk(child);
|
|
229
|
+
if (!result.terminal)
|
|
230
|
+
terminal = false;
|
|
231
|
+
}
|
|
232
|
+
if (terminal) {
|
|
233
|
+
readyRalphs.push({
|
|
234
|
+
id: node.id,
|
|
235
|
+
until: node.until,
|
|
236
|
+
maxIterations: node.maxIterations,
|
|
237
|
+
onMaxReached: node.onMaxReached,
|
|
238
|
+
continueAsNewEvery: node.continueAsNewEvery,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
return { terminal: false };
|
|
242
|
+
}
|
|
243
|
+
case "continue-as-new":
|
|
244
|
+
continuation = { stateJson: node.stateJson };
|
|
245
|
+
return { terminal: false };
|
|
246
|
+
case "saga": {
|
|
247
|
+
let completedActions = 0;
|
|
248
|
+
let failed = false;
|
|
249
|
+
for (const child of node.actionChildren) {
|
|
250
|
+
const status = inspect(child);
|
|
251
|
+
if (!status.terminal)
|
|
252
|
+
return walk(child);
|
|
253
|
+
if (status.failed) {
|
|
254
|
+
failed = true;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
completedActions += 1;
|
|
258
|
+
}
|
|
259
|
+
if (!failed)
|
|
260
|
+
return { terminal: true };
|
|
261
|
+
if (node.onFailure === "fail") {
|
|
262
|
+
fatalError ??= `Saga ${node.id} failed`;
|
|
263
|
+
return { terminal: true };
|
|
264
|
+
}
|
|
265
|
+
for (let index = completedActions - 1; index >= 0; index -= 1) {
|
|
266
|
+
const compensation = node.compensationChildren[index];
|
|
267
|
+
if (!compensation)
|
|
268
|
+
continue;
|
|
269
|
+
const result = walk(compensation);
|
|
270
|
+
if (!result.terminal)
|
|
271
|
+
return { terminal: false };
|
|
272
|
+
}
|
|
273
|
+
if (node.onFailure === "compensate-and-fail") {
|
|
274
|
+
fatalError ??= `Saga ${node.id} failed`;
|
|
275
|
+
}
|
|
276
|
+
return { terminal: true };
|
|
277
|
+
}
|
|
278
|
+
case "try-catch-finally": {
|
|
279
|
+
let tryFailed = false;
|
|
280
|
+
for (const child of node.tryChildren) {
|
|
281
|
+
const status = inspect(child);
|
|
282
|
+
if (!status.terminal)
|
|
283
|
+
return walk(child);
|
|
284
|
+
if (status.failed) {
|
|
285
|
+
tryFailed = true;
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (tryFailed) {
|
|
290
|
+
if (node.catchChildren.length > 0) {
|
|
291
|
+
const catchResult = walkSequence(node.catchChildren);
|
|
292
|
+
if (!catchResult.terminal)
|
|
293
|
+
return catchResult;
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
fatalError ??= `TryCatchFinally ${node.id} failed`;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const finallyResult = walkSequence(node.finallyChildren);
|
|
300
|
+
if (!finallyResult.terminal)
|
|
301
|
+
return finallyResult;
|
|
302
|
+
return { terminal: true };
|
|
303
|
+
}
|
|
304
|
+
case "group": {
|
|
305
|
+
let terminal = true;
|
|
306
|
+
for (const child of node.children) {
|
|
307
|
+
const result = walk(child);
|
|
308
|
+
if (!result.terminal)
|
|
309
|
+
terminal = false;
|
|
310
|
+
}
|
|
311
|
+
return { terminal };
|
|
312
|
+
}
|
|
313
|
+
default:
|
|
314
|
+
return { terminal: true };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (plan)
|
|
318
|
+
walk(plan);
|
|
319
|
+
return {
|
|
320
|
+
runnable,
|
|
321
|
+
pendingExists,
|
|
322
|
+
waitingApprovalExists,
|
|
323
|
+
waitingEventExists,
|
|
324
|
+
waitingTimerExists,
|
|
325
|
+
readyRalphs,
|
|
326
|
+
continuation,
|
|
327
|
+
nextRetryAtMs,
|
|
328
|
+
fatalError,
|
|
329
|
+
};
|
|
330
|
+
}
|