Skip to main content

orbit_runtime/
decide.rs

1//! The deterministic workflow decider.
2//!
3//! `decide(state, definition, now)` inspects an execution and returns the
4//! events that should happen next (schedule ready steps, fan out `for` steps,
5//! fire timers, apply retry policy, start compensation, finish the
6//! workflow...). It performs no I/O. The engine applies the events, persists
7//! them, and calls `decide` again until it returns nothing.
8//!
9//! Unit keys: `step` for a step, `step[i]` for element `i` of a `for` step,
10//! and `compensate:<unit>` for the compensation of a unit.
11
12use crate::model::*;
13use orbit_ir::{eval, Action, Invocation, Scope, StepIr, WorkflowIr};
14use serde_json::Value;
15
16/// Expression scope over an execution, optionally with a loop variable bound
17/// and with one step's output overridden (an element's own output inside its
18/// compensation).
19pub struct StateScope<'a> {
20    pub state: &'a ExecutionState,
21    pub local: Option<(&'a str, &'a Value)>,
22    pub own_output: Option<(&'a str, &'a Value)>,
23}
24
25impl<'a> StateScope<'a> {
26    pub fn new(state: &'a ExecutionState) -> Self {
27        StateScope {
28            state,
29            local: None,
30            own_output: None,
31        }
32    }
33}
34
35impl Scope for StateScope<'_> {
36    fn input(&self) -> &Value {
37        &self.state.input
38    }
39
40    fn step_output(&self, step: &str) -> Option<&Value> {
41        if let Some((s, v)) = self.own_output {
42            if s == step {
43                return Some(v);
44            }
45        }
46        let u = self.state.units.get(step)?;
47        match u.status() {
48            UnitStatus::Succeeded | UnitStatus::Skipped => u.output.as_ref(),
49            _ => None,
50        }
51    }
52
53    fn execution_field(&self, field: &str) -> Option<Value> {
54        Some(Value::String(match field {
55            "id" => self.state.id.clone(),
56            "workflow" => self.state.workflow.clone(),
57            "version" => self.state.version.clone(),
58            _ => return None,
59        }))
60    }
61
62    fn local(&self, name: &str) -> Option<&Value> {
63        self.local.filter(|(n, _)| *n == name).map(|(_, v)| v)
64    }
65}
66
67/// The invocation behind a unit key.
68pub fn invocation<'a>(def: &'a WorkflowIr, key: &str) -> Option<&'a Invocation> {
69    let k = parse_key(key);
70    let step = def.step(k.step)?;
71    if k.compensation {
72        step.compensation.as_ref()
73    } else {
74        step.invocation()
75    }
76}
77
78fn unit(state: &ExecutionState, key: &str) -> UnitState {
79    state.units.get(key).cloned().unwrap_or_default()
80}
81
82/// The `for` element bound to a unit, if any: (variable, value).
83fn element<'a>(
84    state: &'a ExecutionState,
85    def: &'a WorkflowIr,
86    key: &str,
87) -> Option<(&'a str, &'a Value)> {
88    let k = parse_key(key);
89    let index = k.index?;
90    let fe = def.step(k.step)?.for_each.as_ref()?;
91    let items = state.units.get(k.step)?.input.as_ref()?.as_array()?;
92    Some((fe.variable.as_str(), items.get(index)?))
93}
94
95/// Evaluate the payload and idempotency key for a unit's first attempt.
96fn schedule(state: &ExecutionState, def: &WorkflowIr, key: &str, inv: &Invocation) -> Event {
97    let k = parse_key(key);
98    let mut scope = StateScope::new(state);
99    scope.local = element(state, def, key);
100    // Inside the compensation of an element, `<step>` is that element's output.
101    let element_unit = if k.compensation && k.index.is_some() {
102        key.strip_prefix("compensate:")
103    } else {
104        None
105    };
106    let own = element_unit
107        .and_then(|e| state.units.get(e))
108        .and_then(|u| u.output.as_ref());
109    if let Some(v) = own {
110        scope.own_output = Some((k.step, v));
111    }
112    let fail = |code: &str, msg: String| Event::StepFailed {
113        step: key.into(),
114        error: TaskError::new(code, msg, false),
115    };
116    let mut input = match eval(&inv.input, &scope) {
117        Ok(v) => v,
118        Err(e) => return fail(codes::EXPRESSION_ERROR, format!("evaluating input: {e}")),
119    };
120    if let Some(contract) = def.contracts.get(&inv.task) {
121        contract.input.apply_defaults(&mut input);
122        if let Err(e) = contract.input.validate(&input, false) {
123            return fail(
124                codes::INPUT_CONTRACT_VIOLATION,
125                format!("input for `{}` is invalid at {e}", inv.task),
126            );
127        }
128    }
129    let idempotency_key = match &inv.idempotency_key {
130        Some(expr) => match eval(expr, &scope) {
131            Ok(Value::String(s)) => s,
132            Ok(Value::Null) => format!("{}/{key}", state.id),
133            Ok(other) => other.to_string(),
134            Err(e) => {
135                return fail(
136                    codes::EXPRESSION_ERROR,
137                    format!("evaluating idempotency_key: {e}"),
138                )
139            }
140        },
141        None => format!("{}/{key}", state.id),
142    };
143    Event::StepScheduled {
144        step: key.into(),
145        attempt: 1,
146        input,
147        idempotency_key,
148        redelivery: false,
149    }
150}
151
152/// Retry-or-fail decision after a failed attempt.
153fn after_failure(
154    state: &ExecutionState,
155    key: &str,
156    u: &UnitState,
157    inv: Option<&Invocation>,
158    now: u64,
159) -> Event {
160    let error = u
161        .last_error
162        .clone()
163        .unwrap_or_else(|| TaskError::new("UNKNOWN", "attempt failed", true));
164    let is_compensation = key.starts_with("compensate:");
165    let aborting = state.abort.is_some() && !is_compensation;
166    if let Some(inv) = inv {
167        let policy = &inv.retry;
168        let retryable = error.retryable && !policy.non_retryable.contains(&error.code);
169        if retryable && !aborting && u.attempt < policy.max_attempts {
170            let next = u.attempt + 1;
171            let delay = policy.delay_before(next);
172            return Event::RetryScheduled {
173                step: key.into(),
174                next_attempt: next,
175                delay_ms: delay,
176                fire_at_ms: now + delay,
177            };
178        }
179        if retryable && u.attempt >= policy.max_attempts && policy.max_attempts > 1 {
180            let mut e = error.clone();
181            e.message = format!("{} (gave up after {} attempts)", e.message, u.attempt);
182            return Event::StepFailed {
183                step: key.into(),
184                error: e,
185            };
186        }
187    }
188    Event::StepFailed {
189        step: key.into(),
190        error,
191    }
192}
193
194fn finish(state: &ExecutionState, outcome: CompensationOutcome) -> Event {
195    match &state.abort {
196        Some(AbortReason::Cancelled { .. }) => Event::WorkflowCancelled {
197            compensation: outcome,
198        },
199        Some(AbortReason::TimedOut) => Event::WorkflowTimedOut {
200            compensation: outcome,
201        },
202        Some(AbortReason::Failed { error }) => Event::WorkflowFailed {
203            error: error.clone(),
204            compensation: outcome,
205        },
206        None => Event::WorkflowFailed {
207            error: TaskError::new("UNKNOWN", "workflow aborted", false),
208            compensation: outcome,
209        },
210    }
211}
212
213/// Compute the next events. Returns an empty vector at a fixpoint.
214pub fn decide(state: &ExecutionState, def: &WorkflowIr, now: u64) -> Vec<Event> {
215    if state.status.is_terminal() {
216        return vec![];
217    }
218    let mut out = vec![];
219
220    // 1. Workflow deadline.
221    if state.status == WorkflowStatus::Running && state.abort.is_none() {
222        if let Some(t) = def.timeout_ms {
223            if now >= state.created_at_ms + t {
224                return vec![Event::WorkflowTimeoutReached {}];
225            }
226        }
227    }
228
229    // 2. Per-unit transitions: deadlines, timers, retries, redelivery.
230    // Signals delivered in this pass (so two waiting units never take the same one).
231    let mut taken: Vec<u64> = vec![];
232    for (key, u) in &state.units {
233        let inv = invocation(def, key);
234        match u.status() {
235            UnitStatus::Dispatched => {
236                if u.deadline_ms.is_some_and(|d| now >= d) {
237                    out.push(Event::StepAttemptFailed {
238                        step: key.clone(),
239                        attempt: u.attempt,
240                        error: TaskError::new(
241                            codes::TIMEOUT,
242                            format!("attempt {} exceeded its timeout", u.attempt),
243                            true,
244                        ),
245                    });
246                }
247            }
248            UnitStatus::Scheduled => {
249                let qt = inv.and_then(|i| i.queue_timeout_ms);
250                if let (Some(qt), Some(at)) = (qt, u.scheduled_at_ms) {
251                    if !u.redelivery && now >= at + qt {
252                        out.push(Event::StepAttemptFailed {
253                            step: key.clone(),
254                            attempt: u.attempt,
255                            error: TaskError::new(
256                                codes::QUEUE_TIMEOUT,
257                                "no worker picked up the task before queue_timeout",
258                                false,
259                            ),
260                        });
261                    }
262                }
263            }
264            UnitStatus::Waiting => {
265                if let Some(signal) = awaited_signal(def, key) {
266                    // `await`: take the oldest undelivered signal of this name.
267                    let next = state.signals.iter().find(|r| {
268                        r.name == signal && r.delivered_to.is_none() && !taken.contains(&r.seq)
269                    });
270                    if let Some(r) = next {
271                        taken.push(r.seq);
272                        out.push(Event::SignalDelivered {
273                            step: key.clone(),
274                            signal_seq: r.seq,
275                        });
276                    } else if u.wake_at_ms.is_some_and(|w| now >= w) {
277                        out.push(Event::StepFailed {
278                            step: key.clone(),
279                            error: TaskError::new(
280                                codes::SIGNAL_TIMEOUT,
281                                format!("no `{signal}` signal arrived before the step's timeout"),
282                                false,
283                            ),
284                        });
285                    }
286                } else if u.wake_at_ms.is_some_and(|w| now >= w) {
287                    out.push(Event::TimerFired { step: key.clone() });
288                }
289            }
290            UnitStatus::RetryWait => {
291                if u.wake_at_ms.is_some_and(|w| now >= w) {
292                    if state.abort.is_some() && !key.starts_with("compensate:") {
293                        out.push(Event::StepFailed {
294                            step: key.clone(),
295                            error: u.last_error.clone().unwrap_or_else(|| {
296                                TaskError::new(codes::CANCELLED, "aborted", false)
297                            }),
298                        });
299                    } else {
300                        out.push(Event::StepScheduled {
301                            step: key.clone(),
302                            attempt: u.attempt,
303                            input: u.input.clone().unwrap_or(Value::Null),
304                            idempotency_key: u.idempotency_key.clone().unwrap_or_default(),
305                            redelivery: false,
306                        });
307                    }
308                }
309            }
310            UnitStatus::AttemptFailed => out.push(after_failure(state, key, u, inv, now)),
311            UnitStatus::Uncertain => out.push(Event::StepScheduled {
312                step: key.clone(),
313                attempt: u.attempt,
314                input: u.input.clone().unwrap_or(Value::Null),
315                idempotency_key: u.idempotency_key.clone().unwrap_or_default(),
316                redelivery: true,
317            }),
318            _ => {}
319        }
320    }
321    if !out.is_empty() {
322        return out;
323    }
324
325    match state.status {
326        WorkflowStatus::Running => decide_running(state, def, now),
327        WorkflowStatus::Compensating => decide_compensating(state, def),
328        _ => vec![],
329    }
330}
331
332/// The signal an `await` unit waits for.
333fn awaited_signal<'a>(def: &'a WorkflowIr, key: &str) -> Option<&'a str> {
334    let k = parse_key(key);
335    if k.compensation {
336        return None;
337    }
338    match &def.step(k.step)?.action {
339        Action::Await { signal, .. } => Some(signal),
340        _ => None,
341    }
342}
343
344/// Evaluate a `compute` step (or one element of it) in the runtime.
345fn compute(state: &ExecutionState, def: &WorkflowIr, key: &str, expr: &orbit_ir::Expr) -> Event {
346    let mut scope = StateScope::new(state);
347    scope.local = element(state, def, key);
348    match eval(expr, &scope) {
349        Ok(output) => Event::StepSucceeded {
350            step: key.into(),
351            attempt: 1,
352            output,
353        },
354        Err(e) => Event::StepFailed {
355            step: key.into(),
356            error: TaskError::new(
357                codes::EXPRESSION_ERROR,
358                format!("evaluating `compute`: {e}"),
359                false,
360            ),
361        },
362    }
363}
364
365fn deps_done(state: &ExecutionState, step: &StepIr) -> bool {
366    step.dependencies.iter().all(|d| {
367        matches!(
368            unit(state, d).status(),
369            UnitStatus::Succeeded | UnitStatus::Skipped
370        )
371    })
372}
373
374/// Element unit keys of a fanned-out step, in order.
375fn element_keys(state: &ExecutionState, step: &str) -> Vec<String> {
376    let n = state
377        .units
378        .get(step)
379        .and_then(|u| u.input.as_ref())
380        .and_then(Value::as_array)
381        .map(Vec::len)
382        .unwrap_or(0);
383    (0..n).map(|i| element_key(step, i)).collect()
384}
385
386fn in_flight(status: UnitStatus) -> bool {
387    matches!(
388        status,
389        UnitStatus::Scheduled
390            | UnitStatus::Dispatched
391            | UnitStatus::AttemptFailed
392            | UnitStatus::Uncertain
393            | UnitStatus::RetryWait
394    )
395}
396
397/// Progress a running `for` step: schedule elements within `max_parallel`,
398/// and resolve the step once every element is done (or one failed).
399fn advance_fan_out(state: &ExecutionState, def: &WorkflowIr, step: &StepIr, out: &mut Vec<Event>) {
400    let keys = element_keys(state, &step.id);
401    let units: Vec<UnitState> = keys.iter().map(|k| unit(state, k)).collect();
402    if let Some(failed) = units.iter().find(|u| u.status() == UnitStatus::Failed) {
403        out.push(Event::StepFailed {
404            step: step.id.clone(),
405            error: failed
406                .error
407                .clone()
408                .unwrap_or_else(|| TaskError::new("UNKNOWN", "element failed", false)),
409        });
410        return;
411    }
412    if units.iter().all(|u| u.status() == UnitStatus::Succeeded) {
413        let output = Value::Array(
414            units
415                .iter()
416                .map(|u| u.output.clone().unwrap_or(Value::Null))
417                .collect(),
418        );
419        out.push(Event::StepSucceeded {
420            step: step.id.clone(),
421            attempt: 1,
422            output,
423        });
424        return;
425    }
426    if let Action::Compute { expr } = &step.action {
427        for (key, u) in keys.iter().zip(&units) {
428            if u.status() == UnitStatus::Pending {
429                out.push(compute(state, def, key, expr));
430            }
431        }
432        return;
433    }
434    let Some(inv) = step.invocation() else { return };
435    let limit = step
436        .for_each
437        .as_ref()
438        .and_then(|f| f.max_parallel)
439        .map(|n| n as usize)
440        .unwrap_or(usize::MAX);
441    let mut running = units.iter().filter(|u| in_flight(u.status())).count();
442    for (key, u) in keys.iter().zip(&units) {
443        if running >= limit {
444            break;
445        }
446        if u.status() == UnitStatus::Pending {
447            out.push(schedule(state, def, key, inv));
448            running += 1;
449        }
450    }
451}
452
453fn decide_running(state: &ExecutionState, def: &WorkflowIr, now: u64) -> Vec<Event> {
454    let mut out = vec![];
455    if state.abort.is_none() {
456        for step in &def.steps {
457            let u = unit(state, &step.id);
458            if u.status() == UnitStatus::Running {
459                advance_fan_out(state, def, step, &mut out);
460                continue;
461            }
462            if u.status() != UnitStatus::Pending || !deps_done(state, step) {
463                continue;
464            }
465            if let Some(cond) = &step.condition {
466                match eval(cond, &StateScope::new(state)) {
467                    Ok(Value::Bool(true)) => {}
468                    Ok(Value::Bool(false)) | Ok(Value::Null) => {
469                        out.push(Event::StepSkipped {
470                            step: step.id.clone(),
471                            reason: "`when` condition was false".into(),
472                        });
473                        continue;
474                    }
475                    Ok(other) => {
476                        out.push(Event::StepFailed {
477                            step: step.id.clone(),
478                            error: TaskError::new(
479                                codes::EXPRESSION_ERROR,
480                                format!("`when` evaluated to {other}, expected Bool"),
481                                false,
482                            ),
483                        });
484                        continue;
485                    }
486                    Err(e) => {
487                        out.push(Event::StepFailed {
488                            step: step.id.clone(),
489                            error: TaskError::new(
490                                codes::EXPRESSION_ERROR,
491                                format!("evaluating `when`: {e}"),
492                                false,
493                            ),
494                        });
495                        continue;
496                    }
497                }
498            }
499            if let Some(fe) = &step.for_each {
500                match eval(&fe.items, &StateScope::new(state)) {
501                    Ok(Value::Array(items)) => out.push(Event::StepFanOut {
502                        step: step.id.clone(),
503                        items,
504                    }),
505                    Ok(Value::Null) => out.push(Event::StepFanOut {
506                        step: step.id.clone(),
507                        items: vec![],
508                    }),
509                    Ok(other) => out.push(Event::StepFailed {
510                        step: step.id.clone(),
511                        error: TaskError::new(
512                            codes::EXPRESSION_ERROR,
513                            format!("`for` expected a list, got {other}"),
514                            false,
515                        ),
516                    }),
517                    Err(e) => out.push(Event::StepFailed {
518                        step: step.id.clone(),
519                        error: TaskError::new(
520                            codes::EXPRESSION_ERROR,
521                            format!("evaluating `for`: {e}"),
522                            false,
523                        ),
524                    }),
525                }
526                continue;
527            }
528            match &step.action {
529                Action::Sleep { duration_ms } => out.push(Event::TimerStarted {
530                    step: step.id.clone(),
531                    fire_at_ms: now + duration_ms,
532                }),
533                Action::Task(inv) | Action::Workflow(inv) => {
534                    out.push(schedule(state, def, &step.id, inv))
535                }
536                Action::Await { signal, timeout_ms } => out.push(Event::SignalAwaitStarted {
537                    step: step.id.clone(),
538                    signal: signal.clone(),
539                    deadline_ms: timeout_ms.map(|t| now + t),
540                }),
541                Action::Compute { expr } => out.push(compute(state, def, &step.id, expr)),
542            }
543        }
544        if !out.is_empty() {
545            return out;
546        }
547        let all_done = def
548            .steps
549            .iter()
550            .all(|s| unit(state, &s.id).status().is_terminal());
551        if all_done {
552            let output = match &def.result {
553                Some(expr) => match eval(expr, &StateScope::new(state)) {
554                    Ok(v) => v,
555                    Err(e) => {
556                        return vec![Event::WorkflowFailed {
557                            error: TaskError::new(
558                                codes::EXPRESSION_ERROR,
559                                format!("evaluating `return`: {e}"),
560                                false,
561                            ),
562                            compensation: CompensationOutcome::None,
563                        }]
564                    }
565                },
566                None => Value::Object(Default::default()),
567            };
568            if let Err(e) = def.output.validate(&output, false) {
569                return vec![Event::WorkflowFailed {
570                    error: TaskError::new(
571                        codes::OUTPUT_CONTRACT_VIOLATION,
572                        format!("workflow output is invalid at {e}"),
573                        false,
574                    ),
575                    compensation: CompensationOutcome::None,
576                }];
577            }
578            return vec![Event::WorkflowCompleted { output }];
579        }
580        return out;
581    }
582
583    // Aborting: withdraw work that has not started, wait for in-flight work,
584    // then compensate completed units in reverse completion order.
585    let mut waiting = false;
586    let withdraw =
587        |key: &str, u: &UnitState, out: &mut Vec<Event>, waiting: &mut bool| match u.status() {
588            UnitStatus::Pending | UnitStatus::Waiting | UnitStatus::RetryWait => {
589                out.push(Event::StepCancelled { step: key.into() })
590            }
591            UnitStatus::Scheduled if !u.redelivery => {
592                out.push(Event::StepCancelled { step: key.into() })
593            }
594            UnitStatus::Scheduled
595            | UnitStatus::Dispatched
596            | UnitStatus::AttemptFailed
597            | UnitStatus::Uncertain => *waiting = true,
598            _ => {}
599        };
600    for step in &def.steps {
601        let u = unit(state, &step.id);
602        if u.status() == UnitStatus::Running {
603            let mut element_waiting = false;
604            let mut failed = None;
605            for key in element_keys(state, &step.id) {
606                let eu = unit(state, &key);
607                if eu.status() == UnitStatus::Failed && failed.is_none() {
608                    failed = eu.error.clone();
609                }
610                withdraw(&key, &eu, &mut out, &mut element_waiting);
611            }
612            if element_waiting {
613                waiting = true;
614            } else {
615                // Every element is resolved: close the step itself.
616                out.push(match failed {
617                    Some(error) => Event::StepFailed {
618                        step: step.id.clone(),
619                        error,
620                    },
621                    None => Event::StepCancelled {
622                        step: step.id.clone(),
623                    },
624                });
625            }
626        } else {
627            withdraw(&step.id, &u, &mut out, &mut waiting);
628        }
629    }
630    if !out.is_empty() || waiting {
631        return out;
632    }
633    // Units that succeeded and have a compensation: whole steps, or each
634    // succeeded element of a `for` step.
635    let mut done: Vec<(String, u64)> = vec![];
636    for s in def.steps.iter().filter(|s| s.compensation.is_some()) {
637        let keys = if s.for_each.is_some() {
638            element_keys(state, &s.id)
639        } else {
640            vec![s.id.clone()]
641        };
642        for key in keys {
643            if let Some(u) = state.units.get(&key) {
644                if u.status() == UnitStatus::Succeeded {
645                    done.push((key, u.succeeded_seq.unwrap_or(0)));
646                }
647            }
648        }
649    }
650    done.sort_by_key(|d| std::cmp::Reverse(d.1));
651    if done.is_empty() {
652        return vec![finish(state, CompensationOutcome::None)];
653    }
654    let reason = match &state.abort {
655        Some(AbortReason::Failed { error }) => error.message.clone(),
656        Some(AbortReason::Cancelled { reason }) => format!("cancelled: {reason}"),
657        Some(AbortReason::TimedOut) => "workflow timed out".into(),
658        None => String::new(),
659    };
660    vec![Event::CompensationStarted {
661        reason,
662        steps: done.into_iter().map(|(s, _)| s).collect(),
663    }]
664}
665
666fn decide_compensating(state: &ExecutionState, def: &WorkflowIr) -> Vec<Event> {
667    // Compensations run one at a time, newest completed unit first (Saga). A
668    // compensation that fails does not stop the others: every remaining effect
669    // is still undone, and each failure is reported at the end.
670    let mut failures = vec![];
671    for step in &state.compensation_order {
672        let key = compensation_key(step);
673        let u = unit(state, &key);
674        match u.status() {
675            UnitStatus::Succeeded => continue,
676            UnitStatus::Failed | UnitStatus::Cancelled => {
677                let error = u
678                    .error
679                    .unwrap_or_else(|| TaskError::new("UNKNOWN", "compensation failed", false));
680                failures.push(Event::CompensationFailed {
681                    step: step.clone(),
682                    error,
683                });
684                continue;
685            }
686            UnitStatus::Pending => {
687                return match invocation(def, &key) {
688                    Some(inv) => vec![schedule(state, def, &key, inv)],
689                    None => vec![Event::StepFailed {
690                        step: key,
691                        error: TaskError::new("UNKNOWN", "missing compensation", false),
692                    }],
693                };
694            }
695            _ => return vec![],
696        }
697    }
698    if !failures.is_empty() {
699        failures.push(finish(state, CompensationOutcome::Failed));
700        return failures;
701    }
702    vec![
703        Event::CompensationCompleted {},
704        finish(state, CompensationOutcome::Completed),
705    ]
706}