decdn_client/bundle.rs
1//! Pull a whole bundle: every blob a bundle manifest names, into an output
2//! directory, over the paid `cdn/client/v1` path.
3//!
4//! A bundle is a JSON manifest of `(path, hash, size, chunks)` entries
5//! (`adr/appendix-bundles.md`). [`BundlePuller`] fetches each distinct blob
6//! once, from one shared payment pool, and lands it at every path that names
7//! it. It resumes from what an earlier run left: a file already on disk is
8//! skipped, a byte-identical file elsewhere in the output directory is linked,
9//! a range a sibling entry shares is spliced from disk, and a `.partial` an
10//! interrupted run left is the next fetch's prefix.
11//!
12//! # A pull, end to end
13//!
14//! 1. Resolve the holders once for the run with [`select_nodes`]: the pinned
15//! node, or every active registered node for each entry to sample.
16//! 2. Build a [`BundlePuller`] over the chain, the local site, an endpoint, a
17//! buyer-pool store and the [`Funding`] the pull pays from.
18//! 3. Get the manifest: [`BundlePuller::fetch_manifest`] it by its hash, or
19//! [`BundleManifest::parse`] bytes you already hold.
20//! 4. Drop the entries you do not want, then [`BundlePuller::pull`] the rest.
21//! Progress reaches a [`BundleObserver`]; the result is a [`BundleOutcome`].
22//! 5. [`BundlePuller::finish`] waits for the lanes' watermark writes.
23//!
24//! The pull future is not `Send`: every entry and lane runs on the task that
25//! awaits it. Await it in place, or on a `tokio::task::LocalSet`.
26
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29use std::time::Duration;
30
31use alloy::primitives::U256;
32use alloy::providers::{DynProvider, Provider as _};
33use alloy::signers::local::PrivateKeySigner;
34use anyhow::Context as _;
35use decdn_incentive::buyer_pool::BuyerPoolStore;
36use decdn_incentive::payment_pool::PaymentPool;
37use decdn_incentive::{slash_judge_domain, voucher_domain};
38use iroh::{Endpoint, RelayUrl};
39use serde::Deserialize;
40use tokio_util::sync::CancellationToken;
41
42use crate::buyer_pool::{OwnedPayer, UnconfirmedEscrows};
43use crate::funding::RunFunding;
44use crate::ordered_writes::OrderedWrites;
45use crate::session::{
46 ChainAdoption, ChainConfig, DepositPolicy, DiscoveryOptions, PinnedNode, PullSite,
47};
48use crate::stream_cap::LaneStreamCap;
49use crate::uri::DecdnUri;
50use crate::{
51 Connections, CredentialSlot, FundingNeeded, LaneLedgers, PeerHealth, PoolReplaced,
52 ProgressClock, RecoveryGate, RunDemand, StopPolicy,
53};
54
55mod engine;
56mod landing;
57mod manifest_cache;
58mod skip_cache;
59
60#[doc(hidden)]
61pub use crate::lane_sources::FaultContext;
62
63/// The directory under the output root where a pull stages the blobs it
64/// fetches: `<hex>.partial` and its resume record while a blob is in flight,
65/// and `<hex>` once it is verified and before it lands at its paths.
66pub const STAGING_DIR: &str = ".decdn-partial";
67
68/// Parse a BLAKE3 hash as a bundle manifest or a user names it: 64 hex
69/// characters, with an optional `0x` or `b3:` prefix.
70///
71/// # Errors
72///
73/// When `s` is not a 64-character hex digest.
74pub fn parse_hash(s: &str) -> anyhow::Result<[u8; 32]> {
75 let hex = s
76 .strip_prefix("0x")
77 .or_else(|| s.strip_prefix("b3:"))
78 .unwrap_or(s);
79 let h = blake3::Hash::from_hex(hex).map_err(|e| {
80 anyhow::anyhow!(
81 "invalid hash {s:?}: expected 64 hex chars (BLAKE3 digest), optional `0x`/`b3:` \
82 prefix: {e}"
83 )
84 })?;
85 Ok(*h.as_bytes())
86}
87
88/// A bundle manifest: the files a bundle publishes (`adr/appendix-bundles.md`).
89#[derive(Debug, Clone)]
90#[non_exhaustive]
91pub struct BundleManifest {
92 /// The manifest blob's BLAKE3 hash: the hash a `decdn:<hash>/` URI names.
93 hash: [u8; 32],
94 /// Its files, in manifest order.
95 pub entries: Vec<ManifestEntry>,
96}
97
98/// The wire form of a manifest: a version gate around the entries.
99#[derive(Deserialize)]
100struct ManifestV1 {
101 version: u32,
102 entries: Vec<ManifestEntry>,
103}
104
105impl BundleManifest {
106 /// A manifest of `entries` whose blob has the BLAKE3 hash `hash`. A pull
107 /// records `hash` as the source of each file it writes, so a wrong one
108 /// makes a later pull of the same entry see its own files as foreign.
109 #[must_use]
110 pub const fn new(hash: [u8; 32], entries: Vec<ManifestEntry>) -> Self {
111 Self { hash, entries }
112 }
113
114 /// The manifest blob's BLAKE3 hash. A pull records it as the source of
115 /// each file it writes. Filtering `entries` keeps it: it names the whole
116 /// manifest.
117 #[must_use]
118 pub const fn hash(&self) -> [u8; 32] {
119 self.hash
120 }
121
122 /// Parse manifest JSON and check its version.
123 ///
124 /// # Errors
125 ///
126 /// [`NotAManifest`] when the bytes are not a manifest, or name a version
127 /// other than 1.
128 pub fn parse(bytes: &[u8]) -> anyhow::Result<Self> {
129 let manifest: ManifestV1 = serde_json::from_slice(bytes)
130 .map_err(|e| NotAManifest(format!("parse bundle JSON (expected v1 manifest): {e}")))?;
131 if manifest.version != 1 {
132 return Err(NotAManifest(format!(
133 "unsupported bundle version {} (this build supports v1)",
134 manifest.version
135 ))
136 .into());
137 }
138 Ok(Self {
139 hash: *blake3::hash(bytes).as_bytes(),
140 entries: manifest.entries,
141 })
142 }
143
144 /// The manifest blob `hash` a pull into `out_dir` cached, or `None` when
145 /// none is cached. The cache is self-verifying: bytes that do not hash to
146 /// `hash` are an error, as is an unreadable cache file.
147 ///
148 /// # Errors
149 ///
150 /// The cached file cannot be read, does not hash to `hash`, or is not a
151 /// manifest.
152 pub fn cached(out_dir: &Path, hash: [u8; 32]) -> anyhow::Result<Option<Self>> {
153 manifest_cache::load(out_dir, hash)?
154 .map(|bytes| {
155 Self::parse(&bytes).with_context(|| {
156 format!(
157 "cached bundle manifest {}",
158 manifest_cache::cache_path(out_dir, hash).display()
159 )
160 })
161 })
162 .transpose()
163 }
164
165 /// Where a pull into `out_dir` caches the manifest blob `hash`.
166 #[must_use]
167 pub fn cache_path(out_dir: &Path, hash: [u8; 32]) -> PathBuf {
168 manifest_cache::cache_path(out_dir, hash)
169 }
170}
171
172/// The entries of `manifest` whose path under `out_dir` holds something that
173/// a pull of the same entry may replace without the user's say: a file,
174/// directory or link that no record in the skip-cache in `out_dir` vouches
175/// for. A record vouches for a path when the file there still has the
176/// recorded size and mtime, and the record names this entry (this manifest
177/// hash and manifest path) or holds this entry's bytes. A caller that did not
178/// choose `out_dir` can refuse the pull first.
179///
180/// An entry whose path escapes `out_dir`, or that lands on the pull's own
181/// state, is not named: the pull fails that entry and writes nothing there.
182#[must_use]
183pub fn foreign_paths(manifest: &BundleManifest, out_dir: &Path) -> Vec<ForeignPath> {
184 let saved = skip_cache::load_checked(out_dir);
185 manifest
186 .entries
187 .iter()
188 .filter_map(|en| {
189 let dest = engine::writable_dest(out_dir, &en.path).ok()?;
190 let meta = std::fs::symlink_metadata(dest).ok()?;
191 let reason = match &saved {
192 Err(e) => ForeignReason::UnreadableCache(format!("{e:#}")),
193 Ok(saved) => match saved.get(&en.path) {
194 None => ForeignReason::Unrecorded,
195 Some(rec) if !rec.still_matches(&meta) => {
196 ForeignReason::Changed(rec.source.uri())
197 }
198 Some(rec)
199 if rec.source.is(manifest.hash, en.manifest_path())
200 || rec.hash == en.hash =>
201 {
202 return None;
203 }
204 Some(rec) => ForeignReason::OtherSource(rec.source.uri()),
205 },
206 };
207 Some(ForeignPath {
208 path: en.path.clone(),
209 reason,
210 })
211 })
212 .collect()
213}
214
215/// A path a pull may replace that no record vouches for ([`foreign_paths`]).
216#[derive(Debug, Clone, PartialEq, Eq)]
217#[non_exhaustive]
218pub struct ForeignPath {
219 /// The entry's path under the output directory.
220 pub path: String,
221 /// Why no record vouches for `path`.
222 pub reason: ForeignReason,
223}
224
225/// Why no skip-cache record vouches for a [`ForeignPath`].
226///
227/// A held [`DecdnUri`] is the bundle entry an earlier pull wrote the path
228/// from. It is `None` when the record names no readable manifest hash.
229#[derive(Debug, Clone, PartialEq, Eq)]
230#[non_exhaustive]
231pub enum ForeignReason {
232 /// No pull recorded writing the path.
233 Unrecorded,
234 /// The skip-cache file is there, but it cannot be used: it cannot be
235 /// read, it is not a skip-cache, or another build wrote it. Holds the
236 /// cause.
237 UnreadableCache(String),
238 /// A pull wrote the path, and it changed after: its size, its mtime, or
239 /// its kind differs from the record.
240 Changed(Option<DecdnUri>),
241 /// A pull wrote the path from another bundle entry, with other bytes.
242 OtherSource(Option<DecdnUri>),
243}
244
245/// Bytes that are not a v1 bundle manifest: [`BundleManifest::parse`]'s error,
246/// so a caller can tell a blob that is not a manifest from a failed fetch.
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct NotAManifest(String);
249
250impl std::fmt::Display for NotAManifest {
251 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252 f.write_str(&self.0)
253 }
254}
255
256impl std::error::Error for NotAManifest {}
257
258/// One file of a [`BundleManifest`].
259#[derive(Debug, Clone, Deserialize)]
260#[non_exhaustive]
261pub struct ManifestEntry {
262 /// The file's POSIX relative path under the output directory.
263 pub path: String,
264 /// The file's whole-file BLAKE3 hash (`b3:`hex), which the fetch verifies.
265 pub hash: String,
266 /// The file's size. Optional on the wire: the pull uses it as the first
267 /// size claim and for progress totals, and verifies the true size.
268 #[serde(default)]
269 pub size: Option<u64>,
270 /// The file's ordered chunk decomposition, a range-dedup hint. Chunks it
271 /// shares with a sibling entry are spliced from disk instead of paid for
272 /// twice. The whole file is still verified by `hash`.
273 #[serde(default)]
274 pub chunks: Option<Vec<ManifestChunk>>,
275 /// The manifest `path` of an entry that [`Self::land_at`] moved, or `None`
276 /// when `path` is still the manifest path.
277 #[serde(skip)]
278 landed_from: Option<String>,
279}
280
281impl ManifestEntry {
282 /// An entry for the file at `path` with whole-file `hash` and `size`, and
283 /// no chunk hints.
284 #[must_use]
285 pub fn new(path: impl Into<String>, hash: impl Into<String>, size: Option<u64>) -> Self {
286 Self {
287 path: path.into(),
288 hash: hash.into(),
289 size,
290 chunks: None,
291 landed_from: None,
292 }
293 }
294
295 /// This entry, landing at `path` under the output directory in place of
296 /// its manifest path. The pull records the manifest path as the source of
297 /// the file it writes. A second `land_at` keeps the first manifest path.
298 /// Setting the `path` field directly makes the pull record that path as
299 /// the manifest path.
300 #[must_use]
301 pub fn land_at(mut self, path: impl Into<String>) -> Self {
302 let manifest_path = std::mem::replace(&mut self.path, path.into());
303 self.landed_from.get_or_insert(manifest_path);
304 self
305 }
306
307 /// The entry's `path` in its manifest: where it lands, unless
308 /// [`Self::land_at`] moved it.
309 #[must_use]
310 pub fn manifest_path(&self) -> &str {
311 self.landed_from.as_deref().unwrap_or(&self.path)
312 }
313
314 /// This entry with the chunk decomposition `chunks`.
315 #[must_use]
316 pub fn with_chunks(mut self, chunks: Vec<ManifestChunk>) -> Self {
317 self.chunks = Some(chunks);
318 self
319 }
320}
321
322/// One chunk of a [`ManifestEntry`]: an independently BLAKE3-addressed run of
323/// the file's bytes, placed at the running sum of the earlier chunks' sizes.
324#[derive(Debug, Clone, Deserialize)]
325#[non_exhaustive]
326pub struct ManifestChunk {
327 /// The chunk's BLAKE3 hash (`b3:`hex).
328 pub hash: String,
329 /// The chunk's length in bytes. The sizes sum to the entry's size.
330 pub size: u64,
331}
332
333impl ManifestChunk {
334 /// A chunk of `size` bytes with BLAKE3 `hash`.
335 #[must_use]
336 pub fn new(hash: impl Into<String>, size: u64) -> Self {
337 Self {
338 hash: hash.into(),
339 size,
340 }
341 }
342}
343
344/// What a bundle pull pays from.
345#[derive(Debug)]
346#[non_exhaustive]
347pub enum Funding {
348 /// The buyer's own pool: reused from the buyer store, adopted from chain,
349 /// or opened at the working deposit, and topped up when it runs low.
350 SelfOwned {
351 /// The pool owner's key: it signs every pool transaction and the
352 /// capability for the lane key. The vouchers sign under a lane key
353 /// derived from it and the buyer store's salt
354 /// ([`crate::buyer_pool::OwnedPayer::for_store`]), so two stores on one
355 /// owner sign on disjoint lanes.
356 owner: Arc<PrivateKeySigner>,
357 /// How the pool is funded.
358 deposit: DepositPolicy,
359 },
360 /// A pool another owner funds, under the capability the slot holds. The
361 /// pull opens and tops up nothing; when the capability cannot pay for the
362 /// rest, the pull ends [`FundingNeeded`].
363 Delegated {
364 /// The delegate key and the owner-signed capability.
365 slot: CredentialSlot,
366 },
367}
368
369/// Where a pull's entries find their holders: the pinned node, or every
370/// active registered node for each entry to sample. Resolve it with
371/// [`select_nodes`].
372#[derive(Debug, Clone)]
373pub struct NodeSelection {
374 /// The node every entry pulls from, when one is pinned.
375 pinned: Option<PinnedNode>,
376 /// Every active registered node, and whether the list is the
377 /// registry-outage fallback, when none is pinned.
378 registry: Option<engine::Registry>,
379}
380
381/// Resolve where a pull's entries find their holders, once for the run.
382///
383/// With a pinned node every entry pulls from it. Otherwise the run's
384/// candidate list is every active registered node: the peer store's
385/// identity-fresh records when enough of them qualify, else one
386/// `CapacityBond` registry read bounded by
387/// [`DiscoveryOptions::registry_timeout`]. Each entry then probes its own
388/// sample of the list. Resolve the selection before you unlock a key, so a
389/// failed discovery costs no password prompt.
390///
391/// # Errors
392///
393/// No node is pinned and `chain` names no `CapacityBond`, or the registry
394/// read fails with no usable peer store, or the registry lists no node.
395pub async fn select_nodes(
396 chain: &ChainConfig,
397 site: &PullSite,
398 discovery: &DiscoveryOptions,
399) -> anyhow::Result<NodeSelection> {
400 if let Some(pinned) = discovery.pinned {
401 return Ok(NodeSelection {
402 pinned: Some(pinned),
403 registry: None,
404 });
405 }
406 Ok(NodeSelection {
407 pinned: None,
408 registry: Some(engine::discover_candidates(chain, site, discovery).await?),
409 })
410}
411
412/// Where a bundle pull reports its progress. Every method has a default
413/// that does nothing. The pull calls them from the task that awaits it.
414pub trait BundleObserver: Send + Sync {
415 /// The pull starts on the entries it was given. `download_total` is the
416 /// content bytes it plans to pay for, after shared chunks and duplicate
417 /// files count once; `content_total` is the bytes that land on disk.
418 /// Either is `None` when no entry declares a size.
419 fn run_started(&self, download_total: Option<u64>, content_total: Option<u64>) {
420 let _ = (download_total, content_total);
421 }
422
423 /// One blob starts: it lands at every one of `paths`. `download_total` is
424 /// the bytes it plans to pay for, and `reconstruct_total` the bytes it
425 /// plans to splice from disk. The returned observer follows that blob.
426 fn file_started(
427 &self,
428 paths: &[String],
429 download_total: u64,
430 reconstruct_total: u64,
431 ) -> Arc<dyn FileObserver> {
432 let _ = (paths, download_total, reconstruct_total);
433 Arc::new(NoFileObserver)
434 }
435
436 /// One blob landed in its staging file.
437 fn entry_landed(&self, landed: &EntryLanded) {
438 let _ = landed;
439 }
440
441 /// Something went wrong that does not fail the pull, such as a manifest
442 /// cache file that cannot be read or written.
443 fn warning(&self, warning: &str) {
444 let _ = warning;
445 }
446
447 /// A funding recovery step replaced the pool. The pull runs the remaining
448 /// entries once more against the new pool.
449 fn pool_replaced(&self, replaced: &PoolReplaced) {
450 let _ = replaced;
451 }
452}
453
454/// Where one blob of a bundle pull reports its progress. Every method has a
455/// default that does nothing.
456pub trait FileObserver: Send + Sync {
457 /// `received` of the blob's `expected` content bytes are verified. Every
458 /// fetch of the blob reports its whole position.
459 fn delivered(&self, received: u64, expected: u64) {
460 let _ = (received, expected);
461 }
462
463 /// The blob probes its holders before any byte arrives.
464 fn discovering(&self) {}
465
466 /// The blob's own bytes are in, and it waits for a sibling to land the
467 /// chunks it shares.
468 fn pending(&self) {}
469
470 /// The blob is assembled, and its whole-file hash runs.
471 fn reconstructing(&self) {}
472
473 /// The whole-file hash has read `bytes` bytes.
474 fn hashed(&self, bytes: u64) {
475 let _ = bytes;
476 }
477
478 /// The blob's pull settled, landed or failed.
479 fn finished(&self) {}
480}
481
482/// The [`FileObserver`] that ignores everything.
483struct NoFileObserver;
484
485impl FileObserver for NoFileObserver {}
486
487/// One blob that landed ([`BundleObserver::entry_landed`]). No byte counts in
488/// more than one of `paid`, `spliced` and `resumed`.
489#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490#[non_exhaustive]
491pub struct EntryLanded {
492 /// The blob's hash.
493 pub hash: [u8; 32],
494 /// The blob's size.
495 pub size: u64,
496 /// The content bytes this pull fetched and paid for.
497 pub paid: u64,
498 /// The bytes spliced from a file already on disk.
499 pub spliced: u64,
500 /// The bytes an earlier, interrupted pull fetched, which this pull resumed.
501 pub resumed: u64,
502 /// How long the blob took: probing, every fetch, any splice and the
503 /// whole-file check.
504 pub elapsed: Duration,
505}
506
507/// Explains a failed fetch to the caller's user, before the pull records the
508/// error. The `decdn` CLI names its own flags and commands here.
509#[doc(hidden)]
510pub trait FaultHints: Send + Sync {
511 /// `err` with what the user can do about it, given what the failed
512 /// fetch's lanes saw.
513 fn annotate(&self, err: anyhow::Error, at: &FaultContext) -> anyhow::Error;
514}
515
516/// How a bundle pull ended, and what it did.
517#[derive(Debug)]
518#[non_exhaustive]
519pub struct BundleOutcome {
520 /// What every entry did.
521 pub report: BundleReport,
522 /// Why the pull ended.
523 pub end: BundleEnd,
524 /// Every pool replacement this pull's funding recovery steps made, in the
525 /// order they made them. Each closed pool may still hold a balance to
526 /// reclaim once its dispute window ends. A replacement that ended the
527 /// first pass runs the rest again against the new pool. One a sibling
528 /// entry's step made while another entry ended the pull, or one that an
529 /// untracked escrow ended the pull over, runs nothing again, and is still
530 /// listed here. A second replacement, in the pass against the new pool,
531 /// runs nothing again and is listed after the first: the pool the first
532 /// one opened is the one it closed. One that ends that pass ends the pull
533 /// "funding needed". [`BundlePuller::replacements`] also lists a manifest
534 /// fetch's.
535 pub replaced: Vec<PoolReplaced>,
536}
537
538/// Why a bundle pull ended.
539#[derive(Debug)]
540#[non_exhaustive]
541pub enum BundleEnd {
542 /// Every entry settled. Some may have failed
543 /// ([`BundleReport::failures`]); a rerun retries them.
544 Complete,
545 /// The cancel token fired. What landed is recorded, and a rerun resumes.
546 /// The caller names what the puller's handle
547 /// ([`BundlePuller::unconfirmed_escrows`]) still holds: each pending
548 /// `openPool` and `topUp` ([`UnconfirmedEscrows::pending`]) and each
549 /// untracked escrow ([`UnconfirmedEscrows::untracked`]). A rerun before
550 /// those resolve escrows again.
551 Interrupted,
552 /// The pool cannot pay for the rest: the capability is spent, expired or
553 /// revoked, or the delegated pool is unfunded. `error` is the whole
554 /// chain.
555 FundingNeeded {
556 /// What funding the pull needs.
557 needed: FundingNeeded,
558 /// The error the pull ended with.
559 error: anyhow::Error,
560 },
561 /// A fault only the user can fix ended the pull, or it gave up after
562 /// making no progress for its whole limit ([`crate::GaveUp`]). A pull that
563 /// is not interrupted ends here while its handle holds an untracked escrow
564 /// ([`UnconfirmedEscrows::untracked`]) or a pending `openPool` or `topUp`
565 /// ([`UnconfirmedEscrows::pending`]), with an error that names each tx:
566 /// an entry dropped while its receipt read was pending leaves one there.
567 /// This includes every later pull on the same handle.
568 Stopped(anyhow::Error),
569}
570
571/// What a bundle pull did. Counts are per entry, and `fetched`, `linked`,
572/// `skipped`, `reused`, `failed` and `not_run` add up to the manifest's entry
573/// count. Every byte total but
574/// [`Self::paid_bytes`] is content bytes and leaves out the bao proof
575/// overhead.
576#[derive(Debug, Clone, Default, PartialEq, Eq)]
577#[non_exhaustive]
578pub struct BundleReport {
579 /// Entries this pull fetched and paid for, one per distinct blob.
580 pub fetched: u64,
581 /// Entries linked or copied from a blob another path of this pull fetched.
582 pub linked: u64,
583 /// Entries already on disk.
584 pub skipped: u64,
585 /// Entries that failed ([`Self::failures`]).
586 pub failed: u64,
587 /// Entries this pull did not run: another entry ended the pull, or the
588 /// cancel token fired, before their fetch ended. A rerun runs them.
589 pub not_run: u64,
590 /// Distinct blobs linked from a byte-identical file already in the output
591 /// directory, with no download.
592 pub reused: u64,
593 /// The size of those blobs, once each.
594 pub reused_bytes: u64,
595 /// The content bytes this pull fetched and paid for, from entry sizes: a
596 /// blob counts once, less what it spliced or resumed.
597 pub downloaded: u64,
598 /// The lane bytes this pull's vouchers and hash-chain reveals paid for:
599 /// how far it grew the claims the nodes can redeem, summed over every lane
600 /// ([`crate::PoolLedger::paid_bytes`]). These are wire bytes, so they
601 /// include the bao proof overhead. Content delivered twice, or paid for and
602 /// then dropped after a lane fault, counts each time it is paid. An entry
603 /// that fails or is interrupted counts here but not in `downloaded`; a
604 /// later pull resumes its `.partial` and reports it as `resumed_bytes`.
605 pub paid_bytes: u64,
606 /// The bytes written to disk: every landed copy of every blob.
607 pub reconstructed: u64,
608 /// The bytes of `.partial` prefixes earlier pulls fetched, which this pull
609 /// resumed. Never part of `downloaded`.
610 pub resumed_bytes: u64,
611 /// The bytes spliced from files already on disk. Never downloaded.
612 pub spliced_bytes: u64,
613 /// Range-dedup hints a fault dropped: a chunk list that does not parse or
614 /// sum to the file size, or a donor that failed its check.
615 pub hints_ignored: u64,
616 /// Every failed entry, in manifest order.
617 pub failures: Vec<EntryFailure>,
618 /// Lines that do not fail the pull: a blob whose size differs from its
619 /// manifest, or a wallet too short of USDC to top the pool up.
620 pub warnings: Vec<String>,
621}
622
623/// One entry a bundle pull failed to land.
624#[derive(Debug, Clone, Default, PartialEq, Eq)]
625#[non_exhaustive]
626pub struct EntryFailure {
627 /// The entry's path.
628 pub path: String,
629 /// Why, with secrets redacted.
630 pub error: String,
631}
632
633/// Pulls bundles into a directory over one paid run: one pool, one
634/// connection per node, one progress clock.
635///
636/// Build it with [`Self::builder`]. One puller serves one run: a manifest
637/// fetch and one [`Self::pull`], or several pulls that share the run's lanes.
638pub struct BundlePuller {
639 ctx: engine::PullCtx<DynProvider>,
640}
641
642impl std::fmt::Debug for BundlePuller {
643 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
644 f.debug_struct("BundlePuller")
645 .field("chain", &self.ctx.chain)
646 .field("self_address", &self.ctx.self_address)
647 .finish_non_exhaustive()
648 }
649}
650
651/// Builds a [`BundlePuller`] ([`BundlePuller::builder`]).
652#[must_use]
653pub struct BundlePullerBuilder {
654 chain: ChainConfig,
655 site: PullSite,
656 endpoint: Endpoint,
657 store: Arc<dyn BuyerPoolStore>,
658 funding: Funding,
659 discovery: DiscoveryOptions,
660 nodes: NodeSelection,
661 relays: Vec<RelayUrl>,
662 namespace: Option<u64>,
663 jobs: usize,
664 max_lane_streams: usize,
665 max_rate_per_mb: u64,
666 max_blob_bytes: u64,
667 interactive: bool,
668 give_up_after: Option<Duration>,
669 observer: Option<Arc<dyn BundleObserver>>,
670 hints: Option<Arc<dyn FaultHints>>,
671 escrows: UnconfirmedEscrows,
672}
673
674impl std::fmt::Debug for BundlePullerBuilder {
675 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676 f.debug_struct("BundlePullerBuilder")
677 .field("chain", &self.chain)
678 .field("jobs", &self.jobs)
679 .finish_non_exhaustive()
680 }
681}
682
683impl BundlePuller {
684 /// A builder for a pull on `chain`, keeping its peer store and scratch
685 /// files in `site`, dialing from `endpoint`, recording its pool and lanes
686 /// in `store`, and paying from `funding`. `discovery` and `nodes`
687 /// ([`select_nodes`]) say where entries find their holders.
688 pub fn builder(
689 chain: ChainConfig,
690 site: PullSite,
691 endpoint: Endpoint,
692 store: Arc<dyn BuyerPoolStore>,
693 funding: Funding,
694 discovery: DiscoveryOptions,
695 nodes: NodeSelection,
696 ) -> BundlePullerBuilder {
697 BundlePullerBuilder {
698 chain,
699 site,
700 endpoint,
701 store,
702 funding,
703 discovery,
704 nodes,
705 relays: Vec::new(),
706 namespace: None,
707 jobs: 4,
708 max_lane_streams: 4,
709 max_rate_per_mb: 0,
710 max_blob_bytes: 1 << 40,
711 interactive: false,
712 give_up_after: None,
713 observer: None,
714 hints: None,
715 escrows: UnconfirmedEscrows::default(),
716 }
717 }
718
719 /// Fetch the manifest blob `hash`, and cache it in `out_dir`. With
720 /// `use_cache`, a manifest an earlier pull cached there is read instead,
721 /// and nothing is paid; a cache file that cannot be read is reported to
722 /// the observer and fetched over.
723 ///
724 /// # Errors
725 ///
726 /// The fetch fails, or the bytes are not a manifest.
727 pub async fn fetch_manifest(
728 &self,
729 hash: [u8; 32],
730 out_dir: &Path,
731 use_cache: bool,
732 ) -> anyhow::Result<BundleManifest> {
733 Box::pin(self.ctx.fetch_manifest(hash, out_dir, use_cache)).await
734 }
735
736 /// Pull every entry of `manifest` into `out_dir`. With `overwrite`, files
737 /// already on disk are fetched again instead of skipped. A fired `cancel`
738 /// stops every fetch where it is; what landed is recorded, and the next
739 /// pull resumes.
740 ///
741 /// An entry that fails does not stop the others. A fault only the user
742 /// can fix, or the stop policy, ends the pull ([`BundleEnd`]).
743 pub async fn pull(
744 &mut self,
745 manifest: &BundleManifest,
746 out_dir: &Path,
747 overwrite: bool,
748 cancel: &CancellationToken,
749 ) -> BundleOutcome {
750 Box::pin(self.ctx.run(manifest, out_dir, overwrite, cancel)).await
751 }
752
753 /// Every pool replacement this puller's funding recovery steps made, in
754 /// order: its manifest fetches' and its pulls' alike, and a step in a
755 /// pull its caller dropped or cancelled. Each closed pool may still hold
756 /// a balance to reclaim once its dispute window ends. A replacement that
757 /// ended a manifest fetch is in no [`BundleOutcome`].
758 #[must_use]
759 pub fn replacements(&self) -> Vec<PoolReplaced> {
760 self.ctx.recovery.replacements()
761 }
762
763 /// The warning for a wallet seen to hold too little USDC to top the pool
764 /// up during this run, if one was.
765 #[must_use]
766 pub fn funding_shortfall(&self) -> Option<String> {
767 self.ctx.funding.shortfall()
768 }
769
770 /// The handle that holds each `openPool` and `topUp` of the puller that no
771 /// local record credits yet, and each that escrowed with no local record
772 /// crediting it: the one passed to
773 /// [`BundlePullerBuilder::unconfirmed_escrows`], or the puller's own.
774 #[must_use]
775 pub const fn unconfirmed_escrows(&self) -> &UnconfirmedEscrows {
776 self.ctx.funding.escrows()
777 }
778
779 /// Wait for every queued peer-record and watermark write to land. Call it
780 /// before the process exits, after a cancelled pull too, so the next run
781 /// resumes each lane from what it paid.
782 pub async fn finish(self) {
783 self.ctx.writes.settle().await;
784 }
785}
786
787impl BundlePullerBuilder {
788 /// The relays to hint when dialing a node. The first one rides each dial.
789 pub fn relays(mut self, relays: Vec<RelayUrl>) -> Self {
790 self.relays = relays;
791 self
792 }
793
794 /// The namespace (ADR 002) every request carries: a node routes a cache
795 /// miss to that namespace's origins. `None` is the best-effort default.
796 pub const fn namespace(mut self, namespace: Option<u64>) -> Self {
797 self.namespace = namespace;
798 self
799 }
800
801 /// How many blobs fetch at once (at least 1; 4 by default).
802 pub const fn jobs(mut self, jobs: usize) -> Self {
803 self.jobs = jobs;
804 self
805 }
806
807 /// How many streams one provider's lane runs at once across the whole
808 /// pull (at least 1; 4 by default).
809 pub const fn max_lane_streams(mut self, n: usize) -> Self {
810 self.max_lane_streams = n;
811 self
812 }
813
814 /// The highest per-MB rate a lane accepts; `0`, the default, sets no
815 /// ceiling.
816 pub const fn max_rate_per_mb(mut self, rate: u64) -> Self {
817 self.max_rate_per_mb = rate;
818 self
819 }
820
821 /// The largest blob one entry fetches (1 TiB by default).
822 pub const fn max_blob_bytes(mut self, bytes: u64) -> Self {
823 self.max_blob_bytes = bytes;
824 self
825 }
826
827 /// When the pull gives up after making no progress
828 /// ([`crate::StopPolicy`]): an `interactive` pull waits for its caller to
829 /// cancel, and any other stops after 10 minutes. `give_up_after`
830 /// overrides both. Non-interactive by default.
831 pub const fn stop(mut self, interactive: bool, give_up_after: Option<Duration>) -> Self {
832 self.interactive = interactive;
833 self.give_up_after = give_up_after;
834 self
835 }
836
837 /// Where the pull reports its progress. None by default.
838 pub fn observer(mut self, observer: Arc<dyn BundleObserver>) -> Self {
839 self.observer = Some(observer);
840 self
841 }
842
843 /// Where the pull holds each `openPool` and `topUp` that no local record
844 /// credits yet ([`UnconfirmedEscrows::pending`]), across every pass. A
845 /// cancel or a fault that ends the pull can drop a receipt wait, and the
846 /// transaction may still escrow. A pull that is not interrupted ends
847 /// [`BundleEnd::Stopped`] on each one, and the caller keeps a clone to
848 /// name what is left on [`BundleEnd::Interrupted`]. A `topUp` the handle
849 /// holds also refuses every later `topUp` of the pull, across passes. The
850 /// handle also holds each `openPool` and `topUp` that escrowed with no
851 /// local record crediting it ([`UnconfirmedEscrows::untracked`]). It
852 /// refuses every later `openPool` and `topUp`, ends every later pull on
853 /// the handle [`BundleEnd::Stopped`], and is the caller's to name on
854 /// [`BundleEnd::Interrupted`]. A private handle by default
855 /// ([`BundlePuller::unconfirmed_escrows`]).
856 pub fn unconfirmed_escrows(mut self, escrows: UnconfirmedEscrows) -> Self {
857 self.escrows = escrows;
858 self
859 }
860
861 /// What explains a failed fetch to the caller's user.
862 #[doc(hidden)]
863 pub fn fault_hints(mut self, hints: Arc<dyn FaultHints>) -> Self {
864 self.hints = Some(hints);
865 self
866 }
867
868 /// Build the puller: connect to the RPC endpoint and read the pool's
869 /// settlement token.
870 ///
871 /// # Errors
872 ///
873 /// The RPC URL is malformed, the capability does not decode, the buyer
874 /// store cannot read or record its lane salt (a self-owned run), or the
875 /// `PaymentPool` token read fails.
876 pub async fn build(self) -> anyhow::Result<BundlePuller> {
877 let (signer, payer, deposit) = match self.funding {
878 Funding::SelfOwned { owner, deposit } => {
879 let payer = OwnedPayer::for_store(Arc::clone(&owner), self.store.as_ref())?;
880 (owner, engine::RunPayer::Owned(payer), deposit)
881 }
882 Funding::Delegated { slot } => {
883 // A delegate owns no pool, so it funds and adopts none. Its
884 // lanes sign under the slot's credential as each is built; the
885 // provider below sends no transaction.
886 let deposit = DepositPolicy {
887 working_deposit: U256::ZERO,
888 max_approve: false,
889 adoption: ChainAdoption::Refused,
890 };
891 (
892 slot.current().signer,
893 engine::RunPayer::Delegated(slot),
894 deposit,
895 )
896 }
897 };
898 let rpc = crate::provider::build_provider(&self.chain.rpc_url, &signer)?.erased();
899 let contract = PaymentPool::new(self.chain.payment_pool, rpc.clone());
900 let token = contract
901 .usdc()
902 .call()
903 .await
904 .map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()"))?;
905 let ctx = engine::PullCtx {
906 voucher_dom: voucher_domain(self.chain.chain_id, self.chain.payment_pool),
907 slash_dom: slash_judge_domain(self.chain.chain_id, self.chain.slash_judge),
908 connections: Connections::new(self.endpoint.clone()),
909 self_address: signer.address(),
910 endpoint: self.endpoint,
911 store: self.store,
912 contract,
913 rpc,
914 payer,
915 token,
916 chain: self.chain,
917 site: self.site,
918 relays: self.relays,
919 nodes: self.nodes,
920 discovery: self.discovery,
921 max_rate_per_mb: self.max_rate_per_mb,
922 max_blob_bytes: self.max_blob_bytes,
923 deposit,
924 namespace_id: namespace_id(self.namespace),
925 ledgers: LaneLedgers::new(),
926 funding: RunFunding::reporting_escrows_to(self.escrows),
927 recovery: Arc::new(RecoveryGate::new()),
928 dedup_stats: engine::DedupStats::default(),
929 open_lock: tokio::sync::Mutex::new(()),
930 gate: engine::JobGate::new(self.jobs.max(1)),
931 lane_cap: LaneStreamCap::new(self.max_lane_streams),
932 health: Arc::new(PeerHealth::default()),
933 writes: OrderedWrites::default(),
934 // One clock for the whole run: a stuck entry waits while another
935 // lands bytes.
936 stop: StopPolicy::new(
937 self.interactive,
938 self.give_up_after,
939 Arc::new(ProgressClock::new()),
940 ),
941 demand: Arc::new(RunDemand::default()),
942 observer: self.observer,
943 hints: self.hints,
944 };
945 Ok(BundlePuller { ctx })
946 }
947}
948
949/// The namespace id (ADR 002) every paid pull in the run carries: `namespace`
950/// as a big-endian `uint256`, or `NO_NAMESPACE` when it is `None`.
951fn namespace_id(namespace: Option<u64>) -> [u8; 32] {
952 namespace.map_or(decdn_protocol::client::NO_NAMESPACE, |n| {
953 U256::from(n).to_be_bytes()
954 })
955}