OXYGENHUB
# 2bf22305bc48 oxygen / crates / oxy-core / tests / commutativity.rs
8.7 KiB · Rust · last changed Property tests: the order of changes never changes the state 1 month ago source blame history raw
1
//! Property tests for spec §7.3: a state is a function of its set of
2
//! changes, never of the order they arrived in.
3
//!
4
//! Each case seeds a repository with a few files, lets two or three clones
5
//! diverge through random edit sessions (inserts, deletes, replacements,
6
//! new and removed files, so conflicts happen too), then rebuilds the
7
//! union of every clone's changes in many orders: arbitrary orders through
8
//! `State::add_unchecked` and random dependency-respecting orders through
9
//! `State::add`. Every order must validate and give byte-identical
10
//! checkpoints and identical materialized trees. Finally the clones push
11
//! to one origin in a random order and a fresh clone must see that state,
12
//! and a checkpoint of the state must restore it exactly.
13
14
use oxy_core::objects::Change;
15
use oxy_core::repo::Repo;
16
use oxy_core::state::State;
17
use proptest::prelude::*;
18
use rand::rngs::StdRng;
19
use rand::seq::SliceRandom;
20
use rand::{Rng, SeedableRng};
21
use std::collections::{BTreeMap, BTreeSet};
22
use std::path::Path;
23
24
/// Point `$OXY_HOME` at a throwaway directory once per test process.
25
fn hermetic() {
26
    static ONCE: std::sync::Once = std::sync::Once::new();
27
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
28
    ONCE.call_once(|| {
29
        let d = tempfile::tempdir().unwrap();
30
        std::env::set_var("OXY_HOME", d.path());
31
        DIR.set(d).ok();
32
    });
33
}
34
35
/// Files the edits address; the first `SEEDED` exist in the base state.
36
const FILES: [&str; 4] = ["a.txt", "b.txt", "dir/c.txt", "d.txt"];
37
const SEEDED: usize = 3;
38
39
/// A few short lines, so identical lines are common and the diff has
40
/// to choose between equally good alignments.
41
const WORDS: [&str; 5] = ["alpha\n", "beta\n", "gamma\n", "delta\n", "alpha\n"];
42
43
#[derive(Clone, Debug)]
44
enum Edit {
45
    Insert { file: usize, at: usize, word: usize },
46
    Delete { file: usize, at: usize },
47
    Replace { file: usize, at: usize, word: usize },
48
    Remove { file: usize },
49
}
50
51
fn edit() -> impl Strategy<Value = Edit> {
52
    let file = 0..FILES.len();
53
    prop_oneof![
54
        4 => (file.clone(), any::<usize>(), 0..WORDS.len()).prop_map(|(file, at, word)| Edit::Insert { file, at, word }),
55
        3 => (file.clone(), any::<usize>()).prop_map(|(file, at)| Edit::Delete { file, at }),
56
        3 => (file.clone(), any::<usize>(), 0..WORDS.len()).prop_map(|(file, at, word)| Edit::Replace { file, at, word }),
57
        1 => file.prop_map(|file| Edit::Remove { file }),
58
    ]
59
}
60
61
/// One clone's session: a few commits of a few edits each.
62
fn session() -> impl Strategy<Value = Vec<Vec<Edit>>> {
63
    prop::collection::vec(prop::collection::vec(edit(), 1..5), 1..4)
64
}
65
66
fn lines(root: &Path, rel: &str) -> Option<Vec<String>> {
67
    let text = std::fs::read_to_string(root.join(rel)).ok()?;
68
    Some(text.split_inclusive('\n').map(String::from).collect())
69
}
70
71
fn apply(root: &Path, e: &Edit) {
72
    let file = match e {
73
        Edit::Remove { file } => {
74
            let _ = std::fs::remove_file(root.join(FILES[*file]));
75
            return;
76
        }
77
        Edit::Insert { file, .. } | Edit::Delete { file, .. } | Edit::Replace { file, .. } => *file,
78
    };
79
    let mut l = lines(root, FILES[file]).unwrap_or_default();
80
    match *e {
81
        Edit::Insert { at, word, .. } => l.insert(at % (l.len() + 1), WORDS[word].to_string()),
82
        Edit::Delete { at, .. } if !l.is_empty() => {
83
            let i = at % l.len();
84
            l.remove(i);
85
        }
86
        Edit::Replace { at, word, .. } if !l.is_empty() => {
87
            let i = at % l.len();
88
            l[i] = WORDS[word].to_string();
89
        }
90
        _ => {}
91
    }
92
    let path = root.join(FILES[file]);
93
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
94
    std::fs::write(path, l.concat()).unwrap();
95
}
96
97
/// Every change reachable from `repo`'s bookmark heads.
98
fn changes_of(repo: &Repo, into: &mut BTreeMap<oxy_core::hash::ChangeHash, Change>) {
99
    let mut stack = repo.bookmark_heads().unwrap();
100
    while let Some(h) = stack.pop() {
101
        if into.contains_key(&h) {
102
            continue;
103
        }
104
        let c = repo.store().get_change(&h).unwrap();
105
        stack.extend(c.deps.iter().copied());
106
        into.insert(h, c);
107
    }
108
}
109
110
/// What must not depend on order: the checkpoint bytes, the heads and
111
/// the materialized tree.
112
fn fingerprint(
113
    state: &State,
114
) -> (
115
    Vec<u8>,
116
    BTreeSet<oxy_core::hash::ChangeHash>,
117
    oxy_core::tree::TreeView,
118
    BTreeMap<String, oxy_core::state::FileState>,
119
) {
120
    let snap = state.materialize();
121
    (
122
        state.checkpoint_payload(),
123
        state.heads().into_iter().collect(),
124
        snap.tree,
125
        snap.files,
126
    )
127
}
128
129
fn run_case(sessions: Vec<Vec<Vec<Edit>>>, seed: u64) -> Result<(), TestCaseError> {
130
    hermetic();
131
    let dir = tempfile::tempdir().unwrap();
132
    let root = dir.path();
133
    let origin_path = root.join("origin");
134
    Repo::init(&origin_path).unwrap();
135
    let url = origin_path.to_string_lossy().to_string();
136
137
    // The shared base.
138
    let seed_repo = Repo::clone(&url, &root.join("seed")).unwrap();
139
    for (i, f) in FILES.iter().take(SEEDED).enumerate() {
140
        let p = root.join("seed").join(f);
141
        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
142
        std::fs::write(p, WORDS[..=i + 1].concat()).unwrap();
143
    }
144
    seed_repo.commit("base", &[]).unwrap();
145
    assert!(seed_repo
146
        .push("origin", false)
147
        .unwrap()
148
        .iter()
149
        .all(|r| r.ok));
150
151
    // Diverge.
152
    let mut clones = Vec::new();
153
    for (n, session) in sessions.iter().enumerate() {
154
        let wc = root.join(format!("clone{n}"));
155
        let repo = Repo::clone(&url, &wc).unwrap();
156
        for (k, commit) in session.iter().enumerate() {
157
            for e in commit {
158
                apply(&wc, e);
159
            }
160
            let s = repo.snapshot("oxy status").unwrap();
161
            if s.statuses.is_empty() {
162
                continue;
163
            }
164
            repo.commit(&format!("clone {n} commit {k}"), &[]).unwrap();
165
        }
166
        clones.push(repo);
167
    }
168
169
    let mut all = BTreeMap::new();
170
    for c in &clones {
171
        changes_of(c, &mut all);
172
    }
173
    let changes: Vec<Change> = all.values().cloned().collect();
174
175
    // The reference: every change, sorted by hash.
176
    let mut reference = State::new();
177
    for c in &changes {
178
        reference.add_unchecked(c.clone());
179
    }
180
    prop_assert!(reference.validate().is_ok());
181
    let want = fingerprint(&reference);
182
    // A checkpoint restores the same state.
183
    let restored = State::from_checkpoint_payload(&want.0).unwrap();
184
    prop_assert!(
185
        fingerprint(&restored) == want,
186
        "checkpoint round trip changed the state"
187
    );
188
189
    let mut rng = StdRng::seed_from_u64(seed);
190
    for _ in 0..4 {
191
        // Any order at all.
192
        let mut order = changes.clone();
193
        order.shuffle(&mut rng);
194
        let mut st = State::new();
195
        for c in order {
196
            st.add_unchecked(c);
197
        }
198
        prop_assert!(st.validate().is_ok());
199
        prop_assert!(
200
            fingerprint(&st) == want,
201
            "unchecked order changed the state"
202
        );
203
204
        // A random order that respects dependencies, the order a fetch sees.
205
        let mut pending: Vec<&Change> = changes.iter().collect();
206
        let mut st = State::new();
207
        while !pending.is_empty() {
208
            let ready: Vec<usize> = (0..pending.len())
209
                .filter(|&i| pending[i].deps.iter().all(|d| st.contains(d)))
210
                .collect();
211
            prop_assert!(!ready.is_empty(), "dependency cycle");
212
            let pick = ready[rng.gen_range(0..ready.len())];
213
            st.add(pending.swap_remove(pick).clone()).unwrap();
214
        }
215
        prop_assert!(
216
            fingerprint(&st) == want,
217
            "dependency order changed the state"
218
        );
219
    }
220
221
    // Through the protocol: push in a random order, then a fresh clone.
222
    let mut order: Vec<usize> = (0..clones.len()).collect();
223
    order.shuffle(&mut rng);
224
    for i in order {
225
        let c = &clones[i];
226
        // A clone behind the origin is told to pull first, as a user is.
227
        if !c
228
            .push("origin", false)
229
            .is_ok_and(|r| r.iter().all(|r| r.ok))
230
        {
231
            c.pull("origin").unwrap();
232
            prop_assert!(
233
                c.push("origin", false).unwrap().iter().all(|r| r.ok),
234
                "push after pull"
235
            );
236
        }
237
    }
238
    let fresh = Repo::clone(&url, &root.join("fresh")).unwrap();
239
    let heads = fresh.bookmark_heads().unwrap();
240
    let pushed = fresh.load_state_full(&heads).unwrap();
241
    prop_assert!(fingerprint(&pushed) == want, "the pushed state differs");
242
    Ok(())
243
}
244
245
proptest! {
246
    #![proptest_config(ProptestConfig { cases: 32, ..ProptestConfig::default() })]
247
248
    #[test]
249
    fn order_of_changes_is_irrelevant(sessions in prop::collection::vec(session(), 2..4), seed in any::<u64>()) {
250
        run_case(sessions, seed)?;
251
    }
252
}