Skip to main content

orbit_runtime/
replay.rs

1//! Deterministic replay: re-run a recorded history through the current
2//! decider and check that every decision the runtime recorded is reproduced.
3//!
4//! History events have two origins:
5//!
6//! * **External** events record something the decider cannot know: an
7//!   execution was created, a task was handed to a worker, a worker reported
8//!   a result, a signal or cancellation arrived, a worker was lost. Replay
9//!   applies them as recorded.
10//! * **Decided** events are produced by [`decide`] from the state and the
11//!   clock. Replay recomputes `decide(state, definition, timestamp)` at each
12//!   of them and requires the recorded event to be among the results (order
13//!   within one decision batch does not matter).
14//!
15//! Some event types can be either: `StepSucceeded` is decided for `compute`
16//! steps and finished `for` steps but reported by a worker for task steps,
17//! and `StepAttemptFailed` is decided for deadlines but reported by workers
18//! for task errors. Such an event is consistent if the decider produces it
19//! or if it is a legitimate worker result for an unresolved task unit.
20//!
21//! Times computed from the clock (`fire_at_ms`, `deadline_ms`) may differ
22//! by the few milliseconds between the runtime reading its clock and
23//! committing the batch, so they are compared with that much tolerance.
24
25use crate::decide::{decide, invocation};
26use crate::model::*;
27use orbit_ir::WorkflowIr;
28use serde::Serialize;
29use serde_json::Value;
30
31/// Where a recorded event came from, as determined by replay.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "snake_case")]
34pub enum Origin {
35    External,
36    Decided,
37    Divergent,
38}
39
40/// The origin of one recorded event.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
42pub struct EventOrigin {
43    pub seq: u64,
44    pub origin: Origin,
45}
46
47/// A recorded decision the current decider does not reproduce.
48#[derive(Debug, Clone, Serialize)]
49pub struct Divergence {
50    pub seq: u64,
51    pub recorded: Event,
52    /// What `decide` returns at that point instead.
53    pub decider: Vec<Event>,
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct ReplayReport {
58    pub execution_id: String,
59    pub events: usize,
60    pub external: usize,
61    pub decided: usize,
62    pub divergences: Vec<Divergence>,
63    /// The origin of every event, by position in the history.
64    pub origins: Vec<EventOrigin>,
65    /// Set when the history was recorded against a different definition.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub definition_mismatch: Option<(String, String)>,
68    /// What the decider would do next after the last event, if anything
69    /// (for an unfinished execution: the work it is waiting on).
70    pub pending: Vec<Event>,
71    /// The state after the last event.
72    pub state: ExecutionState,
73}
74
75impl ReplayReport {
76    pub fn consistent(&self) -> bool {
77        self.divergences.is_empty()
78    }
79}
80
81/// Events that only ever come from outside the decider.
82fn always_external(e: &Event) -> bool {
83    matches!(
84        e,
85        Event::WorkflowCreated { .. }
86            | Event::WorkflowStarted { .. }
87            | Event::StepDispatched { .. }
88            | Event::StepAttemptUncertain { .. }
89            | Event::SignalReceived { .. }
90            | Event::CancelRequested { .. }
91            | Event::StaleResultIgnored { .. }
92            // Server resource limits are configuration, not decisions: the
93            // engine records them in place of what the decider proposed.
94            | Event::LimitExceeded { .. }
95    ) || matches!(e, Event::StepFailed { error, .. } if error.code == crate::model::codes::LIMIT_EXCEEDED)
96}
97
98/// True if `e` is a result the engine accepts from a worker (or a child
99/// workflow, or a built-in task) for the unit's current state.
100fn worker_result(state: &ExecutionState, def: &WorkflowIr, e: &Event) -> bool {
101    let step = match e {
102        Event::StepSucceeded { step, .. } | Event::StepAttemptFailed { step, .. } => step,
103        _ => return false,
104    };
105    if invocation(def, step).is_none() {
106        return false;
107    }
108    let status = state
109        .units
110        .get(step)
111        .map(UnitState::status)
112        .unwrap_or(UnitStatus::Pending);
113    matches!(
114        status,
115        UnitStatus::Dispatched
116            | UnitStatus::Scheduled
117            | UnitStatus::RetryWait
118            | UnitStatus::Uncertain
119            | UnitStatus::AttemptFailed
120    )
121}
122
123/// Clock-derived fields, compared with tolerance.
124const TIME_FIELDS: &[&str] = &["fire_at_ms", "deadline_ms"];
125
126/// Does the decided event `d` match the recorded event `r`? Clock-derived
127/// times may be up to `slack` ms later in `d` (decided at the commit time)
128/// than in `r` (decided when the runtime read its clock).
129fn same(d: &Event, r: &Event, slack: u64) -> bool {
130    let (Ok(Value::Object(mut dv)), Ok(Value::Object(mut rv))) =
131        (serde_json::to_value(d), serde_json::to_value(r))
132    else {
133        return false;
134    };
135    for f in TIME_FIELDS {
136        match (dv.remove(*f), rv.remove(*f)) {
137            (Some(a), Some(b)) => match (a.as_u64(), b.as_u64()) {
138                (Some(a), Some(b)) => {
139                    if a < b || a - b > slack {
140                        return false;
141                    }
142                }
143                _ if a != b => return false,
144                _ => {}
145            },
146            (None, None) => {}
147            _ => return false,
148        }
149    }
150    dv == rv
151}
152
153fn take(pending: &mut Vec<Event>, recorded: &Event, slack: u64) -> bool {
154    match pending.iter().position(|d| same(d, recorded, slack)) {
155        Some(i) => {
156            pending.remove(i);
157            true
158        }
159        None => false,
160    }
161}
162
163/// Replay `history` (of execution `id`) against `def`. With `stop_at`, the
164/// replay stops right after the event with that sequence number, so the
165/// report's `state` is the execution as it was at that moment.
166pub fn replay(
167    id: &str,
168    history: &[HistoryEvent],
169    def: &WorkflowIr,
170    stop_at: Option<u64>,
171) -> ReplayReport {
172    let mut state = ExecutionState::new(id);
173    let mut report = ReplayReport {
174        execution_id: id.to_string(),
175        events: 0,
176        external: 0,
177        decided: 0,
178        divergences: vec![],
179        origins: vec![],
180        definition_mismatch: None,
181        pending: vec![],
182        state: state.clone(),
183    };
184    // Events the decider produced in its last call that are not recorded yet.
185    let mut pending: Vec<Event> = vec![];
186    let mut prev_ts = history.first().map(|h| h.timestamp_ms).unwrap_or(0);
187    for h in history {
188        report.events += 1;
189        if let Event::WorkflowCreated {
190            definition_hash, ..
191        } = &h.event
192        {
193            if *definition_hash != def.definition_hash {
194                report.definition_mismatch =
195                    Some((definition_hash.clone(), def.definition_hash.clone()));
196            }
197        }
198        let slack = h.timestamp_ms.saturating_sub(prev_ts);
199        let origin = if always_external(&h.event) {
200            pending.clear();
201            Origin::External
202        } else if take(&mut pending, &h.event, slack) || {
203            // A new decision batch. The runtime read its clock somewhere
204            // between the previous commit and this one; try both ends.
205            pending = decide(&state, def, h.timestamp_ms);
206            take(&mut pending, &h.event, slack) || {
207                let earlier = decide(&state, def, prev_ts);
208                if earlier.iter().any(|d| same(d, &h.event, slack)) {
209                    pending = earlier;
210                    take(&mut pending, &h.event, slack)
211                } else {
212                    false
213                }
214            }
215        } {
216            Origin::Decided
217        } else if worker_result(&state, def, &h.event) {
218            pending.clear();
219            Origin::External
220        } else {
221            report.divergences.push(Divergence {
222                seq: h.seq,
223                recorded: h.event.clone(),
224                decider: decide(&state, def, h.timestamp_ms),
225            });
226            pending.clear();
227            Origin::Divergent
228        };
229        match origin {
230            Origin::External => report.external += 1,
231            Origin::Decided => report.decided += 1,
232            Origin::Divergent => {}
233        }
234        report.origins.push(EventOrigin { seq: h.seq, origin });
235        apply(&mut state, h);
236        prev_ts = h.timestamp_ms;
237        if stop_at == Some(h.seq) {
238            break;
239        }
240    }
241    report.pending = decide(&state, def, prev_ts);
242    report.state = state;
243    report
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use crate::testing::{simulate, TaskCall, TaskHandler, TaskOutcome};
250    use serde_json::json;
251
252    const SRC: &str = r#"
253task "charge" { version "1.0.0" input { amount: Float } output { id: String } }
254task "refund" { version "1.0.0" input { id: String } output { ok: Bool } }
255task "ship" { version "1.0.0" input { n: Int } output { ok: Bool } }
256
257workflow Order {
258    version "1.0.0"
259    input { amount: Float, items: List<Int> }
260    signal go { ok: Bool }
261    step assess { compute { big: input.amount > 100.0 } }
262    step approval { await go  when assess.big  timeout 1h }
263    step pause { sleep 5m  after approval }
264    step charge {
265        worker "charge"
266        after pause
267        with { amount: input.amount }
268        retry { attempts: 3, initial_delay: 2s }
269        compensate { worker "refund" with { id: charge.id } }
270    }
271    step ship {
272        worker "ship"
273        for n in input.items
274        with { n: n }
275        after charge
276    }
277    return { id: charge.id }
278}
279"#;
280
281    /// `charge` fails once, `ship` fails for item 3 (so `charge` is refunded).
282    struct Workers;
283    impl TaskHandler for Workers {
284        fn handle(&mut self, call: &TaskCall) -> TaskOutcome {
285            let a = call.assignment;
286            match a.task_name.as_str() {
287                "charge" if call.call == 1 => {
288                    TaskOutcome::Fail(TaskError::new("BUSY", "try again", true))
289                }
290                "charge" => TaskOutcome::Complete(json!({ "id": "tx-1" })),
291                "ship" if a.input["n"] == 3 => {
292                    TaskOutcome::Fail(TaskError::new("NO_STOCK", "out of stock", false))
293                }
294                "ship" => TaskOutcome::Complete(json!({ "ok": true })),
295                _ => TaskOutcome::Complete(json!({ "ok": true })),
296            }
297        }
298    }
299
300    fn recorded() -> (WorkflowIr, Vec<HistoryEvent>) {
301        let c = orbit_compiler::compile_str("order.orbit", SRC);
302        assert!(!c.has_errors(), "{}", c.render_diagnostics(false));
303        let sim = simulate(
304            &c.workflows,
305            "Order",
306            None,
307            json!({ "amount": 250.0, "items": [1, 2, 3] }),
308            &[("go".into(), json!({ "ok": true }))],
309            &mut Workers,
310        )
311        .unwrap();
312        assert_eq!(
313            sim.root.state.status,
314            WorkflowStatus::Failed,
315            "{:?}",
316            sim.problems
317        );
318        (c.workflows[0].clone(), sim.root.history)
319    }
320
321    #[test]
322    fn a_recorded_history_replays_cleanly() {
323        let (def, history) = recorded();
324        let r = replay("sim", &history, &def, None);
325        assert!(r.consistent(), "{:#?}", r.divergences);
326        assert_eq!(r.events, history.len());
327        assert_eq!(r.external + r.decided, history.len());
328        assert!(
329            r.decided > 10 && r.external > 5,
330            "{} {}",
331            r.decided,
332            r.external
333        );
334        assert!(r.definition_mismatch.is_none());
335        assert!(r.pending.is_empty());
336        assert_eq!(r.state.status, WorkflowStatus::Failed);
337        // Worker results for task steps are external; compute results are decided.
338        let origin_of = |pred: &dyn Fn(&Event) -> bool| {
339            let h = history.iter().find(|h| pred(&h.event)).unwrap();
340            r.origins.iter().find(|o| o.seq == h.seq).unwrap().origin
341        };
342        assert_eq!(
343            origin_of(&|e| matches!(e, Event::StepSucceeded { step, .. } if step == "assess")),
344            Origin::Decided
345        );
346        assert_eq!(
347            origin_of(&|e| matches!(e, Event::StepSucceeded { step, .. } if step == "charge")),
348            Origin::External
349        );
350    }
351
352    #[test]
353    fn stopping_at_a_sequence_number_shows_the_state_then() {
354        let (def, history) = recorded();
355        let delivered = history
356            .iter()
357            .find(|h| matches!(h.event, Event::SignalDelivered { .. }))
358            .unwrap()
359            .seq;
360        let r = replay("sim", &history, &def, Some(delivered));
361        assert_eq!(r.events as u64, delivered);
362        assert_eq!(r.state.status, WorkflowStatus::Running);
363        assert_eq!(r.state.units["approval"].status(), UnitStatus::Succeeded);
364        assert_eq!(
365            r.state.units["approval"].output,
366            Some(json!({ "ok": true }))
367        );
368        assert!(!r.state.units.contains_key("charge"));
369        // The decider would start the timer next.
370        assert!(
371            matches!(r.pending[..], [Event::TimerStarted { .. }]),
372            "{:?}",
373            r.pending
374        );
375    }
376
377    #[test]
378    fn a_tampered_history_is_reported() {
379        let (def, mut history) = recorded();
380        // Pretend the runtime skipped the approval although the order is big.
381        let i = history
382            .iter()
383            .position(|h| matches!(h.event, Event::SignalAwaitStarted { .. }))
384            .unwrap();
385        history[i].event = Event::StepSkipped {
386            step: "approval".into(),
387            reason: "`when` condition was false".into(),
388        };
389        let seq = history[i].seq;
390        let r = replay("sim", &history, &def, None);
391        assert!(!r.consistent());
392        let d = &r.divergences[0];
393        assert_eq!(d.seq, seq);
394        assert!(
395            matches!(d.decider[..], [Event::SignalAwaitStarted { .. }]),
396            "{:?}",
397            d.decider
398        );
399
400        // A changed retry delay is a divergence too.
401        let (def, mut history) = recorded();
402        for h in &mut history {
403            if let Event::RetryScheduled { delay_ms, .. } = &mut h.event {
404                *delay_ms = 9_000;
405            }
406        }
407        let r = replay("sim", &history, &def, None);
408        assert!(r
409            .divergences
410            .iter()
411            .any(|d| matches!(d.recorded, Event::RetryScheduled { .. })));
412    }
413
414    #[test]
415    fn a_worker_result_is_external_but_a_forged_compute_result_is_not() {
416        let (def, mut history) = recorded();
417        for h in &mut history {
418            if let Event::StepSucceeded { step, output, .. } = &mut h.event {
419                if step == "assess" {
420                    *output = json!({ "big": false });
421                }
422            }
423        }
424        let r = replay("sim", &history, &def, None);
425        // The first divergence is the forged result (later decisions then
426        // follow from it, so they diverge too).
427        assert!(!r.consistent());
428        assert!(
429            matches!(&r.divergences[0].recorded, Event::StepSucceeded { step, .. } if step == "assess")
430        );
431    }
432}