Skip to main content

repo/
repository_ref_mutation.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Recorded ref mutations routed through the atomic write chokepoint.
3
4use objects::object::MarkerName;
5use oplog::RecordedHead;
6
7use super::*;
8
9fn recorded_head(head: &Head) -> RecordedHead {
10    match head {
11        Head::Attached { thread } => RecordedHead::Attached {
12            thread: thread.to_string(),
13        },
14        Head::Detached { state } => RecordedHead::Detached { state: *state },
15    }
16}
17
18fn is_conflict(error: &HeddleError) -> bool {
19    matches!(error, HeddleError::Conflict(_))
20}
21
22impl Repository {
23    /// Build the record paired with a direct HEAD ref update.
24    pub fn head_update_record(previous: &Head, new: &Head) -> OpRecord {
25        OpRecord::HeadUpdate {
26            previous: recorded_head(previous),
27            new: recorded_head(new),
28        }
29    }
30
31    /// Set a thread ref and derive its create/update record from the
32    /// reconciled value. A concurrent unconditional writer is retried so this
33    /// preserves the original last-writer-wins behavior without recording a
34    /// stale before-image.
35    pub fn set_thread_recorded(&self, name: &ThreadName, state: &StateId) -> Result<()> {
36        loop {
37            let expected = match self.refs.get_thread(name)? {
38                Some(current) if current == *state => return Ok(()),
39                Some(current) => RefExpectation::Value(current),
40                None => RefExpectation::Missing,
41            };
42            match self.set_thread_recorded_cas(name, expected, state) {
43                Err(error) if is_conflict(&error) => continue,
44                result => return result,
45            }
46        }
47    }
48
49    /// CAS variant of [`set_thread_recorded`](Self::set_thread_recorded).
50    pub fn set_thread_recorded_cas(
51        &self,
52        name: &ThreadName,
53        expected: RefExpectation<StateId>,
54        state: &StateId,
55    ) -> Result<()> {
56        let record = match &expected {
57            RefExpectation::Missing => OpRecord::ThreadCreate {
58                name: name.to_string(),
59                state: *state,
60                manager_snapshot: None,
61            },
62            RefExpectation::Value(old_state) if old_state == state => {
63                return match self.refs.get_thread(name)? {
64                    Some(current) if current == *old_state => Ok(()),
65                    current => Err(HeddleError::Conflict(format!(
66                        "thread {} expected {}, found {}",
67                        name,
68                        old_state,
69                        current
70                            .map(|value| value.to_string())
71                            .unwrap_or_else(|| "missing".to_string())
72                    ))),
73                };
74            }
75            RefExpectation::Value(old_state) => OpRecord::ThreadUpdate {
76                name: name.to_string(),
77                old_state: *old_state,
78                new_state: *state,
79                manager_snapshots: None,
80            },
81            RefExpectation::Any => {
82                return self.set_thread_recorded(name, state);
83            }
84        };
85        self.commit_and_publish(
86            vec![record],
87            &[RefUpdate::Thread {
88                name: name.clone(),
89                expected,
90                new: Some(*state),
91            }],
92        )
93    }
94
95    /// Delete a thread ref, returning its prior value. A disappearing or
96    /// concurrently-updated ref is re-read before the record is constructed.
97    pub fn delete_thread_recorded(&self, name: &ThreadName) -> Result<Option<StateId>> {
98        loop {
99            let Some(current) = self.refs.get_thread(name)? else {
100                return Ok(None);
101            };
102            let result = self.commit_and_publish(
103                vec![OpRecord::ThreadDelete {
104                    name: name.to_string(),
105                    state: current,
106                }],
107                &[RefUpdate::Thread {
108                    name: name.clone(),
109                    expected: RefExpectation::Value(current),
110                    new: None,
111                }],
112            );
113            match result {
114                Ok(()) => return Ok(Some(current)),
115                Err(error) if is_conflict(&error) => continue,
116                Err(error) => return Err(error),
117            }
118        }
119    }
120
121    /// Publish HEAD with an exact before/after record.
122    pub fn write_head_recorded(&self, new: &Head) -> Result<()> {
123        loop {
124            let previous = self.refs.read_head()?;
125            if previous == *new {
126                return Ok(());
127            }
128            let result = self.commit_and_publish(
129                vec![Self::head_update_record(&previous, new)],
130                &[RefUpdate::Head {
131                    expected: RefExpectation::Value(previous),
132                    new: new.clone(),
133                }],
134            );
135            match result {
136                Err(error) if is_conflict(&error) => continue,
137                result => return result,
138            }
139        }
140    }
141
142    /// Create a marker only when absent, recording the same CAS condition.
143    pub fn create_marker_recorded(&self, name: &MarkerName, state: &StateId) -> Result<()> {
144        self.commit_and_publish(
145            vec![OpRecord::MarkerCreate {
146                name: name.to_string(),
147                state: *state,
148            }],
149            &[RefUpdate::Marker {
150                name: name.clone(),
151                expected: RefExpectation::Missing,
152                new: Some(*state),
153            }],
154        )
155    }
156
157    /// CAS-update a marker with a reversible delete/create record pair.
158    pub fn set_marker_recorded_cas(
159        &self,
160        name: &MarkerName,
161        expected: RefExpectation<StateId>,
162        state: &StateId,
163    ) -> Result<()> {
164        let records = match &expected {
165            RefExpectation::Missing => vec![OpRecord::MarkerCreate {
166                name: name.to_string(),
167                state: *state,
168            }],
169            RefExpectation::Value(old_state) if old_state == state => {
170                return match self.refs.get_marker(name)? {
171                    Some(current) if current == *old_state => Ok(()),
172                    current => Err(HeddleError::Conflict(format!(
173                        "marker {} expected {}, found {}",
174                        name,
175                        old_state,
176                        current
177                            .map(|value| value.to_string())
178                            .unwrap_or_else(|| "missing".to_string())
179                    ))),
180                };
181            }
182            RefExpectation::Value(old_state) => vec![
183                OpRecord::MarkerDelete {
184                    name: name.to_string(),
185                    state: *old_state,
186                },
187                OpRecord::MarkerCreate {
188                    name: name.to_string(),
189                    state: *state,
190                },
191            ],
192            RefExpectation::Any => loop {
193                let exact = match self.refs.get_marker(name)? {
194                    Some(current) if current == *state => return Ok(()),
195                    Some(current) => RefExpectation::Value(current),
196                    None => RefExpectation::Missing,
197                };
198                match self.set_marker_recorded_cas(name, exact, state) {
199                    Err(error) if is_conflict(&error) => continue,
200                    result => return result,
201                }
202            },
203        };
204        self.commit_and_publish(
205            records,
206            &[RefUpdate::Marker {
207                name: name.clone(),
208                expected,
209                new: Some(*state),
210            }],
211        )
212    }
213
214    /// Delete a marker, returning its prior value.
215    pub fn delete_marker_recorded(&self, name: &MarkerName) -> Result<Option<StateId>> {
216        loop {
217            let Some(current) = self.refs.get_marker(name)? else {
218                return Ok(None);
219            };
220            let result = self.commit_and_publish(
221                vec![OpRecord::MarkerDelete {
222                    name: name.to_string(),
223                    state: current,
224                }],
225                &[RefUpdate::Marker {
226                    name: name.clone(),
227                    expected: RefExpectation::Value(current),
228                    new: None,
229                }],
230            );
231            match result {
232                Ok(()) => return Ok(Some(current)),
233                Err(error) if is_conflict(&error) => continue,
234                Err(error) => return Err(error),
235            }
236        }
237    }
238
239    /// Publish a remote-tracking thread with its reconciliation record.
240    pub fn set_remote_thread_recorded(
241        &self,
242        remote: &str,
243        thread: &ThreadName,
244        state: &StateId,
245    ) -> Result<()> {
246        loop {
247            let expected = match self.refs.get_remote_thread(remote, thread)? {
248                Some(current) if current == *state => return Ok(()),
249                Some(current) => RefExpectation::Value(current),
250                None => RefExpectation::Missing,
251            };
252            let result = self.commit_and_publish(
253                vec![OpRecord::RemoteThreadUpdate {
254                    remote: remote.to_string(),
255                    thread: thread.to_string(),
256                    state: *state,
257                }],
258                &[RefUpdate::RemoteThread {
259                    remote: remote.to_string(),
260                    thread: thread.clone(),
261                    expected,
262                    new: Some(*state),
263                }],
264            );
265            match result {
266                Err(error) if is_conflict(&error) => continue,
267                result => return result,
268            }
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use tempfile::TempDir;
276
277    use super::*;
278
279    fn test_repo() -> (TempDir, Repository) {
280        let temp = TempDir::new().unwrap();
281        let repo = Repository::init_default(temp.path()).unwrap();
282        (temp, repo)
283    }
284
285    #[test]
286    fn recorded_ref_helpers_publish_with_exact_records() {
287        let (_temp, repo) = test_repo();
288        let thread = ThreadName::new("feature");
289        let first = crate::test_state_id();
290        let second = crate::test_state_id();
291
292        repo.set_thread_recorded(&thread, &first).unwrap();
293        repo.set_thread_recorded(&thread, &second).unwrap();
294
295        assert_eq!(repo.refs().get_thread(&thread).unwrap(), Some(second));
296        let recent = repo.oplog().recent(8).unwrap();
297        assert!(recent.iter().any(|entry| matches!(
298            &entry.operation,
299            OpRecord::ThreadCreate { name, state, .. }
300                if name == "feature" && *state == first
301        )));
302        assert!(recent.iter().any(|entry| matches!(
303            &entry.operation,
304            OpRecord::ThreadUpdate {
305                name,
306                old_state,
307                new_state,
308                ..
309            } if name == "feature" && *old_state == first && *new_state == second
310        )));
311    }
312
313    #[test]
314    fn head_and_remote_updates_are_replayable_records() {
315        let (_temp, repo) = test_repo();
316        let previous = repo.refs().read_head().unwrap();
317        let detached = crate::test_state_id();
318        repo.write_head_recorded(&Head::Detached { state: detached })
319            .unwrap();
320
321        let remote_state = crate::test_state_id();
322        let remote_thread = ThreadName::new("main");
323        repo.set_remote_thread_recorded("origin", &remote_thread, &remote_state)
324            .unwrap();
325
326        assert_eq!(
327            repo.refs().read_head().unwrap(),
328            Head::Detached { state: detached }
329        );
330        assert_eq!(
331            repo.refs()
332                .get_remote_thread("origin", &remote_thread)
333                .unwrap(),
334            Some(remote_state)
335        );
336        let recent = repo.oplog().recent(8).unwrap();
337        assert!(recent.iter().any(|entry| matches!(
338            &entry.operation,
339            OpRecord::HeadUpdate {
340                previous: recorded_previous,
341                new: RecordedHead::Detached { state },
342            } if recorded_previous == &recorded_head(&previous) && *state == detached
343        )));
344        assert!(recent.iter().any(|entry| matches!(
345            &entry.operation,
346            OpRecord::RemoteThreadUpdate {
347                remote,
348                thread,
349                state,
350            } if remote == "origin" && thread == "main" && *state == remote_state
351        )));
352    }
353}