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#[derive(Clone, Debug)]
13#[non_exhaustive]
14pub enum MirrorEvent {
15 Discovered { total: usize },
17 Fetching { hash: Hash, index: usize, total: usize },
19 Pinned { hash: Hash },
21 Skipped { hash: Hash },
23 Failed { hash: Hash, error: String },
25}
26
27#[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#[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 #[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 #[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#[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}
118pub fn discover_provider_blobs(service: &ResilienceService, provider: &PublicKey) -> Result<Vec<Hash>> {
124 service.blobs_for_provider(provider)
125}
126
127pub 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
144pub 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
191async 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 pinned = pinned.saturating_add(1);
254 if let Some(ref sender) = progress {
255 let _ = sender.send(MirrorEvent::Pinned { hash: *hash });
256 }
257 }
258 Err(error) => {
259 failed = failed.saturating_add(1);
260 if let Some(ref sender) = progress {
261 let _ = sender.send(MirrorEvent::Failed {
262 hash: *hash,
263 error: error.to_string(),
264 });
265 }
266 }
267 }
268 }
269
270 Ok(MirrorResult {
271 total_blobs: total,
272 pinned,
273 skipped,
274 failed,
275 dry_run: false,
276 })
277}
278
279#[cfg(test)]
280mod tests {
281 use anyhow::Result;
282 use iroh::{EndpointAddr, SecretKey};
283 use iroh_blobs::{BlobFormat, Hash, ticket::BlobTicket};
284
285 use crate::indexing::resilience::{ProviderLease, ReplicationBudget, ResilienceConfig, ResilienceService};
286
287 fn signed_lease_for_test(seed: u8, hash: Hash, sequence: u64) -> Result<(ProviderLease, SecretKey)> {
288 let secret_key = SecretKey::from_bytes(&[seed; 32]);
289 let ticket = BlobTicket::new(EndpointAddr::new(secret_key.public()), hash, BlobFormat::Raw).to_string();
290 let lease = ProviderLease::signed(hash, ticket, sequence, u64::MAX, &secret_key)?;
291 Ok((lease, secret_key))
292 }
293
294 fn make_service_with_leases(lease_count: usize) -> Result<(ResilienceService, Vec<iroh::PublicKey>)> {
295 let service = ResilienceService::new(ResilienceConfig::new(ReplicationBudget::new(1)));
296 let mut providers = Vec::new();
297 for i in 0..lease_count {
298 let hash = Hash::new(format!("blob-{i}").as_bytes());
299 let seed: u8 = i
300 .try_into()
301 .map_err(|e: std::num::TryFromIntError| anyhow::anyhow!("seed overflow: {e}"))?;
302 let (lease, sk) = signed_lease_for_test(seed, hash, 1)?;
303 service.record_lease(&lease)?;
304 providers.push(sk.public());
305 }
306 Ok((service, providers))
307 }
308
309 #[test]
310 fn discover_provider_blobs_returns_all_hashes_for_given_provider() -> Result<()> {
311 let (service, providers) = make_service_with_leases(3)?;
312 let first = providers.first().ok_or_else(|| anyhow::anyhow!("no providers"))?;
313 let hashes = super::discover_provider_blobs(&service, first)?;
314 anyhow::ensure!(hashes.len() == 1, "provider 0 should have 1 blob");
315 let first_hash = hashes.first().ok_or_else(|| anyhow::anyhow!("no hashes"))?;
316 anyhow::ensure!(*first_hash == Hash::new(b"blob-0"));
317 Ok(())
318 }
319
320 #[test]
321 fn discover_provider_blobs_returns_empty_for_unknown_provider() -> Result<()> {
322 let (service, _providers) = make_service_with_leases(2)?;
323 let unknown = SecretKey::from_bytes(&[99; 32]).public();
324 let hashes = super::discover_provider_blobs(&service, &unknown)?;
325 anyhow::ensure!(hashes.is_empty(), "unknown provider should have no blobs");
326 Ok(())
327 }
328
329 #[test]
330 fn mirror_options_defaults() {
331 let opts = super::MirrorOptions::default();
332 assert!(!opts.dry_run);
333 assert!(!opts.no_sharing);
334 assert_eq!(opts.min_providers, 3);
335 }
336
337 #[test]
338 fn mirror_result_json_round_trip() -> Result<()> {
339 let result = super::MirrorResult {
340 total_blobs: 10,
341 pinned: 7,
342 skipped: 2,
343 failed: 1,
344 dry_run: false,
345 };
346 let json = serde_json::to_string(&result)?;
347 let deserialized: super::MirrorResult = serde_json::from_str(&json)?;
348 anyhow::ensure!(deserialized.total_blobs == 10);
349 anyhow::ensure!(deserialized.pinned == 7);
350 anyhow::ensure!(deserialized.skipped == 2);
351 anyhow::ensure!(deserialized.failed == 1);
352 anyhow::ensure!(!deserialized.dry_run);
353 Ok(())
354 }
355}