1use crate::decide::{decide, invocation};
26use crate::model::*;
27use orbit_ir::WorkflowIr;
28use serde::Serialize;
29use serde_json::Value;
30
31#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
42pub struct EventOrigin {
43 pub seq: u64,
44 pub origin: Origin,
45}
46
47#[derive(Debug, Clone, Serialize)]
49pub struct Divergence {
50 pub seq: u64,
51 pub recorded: Event,
52 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 pub origins: Vec<EventOrigin>,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub definition_mismatch: Option<(String, String)>,
68 pub pending: Vec<Event>,
71 pub state: ExecutionState,
73}
74
75impl ReplayReport {
76 pub fn consistent(&self) -> bool {
77 self.divergences.is_empty()
78 }
79}
80
81fn 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 | Event::LimitExceeded { .. }
95 ) || matches!(e, Event::StepFailed { error, .. } if error.code == crate::model::codes::LIMIT_EXCEEDED)
96}
97
98fn 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
123const TIME_FIELDS: &[&str] = &["fire_at_ms", "deadline_ms"];
125
126fn 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
163pub 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 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 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 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 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 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 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 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 assert!(!r.consistent());
428 assert!(
429 matches!(&r.divergences[0].recorded, Event::StepSucceeded { step, .. } if step == "assess")
430 );
431 }
432}