Skip to main content

syncweb_core/sync/
conflict.rs

1//! Conflict detection and materialization for divergent folder entries.
2//!
3//! iroh-docs keeps every variant of a key (one record per author) in its
4//! store; the collapsed `single_latest_per_key` views used for normal reads
5//! hide all but the winner. This module exposes every variant (so losers stay
6//! recoverable) and materializes winner + losers to a mount root:
7//!
8//! - The winner (greatest `timestamp()`, ties broken deterministically by
9//!   author) always keeps the original path — matching the `single_latest_per_key`
10//!   collapsed view used everywhere else.
11//! - Each loser is written as `<path>.conflict.<short-hash>`, so no version is
12//!   ever lost on disk.
13//!
14//! `receiveonly` folders materialize only the winner; a read-only replica must
15//! never resolve a conflict by writing locally.
16
17use std::{
18    collections::BTreeMap,
19    path::{Path, PathBuf},
20};
21
22use iroh_blobs::Hash;
23use iroh_docs::Entry;
24
25use crate::{
26    error::{Result, SyncwebError},
27    folder::FolderManager,
28    node::iroh_node::IrohNode,
29};
30
31/// Outcome of one materialization pass over a folder.
32#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
33#[non_exhaustive]
34pub struct MaterializeSummary {
35    /// Distinct keys whose winner was written to the mount root.
36    pub materialized: usize,
37    /// Loser variants written as `<path>.conflict.<short-hash>`.
38    pub conflicts: usize,
39    /// Keys whose winner blob is not local yet (skipped, retried later).
40    pub pending: usize,
41}
42
43/// Outcome plus the list of absolute paths written during a materialization
44/// pass, so callers can skip re-processing those exact files.
45#[derive(Clone, Debug, Default, Eq, PartialEq)]
46#[non_exhaustive]
47pub struct MaterializeOutcome {
48    /// Aggregate counters from the pass.
49    pub summary: MaterializeSummary,
50    /// Absolute paths that were written (both winners and conflict copies).
51    pub written: Vec<PathBuf>,
52}
53
54/// Select the winner among variants of a single key.
55///
56/// LWW by record timestamp; ties are broken deterministically by author bytes
57/// so every node converges without tie-breaking on arrival order.
58#[must_use]
59pub fn select_winner(entries: &[Entry]) -> Option<&Entry> {
60    entries.iter().max_by(|left, right| {
61        left.timestamp()
62            .cmp(&right.timestamp())
63            .then_with(|| left.author().as_bytes().cmp(right.author().as_bytes()))
64    })
65}
66
67/// Group every non-`sys/` entry by its key, preserving all author variants.
68#[must_use]
69pub fn group_by_key(entries: Vec<Entry>) -> BTreeMap<Vec<u8>, Vec<Entry>> {
70    let mut grouped: BTreeMap<Vec<u8>, Vec<Entry>> = BTreeMap::new();
71    for entry in entries {
72        if entry.key().starts_with(b"sys/") {
73            continue;
74        }
75        grouped.entry(entry.key().to_vec()).or_default().push(entry);
76    }
77    grouped
78}
79
80/// Path for a losing variant: `<path>.conflict.<short-hash>`.
81#[must_use]
82pub fn conflict_path(path: &Path, hash: Hash) -> PathBuf {
83    let short: String = hash.to_hex().chars().take(8).collect();
84    PathBuf::from(format!("{}.conflict.{short}", path.to_string_lossy()))
85}
86
87/// Export one blob to `destination` when the blob is local and the file is not
88/// already up to date. Returns whether a file was written.
89async fn export_if_different(node: &IrohNode, hash: Hash, expected_size: u64, destination: &Path) -> Result<bool> {
90    if !node.blob_store().has(hash).await? {
91        return Ok(false);
92    }
93    if let Ok(metadata) = tokio::fs::metadata(destination).await
94        && metadata.is_file()
95        && metadata.len() == expected_size
96    {
97        return Ok(false);
98    }
99    if let Some(parent) = destination.parent() {
100        tokio::fs::create_dir_all(parent)
101            .await
102            .map_err(|error| SyncwebError::operation("failed to create folder materialization directory", error))?;
103    }
104    node.blob_store()
105        .export_to_path(hash, destination)
106        .await
107        .map_err(|error| SyncwebError::operation("failed to materialize folder entry", error))?;
108    Ok(true)
109}
110
111/// Materialize every entry of `folder` under `mount_root`, resolving conflicts.
112///
113/// The winner of each key keeps its original path; losing variants are written
114/// as `<path>.conflict.<short-hash>`. A `receiveonly` folder materializes only
115/// the winner (it must never resolve a conflict by writing locally). Blobs that
116/// are not yet local are skipped and reported as `pending`.
117///
118/// # Errors
119///
120/// Returns an error if the folder's document cannot be read.
121pub async fn materialize_folder_content(
122    node: &IrohNode,
123    manager: &FolderManager,
124    namespace: iroh_docs::NamespaceId,
125    mount_root: &Path,
126) -> Result<MaterializeOutcome> {
127    let folder = manager.get(namespace).await?;
128    let entries = folder.docs_engine().list_all_entries(folder.doc()).await?;
129    let grouped = group_by_key(entries);
130    let mut summary = MaterializeSummary::default();
131    let mut written = Vec::new();
132    for (key, variants) in grouped {
133        let path = PathBuf::from(String::from_utf8_lossy(&key).into_owned());
134        let Some(winner) = select_winner(&variants) else {
135            continue;
136        };
137        let winner_hash = winner.content_hash();
138        let winner_size = winner.content_len();
139        let destination = mount_root.join(&path);
140        match export_if_different(node, winner_hash, winner_size, &destination).await {
141            Ok(true) => {
142                summary.materialized = summary.materialized.saturating_add(1);
143                written.push(destination);
144            }
145            Ok(false) if !node.blob_store().has(winner_hash).await? => {
146                summary.pending = summary.pending.saturating_add(1);
147            }
148            Ok(false) => {}
149            Err(error) => {
150                tracing::warn!(%namespace, path = %path.display(), %error, "failed to materialize winner");
151            }
152        }
153        if !folder.is_writable() {
154            continue;
155        }
156        for loser in variants
157            .iter()
158            .filter(|variant| variant.content_hash() != winner_hash && variant.content_hash() != Hash::EMPTY)
159        {
160            let loser_path = mount_root.join(conflict_path(&path, loser.content_hash()));
161            match export_if_different(node, loser.content_hash(), loser.content_len(), &loser_path).await {
162                Ok(true) => {
163                    summary.conflicts = summary.conflicts.saturating_add(1);
164                    written.push(loser_path);
165                }
166                Ok(false) => {}
167                Err(error) => {
168                    tracing::warn!(%namespace, path = %loser_path.display(), %error, "failed to materialize conflict copy");
169                }
170            }
171        }
172    }
173    Ok(MaterializeOutcome { summary, written })
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use iroh_docs::{AuthorId, NamespaceId, Record};
180
181    fn entry(timestamp: u64, author: u8, hash_byte: u8, key: &[u8]) -> Entry {
182        let mut author_bytes = [0_u8; 32];
183        author_bytes[0] = author;
184        let author_id = AuthorId::from(author_bytes);
185        let mut hash_bytes = [0_u8; 32];
186        hash_bytes[0] = hash_byte;
187        let id = iroh_docs::RecordIdentifier::new(NamespaceId::from([0; 32]), author_id, key);
188        let record = Record::new(Hash::from_bytes(hash_bytes), 16, timestamp);
189        Entry::new(id, record)
190    }
191
192    fn author_id(byte: u8) -> AuthorId {
193        let mut bytes = [0_u8; 32];
194        bytes[0] = byte;
195        AuthorId::from(bytes)
196    }
197
198    #[test]
199    fn winner_is_latest_timestamp() {
200        let worth = entry(5, 2, 2, b"a.txt");
201        let older = entry(3, 1, 1, b"a.txt");
202        let variants = [older, worth];
203        let winner = select_winner(&variants);
204        assert_eq!(winner.expect("winner should be selected").timestamp(), 5);
205        assert_eq!(winner.expect("winner should be selected").author(), author_id(2));
206    }
207
208    #[test]
209    fn winner_tie_breaks_by_author() {
210        let left = entry(10, 3, 1, b"a.txt");
211        let right = entry(10, 7, 2, b"a.txt");
212        let variants = [left, right];
213        let winner = select_winner(&variants);
214        assert_eq!(winner.expect("winner should be selected").author(), author_id(7));
215    }
216
217    #[test]
218    fn missing_variants_yield_none() {
219        assert!(select_winner(&[]).is_none());
220    }
221
222    #[test]
223    fn grouping_skips_system_keys() {
224        let grouped = group_by_key(vec![
225            entry(1, 1, 1, b"a.txt"),
226            entry(2, 2, 2, b"a.txt"),
227            entry(3, 3, 3, b"sys/syncweb/mode"),
228        ]);
229        assert!(grouped.contains_key(b"a.txt".as_slice()));
230        assert!(!grouped.contains_key(b"sys/syncweb/mode".as_slice()));
231        assert_eq!(grouped.get(b"a.txt".as_slice()).map_or(0, Vec::len), 2);
232    }
233
234    #[test]
235    fn conflict_path_embeds_short_hash() {
236        let mut bytes = [0_u8; 32];
237        bytes[0] = 0xab;
238        bytes[31] = 0xcd;
239        let hash = Hash::from_bytes(bytes);
240        let path = conflict_path(Path::new("report.md"), hash);
241        let text = path.to_string_lossy();
242        assert!(text.starts_with("report.md.conflict."));
243        assert_eq!(text.len(), "report.md.conflict.".len() + 8);
244    }
245
246    #[test]
247    fn empty_hash_is_distinct_from_content() {
248        let mut bytes = [0_u8; 32];
249        bytes[0] = 0xab;
250        let content = Hash::from_bytes(bytes);
251        assert_ne!(Hash::EMPTY, content);
252    }
253}