Skip to main content

reth_cli_commands/download/
fetch.rs

1use super::{
2    progress::{
3        ArchiveDownloadProgress, DownloadProgress, DownloadRequestLimiter, SharedProgress,
4        SharedProgressWriter,
5    },
6    session::DownloadSession,
7    RETRY_BACKOFF_SECS,
8};
9use eyre::Result;
10use reqwest::{blocking::Client as BlockingClient, header::RANGE, StatusCode};
11use reth_cli_util::cancellation::CancellationToken;
12use reth_fs_util as fs;
13use std::{
14    any::Any,
15    collections::VecDeque,
16    fs::OpenOptions,
17    io::{self, BufWriter, Read, Write},
18    os::unix::fs::FileExt,
19    path::{Path, PathBuf},
20    sync::{
21        atomic::{AtomicBool, AtomicU64, Ordering},
22        Arc, Mutex,
23    },
24    time::Duration,
25};
26use tracing::{info, warn};
27use url::Url;
28
29/// Maximum retry attempts for a single download segment.
30const SEGMENT_RETRY_ATTEMPTS: u32 = 3;
31
32/// Minimum archive size that benefits from segmented downloads.
33const SEGMENTED_DOWNLOAD_MIN_FILE_SIZE: u64 = 128 * 1024 * 1024;
34
35/// Piece sizes are large so big downloads do not create too many requests while
36/// still giving multiple workers enough work to do.
37const SEGMENTED_DOWNLOAD_SMALL_PIECE_SIZE: u64 = 32 * 1024 * 1024;
38const SEGMENTED_DOWNLOAD_LARGE_PIECE_SIZE: u64 = 64 * 1024 * 1024;
39
40/// Cap exponential piece retry backoff to avoid overly long stalls.
41const SEGMENTED_DOWNLOAD_MAX_BACKOFF_SECS: u64 = 30;
42
43/// Segmented piece requests should time out quickly enough to recover from slow or stalled
44/// requests.
45const SEGMENTED_DOWNLOAD_REQUEST_TIMEOUT_SECS: u64 = 120;
46
47/// Paths for one downloaded archive and its `.part` file.
48#[derive(Debug, Clone)]
49struct DownloadPaths {
50    /// User-facing archive file name derived from the URL.
51    file_name: String,
52    /// Final path for the completed archive file.
53    final_path: PathBuf,
54    /// Temporary path used while the archive is still downloading.
55    part_path: PathBuf,
56}
57
58impl DownloadPaths {
59    /// Builds the final and partial download paths from the archive URL.
60    fn from_url(url: &str, target_dir: &Path) -> Self {
61        let file_name = Url::parse(url)
62            .ok()
63            .and_then(|u| u.path_segments()?.next_back().map(|s| s.to_string()))
64            .unwrap_or_else(|| "snapshot.tar".to_string());
65
66        Self {
67            final_path: target_dir.join(&file_name),
68            part_path: target_dir.join(format!("{file_name}.part")),
69            file_name,
70        }
71    }
72
73    /// Returns the user-facing file name derived from the archive URL.
74    fn file_name(&self) -> &str {
75        &self.file_name
76    }
77
78    /// Returns the final on-disk path for the completed archive.
79    fn final_path(&self) -> &Path {
80        &self.final_path
81    }
82
83    /// Returns the partial download path used while the archive is still in flight.
84    fn part_path(&self) -> &Path {
85        &self.part_path
86    }
87
88    /// Promotes the partial file into the final archive path.
89    fn finalize(&self) -> Result<()> {
90        fs::rename(&self.part_path, &self.final_path)?;
91        Ok(())
92    }
93
94    /// Removes only the partial `.part` file for the current archive.
95    fn cleanup_partial(&self) {
96        let _ = fs::remove_file(&self.part_path);
97    }
98
99    /// Removes both final and partial archive files so a fresh attempt can restart cleanly.
100    fn cleanup_all(&self) {
101        let _ = fs::remove_file(&self.final_path);
102        self.cleanup_partial();
103    }
104}
105
106/// Fetches one archive to disk and chooses sequential or segmented download.
107pub(crate) struct ArchiveFetcher {
108    /// Remote archive URL.
109    url: String,
110    /// On-disk paths used for this archive download.
111    paths: DownloadPaths,
112    /// Shared command-scoped download state.
113    session: DownloadSession,
114}
115
116impl ArchiveFetcher {
117    /// Creates a fetcher for one archive URL under the given target directory.
118    pub(crate) fn new(url: impl Into<String>, target_dir: &Path, session: DownloadSession) -> Self {
119        let url = url.into();
120        let paths = DownloadPaths::from_url(&url, target_dir);
121        Self { url, paths, session }
122    }
123
124    /// Downloads the archive using the best strategy supported by the remote source.
125    pub(crate) fn download(
126        &self,
127        download_progress: Option<&mut ArchiveDownloadProgress<'_>>,
128    ) -> Result<DownloadedArchive> {
129        if let Some(path) = archive_file_url_path(&self.url)? {
130            let size = fs::metadata(&path)?.len();
131            if !self.quiet() {
132                info!(target: "reth::cli",
133                    file = %path.display(),
134                    size = %DownloadProgress::format_size(size),
135                    "Using local archive"
136                );
137            }
138            return Ok(DownloadedArchive { path, size })
139        }
140
141        let Some(request_limiter) = self.session.request_limiter() else {
142            return self.download_sequential(super::MAX_DOWNLOAD_RETRIES, download_progress)
143        };
144
145        let client = BlockingClient::builder().connect_timeout(Duration::from_secs(30)).build()?;
146        let probe = self.probe(&client)?;
147
148        match choose_fetch_strategy(probe, request_limiter.max_concurrency()) {
149            FetchStrategy::Sequential(reason) => {
150                self.log_sequential_fallback(reason, probe.total_size);
151                self.download_sequential(super::MAX_DOWNLOAD_RETRIES, download_progress)
152            }
153            FetchStrategy::Segmented(plan) => {
154                self.download_segmented(probe.total_size, plan, download_progress)
155            }
156        }
157    }
158
159    /// Removes any archive files created by this fetcher.
160    pub(crate) fn cleanup_downloaded_files(&self) {
161        self.paths.cleanup_all();
162    }
163
164    /// Probes the remote source for file size and HTTP range support.
165    fn probe(&self, client: &BlockingClient) -> Result<RemoteArchiveProbe> {
166        let mut attempt = 1;
167        let probe = loop {
168            let probe = client
169                .get(&self.url)
170                .header(RANGE, "bytes=0-0")
171                .send()
172                .and_then(|response| response.error_for_status());
173            // A transient failure here would permanently downgrade the archive to one stream.
174            if probe.is_ok() || attempt == SEGMENT_RETRY_ATTEMPTS {
175                break probe
176            }
177            std::thread::sleep(self.session.retry_delay(piece_retry_backoff(attempt, false)));
178            attempt += 1;
179        };
180
181        let (supports_ranges, total_size) = match probe {
182            Ok(response) if response.status() == StatusCode::PARTIAL_CONTENT => {
183                let total = response
184                    .headers()
185                    .get("Content-Range")
186                    .and_then(|value| value.to_str().ok())
187                    .and_then(|value| value.split('/').next_back())
188                    .and_then(|value| value.parse::<u64>().ok());
189                (true, total)
190            }
191            _ => {
192                let response = client.head(&self.url).send()?.error_for_status()?;
193                (false, response.content_length())
194            }
195        };
196
197        Ok(RemoteArchiveProbe {
198            total_size: total_size.ok_or_else(|| eyre::eyre!("Server did not return file size"))?,
199            supports_ranges,
200        })
201    }
202
203    /// Downloads the archive as a single resumable stream using one request at a time.
204    fn download_sequential(
205        &self,
206        max_download_retries: u32,
207        mut download_progress: Option<&mut ArchiveDownloadProgress<'_>>,
208    ) -> Result<DownloadedArchive> {
209        let quiet = self.quiet();
210
211        if !quiet {
212            info!(target: "reth::cli", file = %self.paths.file_name(), "Connecting to download server");
213        }
214
215        let client = BlockingClient::builder().timeout(Duration::from_secs(30)).build()?;
216        let mut total_size: Option<u64> = None;
217        let mut last_error: Option<eyre::Error> = None;
218
219        for attempt in 1..=max_download_retries {
220            let existing_size =
221                fs::metadata(self.paths.part_path()).map(|meta| meta.len()).unwrap_or(0);
222
223            if let Some(total) = total_size &&
224                existing_size >= total
225            {
226                return self.finalize_download(total)
227            }
228
229            if attempt > 1 {
230                info!(target: "reth::cli",
231                    file = %self.paths.file_name(),
232                    "Retry attempt {}/{} - resuming from {} bytes",
233                    attempt, max_download_retries, existing_size
234                );
235            }
236
237            let mut request = client.get(&self.url);
238            if existing_size > 0 {
239                request = request.header(RANGE, format!("bytes={existing_size}-"));
240                if !quiet && attempt == 1 {
241                    info!(target: "reth::cli", file = %self.paths.file_name(), "Resuming from {} bytes", existing_size);
242                }
243            }
244
245            let _request_permit = self
246                .session
247                .request_limiter()
248                .map(|limiter| {
249                    limiter.acquire(self.session.progress(), self.session.cancel_token())
250                })
251                .transpose()?;
252
253            let response = match request.send().and_then(|response| response.error_for_status()) {
254                Ok(response) => response,
255                Err(error) => {
256                    last_error = Some(error.into());
257                    if attempt < max_download_retries {
258                        info!(target: "reth::cli",
259                            file = %self.paths.file_name(),
260                            retry_delay = ?self.session.retry_delay(Duration::from_secs(RETRY_BACKOFF_SECS)),
261                            "Download failed, retrying"
262                        );
263                        std::thread::sleep(
264                            self.session.retry_delay(Duration::from_secs(RETRY_BACKOFF_SECS)),
265                        );
266                    }
267                    continue;
268                }
269            };
270
271            let is_partial = response.status() == StatusCode::PARTIAL_CONTENT;
272            let size = if is_partial {
273                response
274                    .headers()
275                    .get("Content-Range")
276                    .and_then(|value| value.to_str().ok())
277                    .and_then(|value| value.split('/').next_back())
278                    .and_then(|value| value.parse().ok())
279            } else {
280                response.content_length()
281            };
282
283            if total_size.is_none() {
284                total_size = size;
285                if !quiet && let Some(size) = size {
286                    info!(target: "reth::cli",
287                        file = %self.paths.file_name(),
288                        size = %DownloadProgress::format_size(size),
289                        "Downloading"
290                    );
291                }
292            }
293
294            let current_total = total_size.ok_or_else(|| {
295                eyre::eyre!("Server did not provide Content-Length or Content-Range header")
296            })?;
297
298            let file = if is_partial && existing_size > 0 {
299                OpenOptions::new()
300                    .append(true)
301                    .open(self.paths.part_path())
302                    .map_err(|error| fs::FsPathError::open(error, self.paths.part_path()))?
303            } else {
304                fs::create_file(self.paths.part_path())?
305            };
306
307            let start_offset = if is_partial { existing_size } else { 0 };
308            let mut reader = response;
309
310            let copy_result;
311            let flush_result;
312
313            if let Some(progress) = self.session.progress() {
314                let mut on_written = |bytes| {
315                    if let Some(download_progress) = download_progress.as_deref_mut() {
316                        download_progress.record_downloaded(bytes);
317                    }
318                };
319                let mut writer = SharedProgressWriter {
320                    inner: BufWriter::new(file),
321                    progress: Arc::clone(progress),
322                    on_written: Some(&mut on_written),
323                };
324                copy_result = io::copy(&mut reader, &mut writer);
325                flush_result = writer.inner.flush();
326            } else {
327                let mut progress = DownloadProgress::new(current_total);
328                progress.downloaded = start_offset;
329                let mut writer = ProgressWriter {
330                    inner: BufWriter::new(file),
331                    progress,
332                    cancel_token: self.session.cancel_token().clone(),
333                };
334                copy_result = io::copy(&mut reader, &mut writer);
335                flush_result = writer.inner.flush();
336                println!();
337            }
338
339            if let Err(error) = copy_result.and(flush_result) {
340                last_error = Some(error.into());
341                if attempt < max_download_retries {
342                    info!(target: "reth::cli",
343                        file = %self.paths.file_name(),
344                        retry_delay = ?self.session.retry_delay(Duration::from_secs(RETRY_BACKOFF_SECS)),
345                        "Download interrupted, retrying"
346                    );
347                    std::thread::sleep(
348                        self.session.retry_delay(Duration::from_secs(RETRY_BACKOFF_SECS)),
349                    );
350                }
351                continue;
352            }
353
354            return self.finalize_download(current_total)
355        }
356
357        Err(last_error.unwrap_or_else(|| {
358            eyre::eyre!("Download failed after {} attempts", max_download_retries)
359        }))
360    }
361
362    /// Downloads the archive by splitting it into large range-request pieces.
363    fn download_segmented(
364        &self,
365        total_size: u64,
366        plan: SegmentedDownloadPlan,
367        download_progress: Option<&mut ArchiveDownloadProgress<'_>>,
368    ) -> Result<DownloadedArchive> {
369        let request_limiter = self.session.require_request_limiter()?;
370        info!(target: "reth::cli",
371            total_size = %DownloadProgress::format_size(total_size),
372            piece_size = %DownloadProgress::format_size(plan.piece_size),
373            pieces = plan.piece_count,
374            workers = plan.worker_count,
375            max_concurrent_requests = request_limiter.max_concurrency(),
376            "Starting queued segmented download"
377        );
378
379        SegmentedDownload::new(
380            self.url.clone(),
381            self.paths.clone(),
382            total_size,
383            plan,
384            self.session.clone(),
385            download_progress,
386        )
387        .run()
388    }
389
390    /// Logs why this archive must fall back to the sequential fetch path.
391    fn log_sequential_fallback(&self, reason: SequentialDownloadFallback, total_size: u64) {
392        match reason {
393            SequentialDownloadFallback::NoRangeSupport => {
394                info!(target: "reth::cli",
395                    file = %self.paths.file_name(),
396                    "Server does not support Range requests, falling back to sequential download"
397                );
398            }
399            SequentialDownloadFallback::EmptyFile => {
400                info!(target: "reth::cli",
401                    file = %self.paths.file_name(),
402                    "Remote archive is empty, falling back to sequential download"
403                );
404            }
405            SequentialDownloadFallback::TooSmall => {
406                let _ = total_size;
407            }
408        }
409    }
410
411    /// Finalizes the downloaded archive and returns its on-disk location and size.
412    fn finalize_download(&self, size: u64) -> Result<DownloadedArchive> {
413        self.paths.finalize()?;
414        if !self.quiet() {
415            info!(target: "reth::cli", file = %self.paths.file_name(), "Download complete");
416        }
417        Ok(DownloadedArchive { path: self.paths.final_path().to_path_buf(), size })
418    }
419
420    /// Returns `true` when this fetch should stay quiet because shared progress is active.
421    fn quiet(&self) -> bool {
422        self.session.progress().is_some()
423    }
424}
425
426/// Resolves a `file://` archive URL to its local path.
427fn archive_file_url_path(url: &str) -> Result<Option<PathBuf>> {
428    let Ok(parsed) = Url::parse(url) else { return Ok(None) };
429    if parsed.scheme() != "file" {
430        return Ok(None)
431    }
432
433    parsed
434        .to_file_path()
435        .map(Some)
436        .map_err(|_| eyre::eyre!("Invalid file:// archive URL path: {url}"))
437}
438
439/// The final path and size of one archive fetched to disk.
440#[derive(Debug, Clone)]
441pub(crate) struct DownloadedArchive {
442    /// Final on-disk path for the downloaded archive.
443    pub(crate) path: PathBuf,
444    /// Total archive size in bytes.
445    pub(crate) size: u64,
446}
447
448/// Remote metadata used to choose between sequential and segmented download.
449#[derive(Debug, Clone, Copy)]
450struct RemoteArchiveProbe {
451    /// Total archive size reported by the remote source.
452    total_size: u64,
453    /// Whether the remote source supports byte-range requests.
454    supports_ranges: bool,
455}
456
457/// Reasons the fetcher may choose the sequential download path.
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459enum SequentialDownloadFallback {
460    /// The remote source does not support byte-range requests.
461    NoRangeSupport,
462    /// The remote source reported an empty archive.
463    EmptyFile,
464    /// The archive is too small to benefit from segmented download.
465    TooSmall,
466}
467
468/// The fetch strategy chosen after probing the remote source.
469#[derive(Debug)]
470enum FetchStrategy {
471    /// Use the single-stream download path.
472    Sequential(SequentialDownloadFallback),
473    /// Use the segmented download path.
474    Segmented(SegmentedDownloadPlan),
475}
476
477/// Chooses the fetch strategy from the remote probe and available worker budget.
478fn choose_fetch_strategy(probe: RemoteArchiveProbe, max_workers: usize) -> FetchStrategy {
479    if !probe.supports_ranges {
480        return FetchStrategy::Sequential(SequentialDownloadFallback::NoRangeSupport)
481    }
482
483    if probe.total_size == 0 {
484        return FetchStrategy::Sequential(SequentialDownloadFallback::EmptyFile)
485    }
486
487    plan_segmented_download(probe.total_size, max_workers)
488        .map(FetchStrategy::Segmented)
489        .unwrap_or(FetchStrategy::Sequential(SequentialDownloadFallback::TooSmall))
490}
491
492/// Wrapper that tracks download progress while writing data.
493/// Used with [`io::copy`] to display progress during downloads.
494struct ProgressWriter<W> {
495    /// Wrapped writer receiving downloaded bytes.
496    inner: W,
497    /// Per-download progress tracker for the legacy path.
498    progress: DownloadProgress,
499    /// Cancellation token checked between writes.
500    cancel_token: CancellationToken,
501}
502
503impl<W: Write> Write for ProgressWriter<W> {
504    /// Writes bytes, checks cancellation, and updates local download progress.
505    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
506        if self.cancel_token.is_cancelled() {
507            return Err(io::Error::new(io::ErrorKind::Interrupted, "download cancelled"));
508        }
509        let n = self.inner.write(buf)?;
510        let _ = self.progress.update(n as u64);
511        Ok(n)
512    }
513
514    /// Flushes the wrapped writer.
515    fn flush(&mut self) -> io::Result<()> {
516        self.inner.flush()
517    }
518}
519
520/// One queued byte range for a segmented archive download.
521#[derive(Debug, Clone, Copy, PartialEq, Eq)]
522struct DownloadPiece {
523    /// Inclusive start byte for this piece.
524    start: u64,
525    /// Inclusive end byte for this piece.
526    end: u64,
527}
528
529/// Fixed plan for a segmented archive: piece size, piece count, and worker count.
530#[derive(Debug)]
531struct SegmentedDownloadPlan {
532    /// Bytes assigned to each piece, except possibly the last.
533    piece_size: u64,
534    /// Number of pieces created for this archive.
535    piece_count: usize,
536    /// Number of worker threads used for this archive.
537    worker_count: usize,
538    /// Queue of pieces to download.
539    pieces: VecDeque<DownloadPiece>,
540}
541
542/// Runs the segmented download workers and piece retries for one archive.
543struct SegmentedDownload {
544    /// Remote archive URL.
545    url: String,
546    /// On-disk paths used for this archive download.
547    paths: DownloadPaths,
548    /// Total archive size in bytes.
549    total_size: u64,
550    /// Piece and worker plan for this archive.
551    plan: SegmentedDownloadPlan,
552    /// Shared command-scoped download state.
553    session: DownloadSession,
554}
555
556/// Shared inputs each segmented download worker needs while draining the piece queue.
557#[derive(Clone, Copy)]
558struct SegmentedWorkerContext<'a> {
559    /// Remote archive URL.
560    url: &'a str,
561    /// Partial file path where pieces are written.
562    part_path: &'a Path,
563    /// Shared progress counters for the whole command, when enabled.
564    shared: Option<&'a Arc<SharedProgress>>,
565    /// Shared cap for in-flight HTTP requests.
566    request_limiter: &'a DownloadRequestLimiter,
567    /// Cancellation token shared by the whole command.
568    cancel_token: &'a CancellationToken,
569    /// Session carrying the retry-delay override.
570    session: &'a DownloadSession,
571}
572
573impl SegmentedDownload {
574    /// Creates the segmented download state for one archive.
575    fn new(
576        url: String,
577        paths: DownloadPaths,
578        total_size: u64,
579        plan: SegmentedDownloadPlan,
580        session: DownloadSession,
581        _download_progress: Option<&mut ArchiveDownloadProgress<'_>>,
582    ) -> Self {
583        Self { url, paths, total_size, plan, session }
584    }
585
586    /// Runs the segmented download to completion or returns the first fatal error.
587    fn run(self) -> Result<DownloadedArchive> {
588        let Self { url, paths, total_size, plan, session } = self;
589        {
590            let file = fs::create_file(paths.part_path())?;
591            file.set_len(total_size)?;
592        }
593
594        let worker_count = plan.worker_count;
595        let state = Arc::new(SegmentedDownloadState::new(plan.pieces));
596        let terminal_failure = Arc::new(TerminalFailure::default());
597        let piece_progress_bytes = Arc::new(AtomicU64::new(0));
598        let worker_client = BlockingClient::builder()
599            .connect_timeout(Duration::from_secs(30))
600            .timeout(Duration::from_secs(SEGMENTED_DOWNLOAD_REQUEST_TIMEOUT_SECS))
601            .build()?;
602        let request_limiter = Arc::clone(session.require_request_limiter()?);
603        let shared = session.progress();
604        let cancel_token = session.cancel_token();
605        let url = url.as_str();
606        let worker_context = SegmentedWorkerContext {
607            url,
608            part_path: paths.part_path(),
609            session: &session,
610            shared,
611            request_limiter: request_limiter.as_ref(),
612            cancel_token,
613        };
614
615        std::thread::scope(|scope| {
616            let mut handles = Vec::with_capacity(worker_count);
617
618            for _ in 0..worker_count {
619                let state = Arc::clone(&state);
620                let terminal_failure = Arc::clone(&terminal_failure);
621                let piece_progress_bytes = Arc::clone(&piece_progress_bytes);
622                let client = worker_client.clone();
623
624                handles.push(scope.spawn(move || {
625                    Self::worker_loop(
626                        &client,
627                        worker_context,
628                        state,
629                        terminal_failure,
630                        piece_progress_bytes,
631                    );
632                }));
633            }
634
635            for handle in handles {
636                if let Err(payload) = handle.join() {
637                    state.note_terminal_failure();
638                    terminal_failure.record(eyre::eyre!(
639                        "Segmented download worker panicked: {}",
640                        panic_payload_message(payload)
641                    ));
642                }
643            }
644        });
645
646        if let Some(error) = terminal_failure.take() {
647            if let Some(shared) = shared {
648                shared.sub_active_download_bytes(piece_progress_bytes.load(Ordering::Relaxed));
649            }
650            paths.cleanup_partial();
651            return Err(error.wrap_err("Parallel download failed"))
652        }
653
654        if let Some(shared) = shared {
655            shared.sub_active_download_bytes(piece_progress_bytes.load(Ordering::Relaxed));
656            shared.record_archive_download_complete(total_size);
657        }
658
659        paths.finalize()?;
660        info!(target: "reth::cli", file = %paths.file_name(), "Download complete");
661        Ok(DownloadedArchive { path: paths.final_path().to_path_buf(), size: total_size })
662    }
663
664    /// Runs one worker until there are no pieces left or another worker fails.
665    fn worker_loop(
666        client: &BlockingClient,
667        context: SegmentedWorkerContext<'_>,
668        state: Arc<SegmentedDownloadState>,
669        terminal_failure: Arc<TerminalFailure>,
670        piece_progress_bytes: Arc<AtomicU64>,
671    ) {
672        let file = match OpenOptions::new().write(true).open(context.part_path) {
673            Ok(file) => file,
674            Err(error) => {
675                state.note_terminal_failure();
676                terminal_failure.record(error.into());
677                return;
678            }
679        };
680
681        while let Some(piece) = state.next_piece(context.cancel_token) {
682            match Self::download_piece_with_retries(
683                client,
684                context.url,
685                &file,
686                piece,
687                context.shared,
688                &piece_progress_bytes,
689                context.request_limiter,
690                context.cancel_token,
691                context.session,
692            ) {
693                Ok(()) => {}
694                Err(PieceAttemptFailure::Retryable { error, .. }) if state.requeue(piece) => {
695                    warn!(target: "reth::cli",
696                        start = piece.start,
697                        end = piece.end,
698                        %error,
699                        "Piece download failed, requeued"
700                    );
701                }
702                Err(
703                    PieceAttemptFailure::Retryable { error, .. } |
704                    PieceAttemptFailure::Terminal(error),
705                ) => {
706                    state.note_terminal_failure();
707                    terminal_failure.record(error);
708                    return;
709                }
710            }
711        }
712    }
713
714    /// Downloads one queued piece with per-piece retry/backoff.
715    ///
716    /// Each attempt acquires a permit from the shared request limit so whole-file and
717    /// piece downloads use the same fixed number of HTTP request slots.
718    #[expect(clippy::too_many_arguments)]
719    fn download_piece_with_retries(
720        client: &BlockingClient,
721        url: &str,
722        file: &std::fs::File,
723        piece: DownloadPiece,
724        shared: Option<&Arc<SharedProgress>>,
725        piece_progress_bytes: &AtomicU64,
726        request_limiter: &DownloadRequestLimiter,
727        cancel_token: &CancellationToken,
728        session: &DownloadSession,
729    ) -> std::result::Result<(), PieceAttemptFailure> {
730        for attempt in 1..=SEGMENT_RETRY_ATTEMPTS {
731            if cancel_token.is_cancelled() {
732                return Err(PieceAttemptFailure::Terminal(eyre::eyre!("Download cancelled")))
733            }
734
735            let _request_permit = request_limiter
736                .acquire(shared, cancel_token)
737                .map_err(PieceAttemptFailure::Terminal)?;
738            match Self::download_piece_once(
739                client,
740                url,
741                file,
742                piece,
743                shared,
744                piece_progress_bytes,
745                cancel_token,
746            ) {
747                Ok(()) => return Ok(()),
748                Err(PieceAttemptFailure::Retryable { error: _, throttled })
749                    if attempt < SEGMENT_RETRY_ATTEMPTS =>
750                {
751                    std::thread::sleep(
752                        session.retry_delay(piece_retry_backoff(attempt, throttled)),
753                    );
754                }
755                Err(failure) => return Err(failure),
756            }
757        }
758
759        Err(PieceAttemptFailure::Terminal(eyre::eyre!(
760            "Piece download failed after {SEGMENT_RETRY_ATTEMPTS} attempts"
761        )))
762    }
763
764    /// Downloads one queued piece once.
765    fn download_piece_once(
766        client: &BlockingClient,
767        url: &str,
768        file: &std::fs::File,
769        piece: DownloadPiece,
770        shared: Option<&Arc<SharedProgress>>,
771        piece_progress_bytes: &AtomicU64,
772        cancel_token: &CancellationToken,
773    ) -> std::result::Result<(), PieceAttemptFailure> {
774        let expected_len = piece.end - piece.start + 1;
775
776        let response = match client
777            .get(url)
778            .header(RANGE, format!("bytes={}-{}", piece.start, piece.end))
779            .send()
780        {
781            Ok(response) if response.status() == StatusCode::PARTIAL_CONTENT => response,
782            Ok(response) if should_retry_piece_status(response.status()) => {
783                return Err(PieceAttemptFailure::Retryable {
784                    error: eyre::eyre!(
785                        "Server returned {} for piece {}-{}",
786                        response.status(),
787                        piece.start,
788                        piece.end
789                    ),
790                    throttled: is_throttle_piece_status(response.status()),
791                });
792            }
793            Ok(response) => {
794                return Err(PieceAttemptFailure::Terminal(eyre::eyre!(
795                    "Server returned {} instead of 206 for Range request",
796                    response.status()
797                )));
798            }
799            Err(error) => {
800                return Err(PieceAttemptFailure::Retryable {
801                    throttled: is_throttle_piece_error(&error),
802                    error: error.into(),
803                });
804            }
805        };
806
807        let mut buf = [0u8; 64 * 1024];
808        let mut reader = response.take(expected_len);
809        let mut offset = piece.start;
810
811        loop {
812            if cancel_token.is_cancelled() {
813                return Err(PieceAttemptFailure::Terminal(eyre::eyre!("Download cancelled")));
814            }
815
816            match reader.read(&mut buf) {
817                Ok(0) => break,
818                Ok(n) => {
819                    file.write_all_at(&buf[..n], offset)
820                        .map_err(|error| PieceAttemptFailure::Terminal(error.into()))?;
821                    offset += n as u64;
822                    if let Some(progress) = shared {
823                        progress.record_session_fetched_bytes(n as u64);
824                    }
825                }
826                Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
827                Err(error) => {
828                    return Err(PieceAttemptFailure::Retryable {
829                        throttled: error.kind() == io::ErrorKind::TimedOut,
830                        error: error.into(),
831                    });
832                }
833            }
834        }
835
836        let downloaded_len = offset - piece.start;
837        if downloaded_len == expected_len {
838            if let Some(progress) = shared {
839                progress.add_active_download_bytes(expected_len);
840            }
841            piece_progress_bytes.fetch_add(expected_len, Ordering::Relaxed);
842            return Ok(())
843        }
844
845        Err(PieceAttemptFailure::Retryable {
846            error: eyre::eyre!(
847                "Piece {}-{} ended early: expected {} bytes, downloaded {}",
848                piece.start,
849                piece.end,
850                expected_len,
851                downloaded_len
852            ),
853            throttled: false,
854        })
855    }
856}
857
858/// Shared queue state for one segmented archive download.
859///
860/// Workers pull pieces until the queue is empty or one worker fails the whole attempt.
861struct SegmentedDownloadState {
862    /// Remaining pieces waiting to be downloaded.
863    pieces: Mutex<VecDeque<DownloadPiece>>,
864    /// Set once a worker hits a fatal error.
865    failed: AtomicBool,
866    /// Remaining number of times a piece that exhausted its retries may be requeued.
867    requeues_left: AtomicU64,
868}
869
870impl SegmentedDownloadState {
871    /// Creates the shared queue state for one segmented archive attempt.
872    fn new(pieces: VecDeque<DownloadPiece>) -> Self {
873        let requeues_left = AtomicU64::new(pieces.len() as u64);
874        Self { pieces: Mutex::new(pieces), failed: AtomicBool::new(false), requeues_left }
875    }
876
877    /// Puts a piece back on the queue, returning `false` once the requeue budget is spent.
878    fn requeue(&self, piece: DownloadPiece) -> bool {
879        if self
880            .requeues_left
881            .try_update(Ordering::Relaxed, Ordering::Relaxed, |left| left.checked_sub(1))
882            .is_err()
883        {
884            return false
885        }
886        self.pieces.lock().unwrap().push_back(piece);
887        true
888    }
889
890    /// Returns the next piece unless cancellation or a fatal error stopped the attempt.
891    fn next_piece(&self, cancel_token: &CancellationToken) -> Option<DownloadPiece> {
892        if cancel_token.is_cancelled() || self.failed.load(Ordering::Relaxed) {
893            return None;
894        }
895
896        self.pieces.lock().unwrap().pop_front()
897    }
898
899    /// Marks the entire segmented attempt as failed so workers stop taking more pieces.
900    fn note_terminal_failure(&self) {
901        self.failed.store(true, Ordering::Relaxed);
902    }
903}
904
905/// Stores the first fatal error seen across segmented download workers.
906#[derive(Default)]
907struct TerminalFailure {
908    /// First fatal worker error, if any.
909    error: Mutex<Option<eyre::Error>>,
910}
911
912impl TerminalFailure {
913    /// Stores the first fatal error and ignores later ones from other workers.
914    fn record(&self, error: eyre::Error) {
915        let mut slot = self.error.lock().unwrap();
916        if slot.is_none() {
917            *slot = Some(error);
918        }
919    }
920
921    /// Returns the stored fatal error after worker execution finishes.
922    fn take(&self) -> Option<eyre::Error> {
923        self.error.lock().unwrap().take()
924    }
925}
926
927/// Splits an archive into contiguous byte ranges for segmented download.
928fn build_download_pieces(total_size: u64, piece_size: u64) -> VecDeque<DownloadPiece> {
929    let mut pieces = VecDeque::new();
930    let mut start = 0;
931
932    while start < total_size {
933        let end = (start + piece_size).min(total_size) - 1;
934        pieces.push_back(DownloadPiece { start, end });
935        start = end + 1;
936    }
937
938    pieces
939}
940
941/// Chooses the fixed piece size for a large archive.
942///
943/// Smaller large files use 32 MiB pieces so there are enough pieces for several workers.
944/// Very large files use 64 MiB pieces to keep the request count down.
945fn segmented_piece_size(total_size: u64) -> u64 {
946    if total_size < 2 * 1024 * 1024 * 1024 {
947        SEGMENTED_DOWNLOAD_SMALL_PIECE_SIZE
948    } else {
949        SEGMENTED_DOWNLOAD_LARGE_PIECE_SIZE
950    }
951}
952
953/// Builds the segmented download plan for one archive.
954///
955/// Small files stay single-stream. Larger files are split into fixed pieces and
956/// can use up to the shared request limit.
957fn plan_segmented_download(total_size: u64, max_workers: usize) -> Option<SegmentedDownloadPlan> {
958    if max_workers == 0 || total_size < SEGMENTED_DOWNLOAD_MIN_FILE_SIZE {
959        return None;
960    }
961
962    let piece_size = segmented_piece_size(total_size);
963    if total_size <= piece_size {
964        return None;
965    }
966
967    let pieces = build_download_pieces(total_size, piece_size);
968    let piece_count = pieces.len();
969    let worker_count = max_workers.min(piece_count).max(1);
970
971    Some(SegmentedDownloadPlan { piece_size, piece_count, worker_count, pieces })
972}
973
974/// Returns the retry backoff for one piece attempt.
975fn piece_retry_backoff(attempt: u32, throttled: bool) -> Duration {
976    let base = if throttled { 2 } else { RETRY_BACKOFF_SECS };
977    let multiplier = 1u64 << attempt.saturating_sub(1).min(3);
978    Duration::from_secs(base.saturating_mul(multiplier).min(SEGMENTED_DOWNLOAD_MAX_BACKOFF_SECS))
979}
980
981/// Returns whether an HTTP status should retry the current piece.
982fn is_retryable_piece_status(status: StatusCode) -> bool {
983    matches!(status, StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_MANY_REQUESTS) ||
984        status.is_server_error()
985}
986
987/// Returns whether a piece request should retry after the given status.
988fn should_retry_piece_status(status: StatusCode) -> bool {
989    status == StatusCode::OK || is_retryable_piece_status(status)
990}
991
992/// Returns whether an HTTP status looks like throttling or timeout.
993fn is_throttle_piece_status(status: StatusCode) -> bool {
994    matches!(
995        status,
996        StatusCode::REQUEST_TIMEOUT |
997            StatusCode::TOO_MANY_REQUESTS |
998            StatusCode::SERVICE_UNAVAILABLE |
999            StatusCode::GATEWAY_TIMEOUT
1000    )
1001}
1002
1003/// Returns whether a reqwest error looks like throttling or timeout.
1004fn is_throttle_piece_error(error: &reqwest::Error) -> bool {
1005    error.is_timeout() || matches!(error.status(), Some(status) if is_throttle_piece_status(status))
1006}
1007
1008/// The result of one piece download attempt.
1009enum PieceAttemptFailure {
1010    /// The piece can be retried.
1011    Retryable { error: eyre::Error, throttled: bool },
1012    /// The piece failed in a way that should stop the archive.
1013    Terminal(eyre::Error),
1014}
1015
1016/// Converts a thread panic payload into a readable message.
1017fn panic_payload_message(payload: Box<dyn Any + Send + 'static>) -> String {
1018    if let Some(message) = payload.downcast_ref::<&'static str>() {
1019        (*message).to_string()
1020    } else if let Some(message) = payload.downcast_ref::<String>() {
1021        message.clone()
1022    } else {
1023        "unknown panic payload".to_string()
1024    }
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030    use reqwest::StatusCode;
1031    use reth_cli_util::cancellation::CancellationToken;
1032    use std::io::Write;
1033
1034    #[test]
1035    fn segmented_plan_skips_small_files() {
1036        assert!(plan_segmented_download(SEGMENTED_DOWNLOAD_MIN_FILE_SIZE - 1, 16).is_none());
1037    }
1038
1039    #[test]
1040    fn segmented_plan_uses_large_pieces_and_adaptive_workers() {
1041        let total_size = 512 * 1024 * 1024;
1042        let plan = plan_segmented_download(total_size, 32).unwrap();
1043
1044        assert_eq!(plan.piece_size, SEGMENTED_DOWNLOAD_SMALL_PIECE_SIZE);
1045        assert_eq!(plan.piece_count, 16);
1046        assert_eq!(plan.worker_count, 16);
1047    }
1048
1049    #[test]
1050    fn build_download_pieces_covers_entire_file() {
1051        let pieces = build_download_pieces(10, 4).into_iter().collect::<Vec<_>>();
1052
1053        assert_eq!(
1054            pieces,
1055            vec![
1056                DownloadPiece { start: 0, end: 3 },
1057                DownloadPiece { start: 4, end: 7 },
1058                DownloadPiece { start: 8, end: 9 },
1059            ]
1060        );
1061    }
1062
1063    #[test]
1064    fn piece_status_retry_policy_retries_200_ok() {
1065        assert!(should_retry_piece_status(StatusCode::OK));
1066        assert!(should_retry_piece_status(StatusCode::TOO_MANY_REQUESTS));
1067        assert!(should_retry_piece_status(StatusCode::from_u16(520).unwrap()));
1068        assert!(!should_retry_piece_status(StatusCode::NOT_FOUND));
1069    }
1070
1071    #[test]
1072    fn choose_fetch_strategy_uses_segmented_when_ranges_are_supported() {
1073        let strategy = choose_fetch_strategy(
1074            RemoteArchiveProbe { total_size: 512 * 1024 * 1024, supports_ranges: true },
1075            16,
1076        );
1077
1078        assert!(matches!(strategy, FetchStrategy::Segmented(_)));
1079    }
1080
1081    #[test]
1082    fn choose_fetch_strategy_falls_back_without_ranges() {
1083        let strategy = choose_fetch_strategy(
1084            RemoteArchiveProbe { total_size: 512 * 1024 * 1024, supports_ranges: false },
1085            16,
1086        );
1087
1088        assert!(matches!(
1089            strategy,
1090            FetchStrategy::Sequential(SequentialDownloadFallback::NoRangeSupport)
1091        ));
1092    }
1093
1094    #[test]
1095    fn archive_fetcher_uses_file_url_archive_directly() {
1096        let dir = tempfile::tempdir().unwrap();
1097        let archive_path = dir.path().join("state.tar.zst");
1098        {
1099            let mut archive = std::fs::File::create(&archive_path).unwrap();
1100            archive.write_all(b"local archive bytes").unwrap();
1101        }
1102
1103        let cache_dir = dir.path().join("cache");
1104        std::fs::create_dir(&cache_dir).unwrap();
1105        let url = Url::from_file_path(&archive_path).unwrap().to_string();
1106        let session = DownloadSession::new(None, None, CancellationToken::new());
1107        let fetcher = ArchiveFetcher::new(url, &cache_dir, session);
1108
1109        let downloaded = fetcher.download(None).unwrap();
1110
1111        assert_eq!(downloaded.path, archive_path);
1112        assert_eq!(downloaded.size, b"local archive bytes".len() as u64);
1113        assert!(!cache_dir.join("state.tar.zst").exists());
1114        assert!(!cache_dir.join("state.tar.zst.part").exists());
1115    }
1116
1117    #[test]
1118    fn segmented_state_requeues_failed_pieces_within_budget() {
1119        let state = SegmentedDownloadState::new(build_download_pieces(8, 4));
1120        let token = CancellationToken::new();
1121        let first = state.next_piece(&token).unwrap();
1122
1123        assert!(state.requeue(first));
1124        assert!(state.requeue(first));
1125        assert!(!state.requeue(first));
1126        assert_eq!(state.pieces.lock().unwrap().back(), Some(&first));
1127    }
1128}