git-ents.gitmain
⌘K
foforge
conformance.rs295 lines · 11.1 KB · rusthistorycomment 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
25use std::sync::Arc;
26use std::sync::atomic::{AtomicUsize, Ordering};
27
28use gix_hash::ObjectId;
29use 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.
34fn 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
40fn refname(s: &str) -> gix::refs::FullName {
41 s.try_into().expect("valid refname")
42}
43
44fn 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]
54fn 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]
123fn 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")]
175fn 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]
244fn 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}