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