1use crate::engine::{ContractAnnouncement, RegisterWorker, TaskAssignment};
13use crate::model::*;
14use crate::{Engine, EngineConfig, Store};
15use orbit_ir::testing::{value_matches, Expectation, MockOutcome, TestCase};
16use orbit_ir::{eval, WORKFLOW_CONTRACT_PREFIX};
17use orbit_ir::{Action, Scope, SourceLocation, TaskContract, Type, WorkflowIr};
18use serde::Serialize;
19use serde_json::{json, Value};
20use std::collections::{BTreeMap, BTreeSet, HashMap};
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::sync::Arc;
23
24pub const SIMULATION_EPOCH_MS: u64 = 1_767_225_600_000;
26pub const ROOT_EXECUTION_ID: &str = "sim";
28const MAX_ROUNDS: usize = 100_000;
30const DEFAULT_RUNTIME: &str = "simulator";
32const MAX_SIMULATED_MS: u64 = 10 * 365 * 24 * 3600 * 1000;
34
35#[derive(Debug, Clone, PartialEq)]
37pub enum TaskOutcome {
38 Complete(Value),
39 Fail(TaskError),
40 Hang,
42 Abort(String),
44}
45
46pub struct TaskCall<'a> {
48 pub assignment: &'a TaskAssignment,
49 pub contract: Option<&'a TaskContract>,
50 pub call: u32,
53}
54
55pub trait TaskHandler {
56 fn handle(&mut self, call: &TaskCall) -> TaskOutcome;
57}
58
59#[derive(Debug, Clone, Serialize)]
61pub struct SimulatedExecution {
62 pub state: ExecutionState,
63 pub history: Vec<HistoryEvent>,
64}
65
66#[derive(Debug, Clone, Serialize)]
67pub struct Simulation {
68 pub root: SimulatedExecution,
70 pub children: Vec<SimulatedExecution>,
72 pub problems: Vec<String>,
75 pub unsent_signals: Vec<String>,
77 pub elapsed_ms: u64,
79}
80
81impl Simulation {
82 pub fn finished(&self) -> bool {
83 self.root.state.status.is_terminal()
84 }
85}
86
87pub struct Simulator {
88 engine: Engine,
89 clock: Arc<AtomicU64>,
90 sessions: Vec<(String, String)>,
92 contracts: HashMap<String, TaskContract>,
93}
94
95impl Simulator {
96 pub fn new(workflows: &[WorkflowIr]) -> Result<Simulator, String> {
98 let clock = Arc::new(AtomicU64::new(SIMULATION_EPOCH_MS));
99 let c = clock.clone();
100 let config = EngineConfig {
101 session_timeout_ms: 1 << 50,
103 heartbeat_interval_ms: 1 << 40,
104 http_adapter: true,
106 ..EngineConfig::default()
107 };
108 let store = Store::in_memory().map_err(|e| e.to_string())?;
109 let mut engine = Engine::open(store, config, Box::new(move || c.load(Ordering::SeqCst)))
110 .map_err(|e| e.to_string())?;
111 let mut pending: Vec<&WorkflowIr> = workflows.iter().collect();
113 while !pending.is_empty() {
114 let mut next = vec![];
115 let mut last_error = None;
116 for wf in &pending {
117 match engine.register_definition((*wf).clone()) {
118 Ok(_) => {}
119 Err(e) if e.code == "CHILD_NOT_REGISTERED" => {
120 last_error = Some(e.message.clone());
121 next.push(*wf);
122 }
123 Err(e) => return Err(format!("{}@{}: {}", wf.name, wf.version, e.message)),
124 }
125 }
126 if next.len() == pending.len() {
127 return Err(last_error.unwrap_or_default());
128 }
129 pending = next;
130 }
131 let mut contracts = HashMap::new();
134 let mut runtimes = BTreeSet::from([DEFAULT_RUNTIME.to_string()]);
135 for wf in workflows {
136 for c in wf.contracts.values() {
137 if !c.name.starts_with(WORKFLOW_CONTRACT_PREFIX) {
138 contracts.insert(c.name.clone(), c.clone());
139 }
140 }
141 for s in &wf.steps {
142 for inv in s.invocation().into_iter().chain(s.compensation.as_ref()) {
143 if let Some(l) = &inv.language {
144 runtimes.insert(l.to_lowercase());
145 }
146 }
147 }
148 }
149 let announced: BTreeSet<(String, String)> = contracts
150 .values()
151 .filter(|c| !c.builtin)
152 .map(|c| (c.name.clone(), c.version.clone()))
153 .collect();
154 let mut sessions = vec![];
155 if !announced.is_empty() {
156 for runtime in runtimes {
157 let reg = engine
158 .register_worker(RegisterWorker {
159 worker_id: if runtime == DEFAULT_RUNTIME {
160 "simulator".to_string()
161 } else {
162 format!("simulator-{runtime}")
163 },
164 runtime: runtime.clone(),
165 sdk: Some("orbit-simulator".into()),
166 contracts: announced
167 .iter()
168 .map(|(n, v)| ContractAnnouncement {
169 task_name: n.clone(),
170 contract_version: v.clone(),
171 })
172 .collect(),
173 })
174 .map_err(|e| e.message)?;
175 sessions.push((runtime, reg.worker_session_id));
176 }
177 }
178 Ok(Simulator {
179 engine,
180 clock,
181 sessions,
182 contracts,
183 })
184 }
185
186 fn now(&self) -> u64 {
187 self.clock.load(Ordering::SeqCst)
188 }
189
190 pub fn run(
192 mut self,
193 workflow: &str,
194 version: Option<&str>,
195 input: Value,
196 signals: &[(String, Value)],
197 handler: &mut dyn TaskHandler,
198 ) -> Result<Simulation, String> {
199 let root = ROOT_EXECUTION_ID.to_string();
200 self.engine
201 .start(workflow, version, input, Some(root.clone()))
202 .map_err(|e| e.message)?;
203 let mut calls: HashMap<(String, String), u32> = HashMap::new();
204 let mut problems = vec![];
205 let mut next_signal = 0;
206 let mut rounds = 0;
207 let mut seen_progress = 0;
208 'run: loop {
209 rounds += 1;
210 if rounds > MAX_ROUNDS {
211 problems.push(format!(
212 "the simulation did not finish within {MAX_ROUNDS} rounds"
213 ));
214 break;
215 }
216 let mut work = self.engine.tick().map_err(|e| e.message)?;
217 for (_, session) in self.sessions.clone() {
218 while let Some(a) = self.engine.poll(&session).map_err(|e| e.message)? {
219 work.push(a);
220 }
221 }
222 for a in work {
223 let n = calls
224 .entry((a.execution_id.clone(), a.step_id.clone()))
225 .or_default();
226 *n += 1;
227 let call = TaskCall {
228 assignment: &a,
229 contract: self.contracts.get(&a.task_name),
230 call: *n,
231 };
232 match handler.handle(&call) {
233 TaskOutcome::Complete(v) => {
234 self.engine.complete(&a.token, v).map_err(|e| e.message)?;
235 }
236 TaskOutcome::Fail(e) => {
237 self.engine.fail(&a.token, e).map_err(|e| e.message)?;
238 }
239 TaskOutcome::Hang => {}
240 TaskOutcome::Abort(msg) => {
241 problems.push(msg);
242 break 'run;
243 }
244 }
245 }
246 let state = self.engine.execution(&root).map_err(|e| e.message)?;
247 if state.status.is_terminal() {
248 break;
249 }
250 let progress = self.progress()?;
253 if progress != seen_progress {
254 seen_progress = progress;
255 continue;
256 }
257 if let Some((name, payload)) = signals.get(next_signal) {
259 if self.awaiting(&state).contains(name.as_str()) {
260 self.engine
261 .signal(&root, name, payload.clone())
262 .map_err(|e| e.message)?;
263 next_signal += 1;
264 continue;
265 }
266 }
267 match self.next_wake_up()? {
269 Some(at) if at - SIMULATION_EPOCH_MS <= MAX_SIMULATED_MS => {
270 self.clock.store(at, Ordering::SeqCst);
271 }
272 Some(_) => {
273 problems.push("the simulation ran past ten years of simulated time".into());
274 break;
275 }
276 None => {
277 problems.push(self.stuck_reason(&state, signals.get(next_signal)));
278 break;
279 }
280 }
281 }
282 let unsent_signals: Vec<String> = signals[next_signal.min(signals.len())..]
283 .iter()
284 .map(|(n, _)| n.clone())
285 .collect();
286 let mut children = vec![];
287 for s in self
288 .engine
289 .list_executions(None, None, 1000)
290 .map_err(|e| e.message)?
291 {
292 if s.execution_id != root {
293 children.push(self.collect(&s.execution_id)?);
294 }
295 }
296 children.sort_by(|a, b| a.state.id.cmp(&b.state.id));
297 let root = self.collect(&root)?;
298 let end = root.state.finished_at_ms.unwrap_or(self.now());
299 Ok(Simulation {
300 elapsed_ms: end.saturating_sub(root.state.created_at_ms),
301 root,
302 children,
303 problems,
304 unsent_signals,
305 })
306 }
307
308 fn progress(&self) -> Result<u64, String> {
310 let mut total = 0;
311 for s in self
312 .engine
313 .list_executions(None, None, 1000)
314 .map_err(|e| e.message)?
315 {
316 total += 1 + self
317 .engine
318 .execution(&s.execution_id)
319 .map_err(|e| e.message)?
320 .last_seq;
321 }
322 Ok(total)
323 }
324
325 fn collect(&self, id: &str) -> Result<SimulatedExecution, String> {
326 Ok(SimulatedExecution {
327 state: self.engine.execution(id).map_err(|e| e.message)?,
328 history: self.engine.history(id).map_err(|e| e.message)?,
329 })
330 }
331
332 fn definition(&self, state: &ExecutionState) -> Option<Arc<WorkflowIr>> {
333 self.engine
334 .definition(&state.workflow, Some(&state.version))
335 }
336
337 fn awaiting(&self, state: &ExecutionState) -> BTreeSet<String> {
339 let Some(def) = self.definition(state) else {
340 return BTreeSet::new();
341 };
342 let handled = def.events.keys().filter(|_| state.abort.is_none()).cloned();
345 state
346 .units
347 .iter()
348 .filter(|(_, u)| u.status() == UnitStatus::Waiting)
349 .filter_map(|(key, _)| awaited(&def, key).map(str::to_string))
350 .chain(handled)
351 .collect()
352 }
353
354 fn next_wake_up(&self) -> Result<Option<u64>, String> {
357 let now = self.now();
358 let mut best: Option<u64> = None;
359 let mut consider = |t: u64| {
360 if t > now && best.is_none_or(|b| t < b) {
361 best = Some(t);
362 }
363 };
364 for s in self
365 .engine
366 .list_executions(None, None, 1000)
367 .map_err(|e| e.message)?
368 {
369 let state = self
370 .engine
371 .execution(&s.execution_id)
372 .map_err(|e| e.message)?;
373 if state.status.is_terminal() {
374 continue;
375 }
376 let def = self.definition(&state);
377 if let (Some(def), None) = (&def, &state.abort) {
378 if let Some(t) = def.timeout_ms {
379 consider(state.created_at_ms + t);
380 }
381 }
382 for (key, u) in &state.units {
383 match u.status() {
384 UnitStatus::Waiting | UnitStatus::RetryWait => {
385 if let Some(t) = u.wake_at_ms {
386 consider(t);
387 }
388 }
389 UnitStatus::Dispatched => {
390 if let Some(t) = u.deadline_ms {
391 consider(t);
392 }
393 }
394 UnitStatus::Scheduled => {
395 let qt = def
396 .as_ref()
397 .and_then(|d| crate::decide::invocation(d, key))
398 .and_then(|i| i.queue_timeout_ms);
399 if let (Some(qt), Some(at), false) = (qt, u.scheduled_at_ms, u.redelivery) {
400 consider(at + qt);
401 }
402 }
403 _ => {}
404 }
405 }
406 }
407 Ok(best)
408 }
409
410 fn stuck_reason(&self, state: &ExecutionState, next: Option<&(String, Value)>) -> String {
411 let def = self.definition(state);
412 let mut waits = vec![];
413 for (key, u) in &state.units {
414 match u.status() {
415 UnitStatus::Waiting => {
416 if let Some(sig) = def.as_ref().and_then(|d| awaited(d, key)) {
417 let detail = match next {
418 Some((n, _)) if n != sig => format!(
419 "signal `{sig}`, but the next signal to send is `{n}` (signals are sent in order)"
420 ),
421 Some(_) => format!("signal `{sig}`"),
422 None => format!("signal `{sig}`, and no signal is left to send"),
423 };
424 waits.push(format!("step `{key}` waits for {detail}"));
425 }
426 }
427 UnitStatus::Dispatched => {
428 let task = def
429 .as_ref()
430 .and_then(|d| crate::decide::invocation(d, key))
431 .map(|i| i.task.clone())
432 .unwrap_or_default();
433 if !task.starts_with(WORKFLOW_CONTRACT_PREFIX) {
434 waits.push(format!(
435 "step `{key}` waits for task `{task}`, which never answers and has no timeout"
436 ));
437 }
438 }
439 _ => {}
440 }
441 }
442 if waits.is_empty() {
443 format!(
444 "the execution stopped making progress while {}",
445 state.status.as_str()
446 )
447 } else {
448 format!("the execution cannot make progress: {}", waits.join("; "))
449 }
450 }
451}
452
453fn awaited<'a>(def: &'a WorkflowIr, key: &str) -> Option<&'a str> {
455 let k = parse_key(key);
456 if k.compensation {
457 return None;
458 }
459 match &def.step(k.step)?.action {
460 Action::Await { signal, .. } => Some(signal),
461 _ => None,
462 }
463}
464
465pub fn simulate(
467 workflows: &[WorkflowIr],
468 workflow: &str,
469 version: Option<&str>,
470 input: Value,
471 signals: &[(String, Value)],
472 handler: &mut dyn TaskHandler,
473) -> Result<Simulation, String> {
474 Simulator::new(workflows)?.run(workflow, version, input, signals, handler)
475}
476
477pub fn sample_value(ty: &Type, name: &str) -> Value {
498 match ty {
499 Type::String => json!(format!("{name}-sample")),
500 Type::Int => json!(1),
501 Type::Float => json!(1.0),
502 Type::Bool => json!(true),
503 Type::Bytes => json!("c2FtcGxl"),
504 Type::Duration => json!(1000),
505 Type::Timestamp => json!("2026-01-01T00:00:00Z"),
506 Type::Uuid => json!("00000000-0000-4000-8000-000000000001"),
507 Type::Json => json!({}),
508 Type::Null | Type::Optional { .. } => Value::Null,
509 Type::List { item } => json!([sample_value(item, name)]),
510 Type::Map { value, .. } => json!({ "key": sample_value(value, name) }),
511 Type::Record { fields, .. } => Value::Object(
512 fields
513 .iter()
514 .map(|f| {
515 let v = f
516 .default
517 .clone()
518 .unwrap_or_else(|| sample_value(&f.ty, &f.name));
519 (f.name.clone(), v)
520 })
521 .collect(),
522 ),
523 }
524}
525
526#[derive(Debug, Clone, Default)]
529pub struct SampleHandler {
530 pub results: BTreeMap<String, Value>,
532 pub failures: BTreeMap<String, String>,
534}
535
536impl TaskHandler for SampleHandler {
537 fn handle(&mut self, call: &TaskCall) -> TaskOutcome {
538 let task = &call.assignment.task_name;
539 if let Some(code) = self.failures.get(task) {
540 return TaskOutcome::Fail(TaskError::new(
541 code,
542 format!("simulated failure of `{task}` (--fail)"),
543 false,
544 ));
545 }
546 if let Some(v) = self.results.get(task) {
547 return TaskOutcome::Complete(v.clone());
548 }
549 TaskOutcome::Complete(
550 call.contract
551 .map(|c| sample_value(&c.output, "value"))
552 .unwrap_or_else(|| json!({})),
553 )
554 }
555}
556
557pub struct MockHandler<'a> {
561 pub test: &'a TestCase,
562}
563
564struct TaskScope<'a> {
565 input: &'a Value,
566 assignment: &'a TaskAssignment,
567}
568
569impl Scope for TaskScope<'_> {
570 fn input(&self) -> &Value {
571 self.input
572 }
573 fn step_output(&self, _: &str) -> Option<&Value> {
574 None
575 }
576 fn execution_field(&self, field: &str) -> Option<Value> {
577 Some(Value::String(match field {
578 "id" => self.assignment.execution_id.clone(),
579 "workflow" => self.assignment.workflow.clone(),
580 "version" => self.assignment.workflow_version.clone(),
581 _ => return None,
582 }))
583 }
584}
585
586impl TaskHandler for MockHandler<'_> {
587 fn handle(&mut self, call: &TaskCall) -> TaskOutcome {
588 let a = call.assignment;
589 let Some(behaviours) = self.test.mocks.get(&a.task_name) else {
590 return TaskOutcome::Abort(format!(
591 "task `{}` (step `{}`) is not mocked; add `mock \"{}\" returns {{ ... }}` to the test",
592 a.task_name, a.step_id, a.task_name
593 ));
594 };
595 let mut covered = 0u32;
596 let mut chosen = behaviours.last();
597 for b in behaviours {
598 match b.times {
599 Some(n) => {
600 covered = covered.saturating_add(n);
601 if call.call <= covered {
602 chosen = Some(b);
603 break;
604 }
605 }
606 None => {
607 chosen = Some(b);
608 break;
609 }
610 }
611 }
612 match chosen.map(|b| &b.outcome) {
613 Some(MockOutcome::Returns { output }) => {
614 let scope = TaskScope {
615 input: &a.input,
616 assignment: a,
617 };
618 match eval(output, &scope) {
619 Ok(v) => TaskOutcome::Complete(v),
620 Err(e) => TaskOutcome::Abort(format!(
621 "the mock of `{}` (step `{}`) failed to evaluate: {e}",
622 a.task_name, a.step_id
623 )),
624 }
625 }
626 Some(MockOutcome::Fails {
627 code,
628 message,
629 retryable,
630 }) => TaskOutcome::Fail(TaskError::new(code, message.clone(), *retryable)),
631 Some(MockOutcome::Hangs) => TaskOutcome::Hang,
632 None => TaskOutcome::Abort(format!("the mock of `{}` has no behaviour", a.task_name)),
633 }
634 }
635}
636
637#[derive(Debug, Clone, Serialize)]
639pub struct TestResult {
640 pub name: String,
641 pub workflow: String,
642 pub version: String,
643 pub location: SourceLocation,
644 pub passed: bool,
645 pub failures: Vec<String>,
647 #[serde(skip)]
648 pub simulation: Option<Simulation>,
649}
650
651pub fn run_test(workflows: &[WorkflowIr], test: &TestCase) -> TestResult {
654 let mut result = TestResult {
655 name: test.name.clone(),
656 workflow: test.workflow.clone(),
657 version: test.version.clone(),
658 location: test.location.clone(),
659 passed: false,
660 failures: vec![],
661 simulation: None,
662 };
663 let signals: Vec<(String, Value)> = test
664 .signals
665 .iter()
666 .map(|s| (s.name.clone(), s.payload.clone()))
667 .collect();
668 let mut handler = MockHandler { test };
669 let sim = match simulate(
670 workflows,
671 &test.workflow,
672 Some(&test.version),
673 test.input.clone(),
674 &signals,
675 &mut handler,
676 ) {
677 Ok(s) => s,
678 Err(e) => {
679 result
680 .failures
681 .push(format!("could not start the execution: {e}"));
682 return result;
683 }
684 };
685 result.failures.extend(sim.problems.iter().cloned());
686 if sim.finished() {
687 for e in &test.expectations {
688 if let Some(f) = check(e, &sim.root.state) {
689 result.failures.push(f);
690 }
691 }
692 for s in &sim.unsent_signals {
693 result.failures.push(format!(
694 "signal `{s}` was never sent: the execution finished without waiting for it"
695 ));
696 }
697 }
698 result.passed = result.failures.is_empty();
699 result.simulation = Some(sim);
700 result
701}
702
703fn compact(v: &Value) -> String {
704 serde_json::to_string(v).unwrap_or_default()
705}
706
707fn status_name(s: UnitStatus) -> String {
708 serde_json::to_value(s)
709 .ok()
710 .and_then(|v| v.as_str().map(str::to_string))
711 .unwrap_or_default()
712}
713
714fn describe_end(state: &ExecutionState) -> String {
715 match &state.error {
716 Some(e) => format!("{} ({}: {})", state.status.as_str(), e.code, e.message),
717 None => state.status.as_str().to_string(),
718 }
719}
720
721fn check(e: &Expectation, state: &ExecutionState) -> Option<String> {
725 let event_step = match e {
726 Expectation::StepStatus { step, .. }
727 | Expectation::StepOutput { step, .. }
728 | Expectation::StepAttempts { step, .. } => step
729 .strip_prefix("on_event:")
730 .and_then(|r| r.split_once(':'))
731 .map(|(signal, id)| (step.clone(), signal.to_string(), id.to_string())),
732 _ => None,
733 };
734 if let Some((written, signal, id)) = event_step {
735 let instances: Vec<String> = state
736 .signals
737 .iter()
738 .filter_map(|r| r.delivered_to.clone())
739 .filter(|k| k.starts_with(&format!("on_event:{signal}@")))
740 .collect();
741 if instances.is_empty() {
742 return Some(format!(
743 "expected step `{written}`, but no `{signal}` signal started the `on event {signal}` block"
744 ));
745 }
746 for instance in instances {
747 let key = format!("{instance}:{id}");
748 let mut one = e.clone();
749 match &mut one {
750 Expectation::StepStatus { step, .. }
751 | Expectation::StepOutput { step, .. }
752 | Expectation::StepAttempts { step, .. } => *step = key,
753 _ => {}
754 }
755 if let Some(why) = check_one(&one, state) {
756 return Some(why.replace(&format!("{instance}:{id}"), &written));
757 }
758 }
759 return None;
760 }
761 check_one(e, state)
762}
763
764fn check_one(e: &Expectation, state: &ExecutionState) -> Option<String> {
765 let unit = |step: &str| state.units.get(step).cloned().unwrap_or_default();
766 match e {
767 Expectation::Status { status } => (state.status.as_str() != status)
768 .then(|| format!("expected status {status}, got {}", describe_end(state))),
769 Expectation::Output { expected } => {
770 let actual = state.output.clone().unwrap_or(Value::Null);
771 (!value_matches(expected, &actual)).then(|| {
772 if state.output.is_none() {
773 format!(
774 "expected output {}, but the workflow produced none (it ended {})",
775 compact(expected),
776 describe_end(state)
777 )
778 } else {
779 format!(
780 "expected output {}, got {}",
781 compact(expected),
782 compact(&actual)
783 )
784 }
785 })
786 }
787 Expectation::Error { code } => match &state.error {
788 Some(err) if err.code == *code => None,
789 Some(err) => Some(format!(
790 "expected error {code:?}, got {:?} ({})",
791 err.code, err.message
792 )),
793 None => Some(format!(
794 "expected error {code:?}, but the workflow ended {} without an error",
795 state.status.as_str()
796 )),
797 },
798 Expectation::StepStatus { step, status } => {
799 let actual = status_name(unit(step).status());
800 (actual != *status).then(|| {
801 let u = unit(step);
802 let why = u
803 .error
804 .as_ref()
805 .map(|e| format!(" ({}: {})", e.code, e.message))
806 .unwrap_or_default();
807 format!("expected step `{step}` to be {status}, got {actual}{why}")
808 })
809 }
810 Expectation::StepOutput { step, expected } => {
811 let u = unit(step);
812 let actual = u.output.clone().unwrap_or(Value::Null);
813 (!value_matches(expected, &actual)).then(|| {
814 format!(
815 "expected step `{step}` output {}, got {} (step is {})",
816 compact(expected),
817 compact(&actual),
818 status_name(u.status())
819 )
820 })
821 }
822 Expectation::StepAttempts { step, attempts } => {
823 let actual = unit(step).attempt;
824 (actual != *attempts).then(|| {
825 format!("expected step `{step}` to take {attempts} attempt(s), took {actual}")
826 })
827 }
828 Expectation::Compensated { step } => {
829 let element = format!("{}[", compensation_key(step));
830 let units: Vec<(&String, &UnitState)> = state
831 .units
832 .iter()
833 .filter(|(k, _)| **k == compensation_key(step) || k.starts_with(&element))
834 .collect();
835 if units.is_empty() {
836 return Some(format!(
837 "expected step `{step}` to be compensated, but its compensation never ran"
838 ));
839 }
840 units
841 .iter()
842 .find(|(_, u)| u.status() != UnitStatus::Succeeded)
843 .map(|(k, u)| {
844 let why = u
845 .error
846 .as_ref()
847 .map(|e| format!(" ({}: {})", e.code, e.message))
848 .unwrap_or_default();
849 format!(
850 "expected step `{step}` to be compensated, but `{k}` is {}{why}",
851 status_name(u.status())
852 )
853 })
854 }
855 }
856}