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;