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 let started_at = self.now();
202 self.engine
203 .start(workflow, version, input, Some(root.clone()))
204 .map_err(|e| e.message)?;
205 let mut calls: HashMap<(String, String), u32> = HashMap::new();
206 let mut problems = vec![];
207 let mut next_signal = 0;
208 let mut rounds = 0;
209 let mut seen_progress = 0;
210 'run: loop {
211 rounds += 1;
212 if rounds > MAX_ROUNDS {
213 problems.push(format!(
214 "the simulation did not finish within {MAX_ROUNDS} rounds"
215 ));
216 break;
217 }
218 let mut work = self.engine.tick().map_err(|e| e.message)?;
219 for (_, session) in self.sessions.clone() {
220 while let Some(a) = self.engine.poll(&session).map_err(|e| e.message)? {
221 work.push(a);
222 }
223 }
224 for a in work {
225 let n = calls
226 .entry((a.execution_id.clone(), a.step_id.clone()))
227 .or_default();
228 *n += 1;
229 let call = TaskCall {
230 assignment: &a,
231 contract: self.contracts.get(&a.task_name),
232 call: *n,
233 };
234 match handler.handle(&call) {
235 TaskOutcome::Complete(v) => {
236 self.engine.complete(&a.token, v).map_err(|e| e.message)?;
237 }
238 TaskOutcome::Fail(e) => {
239 self.engine.fail(&a.token, e).map_err(|e| e.message)?;
240 }
241 TaskOutcome::Hang => {}
242 TaskOutcome::Abort(msg) => {
243 problems.push(msg);
244 break 'run;
245 }
246 }
247 }
248 let state = self.engine.execution(&root).map_err(|e| e.message)?;
249 if state.status.is_terminal() {
250 break;
251 }
252 let progress = self.progress()?;
255 if progress != seen_progress {
256 seen_progress = progress;
257 continue;
258 }
259 if let Some((name, payload)) = signals.get(next_signal) {
261 if self.awaiting(&state).contains(name.as_str()) {
262 self.engine
263 .signal(&root, name, payload.clone())
264 .map_err(|e| e.message)?;
265 next_signal += 1;
266 continue;
267 }
268 }
269 match self.next_wake_up()? {
271 Some(at) if at - SIMULATION_EPOCH_MS <= MAX_SIMULATED_MS => {
272 self.clock.store(at, Ordering::SeqCst);
273 }
274 Some(_) => {
275 problems.push("the simulation ran past ten years of simulated time".into());
276 break;
277 }
278 None => {
279 problems.push(self.stuck_reason(&state, signals.get(next_signal)));
280 break;
281 }
282 }
283 }
284 let unsent_signals: Vec<String> = signals[next_signal.min(signals.len())..]
285 .iter()
286 .map(|(n, _)| n.clone())
287 .collect();
288 let mut children = vec![];
289 for s in self
290 .engine
291 .list_executions(None, None, 1000)
292 .map_err(|e| e.message)?
293 {
294 if s.execution_id != root {
295 children.push(self.collect(&s.execution_id)?);
296 }
297 }
298 children.sort_by(|a, b| a.state.id.cmp(&b.state.id));
299 let root = self.collect(&root)?;
300 let end = root.state.finished_at_ms.unwrap_or(self.now());
301 Ok(Simulation {
302 elapsed_ms: end.saturating_sub(started_at),
303 root,
304 children,
305 problems,
306 unsent_signals,
307 })
308 }
309
310 fn progress(&self) -> Result<u64, String> {
312 let mut total = 0;
313 for s in self
314 .engine
315 .list_executions(None, None, 1000)
316 .map_err(|e| e.message)?
317 {
318 let state = self
320 .engine
321 .execution(&s.execution_id)
322 .map_err(|e| e.message)?;
323 total += 1 + state.last_seq + (state.run as u64) * (1 << 32);
324 }
325 Ok(total)
326 }
327
328 fn collect(&self, id: &str) -> Result<SimulatedExecution, String> {
329 Ok(SimulatedExecution {
330 state: self.engine.execution(id).map_err(|e| e.message)?,
331 history: self.engine.history(id).map_err(|e| e.message)?,
332 })
333 }
334
335 fn definition(&self, state: &ExecutionState) -> Option<Arc<WorkflowIr>> {
336 self.engine
337 .definition(&state.workflow, Some(&state.version))
338 }
339
340 fn awaiting(&self, state: &ExecutionState) -> BTreeSet<String> {
342 let Some(def) = self.definition(state) else {
343 return BTreeSet::new();
344 };
345 let handled = def.events.keys().filter(|_| state.abort.is_none()).cloned();
348 state
349 .units
350 .iter()
351 .filter(|(_, u)| u.status() == UnitStatus::Waiting)
352 .filter_map(|(key, _)| awaited(&def, key).map(str::to_string))
353 .chain(handled)
354 .collect()
355 }
356
357 fn next_wake_up(&self) -> Result<Option<u64>, String> {
360 let now = self.now();
361 let mut best: Option<u64> = None;
362 let mut consider = |t: u64| {
363 if t > now && best.is_none_or(|b| t < b) {
364 best = Some(t);
365 }
366 };
367 for s in self
368 .engine
369 .list_executions(None, None, 1000)
370 .map_err(|e| e.message)?
371 {
372 let state = self
373 .engine
374 .execution(&s.execution_id)
375 .map_err(|e| e.message)?;
376 if state.status.is_terminal() {
377 continue;
378 }
379 let def = self.definition(&state);
380 if let (Some(def), None) = (&def, &state.abort) {
381 if let Some(t) = def.timeout_ms {
382 consider(state.created_at_ms + t);
383 }
384 }
385 for (key, u) in &state.units {
386 match u.status() {
387 UnitStatus::Waiting | UnitStatus::RetryWait => {
388 if let Some(t) = u.wake_at_ms {
389 consider(t);
390 }
391 }
392 UnitStatus::Dispatched => {
393 if let Some(t) = u.deadline_ms {
394 consider(t);
395 }
396 }
397 UnitStatus::Scheduled => {
398 let qt = def
399 .as_ref()
400 .and_then(|d| crate::decide::invocation(d, key))
401 .and_then(|i| i.queue_timeout_ms);
402 if let (Some(qt), Some(at), false) = (qt, u.scheduled_at_ms, u.redelivery) {
403 consider(at + qt);
404 }
405 }
406 _ => {}
407 }
408 }
409 }
410 Ok(best)
411 }
412
413 fn stuck_reason(&self, state: &ExecutionState, next: Option<&(String, Value)>) -> String {
414 let def = self.definition(state);
415 let mut waits = vec![];
416 for (key, u) in &state.units {
417 match u.status() {
418 UnitStatus::Waiting => {
419 if let Some(sig) = def.as_ref().and_then(|d| awaited(d, key)) {
420 let detail = match next {
421 Some((n, _)) if n != sig => format!(
422 "signal `{sig}`, but the next signal to send is `{n}` (signals are sent in order)"
423 ),
424 Some(_) => format!("signal `{sig}`"),
425 None => format!("signal `{sig}`, and no signal is left to send"),
426 };
427 waits.push(format!("step `{key}` waits for {detail}"));
428 }
429 }
430 UnitStatus::Dispatched => {
431 let task = def
432 .as_ref()
433 .and_then(|d| crate::decide::invocation(d, key))
434 .map(|i| i.task.clone())
435 .unwrap_or_default();
436 if !task.starts_with(WORKFLOW_CONTRACT_PREFIX) {
437 waits.push(format!(
438 "step `{key}` waits for task `{task}`, which never answers and has no timeout"
439 ));
440 }
441 }
442 _ => {}
443 }
444 }
445 if waits.is_empty() {
446 format!(
447 "the execution stopped making progress while {}",
448 state.status.as_str()
449 )
450 } else {
451 format!("the execution cannot make progress: {}", waits.join("; "))
452 }
453 }
454}
455
456fn awaited<'a>(def: &'a WorkflowIr, key: &str) -> Option<&'a str> {
458 let k = parse_key(key);
459 if k.compensation {
460 return None;
461 }
462 match &def.step(k.step)?.action {
463 Action::Await { signal, .. } => Some(signal),
464 _ => None,
465 }
466}
467
468pub fn simulate(
470 workflows: &[WorkflowIr],
471 workflow: &str,
472 version: Option<&str>,
473 input: Value,
474 signals: &[(String, Value)],
475 handler: &mut dyn TaskHandler,
476) -> Result<Simulation, String> {
477 Simulator::new(workflows)?.run(workflow, version, input, signals, handler)
478}
479
480pub fn sample_value(ty: &Type, name: &str) -> Value {
501 match ty {
502 Type::String => json!(format!("{name}-sample")),
503 Type::Int => json!(1),
504 Type::Float => json!(1.0),
505 Type::Bool => json!(true),
506 Type::Bytes => json!("c2FtcGxl"),
507 Type::Duration => json!(1000),
508 Type::Timestamp => json!("2026-01-01T00:00:00Z"),
509 Type::Uuid => json!("00000000-0000-4000-8000-000000000001"),
510 Type::Json => json!({}),
511 Type::Null | Type::Optional { .. } => Value::Null,
512 Type::List { item } => json!([sample_value(item, name)]),
513 Type::Map { value, .. } => json!({ "key": sample_value(value, name) }),
514 Type::Record { fields, .. } => Value::Object(
515 fields
516 .iter()
517 .map(|f| {
518 let v = f
519 .default
520 .clone()
521 .unwrap_or_else(|| sample_value(&f.ty, &f.name));
522 (f.name.clone(), v)
523 })
524 .collect(),
525 ),
526 }
527}
528
529#[derive(Debug, Clone, Default)]
532pub struct SampleHandler {
533 pub results: BTreeMap<String, Value>,
535 pub failures: BTreeMap<String, String>,
537}
538
539impl TaskHandler for SampleHandler {
540 fn handle(&mut self, call: &TaskCall) -> TaskOutcome {
541 let task = &call.assignment.task_name;
542 if let Some(code) = self.failures.get(task) {
543 return TaskOutcome::Fail(TaskError::new(
544 code,
545 format!("simulated failure of `{task}` (--fail)"),
546 false,
547 ));
548 }
549 if let Some(v) = self.results.get(task) {
550 return TaskOutcome::Complete(v.clone());
551 }
552 TaskOutcome::Complete(
553 call.contract
554 .map(|c| sample_value(&c.output, "value"))
555 .unwrap_or_else(|| json!({})),
556 )
557 }
558}
559
560pub struct MockHandler<'a> {
564 pub test: &'a TestCase,
565}
566
567struct TaskScope<'a> {
568 input: &'a Value,
569 assignment: &'a TaskAssignment,
570}
571
572impl Scope for TaskScope<'_> {
573 fn input(&self) -> &Value {
574 self.input
575 }
576 fn step_output(&self, _: &str) -> Option<&Value> {
577 None
578 }
579 fn execution_field(&self, field: &str) -> Option<Value> {
580 Some(Value::String(match field {
581 "id" => self.assignment.execution_id.clone(),
582 "workflow" => self.assignment.workflow.clone(),
583 "version" => self.assignment.workflow_version.clone(),
584 _ => return None,
585 }))
586 }
587}
588
589impl TaskHandler for MockHandler<'_> {
590 fn handle(&mut self, call: &TaskCall) -> TaskOutcome {
591 let a = call.assignment;
592 let Some(behaviours) = self.test.mocks.get(&a.task_name) else {
593 return TaskOutcome::Abort(format!(
594 "task `{}` (step `{}`) is not mocked; add `mock \"{}\" returns {{ ... }}` to the test",
595 a.task_name, a.step_id, a.task_name
596 ));
597 };
598 let mut covered = 0u32;
599 let mut chosen = behaviours.last();
600 for b in behaviours {
601 match b.times {
602 Some(n) => {
603 covered = covered.saturating_add(n);
604 if call.call <= covered {
605 chosen = Some(b);
606 break;
607 }
608 }
609 None => {
610 chosen = Some(b);
611 break;
612 }
613 }
614 }
615 match chosen.map(|b| &b.outcome) {
616 Some(MockOutcome::Returns { output }) => {
617 let scope = TaskScope {
618 input: &a.input,
619 assignment: a,
620 };
621 match eval(output, &scope) {
622 Ok(v) => TaskOutcome::Complete(v),
623 Err(e) => TaskOutcome::Abort(format!(
624 "the mock of `{}` (step `{}`) failed to evaluate: {e}",
625 a.task_name, a.step_id
626 )),
627 }
628 }
629 Some(MockOutcome::Fails {
630 code,
631 message,
632 retryable,
633 }) => TaskOutcome::Fail(TaskError::new(code, message.clone(), *retryable)),
634 Some(MockOutcome::Hangs) => TaskOutcome::Hang,
635 None => TaskOutcome::Abort(format!("the mock of `{}` has no behaviour", a.task_name)),
636 }
637 }
638}
639
640#[derive(Debug, Clone, Serialize)]
642pub struct TestResult {
643 pub name: String,
644 pub workflow: String,
645 pub version: String,
646 pub location: SourceLocation,
647 pub passed: bool,
648 pub failures: Vec<String>,
650 #[serde(skip)]
651 pub simulation: Option<Simulation>,
652}
653
654pub fn run_test(workflows: &[WorkflowIr], test: &TestCase) -> TestResult {
657 let mut result = TestResult {
658 name: test.name.clone(),
659 workflow: test.workflow.clone(),
660 version: test.version.clone(),
661 location: test.location.clone(),
662 passed: false,
663 failures: vec![],
664 simulation: None,
665 };
666 let signals: Vec<(String, Value)> = test
667 .signals
668 .iter()
669 .map(|s| (s.name.clone(), s.payload.clone()))
670 .collect();
671 let mut handler = MockHandler { test };
672 let sim = match simulate(
673 workflows,
674 &test.workflow,
675 Some(&test.version),
676 test.input.clone(),
677 &signals,
678 &mut handler,
679 ) {
680 Ok(s) => s,
681 Err(e) => {
682 result
683 .failures
684 .push(format!("could not start the execution: {e}"));
685 return result;
686 }
687 };
688 result.failures.extend(sim.problems.iter().cloned());
689 if sim.finished() {
690 for e in &test.expectations {
691 if let Some(f) = check(e, &sim.root.state) {
692 result.failures.push(f);
693 }
694 }
695 for s in &sim.unsent_signals {
696 result.failures.push(format!(
697 "signal `{s}` was never sent: the execution finished without waiting for it"
698 ));
699 }
700 }
701 result.passed = result.failures.is_empty();
702 result.simulation = Some(sim);
703 result
704}
705
706fn compact(v: &Value) -> String {
707 serde_json::to_string(v).unwrap_or_default()
708}
709
710fn status_name(s: UnitStatus) -> String {
711 serde_json::to_value(s)
712 .ok()
713 .and_then(|v| v.as_str().map(str::to_string))
714 .unwrap_or_default()
715}
716
717fn describe_end(state: &ExecutionState) -> String {
718 match &state.error {
719 Some(e) => format!("{} ({}: {})", state.status.as_str(), e.code, e.message),
720 None => state.status.as_str().to_string(),
721 }
722}
723
724fn check(e: &Expectation, state: &ExecutionState) -> Option<String> {
728 let event_step = match e {
729 Expectation::StepStatus { step, .. }
730 | Expectation::StepOutput { step, .. }
731 | Expectation::StepAttempts { step, .. } => step
732 .strip_prefix("on_event:")
733 .and_then(|r| r.split_once(':'))
734 .map(|(signal, id)| (step.clone(), signal.to_string(), id.to_string())),
735 _ => None,
736 };
737 if let Some((written, signal, id)) = event_step {
738 let instances: Vec<String> = state
739 .signals
740 .iter()
741 .filter_map(|r| r.delivered_to.clone())
742 .filter(|k| k.starts_with(&format!("on_event:{signal}@")))
743 .collect();
744 if instances.is_empty() {
745 return Some(format!(
746 "expected step `{written}`, but no `{signal}` signal started the `on event {signal}` block"
747 ));
748 }
749 for instance in instances {
750 let key = format!("{instance}:{id}");
751 let mut one = e.clone();
752 match &mut one {
753 Expectation::StepStatus { step, .. }
754 | Expectation::StepOutput { step, .. }
755 | Expectation::StepAttempts { step, .. } => *step = key,
756 _ => {}
757 }
758 if let Some(why) = check_one(&one, state) {
759 return Some(why.replace(&format!("{instance}:{id}"), &written));
760 }
761 }
762 return None;
763 }
764 check_one(e, state)
765}
766
767fn check_one(e: &Expectation, state: &ExecutionState) -> Option<String> {
768 let unit = |step: &str| state.units.get(step).cloned().unwrap_or_default();
769 match e {
770 Expectation::Runs { runs } => (state.run != *runs).then(|| {
771 format!(
772 "expected the execution to end in run {runs}, it ended in run {}",
773 state.run
774 )
775 }),
776 Expectation::Status { status } => (state.status.as_str() != status)
777 .then(|| format!("expected status {status}, got {}", describe_end(state))),
778 Expectation::Output { expected } => {
779 let actual = state.output.clone().unwrap_or(Value::Null);
780 (!value_matches(expected, &actual)).then(|| {
781 if state.output.is_none() {
782 format!(
783 "expected output {}, but the workflow produced none (it ended {})",
784 compact(expected),
785 describe_end(state)
786 )
787 } else {
788 format!(
789 "expected output {}, got {}",
790 compact(expected),
791 compact(&actual)
792 )
793 }
794 })
795 }
796 Expectation::Error { code } => match &state.error {
797 Some(err) if err.code == *code => None,
798 Some(err) => Some(format!(
799 "expected error {code:?}, got {:?} ({})",
800 err.code, err.message
801 )),
802 None => Some(format!(
803 "expected error {code:?}, but the workflow ended {} without an error",
804 state.status.as_str()
805 )),
806 },
807 Expectation::StepStatus { step, status } => {
808 let actual = status_name(unit(step).status());
809 (actual != *status).then(|| {
810 let u = unit(step);
811 let why = u
812 .error
813 .as_ref()
814 .map(|e| format!(" ({}: {})", e.code, e.message))
815 .unwrap_or_default();
816 format!("expected step `{step}` to be {status}, got {actual}{why}")
817 })
818 }
819 Expectation::StepOutput { step, expected } => {
820 let u = unit(step);
821 let actual = u.output.clone().unwrap_or(Value::Null);
822 (!value_matches(expected, &actual)).then(|| {
823 format!(
824 "expected step `{step}` output {}, got {} (step is {})",
825 compact(expected),
826 compact(&actual),
827 status_name(u.status())
828 )
829 })
830 }
831 Expectation::StepAttempts { step, attempts } => {
832 let actual = unit(step).attempt;
833 (actual != *attempts).then(|| {
834 format!("expected step `{step}` to take {attempts} attempt(s), took {actual}")
835 })
836 }
837 Expectation::Compensated { step } => {
838 let element = format!("{}[", compensation_key(step));
839 let units: Vec<(&String, &UnitState)> = state
840 .units
841 .iter()
842 .filter(|(k, _)| **k == compensation_key(step) || k.starts_with(&element))
843 .collect();
844 if units.is_empty() {
845 return Some(format!(
846 "expected step `{step}` to be compensated, but its compensation never ran"
847 ));
848 }
849 units
850 .iter()
851 .find(|(_, u)| u.status() != UnitStatus::Succeeded)
852 .map(|(k, u)| {
853 let why = u
854 .error
855 .as_ref()
856 .map(|e| format!(" ({}: {})", e.code, e.message))
857 .unwrap_or_default();
858 format!(
859 "expected step `{step}` to be compensated, but `{k}` is {}{why}",
860 status_name(u.status())
861 )
862 })
863 }
864 }
865}