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 get -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, RunDemand};
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    /// What the caller's run still needs ([`Self::run_demand`]).
125    run: Option<Arc<RunDemand>>,
126}
127
128impl<P, F> std::fmt::Debug for Downloader<P, F> {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.debug_struct("Downloader")
131            .field("holders", &self.holders.len())
132            .field("recovery", &self.recovery)
133            .finish_non_exhaustive()
134    }
135}
136
137impl<P, F> Downloader<P, F> {
138    /// Build a downloader whose holders and lanes `provider` finds and builds,
139    /// funded through `funder`.
140    ///
141    /// Each target starts by discovering its holders, every holder streams at
142    /// once, and the downloader's targets share a fresh [`RecoveryGate`]. The
143    /// builder methods change each of these.
144    #[must_use]
145    pub fn new(provider: P, funder: F) -> Self {
146        Self {
147            provider,
148            holders: Vec::new(),
149            health: Arc::default(),
150            funder,
151            recovery: Arc::default(),
152            credentials: None,
153            max_lanes: usize::MAX,
154            max_blob_bytes: 0,
155            refetched: AtomicU64::new(0),
156            run: None,
157        }
158    }
159
160    /// Start every target from `holders`, in preference order, instead of
161    /// discovering them. A probe's measured holders go here.
162    #[must_use]
163    pub fn holders(mut self, holders: Vec<Holder>) -> Self {
164        self.holders = holders;
165        self
166    }
167
168    /// Stream from at most `max_lanes` holders at once (at least one). The
169    /// other holders wait as reserves, and a holder that discovery adds later
170    /// can take a free lane.
171    #[must_use]
172    pub const fn max_lanes(mut self, max_lanes: usize) -> Self {
173        self.max_lanes = max_lanes;
174        self
175    }
176
177    /// Run the funding recovery step under `recovery`, the fetch's gate. A
178    /// caller that runs its remaining work again after a
179    /// [`crate::PoolReplaced`] passes the gate the first pass used, so the
180    /// progress rule spans both passes.
181    #[must_use]
182    pub fn recovery(mut self, recovery: Arc<RecoveryGate>) -> Self {
183        self.recovery = recovery;
184        self
185    }
186
187    /// Pay as a delegate under `slot`'s credential. The provider builds each
188    /// lane from [`CredentialSlot::current`]. After a swap, the lanes on the
189    /// old key end at a voucher boundary and new lanes resume under the new
190    /// key; at the exhausted candidate set the fetch waits for a swap instead
191    /// of topping up, and ends with a [`crate::FundingNeeded`].
192    #[must_use]
193    pub fn credentials(mut self, slot: CredentialSlot) -> Self {
194        self.credentials = Some(slot);
195        self
196    }
197
198    /// Price the delegated funding signal on `run`: the work the caller's
199    /// run still plans, when that is more than the targets' own missing
200    /// bytes, at the highest quote any lane of the run got. A bundle pull
201    /// keeps it at the bundle's undelivered download, so every entry reports
202    /// one bundle-wide need.
203    #[doc(hidden)]
204    #[must_use]
205    pub fn run_demand(mut self, run: Arc<RunDemand>) -> Self {
206        self.run = Some(run);
207        self
208    }
209
210    /// Record source faults into `health`, which a command shares across its
211    /// fetches so a holder that faulted on one stays cooled on the next.
212    #[doc(hidden)]
213    #[must_use]
214    pub fn health(mut self, health: Arc<PeerHealth>) -> Self {
215        self.health = health;
216        self
217    }
218
219    /// How many targets this downloader fetched again from scratch because
220    /// their finalized file did not match its hash. Such a pass drops every
221    /// byte the store held, including a prefix an earlier run resumed, so a
222    /// caller that counts resumed bytes apart from fetched ones reads this to
223    /// know the prefix was fetched again.
224    #[must_use]
225    pub fn refetched_targets(&self) -> u64 {
226        self.refetched.load(Ordering::Relaxed)
227    }
228
229    /// Cap the bytes held for every target at `max_blob_bytes` (`0` for no
230    /// cap). A target's ranges are fetched at any offset while the bytes held
231    /// fit; ranges that would hold more fail with [`crate::BlobTooLarge`]
232    /// ([`crate::AcquireEnv::max_blob_bytes`]). A size claim past the
233    /// furthest range start plus the cap never sizes the store.
234    #[must_use]
235    pub const fn max_blob_bytes(mut self, max_blob_bytes: u64) -> Self {
236        self.max_blob_bytes = max_blob_bytes;
237        self
238    }
239}
240
241impl<P, F> Downloader<P, F>
242where
243    P: SourceProvider,
244    F: Funder,
245{
246    /// Fetch every `(hash, total_bytes)` entry to a file named by its
247    /// content-address hex inside `dir`, returning the written paths in entry
248    /// order. See [`Self::fetch_to_paths`].
249    ///
250    /// `on_progress`, when set, is called with the verified CONTENT progress for
251    /// the entry CURRENTLY fetching — `(position, total_bytes)`, both in content
252    /// bytes, resetting to that entry's own total at each new entry. A
253    /// multi-entry caller drawing one bar accumulates the completed entries'
254    /// totals itself; a single-entry download can use it directly.
255    ///
256    /// # Errors
257    ///
258    /// Any error [`Self::fetch_to_paths`] returns.
259    pub async fn fetch_to_dir(
260        &self,
261        entries: &[([u8; 32], u64)],
262        dir: &Path,
263        on_progress: Option<&(dyn Fn(u64, u64) + Send + Sync + '_)>,
264    ) -> anyhow::Result<Vec<PathBuf>> {
265        let dests: Vec<PathBuf> = entries
266            .iter()
267            .map(|&(hash, _)| dir.join(blake3::Hash::from_bytes(hash).to_hex().as_str()))
268            .collect();
269        let targets: Vec<DownloadTarget<'_>> = entries
270            .iter()
271            .zip(dests.iter())
272            .map(|(&(hash, total_bytes), dest)| DownloadTarget {
273                hash,
274                total_bytes,
275                dest,
276                ranges: None,
277            })
278            .collect();
279        self.fetch_to_paths(&targets, on_progress).await
280    }
281
282    /// [`Self::fetch_to_paths_until`] under a stop policy with no limit: the
283    /// fetch waits for its sources until the caller drops it.
284    ///
285    /// # Errors
286    ///
287    /// Any error [`Self::fetch_to_paths_until`] returns except a give-up.
288    pub async fn fetch_to_paths(
289        &self,
290        targets: &[DownloadTarget<'_>],
291        on_progress: Option<&(dyn Fn(u64, u64) + Send + Sync + '_)>,
292    ) -> anyhow::Result<Vec<PathBuf>> {
293        let stop = StopPolicy::new(true, None, Arc::new(ProgressClock::new()));
294        self.fetch_to_paths_until(targets, on_progress, &stop).await
295    }
296
297    /// Fetch every [`DownloadTarget`] to its own `dest` path, returning the
298    /// `dest` of each target it promoted, in target order. A target without
299    /// explicit ranges is always promoted, so a whole-blob batch returns every
300    /// `dest`; a ranged target that leaves bytes missing is not in the result,
301    /// because no file exists at its `dest`. Each blob's `.partial` and final file are
302    /// keyed by `dest` (its parent directory and file name), so a resumed
303    /// download re-pulls only what it lacks and the promoted file IS `dest` — no
304    /// post-finalize rename. The parent directory of each `dest` is created if
305    /// missing.
306    ///
307    /// Each target opens a [`ClientRangedStore`] beside its file and fills its
308    /// ranges through [`crate::acquire`] across a fresh [`SourceSet`] of the
309    /// holders, then finalizes — promoting `.partial` to `dest`. A target with
310    /// explicit [`DownloadTarget::ranges`] that leave bytes missing is not
311    /// finalized: its landed ranges stay in the `.partial`. Writes land at
312    /// their absolute offsets, so out-of-order and multi-source fills assemble
313    /// correctly. The whole blob is fetched at full throughput, with no
314    /// read-ahead bound.
315    ///
316    /// `total_bytes` is the first size claim, a hint: a resumed store's bound
317    /// wins over it, and a leg that verifies the final chunk proves the size.
318    /// The target `hash` is the bao root every ingested byte is verified
319    /// against: a wrong hash surfaces as a verification failure, never as
320    /// silent corruption. A finalize whose whole-file hash does not match
321    /// (bytes that drifted on disk after they verified) fetches the target
322    /// once more.
323    ///
324    /// `stop` decides when a target that makes no verified progress gives up.
325    /// A target that gives up or fails keeps its landed bytes recorded in its
326    /// `.partial` for a resume, and is not finalized.
327    ///
328    /// # Errors
329    ///
330    /// A `dest` with no file name, a store
331    /// open/create/finalize I/O error, a second [`crate::HashMismatch`] at
332    /// finalize, or the error the fetch ends a target with: a fatal fault, a
333    /// unanimous verdict of the sources, or [`crate::GaveUp`]. The first failing
334    /// target aborts the batch; targets already written stay on disk.
335    pub async fn fetch_to_paths_until(
336        &self,
337        targets: &[DownloadTarget<'_>],
338        on_progress: Option<&(dyn Fn(u64, u64) + Send + Sync + '_)>,
339        stop: &StopPolicy,
340    ) -> anyhow::Result<Vec<PathBuf>> {
341        self.fetch(targets, None, on_progress, stop).await
342    }
343
344    /// [`Self::fetch_to_paths_until`] over a run-wide voucher-ledger registry
345    /// (a bundle run's [`LaneLedgers`]). The loop reads and credits every lane
346    /// registered across the run for its deposit-solvency view, so concurrent
347    /// entries that share one on-chain pool cannot jointly over-draw it.
348    ///
349    /// # Errors
350    ///
351    /// Any error [`Self::fetch_to_paths_until`] returns.
352    #[doc(hidden)]
353    pub async fn fetch_to_paths_shared(
354        &self,
355        targets: &[DownloadTarget<'_>],
356        ledgers: &LaneLedgers,
357        on_progress: Option<&(dyn Fn(u64, u64) + Send + Sync + '_)>,
358        stop: &StopPolicy,
359    ) -> anyhow::Result<Vec<PathBuf>> {
360        self.fetch(targets, Some(ledgers), on_progress, stop).await
361    }
362
363    /// The fetch behind both entry points. `ledgers` of `None` folds only this
364    /// fetch's own lanes, the right view for a solo download.
365    async fn fetch(
366        &self,
367        targets: &[DownloadTarget<'_>],
368        ledgers: Option<&LaneLedgers>,
369        on_progress: Option<&(dyn Fn(u64, u64) + Send + Sync + '_)>,
370        stop: &StopPolicy,
371    ) -> anyhow::Result<Vec<PathBuf>> {
372        let pacer = BudgetPacer::new();
373        let mut written = Vec::with_capacity(targets.len());
374        for target in targets {
375            let (dir, stem) = dest_store_location(target.dest)?;
376            tokio::fs::create_dir_all(&dir)
377                .await
378                .map_err(|e| anyhow::anyhow!("create download dir {}: {e}", dir.display()))?;
379            let whole = [(0, target.total_bytes)];
380            let ranges = target.ranges.unwrap_or(&whole);
381            // Off the runtime: opening a finalized blob hashes its final file.
382            let store = {
383                let (dir, stem) = (dir.clone(), stem.clone());
384                let (hash, total) = (target.hash, target.total_bytes);
385                // A claim past what the cap on held bytes lets the ranges
386                // reach never sizes the store.
387                let total = total.min(crate::scheduler::claim_reach(ranges, self.max_blob_bytes));
388                tokio::task::spawn_blocking(move || {
389                    ClientRangedStore::open_or_create(&dir, &stem, hash, total)
390                })
391                .await
392                .map_err(|e| anyhow::anyhow!("open ranged store task: {e}"))?
393                .map_err(|e| {
394                    anyhow::anyhow!("open ranged store for {}: {e}", target.dest.display())
395                })?
396            };
397            let mut sources = SourceSet::new(
398                &self.provider,
399                target.hash,
400                Arc::clone(&self.health),
401                self.holders.clone(),
402            );
403            let env = AcquireEnv {
404                pacer: &pacer,
405                funder: &self.funder,
406                recovery: &self.recovery,
407                credentials: self.credentials.as_ref(),
408                max_lanes: self.max_lanes.max(1),
409                stop,
410                on_progress,
411                ledgers,
412                // No consumption pacing: a download runs at full throughput.
413                pacing: None,
414                max_blob_bytes: self.max_blob_bytes,
415                run: self.run.as_deref(),
416            };
417            // A finalize whose hash does not match clears every claim and
418            // keeps the file, so one more pass fetches the blob again. A
419            // second mismatch is a local fault and ends the target.
420            let mut passes_left = 2u8;
421            loop {
422                passes_left = passes_left.saturating_sub(1);
423                // A dropped download still records the ranges that landed.
424                let flush_on_drop = store.flush_on_drop();
425                let fetched = acquire(
426                    AcquireTarget {
427                        store: &store,
428                        hash: target.hash,
429                        total_bytes: target.total_bytes,
430                        ranges,
431                    },
432                    &mut sources,
433                    &env,
434                )
435                .await;
436                flush_on_drop.disarm();
437                fetched?;
438                // `acquire` flushes the present record but does not promote; a
439                // download keeps the file, so finalize (verify + promote
440                // `.partial`) once every byte is present. Explicit ranges can
441                // leave bytes to another writer, and the `.partial` then stays
442                // for that writer.
443                if target.ranges.is_some() && !store.is_complete().await? {
444                    break;
445                }
446                match store.finalize().await {
447                    Ok(()) => {
448                        written.push(target.dest.to_path_buf());
449                        break;
450                    }
451                    Err(err) if passes_left > 0 && is_hash_mismatch(&err) => {
452                        self.refetched.fetch_add(1, Ordering::Relaxed);
453                        tracing::warn!(
454                            hash = %blake3::Hash::from_bytes(target.hash).to_hex(),
455                            dest = %target.dest.display(),
456                            "the finalized file does not match its hash; fetching it once more"
457                        );
458                    }
459                    Err(err) => return Err(err.into()),
460                }
461            }
462        }
463        Ok(written)
464    }
465}
466
467/// Whether a finalize failed its whole-file hash ([`crate::HashMismatch`]).
468fn is_hash_mismatch(err: &decdn_bao_range::RangedStoreError) -> bool {
469    matches!(
470        err,
471        decdn_bao_range::RangedStoreError::Backend(inner) if inner.is::<crate::HashMismatch>()
472    )
473}
474
475/// The directory and stem a [`DownloadTarget`]'s `.partial` store lives under, so
476/// its promoted final path IS `dest` (no post-finalize rename): `dest`'s parent
477/// (or the current directory for a bare file name) and `dest`'s own file name.
478fn dest_store_location(dest: &Path) -> anyhow::Result<(PathBuf, String)> {
479    let dir = match dest.parent() {
480        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
481        _ => PathBuf::from("."),
482    };
483    let stem = dest
484        .file_name()
485        .and_then(|n| n.to_str())
486        .ok_or_else(|| anyhow::anyhow!("download dest {} has no usable file name", dest.display()))?
487        .to_string();
488    Ok((dir, stem))
489}
490
491#[cfg(test)]
492mod tests;