1use std::time::{Duration, Instant};
2
3use iroh::{PublicKey, endpoint::Endpoint};
4use iroh_blobs::{
5 ALPN, Hash,
6 get::fsm::{self, ConnectedNext, EndBlobNext, RequestCounters},
7 protocol::{ChunkRanges, ChunkRangesExt, ChunkRangesSeq, GetRequest},
8 ticket::BlobTicket,
9};
10use tokio::task::JoinSet;
11use tracing::{debug, info, warn};
12
13use crate::error::{Result, SyncwebError};
14use crate::node::blob_store::BlobStore;
15
16const DEFAULT_RATE_BYTES_PER_MS: u64 = 125;
18const TIMEOUT_FACTOR: f64 = 8.0;
20const RETRY_TIMEOUT_FACTOR: f64 = 4.0;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct ParallelDownloadConfig {
27 pub min_blob_size: u64,
29 pub max_concurrent_connections: usize,
31 pub min_chunks_per_range: u64,
33}
34
35impl Default for ParallelDownloadConfig {
36 fn default() -> Self {
37 Self {
38 min_blob_size: 16 * 1024 * 1024,
39 max_concurrent_connections: 5,
40 min_chunks_per_range: 64,
41 }
42 }
43}
44
45#[derive(Clone, Debug)]
47struct Assignment {
48 provider: PublicKey,
49 ticket: BlobTicket,
50 ranges: ChunkRanges,
51 estimated_bytes: u64,
52}
53
54#[derive(Debug)]
56#[non_exhaustive]
57pub enum TryParallelResult {
58 Inapplicable,
60 Downloaded(Vec<PublicKey>),
62 AllFailed,
64}
65
66struct TaskOutcome {
68 provider: PublicKey,
69 bytes_written: u64,
70 elapsed: Duration,
71}
72
73async fn probe_size(endpoint: &Endpoint, hash: Hash, ticket: &BlobTicket) -> std::result::Result<u64, SyncwebError> {
75 let connection = endpoint
76 .connect(ticket.addr().clone(), ALPN)
77 .await
78 .map_err(|e| SyncwebError::operation("parallel: size probe connect failed", e))?;
79
80 let request = GetRequest::new(hash, ChunkRangesSeq::from_ranges([ChunkRanges::last_chunk()]));
81 let start = fsm::start(connection, request, RequestCounters::default());
82 let connected = start
83 .next()
84 .await
85 .map_err(|e| SyncwebError::operation("parallel: size probe negotiation failed", e))?;
86 let ConnectedNext::StartRoot(root) = connected
87 .next()
88 .await
89 .map_err(|e| SyncwebError::operation("parallel: size probe request failed", e))?
90 else {
91 return Err(SyncwebError::operation(
92 "parallel: size probe unexpected response",
93 "provider did not return a blob",
94 ));
95 };
96 let header = root.next();
97 let (_content, size) = header
98 .next()
99 .await
100 .map_err(|e| SyncwebError::operation("parallel: size probe read failed", e))?;
101 Ok(size)
102}
103
104fn stripe_score(provider: &PublicKey, stripe_index: u64) -> u64 {
105 let mut data = [0_u8; 40];
106 data[..32].copy_from_slice(provider.as_bytes());
107 data[32..40].copy_from_slice(&stripe_index.to_be_bytes());
108 let hash = blake3::hash(&data);
109 let first_8: [u8; 8] = hash.as_bytes()[..8].try_into().unwrap_or([0_u8; 8]);
110 u64::from_be_bytes(first_8)
111}
112
113fn assign_chunks_striped(
118 providers: &[(PublicKey, BlobTicket)],
119 total_chunks: u64,
120 n_providers: usize,
121 total_size: u64,
122) -> Vec<Assignment> {
123 let n = n_providers.min(providers.len());
124 if total_chunks == 0 || n == 0 {
125 return Vec::new();
126 }
127
128 let n_u64 = u64::try_from(n).unwrap_or(u64::MAX);
129
130 let mut provider_data: Vec<(PublicKey, BlobTicket, ChunkRanges)> = providers
131 .iter()
132 .take(n)
133 .map(|(pk, ticket)| (*pk, ticket.clone(), ChunkRanges::empty()))
134 .collect();
135
136 let n_stripes = total_chunks.div_ceil(1024);
137 for s in 0..n_stripes {
138 let start = s.saturating_mul(1024);
139 if start >= total_chunks {
140 break;
141 }
142 let end = start.saturating_add(1024).min(total_chunks);
143 let stripe_range = ChunkRanges::chunks(start..end);
144
145 let best_idx = provider_data
146 .iter()
147 .enumerate()
148 .max_by_key(|(_, (pk, _, _))| stripe_score(pk, s))
149 .map_or(0, |(i, _)| i);
150 if let Some((_, _, target)) = provider_data.get_mut(best_idx) {
151 *target = target.clone() | stripe_range;
152 }
153 }
154
155 let bytes_per_provider = total_size.checked_div(n_u64).unwrap_or(0);
156 let remainder = total_size.checked_rem(n_u64).unwrap_or(0);
157
158 provider_data
159 .into_iter()
160 .filter(|(_, _, ranges)| !ranges.is_empty())
161 .enumerate()
162 .map(|(i, (pk, ticket, ranges))| {
163 let i_u64 = u64::try_from(i).unwrap_or(u64::MAX);
164 let extra = u64::from(u8::from(i_u64 < remainder));
165 Assignment {
166 provider: pk,
167 ticket,
168 ranges,
169 estimated_bytes: bytes_per_provider.saturating_add(extra),
170 }
171 })
172 .collect()
173}
174
175async fn download_assignment(
177 endpoint: &Endpoint,
178 hash: Hash,
179 ticket: &BlobTicket,
180 ranges: ChunkRanges,
181 writer: iroh_io::File,
182 deadline: Duration,
183) -> std::result::Result<TaskOutcome, (PublicKey, SyncwebError)> {
184 let provider = ticket.addr().id;
185 let t0 = Instant::now();
186
187 let connection = endpoint
188 .connect(ticket.addr().clone(), ALPN)
189 .await
190 .map_err(|e| (provider, SyncwebError::operation("parallel: connect failed", e)))?;
191
192 let request = GetRequest::blob_ranges(hash, ranges);
193 let start = fsm::start(connection, request, RequestCounters::default());
194
195 let result = tokio::time::timeout(deadline, async {
196 let connected = start
197 .next()
198 .await
199 .map_err(|e| (provider, SyncwebError::operation("parallel: negotiation failed", e)))?;
200 let ConnectedNext::StartRoot(root) = connected
201 .next()
202 .await
203 .map_err(|e| (provider, SyncwebError::operation("parallel: request failed", e)))?
204 else {
205 return Err((
206 provider,
207 SyncwebError::operation("parallel: unexpected response", "provider did not return a blob"),
208 ));
209 };
210 let header = root.next();
211 let (content, _size) = header
212 .next()
213 .await
214 .map_err(|e| (provider, SyncwebError::operation("parallel: header read failed", e)))?;
215 let end = content
216 .write_all(writer)
217 .await
218 .map_err(|e| (provider, SyncwebError::operation("parallel: write failed", e)))?;
219 let EndBlobNext::Closing(closing) = end.next() else {
220 return Err((
221 provider,
222 SyncwebError::operation("parallel: unexpected end of stream", "expected closing"),
223 ));
224 };
225 let stats = closing
226 .next()
227 .await
228 .map_err(|e| (provider, SyncwebError::operation("parallel: close failed", e)))?;
229 let elapsed = t0.elapsed();
230 Ok(TaskOutcome {
231 provider,
232 bytes_written: stats.payload_bytes_read,
233 elapsed,
234 })
235 })
236 .await;
237
238 match result {
239 Ok(inner) => inner,
240 Err(_elapsed) => Err((
241 provider,
242 SyncwebError::operation("parallel: timeout", "download exceeded deadline"),
243 )),
244 }
245}
246
247async fn run_phase(
249 assignments: &[Assignment],
250 endpoint: &Endpoint,
251 hash: Hash,
252 file_handle: &std::fs::File,
253 rate_bytes_per_ms: u64,
254 timeout_factor: f64,
255) -> (Vec<TaskOutcome>, Vec<(PublicKey, SyncwebError)>) {
256 let mut join_set: JoinSet<std::result::Result<TaskOutcome, (PublicKey, SyncwebError)>> = JoinSet::new();
257
258 for asgn in assignments {
259 let f_clone = match file_handle.try_clone() {
260 Ok(f) => f,
261 Err(e) => {
262 warn!(%hash, error = %e, "parallel: failed to clone file handle");
263 continue;
264 }
265 };
266 let writer = iroh_io::File::from_std(f_clone);
267 let ep = endpoint.clone();
268 let ticket = asgn.ticket.clone();
269 let ranges = asgn.ranges.clone();
270
271 let rate = rate_bytes_per_ms.max(1);
272 let estimated_ms = asgn.estimated_bytes.checked_div(rate).unwrap_or(u64::MAX);
273 let estimated = Duration::from_millis(estimated_ms);
274 let deadline = estimated.mul_f64(timeout_factor);
275
276 join_set.spawn(async move { download_assignment(&ep, hash, &ticket, ranges, writer, deadline).await });
277 }
278
279 let mut successes = Vec::new();
280 let mut failures = Vec::new();
281
282 while let Some(result) = join_set.join_next().await {
283 match result {
284 Ok(Ok(outcome)) => {
285 successes.push(outcome);
286 }
287 Ok(Err((provider, error))) => {
288 warn!(%provider, %hash, error = %error, "parallel: download failed");
289 failures.push((provider, error));
290 }
291 Err(e) => {
292 warn!(%hash, error = %e, "parallel: task panicked");
293 }
294 }
295 }
296
297 (successes, failures)
298}
299
300fn compute_rate_bytes_per_ms(successes: &[TaskOutcome]) -> u64 {
302 let total_ms: u128 = successes.iter().map(|o| o.elapsed.as_millis()).sum();
303 let total_bytes: u64 = successes.iter().map(|o| o.bytes_written).sum();
304 if total_ms == 0 {
305 return DEFAULT_RATE_BYTES_PER_MS;
306 }
307 let ms = u64::try_from(total_ms.min(u128::from(u64::MAX))).unwrap_or(u64::MAX);
308 total_bytes.checked_div(ms).unwrap_or(1).max(1)
309}
310
311fn collect_unclaimed(assignments: &[Assignment], successes: &[TaskOutcome]) -> ChunkRanges {
313 let successful_providers: Vec<PublicKey> = successes.iter().map(|o| o.provider).collect();
314 let mut unclaimed = ChunkRanges::empty();
315 for asgn in assignments {
316 if !successful_providers.contains(&asgn.provider) {
317 unclaimed |= asgn.ranges.clone();
318 }
319 }
320 unclaimed
321}
322
323async fn run_retry_phase(
325 assignments: &[Assignment],
326 phase1_ok: &[TaskOutcome],
327 providers: &[(PublicKey, BlobTicket)],
328 temp_path: &std::path::Path,
329 download_size: u64,
330 hash: Hash,
331 endpoint: &Endpoint,
332) -> Result<()> {
333 if phase1_ok.is_empty() {
334 return Ok(());
335 }
336 let unclaimed = collect_unclaimed(assignments, phase1_ok);
337 if unclaimed.is_empty() {
338 return Ok(());
339 }
340 let survivors: Vec<(PublicKey, BlobTicket)> = providers
341 .iter()
342 .filter(|(p, _)| phase1_ok.iter().any(|o| o.provider == *p))
343 .cloned()
344 .collect();
345
346 if survivors.len() < 2 {
347 return Ok(());
348 }
349
350 info!(
351 %hash,
352 n_survivors = survivors.len(),
353 "parallel: retrying unclaimed stripes"
354 );
355
356 let retry_file = std::fs::File::open(temp_path)
357 .map_err(|e| SyncwebError::operation("parallel: failed to reopen temp file", e))?;
358
359 let unclaimed_chunks = estimate_chunks_in_ranges(&unclaimed, download_size.div_ceil(1024));
360 let retry_assignments = assign_chunks_striped(&survivors, unclaimed_chunks, survivors.len(), download_size);
361
362 let rate = compute_rate_bytes_per_ms(phase1_ok);
363 let (_retry_ok, _) = run_phase(
364 &retry_assignments,
365 endpoint,
366 hash,
367 &retry_file,
368 rate,
369 RETRY_TIMEOUT_FACTOR,
370 )
371 .await;
372
373 drop(retry_file);
374 Ok(())
375}
376
377async fn import_assembled_blob(blobs: &BlobStore, temp_path: &std::path::Path, hash: Hash) -> Result<()> {
379 {
380 let f = std::fs::File::open(temp_path)
381 .map_err(|e| SyncwebError::operation("parallel: failed to open temp file for sync", e))?;
382 f.sync_all()
383 .map_err(|e| SyncwebError::operation("parallel: failed to sync temp file", e))?;
384 }
385
386 info!(%hash, path = %temp_path.display(), "parallel: importing assembled blob");
387 let actual_hash = blobs.add_file(temp_path).await?;
388 if actual_hash != hash {
389 let _ = tokio::fs::remove_file(temp_path).await;
390 return Err(SyncwebError::operation(
391 "parallel: assembled blob hash mismatch",
392 format!("expected {hash}, got {actual_hash}"),
393 ));
394 }
395 tokio::fs::remove_file(temp_path).await?;
396 Ok(())
397}
398
399pub async fn try_fetch_parallel(
415 endpoint: &Endpoint,
416 blobs: &BlobStore,
417 hash: Hash,
418 providers: &[(PublicKey, BlobTicket)],
419 config: &ParallelDownloadConfig,
420) -> Result<TryParallelResult> {
421 if providers.len() < 2 {
422 return Ok(TryParallelResult::Inapplicable);
423 }
424
425 let total_size = match providers.first() {
426 Some((_, ticket)) => match probe_size(endpoint, hash, ticket).await {
427 Ok(s) => s,
428 Err(e) => {
429 debug!(%hash, error = %e, "parallel: size probe failed, falling back");
430 return Ok(TryParallelResult::Inapplicable);
431 }
432 },
433 None => return Ok(TryParallelResult::Inapplicable),
434 };
435 if total_size < config.min_blob_size {
436 return Ok(TryParallelResult::Inapplicable);
437 }
438
439 let total_chunks = total_size.div_ceil(1024);
440 let n_providers = providers.len().min(config.max_concurrent_connections);
441
442 let assignments = assign_chunks_striped(providers, total_chunks, n_providers, total_size);
443 if assignments.is_empty() {
444 return Ok(TryParallelResult::Inapplicable);
445 }
446
447 let temp_dir = std::env::temp_dir().join("syncweb-parallel");
448 tokio::fs::create_dir_all(&temp_dir).await?;
449 let temp_path = temp_dir.join(format!("{hash}.tmp"));
450
451 let file = std::fs::File::create_new(&temp_path)
452 .map_err(|e| SyncwebError::operation("parallel: failed to create temp file", e))?;
453 file.set_len(total_size)
454 .map_err(|e| SyncwebError::operation("parallel: failed to set temp file length", e))?;
455
456 let (phase1_ok, phase1_err) = run_phase(
458 &assignments,
459 endpoint,
460 hash,
461 &file,
462 DEFAULT_RATE_BYTES_PER_MS,
463 TIMEOUT_FACTOR,
464 )
465 .await;
466
467 drop(file);
468
469 run_retry_phase(
471 &assignments,
472 &phase1_ok,
473 providers,
474 &temp_path,
475 total_size,
476 hash,
477 endpoint,
478 )
479 .await?;
480
481 let all_successful: Vec<PublicKey> = phase1_ok.iter().map(|o| o.provider).collect();
482 if all_successful.is_empty() {
483 let _ = tokio::fs::remove_file(&temp_path).await;
484 return Ok(TryParallelResult::AllFailed);
485 }
486
487 import_assembled_blob(blobs, &temp_path, hash).await?;
488
489 info!(
490 %hash,
491 successes = all_successful.len(),
492 failures = phase1_err.len(),
493 "parallel: download complete"
494 );
495 Ok(TryParallelResult::Downloaded(all_successful))
496}
497
498fn estimate_chunks_in_ranges(ranges: &ChunkRanges, max_chunks: u64) -> u64 {
500 let bounds = ranges.boundaries();
501 let mut total = 1_u64;
502 let mut i = 0_usize;
503 while let Some(start) = bounds.get(i) {
504 let next = i.saturating_add(1);
505 let Some(end_ref) = bounds.get(next) else {
506 break;
507 };
508 let end = end_ref.0.min(max_chunks);
509 if let Some(count) = end.checked_sub(start.0) {
510 total = total.saturating_add(count);
511 }
512 i = i.saturating_add(2);
513 }
514 total
515}