Skip to main content

canic_backup/execution/
mod.rs

1//! Module: execution
2//!
3//! Responsibility: build and advance backup execution journals.
4//! Does not own: backup plan construction, artifact IO, or manifest storage.
5//! Boundary: tracks runner progress from validated plans through receipts.
6
7mod operation;
8mod receipt;
9#[cfg(test)]
10mod tests;
11mod types;
12mod validation;
13
14pub use types::*;
15
16use crate::plan::{BackupExecutionPreflightReceipts, BackupOperationKind, BackupPlan};
17use validation::{
18    operation_kind_is_mutating, operation_kind_is_preflight, validate_nonempty,
19    validate_operation_sequences,
20};
21
22const BACKUP_EXECUTION_JOURNAL_VERSION: u16 = 1;
23const PREFLIGHT_NOT_ACCEPTED: &str = "preflight-not-accepted";
24
25impl BackupExecutionJournal {
26    /// Build an execution journal from a validated backup plan.
27    pub fn from_plan(plan: &BackupPlan) -> Result<Self, BackupExecutionJournalError> {
28        plan.validate()
29            .map_err(|error| BackupExecutionJournalError::InvalidPlan(error.to_string()))?;
30        let operations = plan
31            .phases
32            .iter()
33            .map(BackupExecutionJournalOperation::from_plan_operation)
34            .collect::<Vec<_>>();
35        let mut journal = Self {
36            journal_version: BACKUP_EXECUTION_JOURNAL_VERSION,
37            plan_id: plan.plan_id.clone(),
38            run_id: plan.run_id.clone(),
39            preflight_id: None,
40            preflight_accepted: false,
41            restart_required: false,
42            operations,
43            operation_receipts: Vec::new(),
44        };
45        journal.refresh_blocked_operations();
46        journal.validate()?;
47        Ok(journal)
48    }
49
50    /// Validate journal structure and operation receipts.
51    pub fn validate(&self) -> Result<(), BackupExecutionJournalError> {
52        if self.journal_version != BACKUP_EXECUTION_JOURNAL_VERSION {
53            return Err(BackupExecutionJournalError::UnsupportedVersion(
54                self.journal_version,
55            ));
56        }
57        validate_nonempty("plan_id", &self.plan_id)?;
58        validate_nonempty("run_id", &self.run_id)?;
59        if let Some(preflight_id) = &self.preflight_id {
60            validate_nonempty("preflight_id", preflight_id)?;
61        } else if self.preflight_accepted {
62            return Err(BackupExecutionJournalError::AcceptedPreflightMissingId);
63        }
64        if self.restart_required != self.derived_restart_required() {
65            return Err(BackupExecutionJournalError::RestartRequiredMismatch);
66        }
67        validate_operation_sequences(&self.operations)?;
68        for operation in &self.operations {
69            operation.validate()?;
70            if !self.preflight_accepted && operation_kind_is_mutating(&operation.kind) {
71                match operation.state {
72                    BackupExecutionOperationState::Blocked => {}
73                    BackupExecutionOperationState::Ready
74                    | BackupExecutionOperationState::Pending
75                    | BackupExecutionOperationState::Completed
76                    | BackupExecutionOperationState::Failed
77                    | BackupExecutionOperationState::Skipped => {
78                        return Err(BackupExecutionJournalError::MutationReadyBeforePreflight {
79                            sequence: operation.sequence,
80                        });
81                    }
82                }
83            }
84        }
85        for receipt in &self.operation_receipts {
86            receipt.validate_against(self)?;
87        }
88        Ok(())
89    }
90
91    /// Mark all preflight operations completed and unblock mutating operations.
92    pub fn accept_preflight_bundle_at(
93        &mut self,
94        preflight_id: String,
95        updated_at: Option<String>,
96    ) -> Result<(), BackupExecutionJournalError> {
97        validate_nonempty("preflight_id", &preflight_id)?;
98        validate_nonempty("updated_at", updated_at.as_deref().unwrap_or_default())?;
99        if let Some(existing) = &self.preflight_id
100            && existing != &preflight_id
101        {
102            return Err(BackupExecutionJournalError::PreflightAlreadyAccepted {
103                existing: existing.clone(),
104                attempted: preflight_id,
105            });
106        }
107
108        self.preflight_id = Some(preflight_id);
109        self.preflight_accepted = true;
110        for operation in &mut self.operations {
111            if operation_kind_is_preflight(&operation.kind) {
112                operation.state = BackupExecutionOperationState::Completed;
113                operation.state_updated_at.clone_from(&updated_at);
114                operation.blocking_reasons.clear();
115            } else if operation.state == BackupExecutionOperationState::Blocked {
116                operation.state = BackupExecutionOperationState::Ready;
117                operation.blocking_reasons.clear();
118            }
119        }
120        self.refresh_restart_required();
121        self.validate()
122    }
123
124    /// Accept a typed preflight receipt bundle and unblock mutating operations.
125    pub fn accept_preflight_receipts_at(
126        &mut self,
127        receipts: &BackupExecutionPreflightReceipts,
128        updated_at: Option<String>,
129    ) -> Result<(), BackupExecutionJournalError> {
130        validate_nonempty("preflight_receipts.plan_id", &receipts.plan_id)?;
131        if receipts.plan_id != self.plan_id {
132            return Err(BackupExecutionJournalError::PreflightPlanMismatch {
133                expected: self.plan_id.clone(),
134                actual: receipts.plan_id.clone(),
135            });
136        }
137        self.accept_preflight_bundle_at(receipts.preflight_id.clone(), updated_at)
138    }
139
140    /// Return the next operation that should control runner progress.
141    #[must_use]
142    pub fn next_ready_operation(&self) -> Option<&BackupExecutionJournalOperation> {
143        self.operations
144            .iter()
145            .filter(|operation| {
146                matches!(
147                    operation.state,
148                    BackupExecutionOperationState::Ready
149                        | BackupExecutionOperationState::Pending
150                        | BackupExecutionOperationState::Failed
151                )
152            })
153            .min_by_key(|operation| operation.sequence)
154    }
155
156    /// Mark the next transitionable operation pending.
157    pub fn mark_next_operation_pending_at(
158        &mut self,
159        updated_at: Option<String>,
160    ) -> Result<(), BackupExecutionJournalError> {
161        let sequence = self
162            .next_ready_operation()
163            .ok_or(BackupExecutionJournalError::NoTransitionableOperation)?
164            .sequence;
165        self.mark_operation_pending_at(sequence, updated_at)
166    }
167
168    /// Mark one operation pending.
169    pub fn mark_operation_pending_at(
170        &mut self,
171        sequence: usize,
172        updated_at: Option<String>,
173    ) -> Result<(), BackupExecutionJournalError> {
174        self.mark_operation_pending_with_snapshot_inventory_at(sequence, updated_at, None)
175    }
176
177    /// Mark one snapshot-create operation pending with its exact pre-effect inventory.
178    pub fn mark_snapshot_create_pending_at(
179        &mut self,
180        sequence: usize,
181        updated_at: Option<String>,
182        snapshot_ids_before: Vec<String>,
183    ) -> Result<(), BackupExecutionJournalError> {
184        self.mark_operation_pending_with_snapshot_inventory_at(
185            sequence,
186            updated_at,
187            Some(snapshot_ids_before),
188        )
189    }
190
191    fn mark_operation_pending_with_snapshot_inventory_at(
192        &mut self,
193        sequence: usize,
194        updated_at: Option<String>,
195        snapshot_ids_before: Option<Vec<String>>,
196    ) -> Result<(), BackupExecutionJournalError> {
197        validate_nonempty("updated_at", updated_at.as_deref().unwrap_or_default())?;
198        let expected = self
199            .next_ready_operation()
200            .ok_or(BackupExecutionJournalError::NoTransitionableOperation)?
201            .sequence;
202        if sequence != expected {
203            return Err(BackupExecutionJournalError::OutOfOrderOperationTransition {
204                requested: sequence,
205                next: expected,
206            });
207        }
208        let index = self.operation_index(sequence)?;
209        let operation = &self.operations[index];
210        if operation_kind_is_mutating(&operation.kind) && !self.preflight_accepted {
211            return Err(BackupExecutionJournalError::MutationBeforePreflightAccepted { sequence });
212        }
213        if !matches!(
214            operation.state,
215            BackupExecutionOperationState::Ready | BackupExecutionOperationState::Failed
216        ) {
217            return Err(BackupExecutionJournalError::InvalidOperationTransition {
218                sequence,
219                from: operation.state.clone(),
220                to: BackupExecutionOperationState::Pending,
221            });
222        }
223
224        let previous_operation = self.operations[index].clone();
225        let previous_restart_required = self.restart_required;
226        let operation = &mut self.operations[index];
227        operation.state = BackupExecutionOperationState::Pending;
228        operation.state_updated_at = updated_at;
229        operation.snapshot_ids_before = snapshot_ids_before;
230        operation.blocking_reasons.clear();
231        self.refresh_restart_required();
232        if let Err(error) = self.validate() {
233            self.operations[index] = previous_operation;
234            self.restart_required = previous_restart_required;
235            return Err(error);
236        }
237        Ok(())
238    }
239
240    /// Record one operation receipt and transition the matching operation.
241    pub fn record_operation_receipt(
242        &mut self,
243        receipt: BackupExecutionOperationReceipt,
244    ) -> Result<(), BackupExecutionJournalError> {
245        receipt.validate_against(self)?;
246        let index = self.operation_index(receipt.sequence)?;
247        let operation = &self.operations[index];
248        if operation.state != BackupExecutionOperationState::Pending {
249            return Err(
250                BackupExecutionJournalError::ReceiptWithoutPendingOperation {
251                    sequence: receipt.sequence,
252                },
253            );
254        }
255
256        let next_state = match receipt.outcome {
257            BackupExecutionOperationReceiptOutcome::Completed => {
258                BackupExecutionOperationState::Completed
259            }
260            BackupExecutionOperationReceiptOutcome::Failed => BackupExecutionOperationState::Failed,
261            BackupExecutionOperationReceiptOutcome::Skipped => {
262                BackupExecutionOperationState::Skipped
263            }
264        };
265        let failure_reason = receipt.failure_reason.clone();
266        let previous_operation = self.operations[index].clone();
267        let previous_restart_required = self.restart_required;
268        self.operation_receipts.push(receipt);
269
270        let operation = &mut self.operations[index];
271        operation.state = next_state;
272        operation.state_updated_at = self
273            .operation_receipts
274            .last()
275            .and_then(|receipt| receipt.updated_at.clone());
276        operation.blocking_reasons = failure_reason.into_iter().collect();
277        self.refresh_restart_required();
278        if let Err(error) = self.validate() {
279            self.operation_receipts.pop();
280            self.operations[index] = previous_operation;
281            self.restart_required = previous_restart_required;
282            return Err(error);
283        }
284        Ok(())
285    }
286
287    /// Move a failed operation back to ready for retry.
288    pub fn retry_failed_operation_at(
289        &mut self,
290        sequence: usize,
291        updated_at: Option<String>,
292    ) -> Result<(), BackupExecutionJournalError> {
293        validate_nonempty("updated_at", updated_at.as_deref().unwrap_or_default())?;
294        let index = self.operation_index(sequence)?;
295        if self.operations[index].state != BackupExecutionOperationState::Failed {
296            return Err(BackupExecutionJournalError::OperationNotFailed(sequence));
297        }
298        self.operations[index].state = BackupExecutionOperationState::Ready;
299        self.operations[index].state_updated_at = updated_at;
300        self.operations[index].blocking_reasons.clear();
301        self.refresh_restart_required();
302        self.validate()
303    }
304
305    /// Build a compact resumability summary.
306    #[must_use]
307    pub fn resume_summary(&self) -> BackupExecutionResumeSummary {
308        let mut summary = BackupExecutionResumeSummary {
309            plan_id: self.plan_id.clone(),
310            run_id: self.run_id.clone(),
311            preflight_id: self.preflight_id.clone(),
312            preflight_accepted: self.preflight_accepted,
313            restart_required: self.restart_required,
314            total_operations: self.operations.len(),
315            ready_operations: 0,
316            pending_operations: 0,
317            blocked_operations: 0,
318            completed_operations: 0,
319            failed_operations: 0,
320            skipped_operations: 0,
321            next_operation: self.next_ready_operation().cloned(),
322        };
323        for operation in &self.operations {
324            match operation.state {
325                BackupExecutionOperationState::Ready => summary.ready_operations += 1,
326                BackupExecutionOperationState::Pending => summary.pending_operations += 1,
327                BackupExecutionOperationState::Blocked => summary.blocked_operations += 1,
328                BackupExecutionOperationState::Completed => summary.completed_operations += 1,
329                BackupExecutionOperationState::Failed => summary.failed_operations += 1,
330                BackupExecutionOperationState::Skipped => summary.skipped_operations += 1,
331            }
332        }
333        summary
334    }
335
336    fn operation_index(&self, sequence: usize) -> Result<usize, BackupExecutionJournalError> {
337        self.operations
338            .iter()
339            .position(|operation| operation.sequence == sequence)
340            .ok_or(BackupExecutionJournalError::OperationNotFound(sequence))
341    }
342
343    fn refresh_blocked_operations(&mut self) {
344        if self.preflight_accepted {
345            return;
346        }
347        for operation in &mut self.operations {
348            if operation_kind_is_mutating(&operation.kind) {
349                operation.state = BackupExecutionOperationState::Blocked;
350                operation.blocking_reasons = vec![PREFLIGHT_NOT_ACCEPTED.to_string()];
351            }
352        }
353    }
354
355    fn refresh_restart_required(&mut self) {
356        self.restart_required = self.derived_restart_required();
357    }
358
359    fn derived_restart_required(&self) -> bool {
360        let stopped = self.operations.iter().any(|operation| {
361            operation.kind == BackupOperationKind::Stop
362                && operation.state == BackupExecutionOperationState::Completed
363        });
364        let unstarted = self.operations.iter().any(|operation| {
365            operation.kind == BackupOperationKind::Start
366                && !matches!(
367                    operation.state,
368                    BackupExecutionOperationState::Completed
369                        | BackupExecutionOperationState::Skipped
370                )
371        });
372        stopped && unstarted
373    }
374}