Skip to main content

syncweb_core/
mirror.rs

1use iroh::Endpoint;
2use iroh::PublicKey;
3use iroh_blobs::Hash;
4use iroh_docs::NamespaceId;
5
6use crate::error::Result;
7use crate::indexing::resilience::ResilienceService;
8use crate::node::blob_store::BlobStore;
9use crate::node::docs_engine::DocsEngine;
10
11/// Progress event emitted during a mirror operation.
12#[derive(Clone, Debug)]
13#[non_exhaustive]
14pub enum MirrorEvent {
15    /// All blobs to mirror have been discovered.
16    Discovered { total: usize },
17    /// Starting to fetch a specific blob.
18    Fetching { hash: Hash, index: usize, total: usize },
19    /// A blob was successfully fetched and pinned.
20    Pinned { hash: Hash },
21    /// A blob was skipped (already pinned locally).
22    Skipped { hash: Hash },
23    /// A blob failed to fetch from all providers.
24    Failed { hash: Hash, error: String },
25}
26
27/// Result of a mirror operation.
28#[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
29#[non_exhaustive]
30pub struct MirrorResult {
31    pub total_blobs: usize,
32    pub pinned: usize,
33    pub skipped: usize,
34    pub failed: usize,
35    pub dry_run: bool,
36}
37
38/// Options for a mirror operation.
39#[derive(Clone, Copy, Debug)]
40#[non_exhaustive]
41pub struct MirrorOptions {
42    pub dry_run: bool,
43    pub no_sharing: bool,
44    pub min_providers: usize,
45}
46
47impl MirrorOptions {
48    #[must_use]
49    pub const fn new(min_providers: usize) -> Self {
50        Self {
51            dry_run: false,
52            no_sharing: false,
53            min_providers,
54        }
55    }
56
57    /// Enable dry-run mode (report without fetching).
58    #[must_use]
59    pub const fn with_dry_run(mut self, dry_run: bool) -> Self {
60        self.dry_run = dry_run;
61        self
62    }
63
64    /// Enable no-sharing mode (skip lease announcements).
65    #[must_use]
66    pub const fn with_no_sharing(mut self, no_sharing: bool) -> Self {
67        self.no_sharing = no_sharing;
68        self
69    }
70}
71
72impl Default for MirrorOptions {
73    fn default() -> Self {
74        Self::new(3)
75    }
76}
77
78/// Context for a mirror operation, grouping arguments to stay under the arg limit.
79#[non_exhaustive]
80pub struct MirrorContext<'a> {
81    pub endpoint: &'a Endpoint,
82    pub blobs: &'a BlobStore,
83    pub service: &'a ResilienceService,
84    pub docs_engine: Option<&'a DocsEngine>,
85    pub namespace_ids: Option<&'a [NamespaceId]>,
86    pub provider: Option<PublicKey>,
87    pub options: &'a MirrorOptions,
88    pub progress: Option<tokio::sync::mpsc::UnboundedSender<MirrorEvent>>,
89}
90
91impl<'a> MirrorContext<'a> {
92    #[must_use]
93    pub const fn new(
94        endpoint: &'a Endpoint,
95        blobs: &'a BlobStore,
96        service: &'a ResilienceService,
97        docs: Option<(&'a DocsEngine, &'a [NamespaceId])>,
98        provider: Option<PublicKey>,
99        options: &'a MirrorOptions,
100        progress: Option<tokio::sync::mpsc::UnboundedSender<MirrorEvent>>,
101    ) -> Self {
102        let (docs_engine, namespace_ids) = match docs {
103            Some((engine, ids)) => (Some(engine), Some(ids)),
104            None => (None, None),
105        };
106        Self {
107            endpoint,
108            blobs,
109            service,
110            docs_engine,
111            namespace_ids,
112            provider,
113            options,
114            progress,
115        }
116    }
117}
118/// Discover all blob hashes advertised by a given provider.
119///
120/// # Errors
121///
122/// Returns an error if the service state lock is poisoned.
123pub fn discover_provider_blobs(service: &ResilienceService, provider: &PublicKey) -> Result<Vec<Hash>> {
124    service.blobs_for_provider(provider)
125}
126
127/// Mirror all blobs from a single provider.
128///
129/// # Errors
130///
131/// Returns an error if the node cannot be opened or blob fetching fails.
132pub async fn mirror_provider(
133    endpoint: &Endpoint,
134    blobs: &BlobStore,
135    service: &ResilienceService,
136    provider: &PublicKey,
137    options: &MirrorOptions,
138    progress: Option<tokio::sync::mpsc::UnboundedSender<MirrorEvent>>,
139) -> Result<MirrorResult> {
140    let hashes = discover_provider_blobs(service, provider)?;
141    mirror_hashes(endpoint, blobs, service, &hashes, options, progress).await
142}
143
144/// Mirror all blobs referenced within a set of namespaces.
145///
146/// If `provider` is `Some`, only blobs that the given provider has an active
147/// lease for are mirrored (provider-scoped within the network).
148///
149/// # Errors
150///
151/// Returns an error if the service state lock is poisoned, namespaces cannot
152/// be opened, or blob fetching fails.
153pub async fn mirror_network(ctx: MirrorContext<'_>) -> Result<MirrorResult> {
154    let provider_filter = ctx.provider.as_ref().map(|prov| {
155        ctx.service
156            .blobs_for_provider(prov)
157            .unwrap_or_default()
158            .into_iter()
159            .collect::<std::collections::HashSet<Hash>>()
160    });
161
162    let mut all_hashes: Vec<Hash> = Vec::new();
163    let docs_engine = ctx.docs_engine.ok_or_else(|| {
164        crate::error::SyncwebError::InvalidConfig("docs_engine is required for network mirror".to_owned())
165    })?;
166    let ns_ids = ctx.namespace_ids.unwrap_or_default();
167    for ns in ns_ids {
168        if let Some(doc) = docs_engine.open(*ns).await? {
169            let entries = docs_engine.list_latest(&doc).await?;
170            for entry in entries {
171                let hash = entry.content_hash();
172                if provider_filter.as_ref().is_none_or(|filter| filter.contains(&hash)) {
173                    all_hashes.push(hash);
174                }
175            }
176        }
177    }
178    all_hashes.sort_by_key(std::string::ToString::to_string);
179    all_hashes.dedup();
180    mirror_hashes(
181        ctx.endpoint,
182        ctx.blobs,
183        ctx.service,
184        &all_hashes,
185        ctx.options,
186        ctx.progress,
187    )
188    .await
189}
190
191/// Internal: mirror a list of hashes.
192async fn mirror_hashes(
193    endpoint: &Endpoint,
194    blobs: &BlobStore,
195    service: &ResilienceService,
196    hashes: &[Hash],
197    options: &MirrorOptions,
198    progress: Option<tokio::sync::mpsc::UnboundedSender<MirrorEvent>>,
199) -> Result<MirrorResult> {
200    let total = hashes.len();
201    if let Some(ref sender) = progress {
202        let _ = sender.send(MirrorEvent::Discovered { total });
203    }
204
205    if options.dry_run {
206        return Ok(MirrorResult {
207            total_blobs: total,
208            pinned: 0,
209            skipped: 0,
210            failed: 0,
211            dry_run: true,
212        });
213    }
214
215    let mut pinned = 0_usize;
216    let mut skipped = 0_usize;
217    let mut failed = 0_usize;
218
219    for (index, hash) in hashes.iter().enumerate() {
220        if let Some(ref sender) = progress {
221            let _ = sender.send(MirrorEvent::Fetching {
222                hash: *hash,
223                index,
224                total,
225            });
226        }
227
228        if blobs.has(*hash).await? {
229            skipped = skipped.saturating_add(1);
230            if let Some(ref sender) = progress {
231                let _ = sender.send(MirrorEvent::Skipped { hash: *hash });
232            }
233            continue;
234        }
235
236        match service.ensure_replication(endpoint, blobs, *hash).await {
237            Ok(result) if result.pinned => {
238                pinned = pinned.saturating_add(1);
239                if let Some(ref sender) = progress {
240                    let _ = sender.send(MirrorEvent::Pinned { hash: *hash });
241                }
242            }
243            Ok(result) if result.fetched_from.is_empty() && !result.pinned => {
244                failed = failed.saturating_add(1);
245                if let Some(ref sender) = progress {
246                    let _ = sender.send(MirrorEvent::Failed {
247                        hash: *hash,
248                        error: "no providers available".to_owned(),
249                    });
250                }
251            }
252            Ok(_) => {
253                failed = failed.saturating_add(1);
254                if let Some(ref sender) = progress {
255                    let _ = sender.send(MirrorEvent::Failed {
256                        hash: *hash,
257                        error: "blob was fetched but not pinned".to_owned(),
258                    });
259                }
260            }
261            Err(error) => {
262                failed = failed.saturating_add(1);
263                if let Some(ref sender) = progress {
264                    let _ = sender.send(MirrorEvent::Failed {
265                        hash: *hash,
266                        error: error.to_string(),
267                    });
268                }
269            }
270        }
271    }
272
273    Ok(MirrorResult {
274        total_blobs: total,
275        pinned,
276        skipped,
277        failed,
278        dry_run: false,
279    })
280}
281
282#[cfg(test)]
283mod tests {
284    use anyhow::Result;
285    use iroh::{EndpointAddr, SecretKey};
286    use iroh_blobs::{BlobFormat, Hash, ticket::BlobTicket};
287
288    use crate::indexing::resilience::{ProviderLease, ReplicationBudget, ResilienceConfig, ResilienceService};
289
290    fn signed_lease_for_test(seed: u8, hash: Hash, sequence: u64) -> Result<(ProviderLease, SecretKey)> {
291        let secret_key = SecretKey::from_bytes(&[seed; 32]);
292        let ticket = BlobTicket::new(EndpointAddr::new(secret_key.public()), hash, BlobFormat::Raw).to_string();
293        let lease = ProviderLease::signed(hash, ticket, sequence, u64::MAX, &secret_key)?;
294        Ok((lease, secret_key))
295    }
296
297    fn make_service_with_leases(lease_count: usize) -> Result<(ResilienceService, Vec<iroh::PublicKey>)> {
298        let service = ResilienceService::new(ResilienceConfig::new(ReplicationBudget::new(1)));
299        let mut providers = Vec::new();
300        for i in 0..lease_count {
301            let hash = Hash::new(format!("blob-{i}").as_bytes());
302            let seed: u8 = i
303                .try_into()
304                .map_err(|e: std::num::TryFromIntError| anyhow::anyhow!("seed overflow: {e}"))?;
305            let (lease, sk) = signed_lease_for_test(seed, hash, 1)?;
306            service.record_lease(&lease)?;
307            providers.push(sk.public());
308        }
309        Ok((service, providers))
310    }
311
312    #[test]
313    fn discover_provider_blobs_returns_all_hashes_for_given_provider() -> Result<()> {
314        let (service, providers) = make_service_with_leases(3)?;
315        let first = providers.first().ok_or_else(|| anyhow::anyhow!("no providers"))?;
316        let hashes = super::discover_provider_blobs(&service, first)?;
317        anyhow::ensure!(hashes.len() == 1, "provider 0 should have 1 blob");
318        let first_hash = hashes.first().ok_or_else(|| anyhow::anyhow!("no hashes"))?;
319        anyhow::ensure!(*first_hash == Hash::new(b"blob-0"));
320        Ok(())
321    }
322
323    #[test]
324    fn discover_provider_blobs_returns_empty_for_unknown_provider() -> Result<()> {
325        let (service, _providers) = make_service_with_leases(2)?;
326        let unknown = SecretKey::from_bytes(&[99; 32]).public();
327        let hashes = super::discover_provider_blobs(&service, &unknown)?;
328        anyhow::ensure!(hashes.is_empty(), "unknown provider should have no blobs");
329        Ok(())
330    }
331
332    #[test]
333    fn mirror_options_defaults() {
334        let opts = super::MirrorOptions::default();
335        assert!(!opts.dry_run);
336        assert!(!opts.no_sharing);
337        assert_eq!(opts.min_providers, 3);
338    }
339
340    #[test]
341    fn mirror_result_json_round_trip() -> Result<()> {
342        let result = super::MirrorResult {
343            total_blobs: 10,
344            pinned: 7,
345            skipped: 2,
346            failed: 1,
347            dry_run: false,
348        };
349        let json = serde_json::to_string(&result)?;
350        let deserialized: super::MirrorResult = serde_json::from_str(&json)?;
351        anyhow::ensure!(deserialized.total_blobs == 10);
352        anyhow::ensure!(deserialized.pinned == 7);
353        anyhow::ensure!(deserialized.skipped == 2);
354        anyhow::ensure!(deserialized.failed == 1);
355        anyhow::ensure!(!deserialized.dry_run);
356        Ok(())
357    }
358}