Skip to main content

weavatrix_worktree/transaction/
commit.rs

1use crate::{
2    error::{TransactionPhase, WorktreeError, WorktreeErrorCode},
3    journal::{FinishOutcome, JournalRecord},
4    report::{AbortReport, ApplyReport},
5};
6
7use super::{
8    PreparedTransaction,
9    rollback::rollback_committed,
10    stage::cleanup_staged,
11    util::{fs_error, journal_error},
12    verify::{verify_artifact, verify_target},
13};
14
15enum CommitFailureState {
16    Unchanged,
17    Changed,
18    Ambiguous,
19}
20
21impl PreparedTransaction {
22    /// Revalidates and commits every staged target in deterministic path order.
23    pub fn commit(mut self) -> Result<ApplyReport, WorktreeError> {
24        let mut committed = Vec::with_capacity(self.files.len());
25        if let Err(error) = verify_all_backups(&self) {
26            return rollback_committed(&mut self, &committed).map_or_else(Err, |()| Err(error));
27        }
28        for index in 0..self.files.len() {
29            if let Err((error, state)) = commit_one(&mut self, index) {
30                match state {
31                    CommitFailureState::Changed => committed.push(index),
32                    CommitFailureState::Unchanged => {}
33                    CommitFailureState::Ambiguous => return Err(error),
34                }
35                return rollback_committed(&mut self, &committed).map_or_else(Err, |()| Err(error));
36            }
37            committed.push(index);
38        }
39        self.journal
40            .append(&JournalRecord::Finished {
41                outcome: FinishOutcome::Committed,
42            })
43            .map_err(|error| {
44                journal_error(
45                    TransactionPhase::Commit,
46                    "all targets changed but final journal sync failed",
47                    error,
48                )
49                .requiring_recovery()
50            })?;
51        cleanup_staged(&self.files)?;
52        self.control.remove_journal().map_err(|error| {
53            WorktreeError::with_source(
54                WorktreeErrorCode::RecoveryRequired,
55                TransactionPhase::Cleanup,
56                "commit succeeded but journal cleanup failed",
57                error,
58            )
59            .requiring_recovery()
60        })?;
61        Ok(ApplyReport::new(
62            self.transaction_id,
63            self.operation,
64            self.files.iter().map(|file| file.change.clone()).collect(),
65        ))
66    }
67
68    /// Discards a prepared transaction without changing a target.
69    pub fn abort(mut self) -> Result<AbortReport, WorktreeError> {
70        self.journal
71            .append(&JournalRecord::Finished {
72                outcome: FinishOutcome::Aborted,
73            })
74            .map_err(|error| {
75                journal_error(
76                    TransactionPhase::Cleanup,
77                    "failed to record explicit abort",
78                    error,
79                )
80                .requiring_recovery()
81            })?;
82        let removed = cleanup_staged(&self.files)?;
83        self.control.remove_journal().map_err(|error| {
84            WorktreeError::with_source(
85                WorktreeErrorCode::RecoveryRequired,
86                TransactionPhase::Cleanup,
87                "abort succeeded but journal cleanup failed",
88                error,
89            )
90            .requiring_recovery()
91        })?;
92        Ok(AbortReport::new(
93            self.transaction_id,
94            self.files.len(),
95            removed,
96        ))
97    }
98}
99
100fn verify_all_backups(transaction: &PreparedTransaction) -> Result<(), WorktreeError> {
101    for file in &transaction.files {
102        verify_artifact(
103            &file.access,
104            &file.backup_name,
105            file.old_hash,
106            transaction.options.limits.max_source_bytes_per_file,
107            file.original_index,
108            TransactionPhase::Commit,
109        )?;
110    }
111    Ok(())
112}
113
114fn commit_one(
115    transaction: &mut PreparedTransaction,
116    index: usize,
117) -> Result<(), (WorktreeError, CommitFailureState)> {
118    let file = &transaction.files[index];
119    let verify = || -> Result<(), WorktreeError> {
120        verify_target(
121            &file.access,
122            file.old_hash,
123            Some(file.identity),
124            transaction.options.limits.max_source_bytes_per_file,
125            file.original_index,
126            TransactionPhase::Commit,
127        )?;
128        verify_artifact(
129            &file.access,
130            &file.stage_name,
131            file.new_hash,
132            transaction.options.limits.max_output_bytes_per_file,
133            file.original_index,
134            TransactionPhase::Commit,
135        )
136    };
137    verify().map_err(|error| (error, CommitFailureState::Unchanged))?;
138    transaction
139        .journal
140        .append(&JournalRecord::CommitIntent {
141            index: file.stable_index,
142        })
143        .map_err(|error| {
144            (
145                journal_error(
146                    TransactionPhase::Commit,
147                    "failed to synchronize commit intent",
148                    error,
149                )
150                .requiring_recovery(),
151                CommitFailureState::Unchanged,
152            )
153        })?;
154    if let Err(source) = file.access.rename_from(&file.stage_name) {
155        let state = classify_failed_rename(transaction, index);
156        let mut error = fs_error(
157            TransactionPhase::Commit,
158            file.access.path(),
159            file.original_index,
160            "failed to replace target with staged output",
161            source,
162        );
163        if matches!(state, CommitFailureState::Ambiguous) {
164            error = error.requiring_recovery();
165        }
166        return Err((error, state));
167    }
168    file.access.sync_parent().map_err(|error| {
169        (
170            WorktreeError::with_source(
171                WorktreeErrorCode::DurabilityFailed,
172                TransactionPhase::Commit,
173                "target changed but its parent directory did not synchronize",
174                error,
175            )
176            .at_path(file.access.path().to_owned())
177            .at_file(file.original_index)
178            .requiring_recovery(),
179            CommitFailureState::Changed,
180        )
181    })?;
182    transaction
183        .journal
184        .append(&JournalRecord::Committed {
185            index: file.stable_index,
186        })
187        .map_err(|error| {
188            (
189                journal_error(
190                    TransactionPhase::Commit,
191                    "target changed but its completion record did not synchronize",
192                    error,
193                )
194                .requiring_recovery(),
195                CommitFailureState::Changed,
196            )
197        })?;
198    Ok(())
199}
200
201fn classify_failed_rename(transaction: &PreparedTransaction, index: usize) -> CommitFailureState {
202    let file = &transaction.files[index];
203    if verify_target(
204        &file.access,
205        file.new_hash,
206        None,
207        transaction.options.limits.max_output_bytes_per_file,
208        file.original_index,
209        TransactionPhase::Commit,
210    )
211    .is_ok()
212    {
213        CommitFailureState::Changed
214    } else if verify_target(
215        &file.access,
216        file.old_hash,
217        Some(file.identity),
218        transaction.options.limits.max_source_bytes_per_file,
219        file.original_index,
220        TransactionPhase::Commit,
221    )
222    .is_ok()
223    {
224        CommitFailureState::Unchanged
225    } else {
226        CommitFailureState::Ambiguous
227    }
228}