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 with a configured command preview.
207    #[must_use]
208    pub(in crate::restore) fn next_command_preview_with_config(
209        &self,
210        config: &RestoreApplyCommandConfig,
211    ) -> RestoreApplyCommandPreview {
212        RestoreApplyCommandPreview::from_journal_with_config(self, config)
213    }
214
215    /// Store one durable operation receipt/output and revalidate the journal.
216    pub(in crate::restore) fn record_operation_receipt(
217        &mut self,
218        receipt: RestoreApplyOperationReceipt,
219    ) -> Result<(), RestoreApplyJournalError> {
220        self.operation_receipts.push(receipt);
221        if let Err(error) = self.validate() {
222            self.operation_receipts.pop();
223            return Err(error);
224        }
225
226        Ok(())
227    }
228
229    /// Mark the next transitionable operation pending with an update marker.
230    pub fn mark_next_operation_pending_at(
231        &mut self,
232        updated_at: Option<String>,
233    ) -> Result<(), RestoreApplyJournalError> {
234        let sequence = self
235            .next_transition_sequence()
236            .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
237        self.mark_operation_pending_at(sequence, updated_at)
238    }
239
240    /// Mark one restore apply operation pending with an update marker.
241    pub(in crate::restore) fn mark_operation_pending_at(
242        &mut self,
243        sequence: usize,
244        updated_at: Option<String>,
245    ) -> Result<(), RestoreApplyJournalError> {
246        self.transition_operation(
247            sequence,
248            RestoreApplyOperationState::Pending,
249            Vec::new(),
250            updated_at,
251        )
252    }
253
254    /// Mark one upload pending with its exact pre-effect snapshot inventory.
255    pub(in crate::restore) fn mark_upload_snapshot_pending_at(
256        &mut self,
257        sequence: usize,
258        updated_at: Option<String>,
259        snapshot_ids_before: Vec<String>,
260    ) -> Result<(), RestoreApplyJournalError> {
261        let index = self
262            .operations
263            .iter()
264            .position(|operation| operation.sequence == sequence)
265            .ok_or(RestoreApplyJournalError::OperationNotFound(sequence))?;
266        if self.operations[index].operation != RestoreApplyOperationKind::UploadSnapshot {
267            return Err(RestoreApplyJournalError::UnexpectedSnapshotInventory(
268                sequence,
269            ));
270        }
271        let previous = self.operations[index].snapshot_ids_before.clone();
272        self.operations[index].snapshot_ids_before = Some(snapshot_ids_before);
273        if let Err(error) = self.transition_operation(
274            sequence,
275            RestoreApplyOperationState::Pending,
276            Vec::new(),
277            updated_at,
278        ) {
279            self.operations[index].snapshot_ids_before = previous;
280            return Err(error);
281        }
282        Ok(())
283    }
284
285    /// Retry one failed restore apply operation by moving it back to ready.
286    pub fn retry_failed_operation_at(
287        &mut self,
288        sequence: usize,
289        updated_at: Option<String>,
290    ) -> Result<(), RestoreApplyJournalError> {
291        self.transition_operation(
292            sequence,
293            RestoreApplyOperationState::Ready,
294            Vec::new(),
295            updated_at,
296        )
297    }
298
299    /// Mark one restore apply operation completed with an update marker.
300    pub(in crate::restore) fn mark_operation_completed_at(
301        &mut self,
302        sequence: usize,
303        updated_at: Option<String>,
304    ) -> Result<(), RestoreApplyJournalError> {
305        self.transition_operation(
306            sequence,
307            RestoreApplyOperationState::Completed,
308            Vec::new(),
309            updated_at,
310        )
311    }
312
313    /// Mark one restore apply operation failed with an update marker.
314    pub(in crate::restore) fn mark_operation_failed_at(
315        &mut self,
316        sequence: usize,
317        reason: String,
318        updated_at: Option<String>,
319    ) -> Result<(), RestoreApplyJournalError> {
320        if reason.trim().is_empty() {
321            return Err(RestoreApplyJournalError::FailureReasonRequired(sequence));
322        }
323
324        self.transition_operation(
325            sequence,
326            RestoreApplyOperationState::Failed,
327            vec![reason],
328            updated_at,
329        )
330    }
331
332    // Apply one legal operation state transition and revalidate the journal.
333    fn transition_operation(
334        &mut self,
335        sequence: usize,
336        next_state: RestoreApplyOperationState,
337        blocking_reasons: Vec<String>,
338        updated_at: Option<String>,
339    ) -> Result<(), RestoreApplyJournalError> {
340        let index = self
341            .operations
342            .iter()
343            .position(|operation| operation.sequence == sequence)
344            .ok_or(RestoreApplyJournalError::OperationNotFound(sequence))?;
345        let operation = &self.operations[index];
346
347        if !operation.can_transition_to(&next_state) {
348            return Err(RestoreApplyJournalError::InvalidOperationTransition {
349                sequence,
350                from: operation.state.clone(),
351                to: next_state,
352            });
353        }
354
355        self.validate_operation_transition_order(operation, &next_state)?;
356
357        let operation = &mut self.operations[index];
358        operation.state = next_state;
359        operation.blocking_reasons = blocking_reasons;
360        operation.state_updated_at = updated_at;
361        self.refresh_operation_counts();
362        self.validate()
363    }
364
365    // Ensure fresh operation transitions advance in journal order.
366    fn validate_operation_transition_order(
367        &self,
368        operation: &RestoreApplyJournalOperation,
369        next_state: &RestoreApplyOperationState,
370    ) -> Result<(), RestoreApplyJournalError> {
371        if operation.state == *next_state {
372            return Ok(());
373        }
374
375        let next_sequence = self
376            .next_transition_sequence()
377            .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
378
379        if operation.sequence == next_sequence {
380            return Ok(());
381        }
382
383        Err(RestoreApplyJournalError::OutOfOrderOperationTransition {
384            requested: operation.sequence,
385            next: next_sequence,
386        })
387    }
388
389    // Return the next operation sequence that can be advanced by a runner.
390    fn next_transition_sequence(&self) -> Option<usize> {
391        self.next_transition_operation()
392            .map(|operation| operation.sequence)
393    }
394
395    // Recompute operation counts after a journal operation state change.
396    fn refresh_operation_counts(&mut self) {
397        let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
398        self.operation_count = self.operations.len();
399        self.operation_counts = RestoreApplyOperationKindCounts::from_operations(&self.operations);
400        self.pending_operations = state_counts.pending;
401        self.ready_operations = state_counts.ready;
402        self.blocked_operations = state_counts.blocked;
403        self.completed_operations = state_counts.completed;
404        self.failed_operations = state_counts.failed;
405    }
406
407    // Return whether every planned operation has completed.
408    pub(super) const fn is_complete(&self) -> bool {
409        self.operation_count > 0 && self.completed_operations == self.operation_count
410    }
411
412    // Recompute operation-kind counts from concrete operation rows.
413    pub(super) fn operation_kind_counts(&self) -> RestoreApplyOperationKindCounts {
414        RestoreApplyOperationKindCounts::from_operations(&self.operations)
415    }
416
417    // Ensure one operation attempt has exactly one durable command outcome.
418    fn validate_operation_receipt_attempts(&self) -> Result<(), RestoreApplyJournalError> {
419        let mut attempts = BTreeSet::new();
420        for receipt in &self.operation_receipts {
421            if receipt.attempt == 0 {
422                return Err(RestoreApplyJournalError::InvalidOperationReceiptAttempt {
423                    sequence: receipt.sequence,
424                    attempt: receipt.attempt,
425                });
426            }
427            if !attempts.insert((receipt.sequence, receipt.attempt)) {
428                return Err(RestoreApplyJournalError::DuplicateOperationReceiptAttempt {
429                    sequence: receipt.sequence,
430                    attempt: receipt.attempt,
431                });
432            }
433        }
434
435        Ok(())
436    }
437
438    // Find the uploaded target snapshot ID required by one load operation.
439    pub(super) fn uploaded_snapshot_id_for_load(
440        &self,
441        load: &RestoreApplyJournalOperation,
442    ) -> Option<&str> {
443        self.operation_receipts
444            .iter()
445            .find(|receipt| {
446                receipt.matches_load_operation(load)
447                    && self.operations.iter().any(|operation| {
448                        operation.sequence == receipt.sequence
449                            && operation.operation == RestoreApplyOperationKind::UploadSnapshot
450                            && operation.state == RestoreApplyOperationState::Completed
451                    })
452            })
453            .and_then(|receipt| receipt.uploaded_snapshot_id.as_deref())
454    }
455}