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