Skip to main content

turbovault_git/
cas.rs

1//! Ref compare-and-swap + optimistic rebuild-on-conflict (GWS.3).
2//!
3//! `cas_ref` is the serialization primitive: it advances a branch ref from an
4//! expected old value to a new commit **under git's ref lock**, mirroring
5//! `git update-ref <new> <old>`. It is atomic and **cross-process** (the lock is
6//! a lockfile in `.git`), which is the property in-process mutexes cannot give.
7//!
8//! `commit_with_retry` is the optimistic loop: build a commit on the current
9//! tip, CAS the ref; if a concurrent writer advanced it first, re-read the tip,
10//! rebuild on the new tip, and retry. The caller's builder re-runs its per-file
11//! preconditions (GWS.4) on each rebuild, so a conflicting change to one of the
12//! changeset's own paths surfaces as an abort rather than a silent overwrite.
13
14use crate::error::{Error, Result};
15use crate::repo::VaultRepo;
16use git2::Oid;
17use tracing::instrument;
18
19/// How many times `commit_with_retry` rebuilds before giving up. Contention is
20/// rare; this only guards against pathological live-lock.
21const DEFAULT_MAX_RETRIES: u32 = 8;
22
23impl VaultRepo {
24    /// Atomically advance `refname` from `expected_old` to `new`, under git's
25    /// ref lock (mirrors `update-ref <new> <old>`).
26    ///
27    /// `expected_old == None` means the ref must **not** yet exist (the
28    /// initial-commit case). On any mismatch returns [`Error::CasConflict`] with
29    /// **nothing applied** — the ref is untouched.
30    #[instrument(
31        skip(self),
32        fields(refname = %refname, expected = ?expected_old, new = %new),
33        name = "git_cas_ref"
34    )]
35    pub fn cas_ref(&self, refname: &str, expected_old: Option<Oid>, new: Oid) -> Result<()> {
36        let repo = self.git();
37        let mut tx = repo.transaction()?;
38        tx.lock_ref(refname)?;
39        // Read the current value *under the lock* — this is the CAS comparison.
40        // tlx.9: discriminate "ref absent" (NotFound) from a real read error.
41        // `.ok()` would flatten an I/O/corruption error into `None`, which on
42        // the initial-commit path (expected_old == None) could be misread as
43        // "ref doesn't exist yet" and let a blind advance through.
44        //
45        // hq8: the non-NotFound arm here is irreducible-defensive — `lock_ref`
46        // above already validated + read the ref, so a corrupt ref aborts at
47        // the lock, never reaching this read. The same guard in
48        // `commit_with_retry_n` (which has NO prior lock) IS reachable and is
49        // killed by `corrupt_ref_surfaces_error_instead_of_silent_absent`.
50        let current = match repo.refname_to_id(refname) {
51            Ok(oid) => Some(oid),
52            Err(e) if e.code() == git2::ErrorCode::NotFound => None,
53            Err(e) => return Err(Error::Git(e)),
54        };
55        if current != expected_old {
56            // Dropping `tx` here releases the lock without committing.
57            return Err(Error::CasConflict {
58                refname: refname.to_string(),
59                expected: expected_old,
60                found: current,
61            });
62        }
63        tx.set_target(refname, new, None, "turbovault-git: cas advance")?;
64        tx.commit()?;
65        Ok(())
66    }
67
68    /// Advance `refname` with optimistic retry (default retry budget).
69    /// See [`Self::commit_with_retry_n`].
70    pub fn commit_with_retry<F>(&self, refname: &str, build: F) -> Result<Option<Oid>>
71    where
72        F: FnMut(Option<Oid>) -> Result<Option<Oid>>,
73    {
74        self.commit_with_retry_n(refname, DEFAULT_MAX_RETRIES, build)
75    }
76
77    /// Advance `refname` with optimistic retry. `build` is called with the
78    /// current tip (the parent to build on, `None` if the branch is unborn) and
79    /// returns `Some(commit)` to CAS onto that tip, or `None` to signal a
80    /// **no-op** — there is nothing to commit (e.g. the resulting tree is
81    /// identical to the base), so the ref is left untouched and the method
82    /// returns `Ok(None)`. If the CAS loses to a concurrent advance, the tip is
83    /// re-read and `build` is called again on the new tip, up to `max_retries`
84    /// rebuilds.
85    ///
86    /// The builder owns conflict policy: on a rebuild it re-validates its
87    /// per-file preconditions against the new tip (GWS.4) and may itself return
88    /// an error to abort (the reconsideration domino) instead of rebuilding.
89    #[instrument(
90        skip(self, build),
91        fields(refname = %refname, max_retries),
92        name = "git_commit_with_retry"
93    )]
94    pub fn commit_with_retry_n<F>(
95        &self,
96        refname: &str,
97        max_retries: u32,
98        mut build: F,
99    ) -> Result<Option<Oid>>
100    where
101        F: FnMut(Option<Oid>) -> Result<Option<Oid>>,
102    {
103        for _ in 0..=max_retries {
104            // tlx.9: same NotFound-vs-real-error discrimination as cas_ref — an
105            // unborn ref is `None`, but a real read error must surface, not
106            // masquerade as "branch has no commits yet".
107            let tip = match self.git().refname_to_id(refname) {
108                Ok(oid) => Some(oid),
109                Err(e) if e.code() == git2::ErrorCode::NotFound => None,
110                Err(e) => return Err(Error::Git(e)),
111            };
112            // `None` from the builder = no-op (e.g. an identity tree): nothing
113            // to commit, so skip the CAS and leave the ref where it is.
114            let new = match build(tip)? {
115                Some(oid) => oid,
116                None => return Ok(None),
117            };
118            match self.cas_ref(refname, tip, new) {
119                Ok(()) => return Ok(Some(new)),
120                // Lost the race: the ref moved between our read and the lock.
121                // Re-read the tip and rebuild on it.
122                Err(Error::CasConflict { .. }) => continue,
123                Err(e) => return Err(e),
124            }
125        }
126        Err(Error::Other(format!(
127            "ref CAS exhausted {max_retries} retries on {refname} (excessive contention)"
128        )))
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::plumbing::TreeChange;
136    use git2::Repository;
137    use std::cell::Cell;
138    use tempfile::TempDir;
139
140    const MAIN: &str = "refs/heads/main";
141
142    fn open_unborn() -> (TempDir, VaultRepo) {
143        let tmp = TempDir::new().unwrap();
144        let mut opts = git2::RepositoryInitOptions::new();
145        opts.initial_head("main");
146        Repository::init_opts(tmp.path(), &opts).unwrap();
147        let vr = VaultRepo::open(tmp.path()).unwrap();
148        (tmp, vr)
149    }
150
151    fn upsert(path: &str, content: &str) -> TreeChange {
152        TreeChange::Upsert {
153            path: path.to_string(),
154            content: content.as_bytes().to_vec(),
155        }
156    }
157
158    /// Build a commit on `parent` (or initial if None) carrying one upsert.
159    fn build_on(vr: &VaultRepo, parent: Option<Oid>, path: &str, content: &str) -> Oid {
160        let base = parent.map(|p| vr.git().find_commit(p).unwrap().tree_id());
161        let tree = vr.build_tree(base, &[upsert(path, content)]).unwrap();
162        let parents: Vec<Oid> = parent.into_iter().collect();
163        vr.commit_tree(tree, &parents, "c").unwrap()
164    }
165
166    #[test]
167    fn cas_ref_initial_then_advance() {
168        let (_tmp, vr) = open_unborn();
169        let c0 = build_on(&vr, None, "a.md", "a");
170        vr.cas_ref(MAIN, None, c0)
171            .expect("initial CAS (None -> c0)");
172        assert_eq!(vr.head_oid(), Some(c0));
173
174        let c1 = build_on(&vr, Some(c0), "b.md", "b");
175        vr.cas_ref(MAIN, Some(c0), c1).expect("advance c0 -> c1");
176        assert_eq!(vr.head_oid(), Some(c1));
177    }
178
179    /// hq8 (tlx.9 follow-up): real fault injection — corrupt a throwaway `.git`
180    /// loose ref so `refname_to_id` fails with a NON-NotFound error, and assert
181    /// `cas_ref` / `commit_with_retry` SURFACE it instead of swallowing to
182    /// `None` (a blind "ref absent"). This is what `.ok()` used to do; it kills
183    /// the NotFound-match-guard mutation survivors without mocking the git2 API.
184    #[test]
185    fn corrupt_ref_surfaces_error_instead_of_silent_absent() {
186        let (tmp, vr) = open_unborn();
187        let c0 = build_on(&vr, None, "a.md", "a");
188        vr.cas_ref(MAIN, None, c0).unwrap();
189        drop(vr); // release the handle before corrupting the ref on disk
190
191        // Malformed oid in the loose ref → libgit2 parse error (not NotFound).
192        std::fs::write(tmp.path().join(".git/refs/heads/main"), "not-a-valid-oid\n").unwrap();
193        let vr = VaultRepo::open(tmp.path()).unwrap();
194
195        // Precondition: the corruption really yields a non-NotFound error — else
196        // the guard would legitimately map it to None and this proves nothing.
197        let code = vr.git().refname_to_id(MAIN).unwrap_err().code();
198        assert_ne!(
199            code,
200            git2::ErrorCode::NotFound,
201            "corruption must produce a non-NotFound error; got {code:?}"
202        );
203
204        // commit_with_retry resolves the tip via refname_to_id first, so a
205        // non-NotFound error must abort, not be treated as an unborn branch.
206        let res = vr.commit_with_retry_n(MAIN, 0, |_tip| Ok(None));
207        assert!(
208            res.is_err(),
209            "commit_with_retry must surface the ref-read error, not swallow to None"
210        );
211
212        // cas_ref's read-under-lock must do the same.
213        let some = Oid::from_str("0000000000000000000000000000000000000001").unwrap();
214        assert!(
215            vr.cas_ref(MAIN, None, some).is_err(),
216            "cas_ref must surface the ref-read error, not blind-write a 'new' ref"
217        );
218    }
219
220    #[test]
221    fn cas_ref_rejects_stale_and_leaves_ref() {
222        let (_tmp, vr) = open_unborn();
223        let c0 = build_on(&vr, None, "a.md", "a");
224        vr.cas_ref(MAIN, None, c0).unwrap();
225
226        let bogus = Oid::from_str("0000000000000000000000000000000000000001").unwrap();
227        let c1 = build_on(&vr, Some(c0), "b.md", "b");
228        match vr.cas_ref(MAIN, Some(bogus), c1) {
229            Err(Error::CasConflict { found, .. }) => assert_eq!(found, Some(c0)),
230            other => panic!("expected CasConflict, got {other:?}"),
231        }
232        assert_eq!(vr.head_oid(), Some(c0), "ref unchanged on reject");
233    }
234
235    #[test]
236    fn cas_ref_initial_rejects_when_ref_exists() {
237        let (_tmp, vr) = open_unborn();
238        let c0 = build_on(&vr, None, "a.md", "a");
239        vr.cas_ref(MAIN, None, c0).unwrap();
240        // expected_old = None means "must not exist", but it does now.
241        let c1 = build_on(&vr, Some(c0), "b.md", "b");
242        assert!(matches!(
243            vr.cas_ref(MAIN, None, c1),
244            Err(Error::CasConflict { .. })
245        ));
246    }
247
248    /// turbovault-a0l (PERF-1 safety guard): a REUSED `VaultRepo` handle must
249    /// still observe a ref advance made by a DIFFERENT handle (another process)
250    /// under `lock_ref`. If libgit2's refdb served a stale cached tip, handle A
251    /// would clobber B's commit — a cross-process lost update, the exact failure
252    /// the substrate exists to prevent. This is the pivotal correctness question
253    /// for caching the repo handle (PERF-1): if it fails, caching is unsafe.
254    #[test]
255    fn reused_handle_detects_external_ref_advance_no_lost_update() {
256        let (tmp, vr_a) = open_unborn();
257        // A makes the initial commit (populates A's refdb with c0).
258        let c0 = build_on(&vr_a, None, "a.md", "v1");
259        vr_a.cas_ref(MAIN, None, c0).unwrap();
260        assert_eq!(vr_a.head_oid(), Some(c0));
261
262        // B = a SEPARATE handle (mimics another process) advances main.
263        let vr_b = VaultRepo::open(tmp.path()).unwrap();
264        let c1 = build_on(&vr_b, Some(c0), "b.md", "from-B");
265        vr_b.cas_ref(MAIN, Some(c0), c1).unwrap();
266
267        // A, REUSING its handle, advances main. commit_with_retry reads the tip
268        // and CAS-locks; correct behavior is to see c1 and commit on top of it,
269        // never clobber it from a stale c0.
270        let got = vr_a
271            .commit_with_retry(MAIN, |tip| Ok(Some(build_on(&vr_a, tip, "c.md", "from-A"))))
272            .unwrap()
273            .expect("a commit was produced");
274        let parent = vr_a.git().find_commit(got).unwrap().parent_id(0).unwrap();
275        assert_eq!(
276            parent, c1,
277            "reused handle committed atop B's external advance (saw the ref change; no lost update)"
278        );
279        assert!(
280            vr_a.git().find_commit(c1).is_ok(),
281            "B's commit is still reachable, not clobbered"
282        );
283    }
284
285    #[test]
286    fn commit_with_retry_no_contention() {
287        let (_tmp, vr) = open_unborn();
288        let c0 = build_on(&vr, None, "a.md", "a");
289        vr.cas_ref(MAIN, None, c0).unwrap();
290
291        let got = vr
292            .commit_with_retry(MAIN, |tip| Ok(Some(build_on(&vr, tip, "b.md", "b"))))
293            .unwrap()
294            .expect("a commit was produced");
295        assert_eq!(vr.head_oid(), Some(got));
296    }
297
298    #[test]
299    fn commit_with_retry_rebuilds_on_conflict() {
300        let (_tmp, vr) = open_unborn();
301        let c0 = build_on(&vr, None, "a.md", "a");
302        vr.cas_ref(MAIN, None, c0).unwrap();
303
304        let calls = Cell::new(0u32);
305        let got = vr
306            .commit_with_retry(MAIN, |tip| {
307                calls.set(calls.get() + 1);
308                let tip = tip.unwrap();
309                // On the FIRST attempt only, a concurrent writer advances the ref
310                // behind our back so our CAS must lose and rebuild.
311                if calls.get() == 1 {
312                    let concurrent = build_on(&vr, Some(tip), "concurrent.md", "x");
313                    vr.cas_ref(MAIN, Some(tip), concurrent).unwrap();
314                }
315                Ok(Some(build_on(&vr, Some(tip), "mine.md", "m")))
316            })
317            .unwrap()
318            .expect("a commit was produced");
319
320        assert_eq!(calls.get(), 2, "exactly one rebuild after the conflict");
321        assert_eq!(vr.head_oid(), Some(got));
322        // We rebuilt on the concurrent tip, so the final tree carries BOTH files.
323        let head_tree = vr.git().find_commit(got).unwrap().tree_id();
324        assert!(
325            vr.blob_oid_at(head_tree, "concurrent.md")
326                .unwrap()
327                .is_some()
328        );
329        assert!(vr.blob_oid_at(head_tree, "mine.md").unwrap().is_some());
330    }
331
332    /// turbovault-uag: relentless contention exhausts the retry budget and
333    /// surfaces a loud error (the live-lock guard) rather than spinning forever
334    /// or silently giving up. Every attempt loses the CAS because a concurrent
335    /// writer advances the ref first.
336    #[test]
337    fn commit_with_retry_exhausts_under_relentless_contention() {
338        let (_tmp, vr) = open_unborn();
339        let c0 = build_on(&vr, None, "a.md", "a");
340        vr.cas_ref(MAIN, None, c0).unwrap();
341
342        let calls = Cell::new(0u32);
343        let err = vr
344            .commit_with_retry_n(MAIN, 2, |tip| {
345                calls.set(calls.get() + 1);
346                let tip = tip.unwrap();
347                // Advance the ref behind our back BEFORE our CAS, every attempt.
348                let concurrent = build_on(&vr, Some(tip), &format!("c{}.md", calls.get()), "x");
349                vr.cas_ref(MAIN, Some(tip), concurrent).unwrap();
350                Ok(Some(build_on(&vr, Some(tip), "mine.md", "m")))
351            })
352            .unwrap_err();
353
354        // max_retries=2 -> the loop runs 0..=2 = 3 attempts, all lose.
355        assert_eq!(
356            calls.get(),
357            3,
358            "builder runs max_retries+1 times then gives up"
359        );
360        assert!(
361            err.to_string().contains("exhausted") && err.to_string().contains("contention"),
362            "loud exhaustion error: {err}"
363        );
364    }
365
366    /// turbovault-xw4: real-thread contention. N threads each open their OWN
367    /// VaultRepo (sharing the CommitLocks registry) and commit a DISTINCT file
368    /// concurrently. Every commit must land — the per-worktree commit lock +
369    /// update-ref CAS serialize them with NO lost update. The whole "no lost
370    /// update under contention" thesis was previously asserted only via
371    /// sequential simulated races; this drives genuine threads.
372    #[test]
373    fn parallel_commit_changeset_lands_every_commit() {
374        let (tmp, vr0) = open_unborn();
375        vr0.commit_changeset(&crate::Changeset::new("seed").create("seed.md", "0"))
376            .unwrap();
377        let path = tmp.path().to_path_buf();
378        let locks = vr0.commit_locks();
379        drop(vr0);
380
381        let n = 8u32;
382        let handles: Vec<_> = (0..n)
383            .map(|i| {
384                let p = path.clone();
385                let l = std::sync::Arc::clone(&locks);
386                std::thread::spawn(move || {
387                    let vr = crate::VaultRepo::open_with_locks(&p, l).unwrap();
388                    vr.commit_changeset(
389                        &crate::Changeset::new("c").create(format!("f{i}.md"), "x"),
390                    )
391                    .unwrap();
392                })
393            })
394            .collect();
395        for h in handles {
396            h.join().unwrap();
397        }
398
399        // Every file + the seed is in HEAD's tree — nothing lost to a race.
400        let vr = crate::VaultRepo::open_with_locks(&path, locks).unwrap();
401        let tree = vr
402            .git()
403            .find_commit(vr.head_oid().unwrap())
404            .unwrap()
405            .tree_id();
406        assert!(vr.blob_oid_at(tree, "seed.md").unwrap().is_some());
407        for i in 0..n {
408            assert!(
409                vr.blob_oid_at(tree, &format!("f{i}.md")).unwrap().is_some(),
410                "f{i}.md must have landed"
411            );
412        }
413    }
414}