Skip to main content

decdn_client/
downloader.rs

1//! The `Downloader` consumption face (#1848): fetch a set of content-addressed
2//! blobs — a bundle, or a single blob — to files in a directory.
3//!
4//! A `Downloader` is an OUTPUT + SCHEDULING adapter over the ONE shared
5//! acquire loop, not a second engine: each entry opens a [`ClientRangedStore`]
6//! beside its file and fills its missing ranges through [`crate::acquire`]
7//! across a [`SourceSet`] of the blob's holders — bao-verifying every byte and
8//! striping across every holder at full throughput. A source that faults cools
9//! and returns; the fetch ends on done, a fatal fault, or the stop policy. It
10//! then finalizes, promoting the `.partial` to the final file. So a resumed
11//! download re-pulls only what it lacks, and a bundle layer above can pre-seed
12//! held ranges (its chunk-hint dedup) into the `.partial` and the same fetch
13//! fills only the complement.
14//!
15//! It is the download half of the same loop the `Streamer` (the
16//! consumption-paced single-blob face) uses — the Downloader just uncaps the
17//! lanes and drops the consumer pacing. Bundles are the main scenario: a bundle
18//! is a set of blobs, so both entry points take a slice and write one file per
19//! blob. `fetch_to_paths` writes each blob to a caller-chosen destination (a
20//! bundle's manifest paths, or a single `decdn fetch -o` target); `fetch_to_dir`
21//! is the convenience over it that names each file by its content-address hex
22//! under a directory. A single blob is a bundle of one.
23
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26use std::sync::atomic::{AtomicU64, Ordering};
27
28use crate::credential::CredentialSlot;
29use crate::health::PeerHealth;
30use crate::ledgers::LaneLedgers;
31use crate::pacer::BudgetPacer;
32use crate::recovery::RecoveryGate;
33use crate::scheduler::{AcquireEnv, AcquireTarget, acquire};
34use crate::source::Funder;
35use crate::source_set::{Holder, SourceProvider, SourceSet};
36use crate::stop::{ProgressClock, StopPolicy};
37use crate::{ClientRangedStore, RangedStore};
38
39/// One blob to fetch and where to write it: the content `hash` (the bao root),
40/// its `total_bytes` (authoritative for keying the store and sizing the fetch),
41/// the caller-chosen `dest` path the `.partial` and final file are keyed by,
42/// and the byte ranges to fill.
43#[derive(Debug, Clone, Copy)]
44pub struct DownloadTarget<'a> {
45    /// The blob's BLAKE3 content address (its bao root); every ingested byte is
46    /// verified against it.
47    pub hash: [u8; 32],
48    /// The blob's content length in bytes.
49    pub total_bytes: u64,
50    /// Where to write the finished blob. The `.partial` store and the promoted
51    /// final file are keyed by this path, so the finished file IS `dest`.
52    pub dest: &'a Path,
53    /// The `(offset, len)` byte ranges to fill, or `None` for the whole blob.
54    /// The fetch promotes the blob only when every byte is present, so a
55    /// caller that writes the bytes outside them itself (a bundle entry's
56    /// donor splice) finds them in the `.partial` beside `dest`.
57    pub ranges: Option<&'a [(u64, u64)]>,
58}
59
60/// Fetch content-addressed blobs — a bundle, or a single blob — to files in a
61/// directory, through the shared acquire loop ([`crate::acquire`] +
62/// [`ClientRangedStore`]) at full throughput.
63///
64/// A `Downloader` is the download face over the same loop the
65/// [`crate::Streamer`] uses: it fetches across a [`SourceSet`] of the injected
66/// `holders`, built through the [`SourceProvider`], with up to `max_lanes`
67/// lanes and no consumption pacing, then promotes each finished blob to its
68/// file. A
69/// source that faults cools (in the command-wide [`PeerHealth`]) and returns;
70/// its ranges move to the other sources meanwhile. A resumed download (an
71/// existing `.partial`, e.g. a bundle layer's chunk-hint dedup) re-pulls only
72/// its missing ranges.
73///
74/// Each target gets a fresh [`SourceSet`], so the provider builds each
75/// holder's lane once per target. A [`crate::StaticSources`] hands each lane
76/// out once, so a `Downloader` over one fetches one target.
77///
78/// The faces do not write the buyer store: record each lane's payment after the
79/// fetch, whatever its outcome (see the crate docs, and the `download` example
80/// for the whole sequence).
81///
82/// ```no_run
83/// use std::path::Path;
84///
85/// use decdn_client::source::BlobSource;
86/// use decdn_client::{DownloadTarget, Downloader, Funder, StaticSources, StreamCandidate};
87///
88/// async fn download<S: BlobSource, F: Funder>(
89///     candidates: Vec<StreamCandidate<S>>,
90///     funder: F,
91///     hash: [u8; 32],
92///     total_bytes: u64,
93///     dest: &Path,
94/// ) -> anyhow::Result<()> {
95///     let downloader = Downloader::new(StaticSources::new(candidates)?, funder);
96///     let target = DownloadTarget { hash, total_bytes, dest, ranges: None };
97///     downloader.fetch_to_paths(&[target], None).await?;
98///     Ok(())
99/// }
100/// ```
101pub struct Downloader<P, F> {
102    /// Where the fetch finds holders and builds their lanes.
103    provider: P,
104    /// The holders every target starts from.
105    holders: Vec<Holder>,
106    /// The command-wide health every target's sources record into.
107    health: Arc<PeerHealth>,
108    /// The funding recovery seam an exhausted candidate set funds through.
109    funder: F,
110    /// The fetch's funding recovery state, shared by every target.
111    recovery: Arc<RecoveryGate>,
112    /// A delegated fetch's swappable voucher credential ([`Self::credentials`]).
113    credentials: Option<CredentialSlot>,
114    /// The most lanes that stream at once, for every target. Holders past it
115    /// wait as reserves, and a holder discovery adds later can use a free
116    /// lane.
117    max_lanes: usize,
118    /// The largest blob accepted, in bytes, or `0` for no cap
119    /// ([`Self::max_blob_bytes`]).
120    max_blob_bytes: u64,
121    /// Targets fetched again from scratch after a finalize hash mismatch
122    /// ([`Self::refetched_targets`]).
123    refetched: AtomicU64,
124}
125
126impl<P, F> std::fmt::Debug for Downloader<P, F> {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        f.debug_struct("Downloader")
129            .field("holders", &self.holders.len())
130            .field("recovery", &self.recovery)
131            .finish_non_exhaustive()
132    }
133}
134
135impl<P, F> Downloader<P, F> {
136    /// Build a downloader whose holders and lanes `provider` finds and builds,
137    /// funded through `funder`.
138    ///
139    /// Each target starts by discovering its holders, every holder streams at
140    /// once, and the downloader's targets share a fresh [`RecoveryGate`]. The
141    /// builder methods change each of these.
142    #[must_use]
143    pub fn new(provider: P, funder: F) -> Self {
144        Self {
145            provider,
146            holders: Vec::new(),
147            health: Arc::default(),
148            funder,
149            recovery: Arc::default(),
150            credentials: None,
151            max_lanes: usize::MAX,
152            max_blob_bytes: 0,
153            refetched: AtomicU64::new(0),
154        }
155    }
156
157    /// Start every target from `holders`, in preference order, instead of
158    /// discovering them. A probe's measured holders go here.
159    #[must_use]
160    pub fn holders(mut self, holders: Vec<Holder>) -> Self {
161        self.holders = holders;
162        self
163    }
164
165    /// Stream from at most `max_lanes` holders at once (at least one). The
166    /// other holders wait as reserves, and a holder that discovery adds later
167    /// can take a free lane.
168    #[must_use]
169    pub const fn max_lanes(mut self, max_lanes: usize) -> Self {
170        self.max_lanes = max_lanes;
171        self
172    }
173
174    /// Run the funding recovery step under `recovery`, the fetch's gate. A
175    /// caller that runs its remaining work again after a
176    /// [`crate::PoolReplaced`] passes the gate the first pass used, so the
177    /// progress rule spans both passes.
178    #[must_use]
179    pub fn recovery(mut self, recovery: Arc<RecoveryGate>) -> Self {
180        self.recovery = recovery;
181        self
182    }
183
184    /// Pay as a delegate under `slot`'s credential. The provider builds each
185    /// lane from [`CredentialSlot::current`]. After a swap, the lanes on the
186    /// old key end at a voucher boundary and new lanes resume under the new
187    /// key; at the exhausted candidate set the fetch waits for a swap instead
188    /// of topping up, and ends with a [`crate::FundingNeeded`].
189    #[must_use]
190    pub fn credentials(mut self, slot: CredentialSlot) -> Self {
191        self.credentials = Some(slot);
192        self
193    }
194
195    /// Record source faults into `health`, which a command shares across its
196    /// fetches so a holder that faulted on one stays cooled on the next.
197    #[doc(hidden)]
198    #[must_use]
199    pub fn health(mut self, health: Arc<PeerHealth>) -> Self {
200        self.health = health;
201        self
202    }
203
204    /// How many targets this downloader fetched again from scratch because
205    /// their finalized file did not match its hash. Such a pass drops every
206    /// byte the store held, including a prefix an earlier run resumed, so a
207    /// caller that counts resumed bytes apart from fetched ones reads this to
208    /// know the prefix was fetched again.
209    #[must_use]
210    pub fn refetched_targets(&self) -> u64 {
211        self.refetched.load(Ordering::Relaxed)
212    }
213
214    /// Cap every target at `max_blob_bytes` (`0` for no cap). A target's size
215    /// claim above the cap is clamped to it before its store is sized, and a
216    /// blob that holds bytes past the cap fails with [`crate::BlobTooLarge`]
217    /// ([`crate::AcquireEnv::max_blob_bytes`]).
218    #[must_use]
219    pub const fn max_blob_bytes(mut self, max_blob_bytes: u64) -> Self {
220        self.max_blob_bytes = max_blob_bytes;
221        self
222    }
223}
224
225impl<P, F> Downloader<P, F>
226where
227    P: SourceProvider,
228    F: Funder,
229{
230    /// Fetch every `(hash, total_bytes)` entry to a file named by its
231    /// content-address hex inside `dir`, returning the written paths in entry
232    /// order. See [`Self::fetch_to_paths`].
233    ///
234    /// `on_progress`, when set, is called with the verified CONTENT progress for
235    /// the entry CURRENTLY fetching — `(position, total_bytes)`, both in content
236    /// bytes, resetting to that entry's own total at each new entry. A
237    /// multi-entry caller drawing one bar accumulates the completed entries'
238    /// totals itself; a single-entry download can use it directly.
239    ///
240    /// # Errors
241    ///
242    /// Any error [`Self::fetch_to_paths`] returns.
243    pub async fn fetch_to_dir(
244        &self,
245        entries: &[([u8; 32], u64)],
246        dir: &Path,
247        on_progress: Option<&(dyn Fn(u64, u64) + Send + Sync + '_)>,
248    ) -> anyhow::Result<Vec<PathBuf>> {
249        let dests: Vec<PathBuf> = entries
250            .iter()
251            .map(|&(hash, _)| dir.join(blake3::Hash::from_bytes(hash).to_hex().as_str()))
252            .collect();
253        let targets: Vec<DownloadTarget<'_>> = entries
254            .iter()
255            .zip(dests.iter())
256            .map(|(&(hash, total_bytes), dest)| DownloadTarget {
257                hash,
258                total_bytes,
259                dest,
260                ranges: None,
261            })
262            .collect();
263        self.fetch_to_paths(&targets, on_progress).await
264    }
265
266    /// [`Self::fetch_to_paths_until`] under a stop policy with no limit: the
267    /// fetch waits for its sources until the caller drops it.
268    ///
269    /// # Errors
270    ///
271    /// Any error [`Self::fetch_to_paths_until`] returns except a give-up.
272    pub async fn fetch_to_paths(
273        &self,
274        targets: &[DownloadTarget<'_>],
275        on_progress: Option<&(dyn Fn(u64, u64) + Send + Sync + '_)>,
276    ) -> anyhow::Result<Vec<PathBuf>> {
277        let stop = StopPolicy::new(true, None, Arc::new(ProgressClock::new()));
278        self.fetch_to_paths_until(targets, on_progress, &stop).await
279    }
280
281    /// Fetch every [`DownloadTarget`] to its own `dest` path, returning the
282    /// `dest` of each target it promoted, in target order. A target without
283    /// explicit ranges is always promoted, so a whole-blob batch returns every
284    /// `dest`; a ranged target that leaves bytes missing is not in the result,
285    /// because no file exists at its `dest`. Each blob's `.partial` and final file are
286    /// keyed by `dest` (its parent directory and file name), so a resumed
287    /// download re-pulls only what it lacks and the promoted file IS `dest` — no
288    /// post-finalize rename. The parent directory of each `dest` is created if
289    /// missing.
290    ///
291    /// Each target opens a [`ClientRangedStore`] beside its file and fills its
292    /// ranges through [`crate::acquire`] across a fresh [`SourceSet`] of the
293    /// holders, then finalizes — promoting `.partial` to `dest`. A target with
294    /// explicit [`DownloadTarget::ranges`] that leave bytes missing is not
295    /// finalized: its landed ranges stay in the `.partial`. Writes land at
296    /// their absolute offsets, so out-of-order and multi-source fills assemble
297    /// correctly. The whole blob is fetched at full throughput, with no
298    /// read-ahead bound.
299    ///
300    /// `total_bytes` is the first size claim, a hint: a resumed store's bound
301    /// wins over it, and a leg that verifies the final chunk proves the size.
302    /// The target `hash` is the bao root every ingested byte is verified
303    /// against: a wrong hash surfaces as a verification failure, never as
304    /// silent corruption. A finalize whose whole-file hash does not match
305    /// (bytes that drifted on disk after they verified) fetches the target
306    /// once more.
307    ///
308    /// `stop` decides when a target that makes no verified progress gives up.
309    /// A target that gives up or fails keeps its landed bytes recorded in its
310    /// `.partial` for a resume, and is not finalized.
311    ///
312    /// # Errors
313    ///
314    /// A `dest` with no file name, a store
315    /// open/create/finalize I/O error, a second [`crate::HashMismatch`] at
316    /// finalize, or the error the fetch ends a target with: a fatal fault, a
317    /// unanimous verdict of the sources, or [`crate::GaveUp`]. The first failing
318    /// target aborts the batch; targets already written stay on disk.
319    pub async fn fetch_to_paths_until(
320        &self,
321        targets: &[DownloadTarget<'_>],
322        on_progress: Option<&(dyn Fn(u64, u64) + Send + Sync + '_)>,
323        stop: &StopPolicy,
324    ) -> anyhow::Result<Vec<PathBuf>> {
325        self.fetch(targets, None, on_progress, stop).await
326    }
327
328    /// [`Self::fetch_to_paths_until`] over a run-wide voucher-ledger registry
329    /// (a bundle run's [`LaneLedgers`]). The loop reads and credits every lane
330    /// registered across the run for its deposit-solvency view, so concurrent
331    /// entries that share one on-chain pool cannot jointly over-draw it.
332    ///
333    /// # Errors
334    ///
335    /// Any error [`Self::fetch_to_paths_until`] returns.
336    #[doc(hidden)]
337    pub async fn fetch_to_paths_shared(
338        &self,
339        targets: &[DownloadTarget<'_>],
340        ledgers: &LaneLedgers,
341        on_progress: Option<&(dyn Fn(u64, u64) + Send + Sync + '_)>,
342        stop: &StopPolicy,
343    ) -> anyhow::Result<Vec<PathBuf>> {
344        self.fetch(targets, Some(ledgers), on_progress, stop).await
345    }
346
347    /// The fetch behind both entry points. `ledgers` of `None` folds only this
348    /// fetch's own lanes, the right view for a solo download.
349    async fn fetch(
350        &self,
351        targets: &[DownloadTarget<'_>],
352        ledgers: Option<&LaneLedgers>,
353        on_progress: Option<&(dyn Fn(u64, u64) + Send + Sync + '_)>,
354        stop: &StopPolicy,
355    ) -> anyhow::Result<Vec<PathBuf>> {
356        let pacer = BudgetPacer::new();
357        let mut written = Vec::with_capacity(targets.len());
358        for target in targets {
359            let (dir, stem) = dest_store_location(target.dest)?;
360            tokio::fs::create_dir_all(&dir)
361                .await
362                .map_err(|e| anyhow::anyhow!("create download dir {}: {e}", dir.display()))?;
363            // Off the runtime: opening a finalized blob hashes its final file.
364            let store = {
365                let (dir, stem) = (dir.clone(), stem.clone());
366                let (hash, total) = (target.hash, target.total_bytes);
367                // A claim above the cap never sizes the store.
368                let total = if self.max_blob_bytes > 0 {
369                    total.min(self.max_blob_bytes)
370                } else {
371                    total
372                };
373                tokio::task::spawn_blocking(move || {
374                    ClientRangedStore::open_or_create(&dir, &stem, hash, total)
375                })
376                .await
377                .map_err(|e| anyhow::anyhow!("open ranged store task: {e}"))?
378                .map_err(|e| {
379                    anyhow::anyhow!("open ranged store for {}: {e}", target.dest.display())
380                })?
381            };
382            let whole = [(0, target.total_bytes)];
383            let ranges = target.ranges.unwrap_or(&whole);
384            let mut sources = SourceSet::new(
385                &self.provider,
386                target.hash,
387                Arc::clone(&self.health),
388                self.holders.clone(),
389            );
390            let env = AcquireEnv {
391                pacer: &pacer,
392                funder: &self.funder,
393                recovery: &self.recovery,
394                credentials: self.credentials.as_ref(),
395                max_lanes: self.max_lanes.max(1),
396                stop,
397                on_progress,
398                ledgers,
399                // No consumption pacing: a download runs at full throughput.
400                pacing: None,
401                max_blob_bytes: self.max_blob_bytes,
402            };
403            // A finalize whose hash does not match clears every claim and
404            // keeps the file, so one more pass fetches the blob again. A
405            // second mismatch is a local fault and ends the target.
406            let mut passes_left = 2u8;
407            loop {
408                passes_left = passes_left.saturating_sub(1);
409                // A dropped download still records the ranges that landed.
410                let flush_on_drop = store.flush_on_drop();
411                let fetched = acquire(
412                    AcquireTarget {
413                        store: &store,
414                        hash: target.hash,
415                        total_bytes: target.total_bytes,
416                        ranges,
417                    },
418                    &mut sources,
419                    &env,
420                )
421                .await;
422                flush_on_drop.disarm();
423                fetched?;
424                // `acquire` flushes the present record but does not promote; a
425                // download keeps the file, so finalize (verify + promote
426                // `.partial`) once every byte is present. Explicit ranges can
427                // leave bytes to another writer, and the `.partial` then stays
428                // for that writer.
429                if target.ranges.is_some() && !store.is_complete().await? {
430                    break;
431                }
432                match store.finalize().await {
433                    Ok(()) => {
434                        written.push(target.dest.to_path_buf());
435                        break;
436                    }
437                    Err(err) if passes_left > 0 && is_hash_mismatch(&err) => {
438                        self.refetched.fetch_add(1, Ordering::Relaxed);
439                        tracing::warn!(
440                            hash = %blake3::Hash::from_bytes(target.hash).to_hex(),
441                            dest = %target.dest.display(),
442                            "the finalized file does not match its hash; fetching it once more"
443                        );
444                    }
445                    Err(err) => return Err(err.into()),
446                }
447            }
448        }
449        Ok(written)
450    }
451}
452
453/// Whether a finalize failed its whole-file hash ([`crate::HashMismatch`]).
454fn is_hash_mismatch(err: &decdn_bao_range::RangedStoreError) -> bool {
455    matches!(
456        err,
457        decdn_bao_range::RangedStoreError::Backend(inner) if inner.is::<crate::HashMismatch>()
458    )
459}
460
461/// The directory and stem a [`DownloadTarget`]'s `.partial` store lives under, so
462/// its promoted final path IS `dest` (no post-finalize rename): `dest`'s parent
463/// (or the current directory for a bare file name) and `dest`'s own file name.
464fn dest_store_location(dest: &Path) -> anyhow::Result<(PathBuf, String)> {
465    let dir = match dest.parent() {
466        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
467        _ => PathBuf::from("."),
468    };
469    let stem = dest
470        .file_name()
471        .and_then(|n| n.to_str())
472        .ok_or_else(|| anyhow::anyhow!("download dest {} has no usable file name", dest.display()))?
473        .to_string();
474    Ok((dir, stem))
475}
476
477#[cfg(test)]
478mod tests;