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