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;