weavatrix_worktree/transaction/
commit.rs1use 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 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 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}