Skip to main content

canic_backup/restore/apply/journal/
mod.rs

1//! Module: restore::apply::journal
2//!
3//! Responsibility: persist and advance restore apply operation state.
4//! Does not own: restore plan construction, command execution, or backup artifact checksums.
5//! Boundary: turns dry-runs into durable journals, reports, previews, and receipts.
6
7mod commands;
8mod counts;
9mod receipts;
10mod reports;
11mod types;
12
13pub use commands::{
14    RestoreApplyCommandConfig, RestoreApplyCommandPreview, RestoreApplyRunnerCommand,
15};
16use counts::RestoreApplyJournalStateCounts;
17pub use counts::RestoreApplyOperationKindCounts;
18pub(in crate::restore) use receipts::RestoreApplyCommandOutputPair;
19pub use receipts::{
20    RestoreApplyCommandOutput, RestoreApplyOperationReceipt, RestoreApplyOperationReceiptOutcome,
21};
22pub(in crate::restore) use reports::RestoreApplyJournalReport;
23pub use reports::{
24    RestoreApplyPendingSummary, RestoreApplyProgressSummary, RestoreApplyReportOperation,
25    RestoreApplyReportOutcome,
26};
27pub use types::{
28    RestoreApplyJournalError, RestoreApplyJournalOperation, RestoreApplyOperationKind,
29    RestoreApplyOperationState,
30};
31
32use crate::restore::{RestoreApplyDryRun, RestoreApplyDryRunOperation};
33
34use std::collections::BTreeSet;
35
36use serde::{Deserialize, Serialize};
37
38use types::{
39    restore_apply_blocked_reasons, validate_apply_journal_count, validate_apply_journal_nonempty,
40    validate_apply_journal_sequences, validate_apply_journal_version,
41};
42
43///
44/// RestoreApplyJournal
45///
46/// Durable restore apply operation journal.
47/// Owned by restore apply journaling and consumed by restore runners and reports.
48///
49
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51#[serde(deny_unknown_fields)]
52pub struct RestoreApplyJournal {
53    pub journal_version: u16,
54    pub backup_id: String,
55    pub ready: bool,
56    pub blocked_reasons: Vec<String>,
57    #[serde(deserialize_with = "crate::serialization::required_option")]
58    pub backup_root: Option<String>,
59    pub operation_count: usize,
60    pub operation_counts: RestoreApplyOperationKindCounts,
61    pub pending_operations: usize,
62    pub ready_operations: usize,
63    pub blocked_operations: usize,
64    pub completed_operations: usize,
65    pub failed_operations: usize,
66    pub operations: Vec<RestoreApplyJournalOperation>,
67    pub operation_receipts: Vec<RestoreApplyOperationReceipt>,
68}
69
70impl RestoreApplyJournal {
71    /// Build the initial no-mutation restore apply journal from a dry-run.
72    pub fn from_dry_run(dry_run: &RestoreApplyDryRun) -> Result<Self, RestoreApplyJournalError> {
73        dry_run.validate()?;
74        let blocked_reasons = restore_apply_blocked_reasons(dry_run);
75        let initial_state = if blocked_reasons.is_empty() {
76            RestoreApplyOperationState::Ready
77        } else {
78            RestoreApplyOperationState::Blocked
79        };
80        let operations = dry_run
81            .operations
82            .iter()
83            .map(|operation| {
84                RestoreApplyJournalOperation::from_dry_run_operation(
85                    operation,
86                    initial_state.clone(),
87                    &blocked_reasons,
88                )
89            })
90            .collect::<Vec<_>>();
91        let ready_operations = operations
92            .iter()
93            .filter(|operation| operation.state == RestoreApplyOperationState::Ready)
94            .count();
95        let blocked_operations = operations
96            .iter()
97            .filter(|operation| operation.state == RestoreApplyOperationState::Blocked)
98            .count();
99        let operation_counts = RestoreApplyOperationKindCounts::from_operations(&operations);
100
101        let journal = Self {
102            journal_version: 1,
103            backup_id: dry_run.backup_id.clone(),
104            ready: blocked_reasons.is_empty(),
105            blocked_reasons,
106            backup_root: dry_run
107                .artifact_validation
108                .as_ref()
109                .map(|validation| validation.backup_root.clone()),
110            operation_count: operations.len(),
111            operation_counts,
112            pending_operations: 0,
113            ready_operations,
114            blocked_operations,
115            completed_operations: 0,
116            failed_operations: 0,
117            operations,
118            operation_receipts: Vec::new(),
119        };
120        journal.validate()?;
121        Ok(journal)
122    }
123
124    /// Validate the structural consistency of a restore apply journal.
125    pub fn validate(&self) -> Result<(), RestoreApplyJournalError> {
126        validate_apply_journal_version(self.journal_version)?;
127        validate_apply_journal_nonempty("backup_id", &self.backup_id)?;
128        if let Some(backup_root) = &self.backup_root {
129            validate_apply_journal_nonempty("backup_root", backup_root)?;
130        }
131        validate_apply_journal_count(
132            "operation_count",
133            self.operation_count,
134            self.operations.len(),
135        )?;
136
137        let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
138        let operation_counts = RestoreApplyOperationKindCounts::from_operations(&self.operations);
139        self.operation_counts.validate_matches(&operation_counts)?;
140        validate_apply_journal_count(
141            "pending_operations",
142            self.pending_operations,
143            state_counts.pending,
144        )?;
145        validate_apply_journal_count(
146            "ready_operations",
147            self.ready_operations,
148            state_counts.ready,
149        )?;
150        validate_apply_journal_count(
151            "blocked_operations",
152            self.blocked_operations,
153            state_counts.blocked,
154        )?;
155        validate_apply_journal_count(
156            "completed_operations",
157            self.completed_operations,
158            state_counts.completed,
159        )?;
160        validate_apply_journal_count(
161            "failed_operations",
162            self.failed_operations,
163            state_counts.failed,
164        )?;
165
166        if self.ready && (!self.blocked_reasons.is_empty() || self.blocked_operations > 0) {
167            return Err(RestoreApplyJournalError::ReadyJournalHasBlockingState);
168        }
169
170        validate_apply_journal_sequences(&self.operations)?;
171        for operation in &self.operations {
172            operation.validate()?;
173        }
174        self.validate_operation_receipt_attempts()?;
175        for receipt in &self.operation_receipts {
176            receipt.validate_against(self)?;
177        }
178
179        Ok(())
180    }
181
182    /// Build an operator-oriented report from this apply journal.
183    #[must_use]
184    pub(in crate::restore) fn report(&self) -> RestoreApplyJournalReport {
185        RestoreApplyJournalReport::from_journal(self)
186    }
187
188    /// Return the next ready or pending operation that controls runner progress.
189    #[must_use]
190    pub(in crate::restore) fn next_transition_operation(
191        &self,
192    ) -> Option<&RestoreApplyJournalOperation> {
193        self.operations
194            .iter()
195            .filter(|operation| {
196                matches!(
197                    operation.state,
198                    RestoreApplyOperationState::Ready
199                        | RestoreApplyOperationState::Pending
200                        | RestoreApplyOperationState::Failed
201                )
202            })
203            .min_by_key(|operation| operation.sequence)
204    }
205
206    /// Render the next transitionable operation as a no-execute command preview.
207    #[must_use]
208    pub fn next_command_preview(&self) -> RestoreApplyCommandPreview {
209        RestoreApplyCommandPreview::from_journal(self)
210    }
211
212    /// Render the next transitionable operation with a configured command preview.
213    #[must_use]
214    pub(in crate::restore) fn next_command_preview_with_config(
215        &self,
216        config: &RestoreApplyCommandConfig,
217    ) -> RestoreApplyCommandPreview {
218        RestoreApplyCommandPreview::from_journal_with_config(self, config)
219    }
220
221    /// Store one durable operation receipt/output and revalidate the journal.
222    pub(in crate::restore) fn record_operation_receipt(
223        &mut self,
224        receipt: RestoreApplyOperationReceipt,
225    ) -> Result<(), RestoreApplyJournalError> {
226        self.operation_receipts.push(receipt);
227        if let Err(error) = self.validate() {
228            self.operation_receipts.pop();
229            return Err(error);
230        }
231
232        Ok(())
233    }
234
235    /// Mark the next transitionable operation pending with an update marker.
236    pub fn mark_next_operation_pending_at(
237        &mut self,
238        updated_at: Option<String>,
239    ) -> Result<(), RestoreApplyJournalError> {
240        let sequence = self
241            .next_transition_sequence()
242            .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
243        self.mark_operation_pending_at(sequence, updated_at)
244    }
245
246    /// Mark one restore apply operation pending with an update marker.
247    pub(in crate::restore) fn mark_operation_pending_at(
248        &mut self,
249        sequence: usize,
250        updated_at: Option<String>,
251    ) -> Result<(), RestoreApplyJournalError> {
252        self.transition_operation(
253            sequence,
254            RestoreApplyOperationState::Pending,
255            Vec::new(),
256            updated_at,
257        )
258    }
259
260    /// Mark one upload pending with its exact pre-effect snapshot inventory.
261    pub(in crate::restore) fn mark_upload_snapshot_pending_at(
262        &mut self,
263        sequence: usize,
264        updated_at: Option<String>,
265        snapshot_ids_before: Vec<String>,
266    ) -> Result<(), RestoreApplyJournalError> {
267        let index = self
268            .operations
269            .iter()
270            .position(|operation| operation.sequence == sequence)
271            .ok_or(RestoreApplyJournalError::OperationNotFound(sequence))?;
272        if self.operations[index].operation != RestoreApplyOperationKind::UploadSnapshot {
273            return Err(RestoreApplyJournalError::UnexpectedSnapshotInventory(
274                sequence,
275            ));
276        }
277        let previous = self.operations[index].snapshot_ids_before.clone();
278        self.operations[index].snapshot_ids_before = Some(snapshot_ids_before);
279        if let Err(error) = self.transition_operation(
280            sequence,
281            RestoreApplyOperationState::Pending,
282            Vec::new(),
283            updated_at,
284        ) {
285            self.operations[index].snapshot_ids_before = previous;
286            return Err(error);
287        }
288        Ok(())
289    }
290
291    /// Retry one failed restore apply operation by moving it back to ready.
292    pub fn retry_failed_operation_at(
293        &mut self,
294        sequence: usize,
295        updated_at: Option<String>,
296    ) -> Result<(), RestoreApplyJournalError> {
297        self.transition_operation(
298            sequence,
299            RestoreApplyOperationState::Ready,
300            Vec::new(),
301            updated_at,
302        )
303    }
304
305    /// Mark one restore apply operation completed with an update marker.
306    pub(in crate::restore) fn mark_operation_completed_at(
307        &mut self,
308        sequence: usize,
309        updated_at: Option<String>,
310    ) -> Result<(), RestoreApplyJournalError> {
311        self.transition_operation(
312            sequence,
313            RestoreApplyOperationState::Completed,
314            Vec::new(),
315            updated_at,
316        )
317    }
318
319    /// Mark one restore apply operation failed with an update marker.
320    pub(in crate::restore) fn mark_operation_failed_at(
321        &mut self,
322        sequence: usize,
323        reason: String,
324        updated_at: Option<String>,
325    ) -> Result<(), RestoreApplyJournalError> {
326        if reason.trim().is_empty() {
327            return Err(RestoreApplyJournalError::FailureReasonRequired(sequence));
328        }
329
330        self.transition_operation(
331            sequence,
332            RestoreApplyOperationState::Failed,
333            vec![reason],
334            updated_at,
335        )
336    }
337
338    // Apply one legal operation state transition and revalidate the journal.
339    fn transition_operation(
340        &mut self,
341        sequence: usize,
342        next_state: RestoreApplyOperationState,
343        blocking_reasons: Vec<String>,
344        updated_at: Option<String>,
345    ) -> Result<(), RestoreApplyJournalError> {
346        let index = self
347            .operations
348            .iter()
349            .position(|operation| operation.sequence == sequence)
350            .ok_or(RestoreApplyJournalError::OperationNotFound(sequence))?;
351        let operation = &self.operations[index];
352
353        if !operation.can_transition_to(&next_state) {
354            return Err(RestoreApplyJournalError::InvalidOperationTransition {
355                sequence,
356                from: operation.state.clone(),
357                to: next_state,
358            });
359        }
360
361        self.validate_operation_transition_order(operation, &next_state)?;
362
363        let operation = &mut self.operations[index];
364        operation.state = next_state;
365        operation.blocking_reasons = blocking_reasons;
366        operation.state_updated_at = updated_at;
367        self.refresh_operation_counts();
368        self.validate()
369    }
370
371    // Ensure fresh operation transitions advance in journal order.
372    fn validate_operation_transition_order(
373        &self,
374        operation: &RestoreApplyJournalOperation,
375        next_state: &RestoreApplyOperationState,
376    ) -> Result<(), RestoreApplyJournalError> {
377        if operation.state == *next_state {
378            return Ok(());
379        }
380
381        let next_sequence = self
382            .next_transition_sequence()
383            .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
384
385        if operation.sequence == next_sequence {
386            return Ok(());
387        }
388
389        Err(RestoreApplyJournalError::OutOfOrderOperationTransition {
390            requested: operation.sequence,
391            next: next_sequence,
392        })
393    }
394
395    // Return the next operation sequence that can be advanced by a runner.
396    fn next_transition_sequence(&self) -> Option<usize> {
397        self.next_transition_operation()
398            .map(|operation| operation.sequence)
399    }
400
401    // Recompute operation counts after a journal operation state change.
402    fn refresh_operation_counts(&mut self) {
403        let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
404        self.operation_count = self.operations.len();
405        self.operation_counts = RestoreApplyOperationKindCounts::from_operations(&self.operations);
406        self.pending_operations = state_counts.pending;
407        self.ready_operations = state_counts.ready;
408        self.blocked_operations = state_counts.blocked;
409        self.completed_operations = state_counts.completed;
410        self.failed_operations = state_counts.failed;
411    }
412
413    // Return whether every planned operation has completed.
414    pub(super) const fn is_complete(&self) -> bool {
415        self.operation_count > 0 && self.completed_operations == self.operation_count
416    }
417
418    // Recompute operation-kind counts from concrete operation rows.
419    pub(super) fn operation_kind_counts(&self) -> RestoreApplyOperationKindCounts {
420        RestoreApplyOperationKindCounts::from_operations(&self.operations)
421    }
422
423    // Ensure one operation attempt has exactly one durable command outcome.
424    fn validate_operation_receipt_attempts(&self) -> Result<(), RestoreApplyJournalError> {
425        let mut attempts = BTreeSet::new();
426        for receipt in &self.operation_receipts {
427            if receipt.attempt == 0 {
428                return Err(RestoreApplyJournalError::InvalidOperationReceiptAttempt {
429                    sequence: receipt.sequence,
430                    attempt: receipt.attempt,
431                });
432            }
433            if !attempts.insert((receipt.sequence, receipt.attempt)) {
434                return Err(RestoreApplyJournalError::DuplicateOperationReceiptAttempt {
435                    sequence: receipt.sequence,
436                    attempt: receipt.attempt,
437                });
438            }
439        }
440
441        Ok(())
442    }
443
444    // Find the uploaded target snapshot ID required by one load operation.
445    pub(super) fn uploaded_snapshot_id_for_load(
446        &self,
447        load: &RestoreApplyJournalOperation,
448    ) -> Option<&str> {
449        self.operation_receipts
450            .iter()
451            .find(|receipt| {
452                receipt.matches_load_operation(load)
453                    && self.operations.iter().any(|operation| {
454                        operation.sequence == receipt.sequence
455                            && operation.operation == RestoreApplyOperationKind::UploadSnapshot
456                            && operation.state == RestoreApplyOperationState::Completed
457                    })
458            })
459            .and_then(|receipt| receipt.uploaded_snapshot_id.as_deref())
460    }
461}