Skip to main content

syncweb_core/sync/
partial_fetch.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use iroh_blobs::Hash;
5use serde::{Deserialize, Serialize};
6
7/// A document blob considered for a partial fetch.
8#[derive(Clone, Debug, Eq, PartialEq)]
9#[non_exhaustive]
10pub struct FetchCandidate {
11    pub path: PathBuf,
12    pub hash: Hash,
13    pub size: u64,
14    pub peer_count: usize,
15    pub local: bool,
16}
17
18impl FetchCandidate {
19    #[must_use]
20    pub fn new(path: impl Into<PathBuf>, hash: Hash, size: u64, peer_count: usize, local: bool) -> Self {
21        Self {
22            path: path.into(),
23            hash,
24            size,
25            peer_count,
26            local,
27        }
28    }
29}
30
31/// Constraints for selecting a subset of folder content.
32#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
33#[non_exhaustive]
34pub struct FetchFilter {
35    pub paths: Option<Vec<PathBuf>>,
36    pub min_size: Option<u64>,
37    pub max_size: Option<u64>,
38    pub min_peers: Option<usize>,
39    pub max_peers: Option<usize>,
40    pub min_count: Option<usize>,
41    pub max_count: Option<usize>,
42}
43
44impl FetchFilter {
45    #[must_use]
46    pub const fn new() -> Self {
47        Self {
48            paths: None,
49            min_size: None,
50            max_size: None,
51            min_peers: None,
52            max_peers: None,
53            min_count: None,
54            max_count: None,
55        }
56    }
57
58    #[must_use]
59    pub fn with_paths(mut self, paths: Vec<PathBuf>) -> Self {
60        self.paths = Some(paths);
61        self
62    }
63
64    #[must_use]
65    pub const fn with_min_size(mut self, size: u64) -> Self {
66        self.min_size = Some(size);
67        self
68    }
69
70    #[must_use]
71    pub const fn with_max_size(mut self, size: u64) -> Self {
72        self.max_size = Some(size);
73        self
74    }
75
76    #[must_use]
77    pub const fn with_min_peers(mut self, peers: usize) -> Self {
78        self.min_peers = Some(peers);
79        self
80    }
81
82    #[must_use]
83    pub const fn with_max_peers(mut self, peers: usize) -> Self {
84        self.max_peers = Some(peers);
85        self
86    }
87
88    #[must_use]
89    pub const fn with_min_count(mut self, count: usize) -> Self {
90        self.min_count = Some(count);
91        self
92    }
93
94    #[must_use]
95    pub const fn with_max_count(mut self, count: usize) -> Self {
96        self.max_count = Some(count);
97        self
98    }
99
100    /// Return candidates satisfying this filter.
101    ///
102    /// Candidates with fewer peers are selected first so a capped fetch
103    /// improves folder seeding rather than repeatedly selecting common blobs.
104    #[must_use]
105    pub fn select(&self, candidates: &[FetchCandidate]) -> Vec<FetchCandidate> {
106        let mut selected = candidates
107            .iter()
108            .filter(|candidate| self.matches(candidate))
109            .cloned()
110            .collect::<Vec<_>>();
111        selected.sort_by(|left, right| {
112            left.peer_count
113                .cmp(&right.peer_count)
114                .then(left.path.cmp(&right.path))
115                .then(left.hash.cmp(&right.hash))
116        });
117        if let Some(max_count) = self.max_count {
118            selected.truncate(max_count);
119        }
120        selected
121    }
122
123    #[must_use]
124    pub fn matches(&self, candidate: &FetchCandidate) -> bool {
125        self.paths.as_ref().is_none_or(|paths| {
126            paths
127                .iter()
128                .any(|path| candidate.path == *path || candidate.path.starts_with(path))
129        }) && self.min_size.is_none_or(|size| candidate.size >= size)
130            && self.max_size.is_none_or(|size| candidate.size <= size)
131            && self.min_peers.is_none_or(|peers| candidate.peer_count >= peers)
132            && self.max_peers.is_none_or(|peers| candidate.peer_count <= peers)
133    }
134}
135
136/// Controls whether a fetch reconciles all content or only matching content.
137#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
138#[non_exhaustive]
139pub enum FetchStrategy {
140    #[default]
141    All,
142    Filter(FetchFilter),
143}
144
145impl FetchStrategy {
146    #[must_use]
147    pub const fn filter(filter: FetchFilter) -> Self {
148        Self::Filter(filter)
149    }
150
151    #[must_use]
152    pub fn select(&self, candidates: &[FetchCandidate]) -> Vec<FetchCandidate> {
153        match self {
154            Self::All => candidates.to_vec(),
155            Self::Filter(filter) => filter.select(candidates),
156        }
157    }
158}
159
160/// Per-blob availability information used by the health command.
161#[derive(Clone, Debug, Eq, PartialEq)]
162#[non_exhaustive]
163pub struct BlobHealth {
164    pub path: PathBuf,
165    pub hash: Hash,
166    pub size: u64,
167    pub peer_count: usize,
168    pub local: bool,
169}
170
171/// Aggregate availability counters for a folder.
172#[derive(Clone, Debug, Eq, PartialEq)]
173#[non_exhaustive]
174pub struct HealthReport {
175    pub total: usize,
176    pub well_seeded: usize,
177    pub under_seeded: usize,
178    pub unseeded: usize,
179    pub least_seeded: Vec<BlobHealth>,
180}
181
182impl HealthReport {
183    /// Build a report using `well_seeded_threshold` as the minimum healthy peer count.
184    #[must_use]
185    pub fn from_candidates(candidates: &[FetchCandidate], well_seeded_threshold: usize) -> Self {
186        let mut least_seeded = candidates
187            .iter()
188            .map(|candidate| BlobHealth {
189                path: candidate.path.clone(),
190                hash: candidate.hash,
191                size: candidate.size,
192                peer_count: candidate.peer_count,
193                local: candidate.local,
194            })
195            .collect::<Vec<_>>();
196        least_seeded.sort_by(|left, right| left.peer_count.cmp(&right.peer_count).then(left.path.cmp(&right.path)));
197        let total = candidates.len();
198        let well_seeded = candidates
199            .iter()
200            .filter(|candidate| candidate.peer_count >= well_seeded_threshold)
201            .count();
202        let unseeded = candidates.iter().filter(|candidate| candidate.peer_count == 0).count();
203        Self {
204            total,
205            well_seeded,
206            under_seeded: total.saturating_sub(well_seeded).saturating_sub(unseeded),
207            unseeded,
208            least_seeded,
209        }
210    }
211
212    /// Build a report with verified peer counts from a `hash -> verified_count` map.
213    ///
214    /// Each candidate's `peer_count` is overridden with the value from
215    /// `peers_per_hash` when present. This lets the health display reflect live
216    /// provider-lease data instead of always showing zero.
217    #[must_use]
218    pub fn from_candidates_with_peers_per_hash(
219        candidates: &[FetchCandidate],
220        peers_per_hash: &HashMap<Hash, usize>,
221        well_seeded_threshold: usize,
222    ) -> Self {
223        let enriched: Vec<FetchCandidate> = candidates
224            .iter()
225            .map(|c| {
226                let peers = peers_per_hash.get(&c.hash).copied().unwrap_or(c.peer_count);
227                FetchCandidate::new(&c.path, c.hash, c.size, peers, c.local)
228            })
229            .collect();
230        Self::from_candidates(&enriched, well_seeded_threshold)
231    }
232}