Skip to main content

sapphire_framework_sync/
merge.rs

1//! Joining path states and choosing what is written to disk.
2
3use crate::entry::Entry;
4use crate::hlc::Hlc;
5use crate::id::ReplicaId;
6use crate::vv::{Dot, VersionVector};
7
8/// Ordering key of a version: edits beat deletes, then later clock, then replica and
9/// counter as tie-breakers. The maximum is the winner.
10fn winner_key(e: &Entry) -> (bool, Hlc, ReplicaId, u64) {
11    (
12        !e.content.is_tombstone(),
13        e.hlc,
14        e.dot.replica,
15        e.dot.counter,
16    )
17}
18
19/// The sibling whose content belongs on disk.
20pub fn winner(versions: &[Entry]) -> &Entry {
21    versions
22        .iter()
23        .max_by_key(|e| winner_key(e))
24        .expect("a path state always has at least one version")
25}
26
27/// DVV-set join of a local state with an incoming one. Returns the joined
28/// `(versions, seen)`, or `None` when the local state already contains everything.
29pub fn join(
30    local: Option<(&[Entry], &VersionVector)>,
31    incoming: &[Entry],
32    incoming_seen: &VersionVector,
33) -> Option<(Vec<Entry>, VersionVector)> {
34    // With no version on either side there is nothing to return but an empty sibling
35    // set, and `winner` — also `pub` — panics on one. The `local = None` branch below
36    // would otherwise hand the empty `incoming` straight back.
37    if incoming.is_empty() && local.is_none_or(|(versions, _)| versions.is_empty()) {
38        tracing::error!("join of two empty sibling sets; keeping the local state");
39        return None;
40    }
41    let Some((local, local_seen)) = local else {
42        let mut versions = incoming.to_vec();
43        versions.sort_by_key(|e| e.dot);
44        return Some((versions, incoming_seen.clone()));
45    };
46
47    let mut versions: Vec<Entry> = local
48        .iter()
49        .filter(|e| !incoming_seen.covers_dot(&e.dot) || incoming.iter().any(|i| i.dot == e.dot))
50        .cloned()
51        .collect();
52    for e in incoming {
53        let keep = !local_seen.covers_dot(&e.dot) || local.iter().any(|l| l.dot == e.dot);
54        if keep && !versions.iter().any(|v| v.dot == e.dot) {
55            versions.push(e.clone());
56        }
57    }
58    versions.sort_by_key(|e| e.dot);
59
60    let mut seen = local_seen.clone();
61    seen.merge(incoming_seen);
62
63    if versions.is_empty() {
64        // Impossible for well-formed states: each side would have to know of a version
65        // superseding everything the other holds. Keep the local state rather than
66        // leave a path with no version.
67        tracing::error!("join produced an empty sibling set; keeping the local state");
68        return None;
69    }
70    if versions.as_slice() == local && seen == *local_seen {
71        return None;
72    }
73    Some((versions, seen))
74}
75
76/// Whether `loser` needs a conflict copy next to `winner`.
77pub fn needs_copy(loser: &Entry, winner: &Entry) -> bool {
78    !loser.content.is_tombstone() && loser.content.hash() != winner.content.hash()
79}
80
81/// `<stem>.conflict-<grain-id>-<counter>.<ext>`, or `<name>.conflict-<grain-id>-<counter>`
82/// when the name has no extension. A leading dot does not start an extension.
83pub fn conflict_path(path: &str, loser: &Dot) -> String {
84    let (dir, name) = match path.rfind('/') {
85        Some(i) => (&path[..=i], &path[i + 1..]),
86        None => ("", path),
87    };
88    let tag = format!("conflict-{}-{}", loser.replica.display_id(), loser.counter);
89    match name.rfind('.') {
90        Some(i) if i > 0 => format!("{dir}{}.{tag}.{}", &name[..i], &name[i + 1..]),
91        _ => format!("{dir}{name}.{tag}"),
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::entry::Content;
99    use crate::hash::ContentHash;
100    use crate::hlc::Hlc;
101    use crate::id::ReplicaId;
102    use grain_id::GrainId;
103    use uuid::Uuid;
104
105    fn rid(n: u128) -> ReplicaId {
106        ReplicaId(Uuid::from_u128(n))
107    }
108
109    fn dot(r: u128, c: u64) -> Dot {
110        Dot {
111            replica: rid(r),
112            counter: c,
113        }
114    }
115
116    fn vv(dots: &[Dot]) -> VersionVector {
117        let mut v = VersionVector::new();
118        for d in dots {
119            v.add_dot(d);
120        }
121        v
122    }
123
124    fn file(d: Dot, wall: u64, body: &str, ctx: &[Dot]) -> Entry {
125        Entry {
126            path: "a.txt".into(),
127            content: Content::File {
128                hash: ContentHash::of_bytes(body.as_bytes()),
129                len: body.len() as u64,
130            },
131            hlc: Hlc {
132                wall_ms: wall,
133                logical: 0,
134            },
135            dot: d,
136            context: vv(ctx),
137            author: GrainId::NIL,
138        }
139    }
140
141    fn tomb(d: Dot, wall: u64, ctx: &[Dot]) -> Entry {
142        Entry {
143            content: Content::Tombstone,
144            ..file(d, wall, "", ctx)
145        }
146    }
147
148    /// A state as (versions, seen), built the way a local write would build it.
149    fn written(e: Entry) -> (Vec<Entry>, VersionVector) {
150        let mut seen = e.context.clone();
151        seen.add_dot(&e.dot);
152        (vec![e], seen)
153    }
154
155    fn j(
156        a: &(Vec<Entry>, VersionVector),
157        b: &(Vec<Entry>, VersionVector),
158    ) -> (Vec<Entry>, VersionVector) {
159        join(Some((&a.0, &a.1)), &b.0, &b.1).unwrap_or_else(|| a.clone())
160    }
161
162    #[test]
163    fn into_nothing_takes_the_incoming_state() {
164        let x = written(file(dot(1, 1), 10, "x", &[]));
165        assert_eq!(join(None, &x.0, &x.1), Some(x.clone()));
166    }
167
168    #[test]
169    fn a_descendant_replaces_its_ancestor() {
170        let x = written(file(dot(1, 1), 10, "x", &[]));
171        let y = written(file(dot(2, 1), 20, "y", &[dot(1, 1)]));
172        let got = j(&x, &y);
173        assert_eq!(got, y);
174        // and the ancestor never comes back
175        assert_eq!(join(Some((&got.0, &got.1)), &x.0, &x.1), None);
176    }
177
178    #[test]
179    fn concurrent_versions_become_siblings() {
180        let x = written(file(dot(1, 1), 10, "x", &[]));
181        let y = written(file(dot(2, 1), 20, "y", &[]));
182        let got = j(&x, &y);
183        assert_eq!(got.0.len(), 2);
184        assert_eq!(got.1, vv(&[dot(1, 1), dot(2, 1)]));
185        assert_eq!(winner(&got.0).dot, dot(2, 1), "later hlc wins");
186    }
187
188    #[test]
189    fn an_edit_beats_a_concurrent_delete_even_if_older() {
190        let x = written(file(dot(1, 1), 10, "x", &[]));
191        let d = written(tomb(dot(2, 1), 99, &[]));
192        let got = j(&x, &d);
193        assert_eq!(winner(&got.0).dot, dot(1, 1));
194    }
195
196    #[test]
197    fn join_is_idempotent() {
198        let x = written(file(dot(1, 1), 10, "x", &[]));
199        let y = written(file(dot(2, 1), 20, "y", &[]));
200        let s = j(&x, &y);
201        assert_eq!(join(Some((&s.0, &s.1)), &s.0, &s.1), None);
202    }
203
204    /// The case a single-winner merge gets wrong: B deletes after seeing X, C edits
205    /// concurrently, and the winner key is not monotonic along causality.
206    #[test]
207    fn join_is_associative_and_commutative_with_deletes() {
208        let a = written(file(dot(1, 1), 50, "x", &[]));
209        let b = written(tomb(dot(2, 1), 60, &[dot(1, 1)]));
210        let c = written(file(dot(3, 1), 40, "z", &[]));
211        let orders = [
212            j(&j(&a, &b), &c),
213            j(&a, &j(&b, &c)),
214            j(&j(&a, &c), &b),
215            j(&j(&c, &a), &b),
216            j(&j(&b, &c), &a),
217        ];
218        for got in &orders[1..] {
219            assert_eq!(got, &orders[0]);
220        }
221        let dots: Vec<Dot> = orders[0].0.iter().map(|e| e.dot).collect();
222        assert_eq!(
223            dots,
224            vec![dot(2, 1), dot(3, 1)],
225            "X is superseded; the delete and Z are siblings"
226        );
227        assert_eq!(winner(&orders[0].0).dot, dot(3, 1));
228    }
229
230    /// `join` and `winner` are both `pub`: a join must never return a sibling set
231    /// `winner` would panic on.
232    #[test]
233    fn an_empty_join_never_returns_an_empty_sibling_set() {
234        let seen = vv(&[dot(1, 1)]);
235        assert_eq!(join(None, &[], &seen), None);
236        assert_eq!(join(Some((&[], &seen)), &[], &seen), None);
237        let x = written(file(dot(1, 1), 10, "x", &[]));
238        for got in [
239            join(Some((&x.0, &x.1)), &[], &seen),
240            join(Some((&[], &seen)), &x.0, &x.1),
241        ]
242        .into_iter()
243        .flatten()
244        {
245            assert!(!got.0.is_empty());
246        }
247    }
248
249    #[test]
250    fn needs_copy_only_for_differing_live_losers() {
251        let w = file(dot(1, 1), 10, "same", &[]);
252        assert!(!needs_copy(&file(dot(2, 1), 5, "same", &[]), &w));
253        assert!(!needs_copy(&tomb(dot(2, 1), 5, &[]), &w));
254        assert!(needs_copy(&file(dot(2, 1), 5, "other", &[]), &w));
255    }
256
257    #[test]
258    fn conflict_path_naming() {
259        let d = Dot {
260            replica: ReplicaId(Uuid::from_u128(0x0190_0000_0000_7000_8000_00ff_ffff_ffff)),
261            counter: 42,
262        };
263        let g = GrainId::MAX.to_string();
264        assert_eq!(
265            conflict_path("note.md", &d),
266            format!("note.conflict-{g}-42.md")
267        );
268        assert_eq!(
269            conflict_path("dir/a.tar.gz", &d),
270            format!("dir/a.tar.conflict-{g}-42.gz")
271        );
272        assert_eq!(
273            conflict_path("dir/Makefile", &d),
274            format!("dir/Makefile.conflict-{g}-42")
275        );
276        assert_eq!(
277            conflict_path(".sapphireignore", &d),
278            format!(".sapphireignore.conflict-{g}-42")
279        );
280    }
281}