crates/substrate/gix-ref-store/tests/conformance.rs
conformance.rshistorycomment on this file
| 1 | //! CAS conformance suite for [`gix_ref_store::LooseRefStore`]. |
| 2 | //! |
| 3 | //! This is the Phase 1 -> 2 gate from `docs/development-plan.adoc`: |
| 4 | //! "`gix-ref-store` passes a CAS conformance suite (concurrent writers, |
| 5 | //! crash injection)." Both properties below exercise gitoxide's own |
| 6 | //! on-disk lock file, not an in-process mutex standing in for it: each |
| 7 | //! "writer" opens its own [`LooseRefStore`] (its own `gix::Repository` |
| 8 | //! handle) against the same on-disk path, the way independent OS |
| 9 | //! processes would. |
| 10 | //! |
| 11 | //! Strategy: rstest table-driven for the fixed-shape crash-injection |
| 12 | //! scenario (a handful of named cases, not an unbounded input space); |
| 13 | //! a hand-rolled multi-thread race for concurrent writers, since the |
| 14 | //! property under test — exactly one of N racing CAS transactions wins, |
| 15 | //! observed from independent store handles — is about thread |
| 16 | //! interleaving, which proptest's shrinking model has nothing to offer |
| 17 | //! for. `@relation(..., role=Verifies)` is on each test. |
| 18 | |
| 19 | #![allow( |
| 20 | clippy::unwrap_used, |
| 21 | clippy::expect_used, |
| 22 | reason = "assertion helpers for a conformance suite, not application code" |
| 23 | )] |
| 24 | |
| 25 | use std::sync::Arc; |
| 26 | use std::sync::atomic::{AtomicUsize, Ordering}; |
| 27 | |
| 28 | use gix_hash::ObjectId; |
| 29 | use gix_ref_store::{Expected, LooseRefStore, RefEdit, RefStore, RefStoreRead, TxOutcome}; |
| 30 | |
| 31 | /// A fresh bare repository. Bare so `dir.path()` *is* the git directory — |
| 32 | /// no `.git` subdirectory indirection to get wrong when a test computes a |
| 33 | /// ref's on-disk path directly, as the crash-injection cases below do. |
| 34 | fn init_repo() -> tempfile::TempDir { |
| 35 | let dir = tempfile::tempdir().expect("tempdir"); |
| 36 | gix::init_bare(dir.path()).expect("gix init_bare"); |
| 37 | dir |
| 38 | } |
| 39 | |
| 40 | fn refname(s: &str) -> gix::refs::FullName { |
| 41 | s.try_into().expect("valid refname") |
| 42 | } |
| 43 | |
| 44 | fn oid(byte: u8) -> ObjectId { |
| 45 | ObjectId::from_bytes_or_panic(&[byte; 20]) |
| 46 | } |
| 47 | |
| 48 | /// N independent store handles race a `MustNotExist` CAS create on the |
| 49 | /// *same* ref, each proposing a different oid. Exactly one must win; every |
| 50 | /// other transaction must observe the ref as already existing and report |
| 51 | /// `Rejected`, never silently overwrite the winner, and never both "win". |
| 52 | // @relation(arch.refstore-read-cas-split, arch.loose-cas-discipline, scope=function, role=Verifies) |
| 53 | #[test] |
| 54 | fn concurrent_writers_exactly_one_cas_wins() { |
| 55 | let dir = init_repo(); |
| 56 | let name = refname("refs/meta/race"); |
| 57 | let writers = 8u8; |
| 58 | |
| 59 | let applied = Arc::new(AtomicUsize::new(0)); |
| 60 | let handles: Vec<_> = (0..writers) |
| 61 | .map(|i| { |
| 62 | let path = dir.path().to_path_buf(); |
| 63 | let name = name.clone(); |
| 64 | let applied = Arc::clone(&applied); |
| 65 | std::thread::spawn(move || { |
| 66 | // Each thread opens its own store handle against the same |
| 67 | // on-disk repository, standing in for independent |
| 68 | // processes contending the same loose ref file. |
| 69 | let store = LooseRefStore::open(&path).expect("open"); |
| 70 | let outcome = store |
| 71 | .transaction(&[RefEdit { |
| 72 | name: name.clone(), |
| 73 | expected: Expected::MustNotExist, |
| 74 | new: Some(oid(i)), |
| 75 | }]) |
| 76 | .expect("transaction must not error under contention, only reject"); |
| 77 | if outcome == TxOutcome::Applied { |
| 78 | applied.fetch_add(1, Ordering::SeqCst); |
| 79 | } |
| 80 | outcome |
| 81 | }) |
| 82 | }) |
| 83 | .collect(); |
| 84 | |
| 85 | let outcomes: Vec<TxOutcome> = handles |
| 86 | .into_iter() |
| 87 | .map(|h| h.join().expect("thread")) |
| 88 | .collect(); |
| 89 | |
| 90 | let applied_count = outcomes |
| 91 | .iter() |
| 92 | .filter(|o| **o == TxOutcome::Applied) |
| 93 | .count(); |
| 94 | assert_eq!( |
| 95 | applied_count, 1, |
| 96 | "exactly one of {writers} racing CAS creates must apply; got {applied_count}: {outcomes:?}" |
| 97 | ); |
| 98 | let rejected_count = outcomes |
| 99 | .iter() |
| 100 | .filter(|o| matches!(o, TxOutcome::Rejected { .. })) |
| 101 | .count(); |
| 102 | assert_eq!( |
| 103 | rejected_count, |
| 104 | (writers - 1) as usize, |
| 105 | "every non-winning transaction must be a clean Rejected, not an error or a second Applied" |
| 106 | ); |
| 107 | |
| 108 | // The ref must hold exactly one of the proposed values, not a torn |
| 109 | // write and not a value nobody proposed. |
| 110 | let store = LooseRefStore::open(dir.path()).expect("open"); |
| 111 | let landed = store.get(name.as_ref()).expect("get").expect("ref exists"); |
| 112 | assert!( |
| 113 | (0..writers).map(oid).any(|candidate| candidate == landed), |
| 114 | "the ref must hold exactly one racing writer's proposed oid" |
| 115 | ); |
| 116 | } |
| 117 | |
| 118 | /// Concurrent writers targeting *different* refs must not falsely |
| 119 | /// serialize into contention with one another: independent refs are |
| 120 | /// independent compare-and-swap units. |
| 121 | // @relation(arch.refstore-read-cas-split, scope=function, role=Verifies) |
| 122 | #[test] |
| 123 | fn concurrent_writers_on_distinct_refs_all_apply() { |
| 124 | let dir = init_repo(); |
| 125 | let writers = 8u8; |
| 126 | |
| 127 | let handles: Vec<_> = (0..writers) |
| 128 | .map(|i| { |
| 129 | let path = dir.path().to_path_buf(); |
| 130 | std::thread::spawn(move || { |
| 131 | let store = LooseRefStore::open(&path).expect("open"); |
| 132 | store |
| 133 | .transaction(&[RefEdit { |
| 134 | name: refname(&format!("refs/meta/independent-{i}")), |
| 135 | expected: Expected::MustNotExist, |
| 136 | new: Some(oid(i)), |
| 137 | }]) |
| 138 | .expect("transaction") |
| 139 | }) |
| 140 | }) |
| 141 | .collect(); |
| 142 | |
| 143 | for (i, handle) in handles.into_iter().enumerate() { |
| 144 | let outcome = handle.join().expect("thread"); |
| 145 | assert_eq!( |
| 146 | outcome, |
| 147 | TxOutcome::Applied, |
| 148 | "writer {i} on its own ref must not be blocked by unrelated concurrent writers" |
| 149 | ); |
| 150 | } |
| 151 | |
| 152 | let store = LooseRefStore::open(dir.path()).expect("open"); |
| 153 | for i in 0..writers { |
| 154 | assert_eq!( |
| 155 | store |
| 156 | .get(refname(&format!("refs/meta/independent-{i}")).as_ref()) |
| 157 | .expect("get"), |
| 158 | Some(oid(i)) |
| 159 | ); |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | /// Simulates the on-disk artifact a writer crashing mid-transaction |
| 164 | /// leaves behind: a `.lock` file next to the ref, created but never |
| 165 | /// cleaned up because the process died holding it. A `LooseRefStore` must |
| 166 | /// neither corrupt the ref's last known-good value nor silently apply a |
| 167 | /// transaction while that lock stands; it must fail the contending |
| 168 | /// transaction cleanly, and a fresh transaction must succeed once the |
| 169 | /// stale lock is cleared, as a real recovery path (fsck / restart) would |
| 170 | /// clear it. |
| 171 | // @relation(arch.loose-cas-discipline, scope=function, role=Verifies) |
| 172 | #[rstest::rstest] |
| 173 | #[case::branch_ref("refs/heads/crash-test")] |
| 174 | #[case::meta_ref("refs/meta/crash-test")] |
| 175 | fn crash_injection_stale_lock_fails_safe_and_recovers(#[case] ref_name: &str) { |
| 176 | let dir = init_repo(); |
| 177 | let name = refname(ref_name); |
| 178 | let good = oid(0xAA); |
| 179 | let attempted = oid(0xBB); |
| 180 | |
| 181 | let store = LooseRefStore::open(dir.path()).expect("open"); |
| 182 | let outcome = store |
| 183 | .transaction(&[RefEdit { |
| 184 | name: name.clone(), |
| 185 | expected: Expected::MustNotExist, |
| 186 | new: Some(good), |
| 187 | }]) |
| 188 | .expect("baseline transaction"); |
| 189 | assert_eq!(outcome, TxOutcome::Applied); |
| 190 | |
| 191 | // Inject the artifact a crash mid-write leaves: an orphaned lock file |
| 192 | // next to the loose ref, never cleaned up because nothing removed it. |
| 193 | let lock_path = dir.path().join(format!("{ref_name}.lock")); |
| 194 | std::fs::create_dir_all(lock_path.parent().expect("lock has a parent")).expect("mkdir -p"); |
| 195 | std::fs::write(&lock_path, b"orphaned by a simulated crash\n").expect("write stale lock"); |
| 196 | |
| 197 | // A contending transaction must fail safely — not hang forever, not |
| 198 | // silently overwrite the ref — while the stale lock stands. |
| 199 | let result = store.transaction(&[RefEdit { |
| 200 | name: name.clone(), |
| 201 | expected: Expected::MustExistAndMatch(good), |
| 202 | new: Some(attempted), |
| 203 | }]); |
| 204 | assert!( |
| 205 | result.is_err(), |
| 206 | "a transaction contending a stale lock must fail, not silently succeed or hang: {result:?}" |
| 207 | ); |
| 208 | |
| 209 | // The ref must be exactly as it was — no torn or partial write from |
| 210 | // the failed attempt. |
| 211 | assert_eq!( |
| 212 | store |
| 213 | .get(name.as_ref()) |
| 214 | .expect("get after failed transaction"), |
| 215 | Some(good), |
| 216 | "a failed transaction under a stale lock must not have changed the ref's value" |
| 217 | ); |
| 218 | |
| 219 | // Recovery: once the stale lock is cleared (as a restart or an fsck |
| 220 | // pass would clear it), a fresh transaction must succeed normally. |
| 221 | std::fs::remove_file(&lock_path).expect("clear the stale lock"); |
| 222 | let recovered = store |
| 223 | .transaction(&[RefEdit { |
| 224 | name: name.clone(), |
| 225 | expected: Expected::MustExistAndMatch(good), |
| 226 | new: Some(attempted), |
| 227 | }]) |
| 228 | .expect("transaction after lock clears"); |
| 229 | assert_eq!(recovered, TxOutcome::Applied); |
| 230 | assert_eq!( |
| 231 | store.get(name.as_ref()).expect("get after recovery"), |
| 232 | Some(attempted) |
| 233 | ); |
| 234 | } |
| 235 | |
| 236 | /// The same exactly-one-wins property as |
| 237 | /// [`concurrent_writers_exactly_one_cas_wins`], but racing a |
| 238 | /// `MustExistAndMatch` update against an already-existing ref rather than |
| 239 | /// a `MustNotExist` create — the pattern `gate.fast-forward` and |
| 240 | /// `gate.atomic-cas` actually describe: a meta-ref advances from a known |
| 241 | /// old tip, not from nothing. |
| 242 | // @relation(gate.atomic-cas, arch.loose-cas-discipline, scope=function, role=Verifies) |
| 243 | #[test] |
| 244 | fn concurrent_writers_exactly_one_cas_update_wins() { |
| 245 | let dir = init_repo(); |
| 246 | let name = refname("refs/meta/update-race"); |
| 247 | let store = LooseRefStore::open(dir.path()).expect("open"); |
| 248 | let base = oid(0x10); |
| 249 | store |
| 250 | .transaction(&[RefEdit { |
| 251 | name: name.clone(), |
| 252 | expected: Expected::MustNotExist, |
| 253 | new: Some(base), |
| 254 | }]) |
| 255 | .expect("baseline"); |
| 256 | |
| 257 | let writers = 8u8; |
| 258 | let handles: Vec<_> = (0..writers) |
| 259 | .map(|i| { |
| 260 | let path = dir.path().to_path_buf(); |
| 261 | let name = name.clone(); |
| 262 | std::thread::spawn(move || { |
| 263 | let store = LooseRefStore::open(&path).expect("open"); |
| 264 | store |
| 265 | .transaction(&[RefEdit { |
| 266 | name: name.clone(), |
| 267 | expected: Expected::MustExistAndMatch(base), |
| 268 | new: Some(oid(0x20 + i)), |
| 269 | }]) |
| 270 | .expect("transaction must not error under contention, only reject") |
| 271 | }) |
| 272 | }) |
| 273 | .collect(); |
| 274 | let outcomes: Vec<TxOutcome> = handles |
| 275 | .into_iter() |
| 276 | .map(|h| h.join().expect("thread")) |
| 277 | .collect(); |
| 278 | |
| 279 | let applied_count = outcomes |
| 280 | .iter() |
| 281 | .filter(|o| **o == TxOutcome::Applied) |
| 282 | .count(); |
| 283 | assert_eq!( |
| 284 | applied_count, 1, |
| 285 | "exactly one of {writers} racing CAS updates from the same known-good tip must apply; got {applied_count}: {outcomes:?}" |
| 286 | ); |
| 287 | |
| 288 | let landed = store.get(name.as_ref()).expect("get").expect("ref exists"); |
| 289 | assert!( |
| 290 | (0..writers) |
| 291 | .map(|i| oid(0x20 + i)) |
| 292 | .any(|candidate| candidate == landed), |
| 293 | "the ref must hold exactly one racing writer's proposed oid, not the stale base value" |
| 294 | ); |
| 295 | } |