Skip to main content

tatara_engine/domain/
saga_executor.rs

1//! Saga executor — multi-step provisioning with compensation on failure.
2//!
3//! Executes saga steps in order. On failure at any step, compensates all
4//! previously completed steps in reverse order. Uses the existing
5//! SagaResult and SagaProgress types from tatara-core.
6
7use anyhow::Result;
8use async_trait::async_trait;
9use tracing::{debug, error, info};
10
11use tatara_core::domain::saga::SagaResult;
12
13/// A single step in a saga.
14#[async_trait]
15pub trait SagaStep: Send + Sync {
16    /// Step name for logging.
17    fn name(&self) -> &str;
18
19    /// Execute the forward action. Returns output for potential compensation.
20    async fn execute(&self) -> Result<serde_json::Value>;
21
22    /// Compensate (undo) this step using the output from execute.
23    async fn compensate(&self, output: &serde_json::Value) -> Result<()>;
24}
25
26/// Executes a sequence of saga steps with compensation.
27pub struct SagaExecutor {
28    steps: Vec<Box<dyn SagaStep>>,
29}
30
31impl SagaExecutor {
32    pub fn new(steps: Vec<Box<dyn SagaStep>>) -> Self {
33        Self { steps }
34    }
35
36    /// Execute all steps in order. On failure, compensate in reverse.
37    pub async fn run(&self) -> SagaResult {
38        let mut completed: Vec<(usize, serde_json::Value)> = Vec::new();
39
40        for (i, step) in self.steps.iter().enumerate() {
41            debug!(step = step.name(), index = i, "saga: executing step");
42
43            match step.execute().await {
44                Ok(output) => {
45                    info!(step = step.name(), "saga: step completed");
46                    completed.push((i, output));
47                }
48                Err(e) => {
49                    error!(
50                        step = step.name(),
51                        error = %e,
52                        "saga: step failed — compensating"
53                    );
54
55                    // Compensate in reverse order
56                    let mut compensation_errors = Vec::new();
57                    for (j, output) in completed.iter().rev() {
58                        let comp_step = &self.steps[*j];
59                        debug!(step = comp_step.name(), "saga: compensating");
60                        if let Err(comp_err) = comp_step.compensate(output).await {
61                            error!(
62                                step = comp_step.name(),
63                                error = %comp_err,
64                                "saga: compensation failed"
65                            );
66                            compensation_errors.push(format!("{}: {}", comp_step.name(), comp_err));
67                        }
68                    }
69
70                    return SagaResult::Compensated {
71                        failed_step: step.name().to_string(),
72                        error: e.to_string(),
73                        steps_completed: completed.len(),
74                        compensations_run: completed.len() - compensation_errors.len(),
75                        compensation_errors,
76                    };
77                }
78            }
79        }
80
81        SagaResult::Completed {
82            steps_run: self.steps.len(),
83        }
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::sync::{Arc, Mutex};
91
92    struct SuccessStep {
93        name: String,
94        log: Arc<Mutex<Vec<String>>>,
95    }
96
97    #[async_trait]
98    impl SagaStep for SuccessStep {
99        fn name(&self) -> &str {
100            &self.name
101        }
102        async fn execute(&self) -> Result<serde_json::Value> {
103            self.log.lock().unwrap().push(format!("exec:{}", self.name));
104            Ok(serde_json::json!({"step": self.name}))
105        }
106        async fn compensate(&self, _output: &serde_json::Value) -> Result<()> {
107            self.log.lock().unwrap().push(format!("comp:{}", self.name));
108            Ok(())
109        }
110    }
111
112    struct FailStep {
113        name: String,
114        log: Arc<Mutex<Vec<String>>>,
115    }
116
117    #[async_trait]
118    impl SagaStep for FailStep {
119        fn name(&self) -> &str {
120            &self.name
121        }
122        async fn execute(&self) -> Result<serde_json::Value> {
123            self.log.lock().unwrap().push(format!("exec:{}", self.name));
124            Err(anyhow::anyhow!("step failed"))
125        }
126        async fn compensate(&self, _output: &serde_json::Value) -> Result<()> {
127            self.log.lock().unwrap().push(format!("comp:{}", self.name));
128            Ok(())
129        }
130    }
131
132    #[tokio::test]
133    async fn test_all_success() {
134        let log = Arc::new(Mutex::new(Vec::new()));
135        let executor = SagaExecutor::new(vec![
136            Box::new(SuccessStep {
137                name: "a".into(),
138                log: log.clone(),
139            }),
140            Box::new(SuccessStep {
141                name: "b".into(),
142                log: log.clone(),
143            }),
144            Box::new(SuccessStep {
145                name: "c".into(),
146                log: log.clone(),
147            }),
148        ]);
149
150        let result = executor.run().await;
151        assert!(matches!(result, SagaResult::Completed { steps_run: 3 }));
152        assert_eq!(*log.lock().unwrap(), vec!["exec:a", "exec:b", "exec:c"]);
153    }
154
155    #[tokio::test]
156    async fn test_fail_at_step_2_compensates_step_1() {
157        let log = Arc::new(Mutex::new(Vec::new()));
158        let executor = SagaExecutor::new(vec![
159            Box::new(SuccessStep {
160                name: "a".into(),
161                log: log.clone(),
162            }),
163            Box::new(FailStep {
164                name: "b".into(),
165                log: log.clone(),
166            }),
167            Box::new(SuccessStep {
168                name: "c".into(),
169                log: log.clone(),
170            }),
171        ]);
172
173        let result = executor.run().await;
174        assert!(matches!(result, SagaResult::Compensated { .. }));
175        // Should execute a, then b fails, then compensate a
176        // c should never execute
177        let events = log.lock().unwrap().clone();
178        assert!(events.contains(&"exec:a".to_string()));
179        assert!(events.contains(&"exec:b".to_string()));
180        assert!(!events.contains(&"exec:c".to_string()));
181        assert!(events.contains(&"comp:a".to_string()));
182    }
183
184    #[tokio::test]
185    async fn test_empty_saga() {
186        let executor = SagaExecutor::new(vec![]);
187        let result = executor.run().await;
188        assert!(matches!(result, SagaResult::Completed { steps_run: 0 }));
189    }
190
191    #[tokio::test]
192    async fn test_single_step_success() {
193        let log = Arc::new(Mutex::new(Vec::new()));
194        let executor = SagaExecutor::new(vec![Box::new(SuccessStep {
195            name: "only".into(),
196            log: log.clone(),
197        })]);
198
199        let result = executor.run().await;
200        assert!(matches!(result, SagaResult::Completed { steps_run: 1 }));
201    }
202
203    #[tokio::test]
204    async fn test_first_step_fails_no_compensation() {
205        let log = Arc::new(Mutex::new(Vec::new()));
206        let executor = SagaExecutor::new(vec![
207            Box::new(FailStep {
208                name: "first".into(),
209                log: log.clone(),
210            }),
211            Box::new(SuccessStep {
212                name: "second".into(),
213                log: log.clone(),
214            }),
215        ]);
216
217        let result = executor.run().await;
218        assert!(matches!(
219            result,
220            SagaResult::Compensated {
221                steps_completed: 0,
222                ..
223            }
224        ));
225        // No compensation needed — nothing completed before failure
226        let events = log.lock().unwrap().clone();
227        assert!(!events.contains(&"comp:first".to_string()));
228    }
229}