Skip to main content

syncweb_core/sync/
partial_fetch.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use iroh_blobs::Hash;
5use serde::{Deserialize, Serialize};
6
7use crate::error::Result;
8use crate::folder::SyncwebFolder;
9use crate::node::iroh_node::IrohNode;
10
11/// Fetch the blobs selected by `strategy` directly from the folder's namespace
12/// peers.
13///
14/// A metadata-only folder syncs entries while deliberately leaving blobs out of
15/// the local store; a later doc re-sync does not re-download that content, so
16/// `download` on such a folder must fetch each selected blob from its peers
17/// directly. Returns the number of bytes fetched.
18///
19/// # Errors
20///
21/// Returns an error if the folder's entries cannot be listed.
22pub async fn fetch_selected_content(node: &IrohNode, folder: &SyncwebFolder, strategy: &FetchStrategy) -> Result<u64> {
23    let mut candidates = Vec::new();
24    for entry in folder.content_entries().await? {
25        let hash = entry.content_hash();
26        let local = folder.has_local(hash).await?;
27        let path = PathBuf::from(String::from_utf8_lossy(entry.key()).into_owned());
28        candidates.push(FetchCandidate::new(path, hash, entry.content_len(), 0, local));
29    }
30    let mut selected = strategy.select(&candidates);
31    // Complete short blobs first so a large transfer does not delay the first
32    // useful results of a mixed-size download.
33    selected.sort_by(|left, right| {
34        left.size
35            .cmp(&right.size)
36            .then(left.path.cmp(&right.path))
37            .then(left.hash.cmp(&right.hash))
38    });
39    let peers = node
40        .topic_tracker()
41        .find_peers(folder.namespace_id())
42        .await
43        .unwrap_or_default();
44    let mut bytes = 0_u64;
45    for candidate in selected {
46        if candidate.local {
47            continue;
48        }
49        for peer in &peers {
50            if node
51                .blob_store()
52                .force_fetch_from_peer(node.endpoint(), peer, candidate.hash)
53                .await
54                .is_ok()
55            {
56                bytes = bytes.saturating_add(candidate.size);
57                break;
58            }
59        }
60    }
61    Ok(bytes)
62}
63
64/// Write the blobs selected by `strategy` to files under `mount_root`, using
65/// the folder-relative entry paths. Returns the number of files written.
66///
67/// This turns a `download` from a store-only fetch into files on disk.
68///
69/// # Errors
70///
71/// Returns an error if `mount_root` cannot be resolved against the current
72/// directory, an entry directory cannot be created, or a blob cannot be
73/// exported to its destination.
74pub async fn materialize_selected_content(
75    node: &IrohNode,
76    folder: &SyncwebFolder,
77    strategy: &FetchStrategy,
78    mount_root: &Path,
79) -> Result<usize> {
80    let mut candidates = Vec::new();
81    for entry in folder.content_entries().await? {
82        let hash = entry.content_hash();
83        let local = folder.has_local(hash).await?;
84        let path = PathBuf::from(String::from_utf8_lossy(entry.key()).into_owned());
85        candidates.push(FetchCandidate::new(path, hash, entry.content_len(), 0, local));
86    }
87    let selected = strategy.select(&candidates);
88    let root = absolute_base(mount_root)?;
89    let mut count = 0_usize;
90    for candidate in selected {
91        let dest = root.join(&candidate.path);
92        if let Some(parent) = dest.parent() {
93            tokio::fs::create_dir_all(parent)
94                .await
95                .map_err(|error| crate::error::SyncwebError::operation("failed to create download directory", error))?;
96        }
97        node.blob_store().export_to_path(candidate.hash, &dest).await?;
98        count = count.saturating_add(1);
99    }
100    Ok(count)
101}
102
103/// Resolve `base` to an absolute path without touching the filesystem.
104///
105/// Blob export rejects relative destinations, so a folder registered with a
106/// relative mount path (the CLI default is a relative `--data-dir`) still needs
107/// an absolute root to materialize into.
108fn absolute_base(base: &Path) -> Result<PathBuf> {
109    if base.is_absolute() {
110        return Ok(base.to_path_buf());
111    }
112    let cwd = std::env::current_dir()
113        .map_err(|error| crate::error::SyncwebError::operation("failed to resolve current directory", error))?;
114    Ok(cwd.join(base))
115}
116
117/// Whether everything selected by `strategy` is already present locally.
118///
119/// A download of already-local content does not need a doc re-sync: fetching
120/// blobs the local node already holds can fail on the publishing node (there is
121/// no remote peer to fetch from), so the caller can skip straight to
122/// materialization. Returns `false` when the strategy selects nothing or when
123/// any selected blob is not yet local.
124///
125/// # Errors
126///
127/// Returns an error if the folder's entries cannot be listed.
128pub async fn strategy_selects_only_local(folder: &SyncwebFolder, strategy: &FetchStrategy) -> Result<bool> {
129    let mut candidates = Vec::new();
130    for entry in folder.content_entries().await? {
131        let hash = entry.content_hash();
132        let local = folder.has_local(hash).await?;
133        let path = PathBuf::from(String::from_utf8_lossy(entry.key()).into_owned());
134        candidates.push(FetchCandidate::new(path, hash, entry.content_len(), 0, local));
135    }
136    let selected = strategy.select(&candidates);
137    Ok(!selected.is_empty() && selected.iter().all(|candidate| candidate.local))
138}
139
140/// A document blob considered for a partial fetch.
141#[derive(Clone, Debug, Eq, PartialEq)]
142#[non_exhaustive]
143pub struct FetchCandidate {
144    pub path: PathBuf,
145    pub hash: Hash,
146    pub size: u64,
147    pub peer_count: usize,
148    pub local: bool,
149}
150
151impl FetchCandidate {
152    #[must_use]
153    pub fn new(path: impl Into<PathBuf>, hash: Hash, size: u64, peer_count: usize, local: bool) -> Self {
154        Self {
155            path: path.into(),
156            hash,
157            size,
158            peer_count,
159            local,
160        }
161    }
162}
163
164/// Constraints for selecting a subset of folder content.
165#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
166#[non_exhaustive]
167pub struct FetchFilter {
168    pub paths: Option<Vec<PathBuf>>,
169    pub min_size: Option<u64>,
170    pub max_size: Option<u64>,
171    pub min_peers: Option<usize>,
172    pub max_peers: Option<usize>,
173    pub min_count: Option<usize>,
174    pub max_count: Option<usize>,
175}
176
177impl FetchFilter {
178    #[must_use]
179    pub const fn new() -> Self {
180        Self {
181            paths: None,
182            min_size: None,
183            max_size: None,
184            min_peers: None,
185            max_peers: None,
186            min_count: None,
187            max_count: None,
188        }
189    }
190
191    #[must_use]
192    pub fn with_paths(mut self, paths: Vec<PathBuf>) -> Self {
193        self.paths = Some(paths);
194        self
195    }
196
197    #[must_use]
198    pub const fn with_min_size(mut self, size: u64) -> Self {
199        self.min_size = Some(size);
200        self
201    }
202
203    #[must_use]
204    pub const fn with_max_size(mut self, size: u64) -> Self {
205        self.max_size = Some(size);
206        self
207    }
208
209    #[must_use]
210    pub const fn with_min_peers(mut self, peers: usize) -> Self {
211        self.min_peers = Some(peers);
212        self
213    }
214
215    #[must_use]
216    pub const fn with_max_peers(mut self, peers: usize) -> Self {
217        self.max_peers = Some(peers);
218        self
219    }
220
221    #[must_use]
222    pub const fn with_min_count(mut self, count: usize) -> Self {
223        self.min_count = Some(count);
224        self
225    }
226
227    #[must_use]
228    pub const fn with_max_count(mut self, count: usize) -> Self {
229        self.max_count = Some(count);
230        self
231    }
232
233    /// Return candidates satisfying this filter.
234    ///
235    /// Candidates with fewer peers are selected first so a capped fetch
236    /// improves folder seeding rather than repeatedly selecting common blobs.
237    #[must_use]
238    pub fn select(&self, candidates: &[FetchCandidate]) -> Vec<FetchCandidate> {
239        let mut selected = candidates
240            .iter()
241            .filter(|candidate| self.matches(candidate))
242            .cloned()
243            .collect::<Vec<_>>();
244        selected.sort_by(|left, right| {
245            left.peer_count
246                .cmp(&right.peer_count)
247                .then(left.path.cmp(&right.path))
248                .then(left.hash.cmp(&right.hash))
249        });
250        if let Some(max_count) = self.max_count {
251            selected.truncate(max_count);
252        }
253        selected
254    }
255
256    #[must_use]
257    pub fn matches(&self, candidate: &FetchCandidate) -> bool {
258        self.paths.as_ref().is_none_or(|paths| {
259            paths
260                .iter()
261                .any(|path| candidate.path == *path || candidate.path.starts_with(path))
262        }) && self.min_size.is_none_or(|size| candidate.size >= size)
263            && self.max_size.is_none_or(|size| candidate.size <= size)
264            && self.min_peers.is_none_or(|peers| candidate.peer_count >= peers)
265            && self.max_peers.is_none_or(|peers| candidate.peer_count <= peers)
266    }
267}
268
269/// Controls whether a fetch reconciles all content or only matching content.
270#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
271#[non_exhaustive]
272pub enum FetchStrategy {
273    #[default]
274    All,
275    Filter(FetchFilter),
276}
277
278impl FetchStrategy {
279    #[must_use]
280    pub const fn filter(filter: FetchFilter) -> Self {
281        Self::Filter(filter)
282    }
283
284    #[must_use]
285    pub fn select(&self, candidates: &[FetchCandidate]) -> Vec<FetchCandidate> {
286        match self {
287            Self::All => candidates.to_vec(),
288            Self::Filter(filter) => filter.select(candidates),
289        }
290    }
291}
292
293/// Per-blob availability information used by the health command.
294#[derive(Clone, Debug, Eq, PartialEq)]
295#[non_exhaustive]
296pub struct BlobHealth {
297    pub path: PathBuf,
298    pub hash: Hash,
299    pub size: u64,
300    pub peer_count: usize,
301    pub local: bool,
302}
303
304/// Aggregate availability counters for a folder.
305#[derive(Clone, Debug, Eq, PartialEq)]
306#[non_exhaustive]
307pub struct HealthReport {
308    pub total: usize,
309    pub well_seeded: usize,
310    pub under_seeded: usize,
311    pub unseeded: usize,
312    pub least_seeded: Vec<BlobHealth>,
313}
314
315impl HealthReport {
316    /// Build a report using `well_seeded_threshold` as the minimum healthy peer count.
317    #[must_use]
318    pub fn from_candidates(candidates: &[FetchCandidate], well_seeded_threshold: usize) -> Self {
319        let mut least_seeded = candidates
320            .iter()
321            .map(|candidate| BlobHealth {
322                path: candidate.path.clone(),
323                hash: candidate.hash,
324                size: candidate.size,
325                peer_count: candidate.peer_count,
326                local: candidate.local,
327            })
328            .collect::<Vec<_>>();
329        least_seeded.sort_by(|left, right| left.peer_count.cmp(&right.peer_count).then(left.path.cmp(&right.path)));
330        let total = candidates.len();
331        let well_seeded = candidates
332            .iter()
333            .filter(|candidate| candidate.peer_count >= well_seeded_threshold)
334            .count();
335        let unseeded = candidates.iter().filter(|candidate| candidate.peer_count == 0).count();
336        Self {
337            total,
338            well_seeded,
339            under_seeded: total.saturating_sub(well_seeded).saturating_sub(unseeded),
340            unseeded,
341            least_seeded,
342        }
343    }
344
345    /// Build a report with verified peer counts from a `hash -> verified_count` map.
346    ///
347    /// Each candidate's `peer_count` is overridden with the value from
348    /// `peers_per_hash` when present. This lets the health display reflect live
349    /// provider-lease data instead of always showing zero.
350    #[must_use]
351    pub fn from_candidates_with_peers_per_hash(
352        candidates: &[FetchCandidate],
353        peers_per_hash: &HashMap<Hash, usize>,
354        well_seeded_threshold: usize,
355    ) -> Self {
356        let enriched: Vec<FetchCandidate> = candidates
357            .iter()
358            .map(|c| {
359                let peers = peers_per_hash.get(&c.hash).copied().unwrap_or(c.peer_count);
360                FetchCandidate::new(&c.path, c.hash, c.size, peers, c.local)
361            })
362            .collect();
363        Self::from_candidates(&enriched, well_seeded_threshold)
364    }
365}
366
367#[cfg(test)]
368mod absolute_base_tests {
369    use super::*;
370
371    #[test]
372    fn absolute_paths_are_returned_unchanged() {
373        assert_eq!(
374            absolute_base(Path::new("/srv/folder")).unwrap(),
375            PathBuf::from("/srv/folder")
376        );
377    }
378
379    #[test]
380    fn relative_paths_are_anchored_to_the_current_directory() {
381        let base = Path::new("relative/folder");
382        let resolved = absolute_base(base).unwrap();
383        assert!(
384            resolved.is_absolute(),
385            "blob export rejects relative roots: {resolved:?}"
386        );
387        assert!(resolved.ends_with(base), "{resolved:?} should end with {base:?}");
388    }
389
390    #[test]
391    fn dot_resolves_to_the_current_directory() {
392        let resolved = absolute_base(Path::new(".")).unwrap();
393        assert!(resolved.is_absolute());
394    }
395}