Skip to main content

syncweb_core/indexing/
parallel.rs

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
16/// Default estimated rate: 125 bytes/ms ≈ 1 Mbps.
17const DEFAULT_RATE_BYTES_PER_MS: u64 = 125;
18/// Timeout multiplier for phase 1 (8× estimated completion).
19const TIMEOUT_FACTOR: f64 = 8.0;
20/// Tighter multiplier for retry phase.
21const RETRY_TIMEOUT_FACTOR: f64 = 4.0;
22
23/// Configuration for parallel multi-provider blob download.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25#[non_exhaustive]
26pub struct ParallelDownloadConfig {
27    /// Blobs below this size are downloaded sequentially.
28    pub min_blob_size: u64,
29    /// Maximum parallel connections downloading a single blob.
30    pub max_concurrent_connections: usize,
31    /// Minimum chunks per range to avoid tiny protocol requests.
32    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/// A provider assigned to download one or more stripe ranges.
46#[derive(Clone, Debug)]
47struct Assignment {
48    provider: PublicKey,
49    ticket: BlobTicket,
50    ranges: ChunkRanges,
51    estimated_bytes: u64,
52}
53
54/// Result of a parallel download attempt.
55#[derive(Debug)]
56#[non_exhaustive]
57pub enum TryParallelResult {
58    /// Blob is too small or has too few providers — caller should fall back.
59    Inapplicable,
60    /// Successfully downloaded and imported into the blob store.
61    Downloaded(Vec<PublicKey>),
62    /// All providers failed — caller may proceed to sequential fallback.
63    AllFailed,
64}
65
66/// Outcome of a single download task.
67struct TaskOutcome {
68    provider: PublicKey,
69    bytes_written: u64,
70    elapsed: Duration,
71}
72
73/// Probe the full blob size from a single provider using the last-chunk trick.
74async 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
113/// Assign stripes to providers using deterministic scoring.
114///
115/// Each stripe is 1024 chunks (1 MiB).  The provider with the highest
116/// `hash(provider, stripe_index)` is chosen for each stripe.
117fn 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
175/// Download all stripe ranges for one provider and write to a file.
176async 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
247/// Run a phase of parallel downloads.
248async 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
300/// Compute observed rate in bytes/ms from successful outcomes.
301fn 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
311/// Collect stripe ranges that need retry from failed assignments.
312fn 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
323/// Run phase 2 retry for unclaimed stripe ranges.
324async 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
377/// Import the assembled blob and verify its hash.
378async 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
399/// Try to download a blob in parallel from multiple providers.
400///
401/// # Phase 1 (stripe interleaving)
402/// Chunks are divided into 1 MiB stripes assigned via deterministic scoring.
403/// Each provider gets a deadline of 8× estimated completion based on observed
404/// download rate.
405///
406/// # Phase 2 (retry)
407/// Stripes that failed in phase 1 are reassigned to surviving providers with
408/// a tighter 4× deadline.
409///
410/// # Errors
411///
412/// Returns an error if the temporary file cannot be created, or if the
413/// assembled blob does not match the expected hash.
414pub 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    // Phase 1: download all stripes
457    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    // Phase 2: retry unclaimed ranges with surviving providers
470    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
498/// Count chunks covered by a finite `ChunkRanges` set.
499fn 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}