use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::IsTerminal as _;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use alloy::dyn_abi::Eip712Domain;
use alloy::primitives::Address;
use alloy::providers::Provider;
use alloy::signers::local::PrivateKeySigner;
use anyhow::{Context as _, anyhow, bail};
use decdn_common::cli::{BundlePullArgs, ClientFetchArgs};
use decdn_common::config::load_file_config;
use decdn_common::redact::sanitize_err_chain;
use decdn_incentive::buyer_pool_redb::RedbBuyerPoolStore;
use decdn_incentive::eth_identity::{PasswordUse, load_signer};
use decdn_incentive::payment_pool::PaymentPool;
use decdn_incentive::{slash_judge_domain, voucher_domain};
use futures_util::StreamExt as _;
use iroh::{Endpoint, PublicKey, RelayUrl};
use serde::{Deserialize, Serialize};
use super::bundle_cache;
use super::bundle_manifest::{self, SavedManifest, SavedMtime};
use super::buyer_store::open_client_store_for_buy;
use super::chain_ctx;
use super::fetch;
use super::interrupt::{Interrupt, Interrupted};
use super::manifest::build_glob_set;
use super::ordered_writes::OrderedWrites;
use super::pull_progress::{self, PullProgress};
use decdn_bao_range::CHUNK_GROUP_BYTES;
use decdn_client::discovery::{self, NodeCandidate};
use decdn_client::endpoint as client_endpoint;
use decdn_client::provider;
use decdn_client::{
ClientRangedStore, Connections, DownloadTarget, Downloader, LaneLedgers, PeerHealth,
PoolReplaced, ProgressCallback, ProgressClock, PullDeadlines, RecoveryGate, StopPolicy,
};
use super::cli_sources::CliSources;
type FetchTarget = (PublicKey, Address);
fn group_by_hash<'a>(entries: &[&'a ManifestEntry]) -> Vec<HashGroup<'a>> {
let mut index: HashMap<&str, usize> = HashMap::new();
let mut groups: Vec<HashGroup<'a>> = Vec::new();
for entry in entries {
let next = groups.len();
let at = *index.entry(entry.hash.as_str()).or_insert(next);
if at == next {
groups.push(HashGroup {
hash: entry.hash.as_str(),
entries: vec![*entry],
});
} else if let Some(group) = groups.get_mut(at) {
group.entries.push(*entry);
}
}
groups
}
fn order_groups_smallest_first(mut groups: Vec<HashGroup<'_>>) -> Vec<HashGroup<'_>> {
groups.sort_by(|a, b| {
let a_size = a.entries.iter().find_map(|e| e.size);
let b_size = b.entries.iter().find_map(|e| e.size);
let key = |s: Option<u64>| s.unwrap_or(u64::MAX);
key(a_size)
.cmp(&key(b_size))
.then_with(|| a.hash.cmp(b.hash))
});
groups
}
async fn off_runtime<T, F>(what: &'static str, f: F) -> anyhow::Result<T>
where
T: Send + 'static,
F: FnOnce() -> anyhow::Result<T> + Send + 'static,
{
tokio::task::spawn_blocking(f)
.await
.map_err(|e| anyhow!("{what} task: {e}"))?
}
async fn link_off_runtime(src: PathBuf, dest: PathBuf) -> anyhow::Result<()> {
off_runtime("link", move || link_or_copy_atomic(&src, &dest)).await
}
fn link_or_copy_atomic(src: &Path, dest: &Path) -> anyhow::Result<()> {
let parent = dest
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
let staged = tempfile::Builder::new()
.prefix(".decdn-link-")
.make_in(parent, |candidate| {
match std::fs::hard_link(src, candidate) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
Err(_) => std::fs::copy(src, candidate).map(|_| ()),
}
})
.with_context(|| {
format!(
"stage link/copy of {} for {}",
src.display(),
dest.display()
)
})?;
staged
.persist(dest)
.map_err(|e| e.error)
.with_context(|| format!("write {}", dest.display()))?;
Ok(())
}
struct SeedDonor {
hash: [u8; 32],
source: PathBuf,
offset: u64,
len: u64,
}
#[derive(Default)]
struct DiskState {
skip: HashSet<String>,
seed: Vec<SeedDonor>,
whole_file: HashMap<[u8; 32], PathBuf>,
}
async fn resolve_disk_state(
entries: &[ManifestEntry],
saved: &SavedManifest,
out_root: &Path,
overwrite: bool,
) -> DiskState {
let mut state = DiskState::default();
if overwrite {
return state;
}
for en in entries {
let Ok(dest) = safe_join(out_root, &en.path) else {
continue; };
if dest.starts_with(out_root.join(STAGING_DIR)) {
continue;
}
let Ok(meta) = std::fs::symlink_metadata(&dest) else {
continue; };
if !meta.is_file() {
continue; }
let fast = saved.get(&en.path).is_some_and(|rec| {
rec.hash == en.hash
&& rec.size == meta.len()
&& SavedMtime::of(&meta).as_ref() == Some(&rec.mtime)
});
if fast {
state.skip.insert(en.path.clone());
seed_new_chunks(&mut state, en, &dest);
continue;
}
let Ok(want) = fetch::parse_hash(&en.hash) else {
continue;
};
let dest_buf = dest.clone();
let got = tokio::task::spawn_blocking(move || hash_partial(&dest_buf)).await;
if let Ok(Ok(got)) = got
&& got == want
{
state.skip.insert(en.path.clone());
seed_new_chunks(&mut state, en, &dest);
continue;
}
if let Some(rec) = saved.get(&en.path)
&& let Some(old) = bundle_manifest::saved_hints(rec)
{
for (chash, offset, len) in old {
if let Ok(h) = fetch::parse_hash(&chash) {
state.seed.push(SeedDonor {
hash: h,
source: dest.clone(),
offset,
len,
});
}
}
}
}
let will_write: HashSet<&str> = entries
.iter()
.map(|e| e.path.as_str())
.filter(|p| !state.skip.contains(*p))
.collect();
for (path, rec) in saved.records() {
let Ok(src) = safe_join(out_root, path) else {
continue;
};
if src.starts_with(out_root.join(STAGING_DIR)) {
continue;
}
let Ok(meta) = std::fs::symlink_metadata(&src) else {
continue; };
if !meta.is_file() {
continue; }
if !will_write.contains(path)
&& let Ok(h) = fetch::parse_hash(&rec.hash)
{
state.whole_file.entry(h).or_insert_with(|| src.clone());
}
if let Some(hints) = bundle_manifest::saved_hints(rec) {
for (chash, offset, len) in hints {
if let Ok(h) = fetch::parse_hash(&chash) {
state.seed.push(SeedDonor {
hash: h,
source: src.clone(),
offset,
len,
});
}
}
}
}
state
}
fn seed_new_chunks(state: &mut DiskState, en: &ManifestEntry, dest: &Path) {
if let Some(hints) = hints_of(en) {
for h in hints {
state.seed.push(SeedDonor {
hash: h.hash,
source: dest.to_path_buf(),
offset: h.offset,
len: h.len,
});
}
}
}
fn plan_slots<'a>(
entries: &[&'a ManifestEntry],
out_root: &Path,
overwrite: bool,
skip: &HashSet<String>,
) -> Vec<Slot<'a>> {
entries
.iter()
.map(|en| match safe_join(out_root, &en.path) {
Err(e) => Slot::Failed(EntryOutcome::failed(&en.path, &e)),
Ok(dest) if dest.starts_with(out_root.join(STAGING_DIR)) => {
Slot::Failed(EntryOutcome::failed(
&en.path,
&anyhow::anyhow!(
"manifest path {:?} is inside the reserved staging directory {STAGING_DIR}/",
en.path
),
))
}
Ok(_) if !overwrite && skip.contains(en.path.as_str()) => Slot::Skip,
Ok(dest) => Slot::Write {
label: en.path.as_str(),
dest,
},
})
.collect()
}
async fn materialize_group<MF, MFut, LF, LFut>(
slots: Vec<Slot<'_>>,
mut materialize: MF,
mut link: LF,
) -> Vec<EntryOutcome>
where
MF: FnMut(PathBuf) -> MFut,
MFut: std::future::Future<Output = anyhow::Result<u64>>,
LF: FnMut(PathBuf, PathBuf) -> LFut,
LFut: std::future::Future<Output = anyhow::Result<()>>,
{
let mut canonical: Option<PathBuf> = None;
let mut outcomes = Vec::with_capacity(slots.len());
for slot in slots {
let outcome = match slot {
Slot::Failed(o) => o,
Slot::Skip => EntryOutcome::Skipped,
Slot::Write { label, dest } => match &canonical {
Some(src) => match link(src.clone(), dest).await {
Ok(()) => EntryOutcome::Linked,
Err(e) => EntryOutcome::failed(label, &e),
},
None => match materialize(dest.clone()).await {
Ok(n) => {
canonical = Some(dest);
EntryOutcome::Fetched(n)
}
Err(e) => EntryOutcome::failed(label, &e),
},
},
};
outcomes.push(outcome);
}
outcomes
}
async fn verified_donor_len(donor: &Path, hash: [u8; 32]) -> Option<u64> {
let target = donor.to_path_buf();
tokio::task::spawn_blocking(move || {
let got = hash_partial(&target).ok()?;
(got == hash)
.then(|| std::fs::metadata(&target).ok())
.flatten()
.map(|m| m.len())
})
.await
.unwrap_or(None)
}
async fn materialize_from_donor(
slots: Vec<Slot<'_>>,
donor: &Path,
size: u64,
) -> Vec<EntryOutcome> {
materialize_group(
slots,
|dest| {
let donor = donor.to_path_buf();
async move { link_off_runtime(donor, dest).await.map(|()| size) }
},
link_off_runtime,
)
.await
.into_iter()
.map(|o| match o {
EntryOutcome::Fetched(n) => EntryOutcome::Deduped(n),
other => other,
})
.collect()
}
#[derive(Debug, Deserialize)]
struct Manifest {
version: u32,
entries: Vec<ManifestEntry>,
}
#[derive(Debug)]
struct Kept {
manifest: Manifest,
excluded: u64,
}
#[derive(Debug, Deserialize)]
struct ManifestEntry {
path: String,
hash: String,
#[serde(default)]
size: Option<u64>,
#[serde(default)]
chunks: Option<Vec<ManifestChunk>>,
}
#[derive(Debug, Deserialize)]
struct ManifestChunk {
hash: String,
size: u64,
}
fn total_content_bytes(entries: &[ManifestEntry]) -> Option<u64> {
let mut by_hash: HashMap<&str, Option<u64>> = HashMap::new();
for entry in entries {
let slot = by_hash.entry(entry.hash.as_str()).or_insert(None);
*slot = slot.or(entry.size);
}
let mut sum: u64 = 0;
let mut any_sized = false;
for size in by_hash.values().flatten() {
sum = sum.saturating_add(*size);
any_sized = true;
}
any_sized.then_some(sum)
}
fn blob_download_reconstruct(
group: &HashGroup<'_>,
fetch_plan: &FetchPlan,
index: &HashMap<[u8; 32], MaterializedRange>,
) -> (u64, u64) {
let Some(total) = group.entries.iter().find_map(|e| e.size) else {
return (0, 0);
};
let hints = group.entries.first().and_then(|e| hints_of(e));
let whole = group
.entries
.first()
.and_then(|e| fetch::parse_hash(&e.hash).ok());
let download = match (hints, whole) {
(Some(hints), Some(whole)) => plan_reassembly(&hints, index, fetch_plan, whole, total)
.drive
.iter()
.map(|r| r.1)
.fold(0u64, u64::saturating_add),
_ => total,
};
(download, total.saturating_sub(download))
}
fn run_label(output: &Path) -> String {
output.file_name().map_or_else(
|| output.to_string_lossy().into_owned(),
|n| n.to_string_lossy().into_owned(),
)
}
fn download_bytes(entries: &[ManifestEntry], fetch_plan: &FetchPlan) -> Option<u64> {
let refs: Vec<&ManifestEntry> = entries.iter().collect();
let empty = HashMap::new();
let mut sum = 0u64;
let mut any = false;
for group in group_by_hash(&refs) {
if group.entries.iter().find_map(|e| e.size).is_none() {
continue;
}
any = true;
let (download, _) = blob_download_reconstruct(&group, fetch_plan, &empty);
sum = sum.saturating_add(download);
}
any.then_some(sum)
}
#[derive(Debug)]
struct EntryFilter {
include: globset::GlobSet,
include_patterns: Vec<String>,
exclude: globset::GlobSet,
exclude_patterns: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Unmatched {
flag: &'static str,
pattern: String,
suggestion: Option<String>,
}
impl Unmatched {
fn warning(&self) -> String {
let hint = self
.suggestion
.as_ref()
.map_or_else(String::new, |suggestion| {
format!(
" (a pattern matches the whole path from the bundle root; did you mean \
'{suggestion}'?)"
)
});
format!("{} '{}' matched no entries{hint}", self.flag, self.pattern)
}
}
#[derive(Debug)]
struct FilterPass {
kept: Vec<ManifestEntry>,
excluded: u64,
unmatched: Vec<Unmatched>,
}
impl EntryFilter {
fn compile(include: &[String], exclude: &[String]) -> anyhow::Result<Self> {
Ok(Self {
include: build_glob_set(include, "--include")?,
include_patterns: include.to_vec(),
exclude: build_glob_set(exclude, "--exclude")?,
exclude_patterns: exclude.to_vec(),
})
}
fn apply_reporting(&self, entries: Vec<ManifestEntry>) -> FilterPass {
let mut include_hits = vec![false; self.include_patterns.len()];
let mut exclude_hits = vec![false; self.exclude_patterns.len()];
let keep: Vec<bool> = entries
.iter()
.map(|e| {
let p = Path::new(&e.path);
let included = mark_hits(&self.include, p, &mut include_hits);
let excluded = mark_hits(&self.exclude, p, &mut exclude_hits);
(self.include_patterns.is_empty() || included) && !excluded
})
.collect();
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
let unmatched = unmatched_patterns("--include", &self.include_patterns, &include_hits)
.chain(unmatched_patterns(
"--exclude",
&self.exclude_patterns,
&exclude_hits,
))
.map(|mut u| {
u.suggestion = anywhere_suggestion(&u.pattern, &paths);
u
})
.collect();
let raw = entries.len();
let kept: Vec<ManifestEntry> = entries
.into_iter()
.zip(keep)
.filter_map(|(e, keep)| keep.then_some(e))
.collect();
let excluded = u64::try_from(raw.saturating_sub(kept.len())).unwrap_or(u64::MAX);
FilterPass {
kept,
excluded,
unmatched,
}
}
fn apply_and_warn(&self, entries: Vec<ManifestEntry>) -> (Vec<ManifestEntry>, u64) {
let pass = self.apply_reporting(entries);
for u in &pass.unmatched {
eprintln!("warning: {}", u.warning());
}
(pass.kept, pass.excluded)
}
}
fn mark_hits(set: &globset::GlobSet, path: &Path, hits: &mut [bool]) -> bool {
let matched = set.matches(path);
for i in &matched {
if let Some(hit) = hits.get_mut(*i) {
*hit = true;
}
}
!matched.is_empty()
}
fn unmatched_patterns<'a>(
flag: &'static str,
patterns: &'a [String],
hits: &'a [bool],
) -> impl Iterator<Item = Unmatched> + 'a {
patterns
.iter()
.zip(hits)
.filter(|(_, hit)| !**hit)
.map(move |(pattern, _)| Unmatched {
flag,
pattern: pattern.clone(),
suggestion: None,
})
}
fn anywhere_suggestion(pattern: &str, paths: &[&str]) -> Option<String> {
if pattern.starts_with("**") || pattern.starts_with('/') {
return None;
}
let anywhere = format!("**/{pattern}");
let set = build_glob_set(std::slice::from_ref(&anywhere), "suggestion").ok()?;
paths
.iter()
.any(|p| set.is_match(Path::new(p)))
.then_some(anywhere)
}
const SELECT_HEADER: &str = "\
# decdn bundle pull --select — choose which files to pull.
#
# Comment out (prefix with #) or delete any line to SKIP that file.
# Save and exit to pull every file still listed below; delete them all to pull
# nothing. Reordering has no effect, and you cannot add files here.
#
# Each line is <path>\t<size> — only the path (before the tab) is read.
";
fn render_selection(entries: &[ManifestEntry]) -> String {
let mut out = String::from(SELECT_HEADER);
for e in entries {
let size = e.size.map_or_else(|| "?".to_string(), human_bytes);
out.push_str(&e.path);
out.push('\t');
out.push_str(&size);
out.push('\n');
}
out
}
fn check_selectable(entries: &[ManifestEntry]) -> anyhow::Result<()> {
if let Some(bad) = entries.iter().find(|e| {
let p = e.path.as_str();
p != p.trim() || p.trim_start().starts_with('#') || p.contains(['\t', '\n', '\r'])
}) {
bail!(
"--select cannot represent the path {:?} (paths with leading/trailing \
whitespace, a leading '#', or a tab/newline are unsupported); use \
--include/--exclude instead",
bad.path
);
}
Ok(())
}
fn parse_selection(
edited: &str,
entries: Vec<ManifestEntry>,
) -> anyhow::Result<Vec<ManifestEntry>> {
let known: HashSet<&str> = entries.iter().map(|e| e.path.as_str()).collect();
let mut kept: HashSet<String> = HashSet::new();
let mut unknown: Vec<&str> = Vec::new();
for line in edited.lines() {
if line.trim_start().is_empty() || line.trim_start().starts_with('#') {
continue;
}
let path = line.split_once('\t').map_or(line, |(p, _)| p).trim();
if path.is_empty() {
continue;
}
if known.contains(path) {
kept.insert(path.to_string());
} else {
unknown.push(path);
}
}
if !unknown.is_empty() {
bail!(
"--select: these lines match no bundle entry (editing only removes files, \
it cannot add them): {}",
unknown.join(", ")
);
}
Ok(entries
.into_iter()
.filter(|e| kept.contains(&e.path))
.collect())
}
fn maybe_select(args: &BundlePullArgs, mut kept: Kept) -> anyhow::Result<Option<Kept>> {
if args.select {
kept = apply_selection(kept, select_entries)?;
if kept.manifest.entries.is_empty() {
report_nothing_to_fetch(NothingReason::Deselected);
warn_leftover_partials(&args.output, &[], args.hash.as_deref());
return Ok(None);
}
}
Ok(Some(kept))
}
fn apply_selection(
mut kept: Kept,
select: impl FnOnce(Vec<ManifestEntry>) -> anyhow::Result<Vec<ManifestEntry>>,
) -> anyhow::Result<Kept> {
let offered = kept.manifest.entries.len();
kept.manifest.entries = select(kept.manifest.entries)?;
let dropped = offered.saturating_sub(kept.manifest.entries.len());
kept.excluded = kept
.excluded
.saturating_add(u64::try_from(dropped).unwrap_or(u64::MAX));
Ok(kept)
}
fn check_select_flags(args: &BundlePullArgs) -> anyhow::Result<()> {
if !args.select {
return Ok(());
}
if args.json {
bail!("--select cannot be combined with --json (it needs an interactive editor)");
}
if args.dry_run {
bail!("--select cannot be combined with --dry-run (it needs an interactive editor)");
}
if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
bail!("--select needs an interactive terminal; use --include/--exclude instead");
}
Ok(())
}
fn select_entries(entries: Vec<ManifestEntry>) -> anyhow::Result<Vec<ManifestEntry>> {
check_selectable(&entries)?;
let edited = edit_in_editor(&render_selection(&entries))?;
parse_selection(&edited, entries)
}
fn editor_command(visual: Option<&str>, editor: Option<&str>) -> Vec<String> {
let chosen = [visual, editor]
.into_iter()
.flatten()
.map(str::trim)
.find(|s| !s.is_empty())
.unwrap_or("vi");
chosen.split_whitespace().map(String::from).collect()
}
fn edit_in_editor(buffer: &str) -> anyhow::Result<String> {
let mut file = tempfile::Builder::new()
.prefix("decdn-select-")
.suffix(".txt")
.tempfile()
.context("create --select scratch file")?;
std::io::Write::write_all(file.as_file_mut(), buffer.as_bytes())
.context("write --select scratch file")?;
file.as_file()
.sync_all()
.context("sync --select scratch file")?;
let command = editor_command(
std::env::var("VISUAL").ok().as_deref(),
std::env::var("EDITOR").ok().as_deref(),
);
let (program, rest) = command
.split_first()
.ok_or_else(|| anyhow!("empty editor command"))?;
let status = std::process::Command::new(program)
.args(rest)
.arg(file.path())
.status()
.with_context(|| format!("launch editor {program:?}"))?;
if !status.success() {
bail!("editor {program:?} exited with {status}; aborting --select");
}
std::fs::read_to_string(file.path()).context("read back --select scratch file")
}
#[derive(Clone)]
struct HashGroup<'a> {
hash: &'a str,
entries: Vec<&'a ManifestEntry>,
}
enum Slot<'a> {
Failed(EntryOutcome),
Skip,
Write { label: &'a str, dest: PathBuf },
}
#[derive(Clone)]
enum EntryOutcome {
Fetched(u64),
Linked,
Skipped,
Deduped(u64),
Failed {
path: String,
err: String,
},
}
struct GroupRun {
outcomes: Vec<EntryOutcome>,
bytes: Option<EntryBytes>,
stop: Option<anyhow::Error>,
}
impl GroupRun {
const fn done(outcomes: Vec<EntryOutcome>) -> Self {
Self {
outcomes,
bytes: None,
stop: None,
}
}
const fn landed(outcomes: Vec<EntryOutcome>, bytes: EntryBytes) -> Self {
Self {
outcomes,
bytes: Some(bytes),
stop: None,
}
}
fn fetch_failed(slots: Vec<Slot<'_>>, err: anyhow::Error) -> Self {
Self {
outcomes: fail_all(slots, &err),
bytes: None,
stop: ends_the_pull(&err).then_some(err),
}
}
}
fn entry_scope(err: &anyhow::Error) -> decdn_client::FatalScope {
match decdn_client::classify(err) {
decdn_client::Fault::Fatal(scope) => scope,
_ => decdn_client::FatalScope::Item,
}
}
fn ends_the_pull(err: &anyhow::Error) -> bool {
entry_scope(err) == decdn_client::FatalScope::Command
|| err.downcast_ref::<decdn_client::GaveUp>().is_some()
}
#[derive(Serialize)]
struct PullReport {
output: String,
fetched: u64,
linked: u64,
skipped: u64,
excluded: u64,
failed: u64,
downloaded: u64,
reconstructed: u64,
spliced_bytes: u64,
hints_ignored: u64,
resumed_bytes: u64,
reused: u64,
reused_bytes: u64,
}
#[derive(Clone, Copy, Default)]
struct DedupSummary {
spliced_bytes: u64,
hints_ignored: u64,
}
#[derive(Clone, Copy, Default, Debug)]
struct DedupOutcome {
spliced_bytes: u64,
resumed_bytes: u64,
hints_ignored: u64,
}
impl DedupOutcome {
fn of(spliced: &[(u64, u64)], resumed: &[(u64, u64)], total: u64, hints_ignored: u64) -> Self {
Self {
spliced_bytes: span_bytes(spliced),
resumed_bytes: span_bytes(&uncovered_runs(resumed, spliced, total)),
hints_ignored,
}
}
const fn refetched_if(self, refetched: bool) -> Self {
if refetched {
Self {
spliced_bytes: 0,
resumed_bytes: 0,
hints_ignored: self.hints_ignored,
}
} else {
self
}
}
}
#[derive(Default)]
struct DedupStats {
spliced_bytes: AtomicU64,
hints_ignored: AtomicU64,
}
#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
struct Transfer {
downloaded: u64,
reconstructed: u64,
resumed: u64,
}
impl Transfer {
const fn add(self, other: Transfer) -> Transfer {
Transfer {
downloaded: self.downloaded.saturating_add(other.downloaded),
reconstructed: self.reconstructed.saturating_add(other.reconstructed),
resumed: self.resumed.saturating_add(other.resumed),
}
}
}
async fn discover_candidates(
chain: &fetch::ResolvedChain,
common: &ClientFetchArgs,
) -> anyhow::Result<Vec<discovery::NodeCandidate>> {
let capacity_bond = chain.capacity_bond.ok_or_else(|| {
anyhow!(
"auto-discovery needs capacity_bond_address (--capacity-bond-address or \
blockchain.capacity_bond_address), or pass --node-id to pull from one node"
)
})?;
if !common.rediscover
&& let Some(cached) = cached_registry(&chain.data_dir, fetch::now_secs_cli())
{
return Ok(cached);
}
let registry_cap = common.discovery_cap();
let bootstrap =
discovery::bootstrap_nodes(&chain.rpc_url, capacity_bond, &chain.data_dir, registry_cap)
.await?;
if let Some(warning) = bootstrap.warning() {
tracing::warn!("{warning}");
}
let all = bootstrap.into_peers();
if all.is_empty() {
bail!("no active nodes in the CapacityBond registry at {capacity_bond}");
}
Ok(all)
}
fn cached_registry(data_dir: &Path, now_secs: u64) -> Option<Vec<NodeCandidate>> {
let store = decdn_client::PeerStore::open(data_dir);
let cfg = decdn_client::StoreConfig::default();
let cached = fetch::identity_fresh_candidates(&store, &cfg, now_secs);
(cached.len() >= cfg.min_fresh_candidates).then_some(cached)
}
fn entry_candidates(registry: &[NodeCandidate], region: Option<&str>) -> Vec<NodeCandidate> {
discovery::select_candidates(registry.to_vec(), region, discovery::SELECT_K)
}
fn load_buyer_signer(
chain: &fetch::ResolvedChain,
) -> anyhow::Result<(Arc<PrivateKeySigner>, Address)> {
let password = super::chain_ctx::read_keystore_password(
&super::chain_ctx::password_sources(
chain.keystore_password_file.as_deref(),
PasswordUse::Unlock,
),
"eth keystore password",
)?
.into_secret();
let signer = Arc::new(load_signer(&chain.keystore, &password)?);
let self_address = signer.address();
Ok((signer, self_address))
}
struct Selection {
explicit: Option<FetchTarget>,
registry: Option<Vec<NodeCandidate>>,
signer: Arc<PrivateKeySigner>,
self_address: Address,
}
async fn resolve_selection(
common: &ClientFetchArgs,
chain: &fetch::ResolvedChain,
) -> anyhow::Result<Selection> {
let explicit = explicit_target(common)?;
let registry = match explicit {
Some(_) => None,
None => Some(discover_candidates(chain, common).await?),
};
let (signer, self_address) = load_buyer_signer(chain)?;
Ok(Selection {
explicit,
registry,
signer,
self_address,
})
}
pub async fn bundle_pull(args: &BundlePullArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
args.common.validate()?;
let filter = EntryFilter::compile(&args.include, &args.exclude)?;
let filters_given = !args.include.is_empty() || !args.exclude.is_empty();
check_select_flags(args)?;
if args.dry_run {
return dry_run(args, &filter, filters_given);
}
let local_manifest = match &args.input {
Some(path) => {
let mut m = read_local_manifest(path)?;
let raw_empty = m.entries.is_empty();
let (entries, excluded) = filter.apply_and_warn(m.entries);
m.entries = entries;
if m.entries.is_empty() {
report_nothing_to_fetch(NothingReason::from_filter(filters_given, raw_empty));
warn_leftover_partials(&args.output, &[], None);
return Ok(());
}
Some(Kept {
manifest: m,
excluded,
})
}
None => None,
};
let common = &args.common;
let relays = client_endpoint::resolve_relays(common.relay_url.as_deref(), config_path)?;
let disc = client_endpoint::client_discovery(config_path)?;
let file = load_file_config(config_path)?;
let mut chain = fetch::resolve_chain(common, &file)?;
let grant = fetch::resolve_delegation_grant(common, &mut chain)?;
let store = Arc::new(open_client_store_for_buy(
&chain.data_dir,
chain.data_dir_source,
"pull",
)?);
let endpoint = client_endpoint::client_endpoint(&relays, &disc).await?;
let writes = OrderedWrites::default();
let result = pull_over(
args,
&chain,
grant,
(&store, &writes),
&endpoint,
&relays,
(&filter, filters_given),
local_manifest,
)
.await;
writes.settle().await;
endpoint.close().await;
result
}
#[allow(clippy::too_many_arguments)]
async fn pull_over(
args: &BundlePullArgs,
chain: &fetch::ResolvedChain,
grant: Option<decdn_incentive::CapabilityGrant>,
(store, writes): (&Arc<RedbBuyerPoolStore>, &OrderedWrites),
endpoint: &Endpoint,
relays: &[RelayUrl],
(filter, filters_given): (&EntryFilter, bool),
local_manifest: Option<Kept>,
) -> anyhow::Result<()> {
let common = &args.common;
let Selection {
explicit,
registry,
signer,
self_address,
} = resolve_selection(common, chain).await?;
let rpc = provider::build_provider(&chain.rpc_url, &signer)?;
let contract = PaymentPool::new(chain.payment_pool, rpc.clone());
let voucher_dom = voucher_domain(chain.chain_id, chain.payment_pool);
let slash_dom = slash_judge_domain(chain.chain_id, chain.slash_judge);
let token = contract
.usdc()
.call()
.await
.map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()"))?;
let namespace_id = namespace_id(args.namespace);
let ctx = PullCtx {
endpoint,
store,
contract: &contract,
rpc: &rpc,
signer: &signer,
self_address,
token,
voucher_dom: &voucher_dom,
slash_dom: &slash_dom,
chain,
relays,
explicit,
registry,
common,
namespace_id,
grant,
ledgers: LaneLedgers::new(),
funding: fetch::RunFunding::default(),
recovery: Arc::new(RecoveryGate::new()),
dedup_stats: DedupStats::default(),
open_lock: tokio::sync::Mutex::new(()),
gate: JobGate::new(args.jobs.max(1)),
lane_cap: LaneStreamCap::new(usize::from(args.max_lane_streams)),
health: Arc::new(PeerHealth::default()),
connections: Connections::new(endpoint.clone()),
writes,
stop: StopPolicy::new(
std::io::stderr().is_terminal(),
common.give_up_after(),
Arc::new(ProgressClock::new()),
),
progress: PullProgress::disabled(),
};
pull_manifest(ctx, args, filter, filters_given, local_manifest).await
}
fn warn_shortfall<P: Provider + Clone>(ctx: &PullCtx<'_, P>) {
if let Some(shortfall) = ctx.funding.shortfall() {
eprintln!("warning: {shortfall}");
}
}
async fn pull_manifest<P: Provider + Clone>(
mut ctx: PullCtx<'_, P>,
args: &BundlePullArgs,
filter: &EntryFilter,
filters_given: bool,
local_manifest: Option<Kept>,
) -> anyhow::Result<()> {
let selected = async {
let Some(kept) = obtain_manifest(&ctx, args, filter, filters_given, local_manifest).await?
else {
return Ok(None);
};
maybe_select(args, kept)
}
.await;
let Some(Kept { manifest, excluded }) = selected.inspect_err(|_| warn_shortfall(&ctx))? else {
warn_shortfall(&ctx);
return Ok(());
};
let manifest_fetch_plan = build_fetch_plan(&manifest.entries);
ctx.progress = PullProgress::new(
args.json,
download_bytes(&manifest.entries, &manifest_fetch_plan),
total_content_bytes(&manifest.entries),
&run_label(&args.output),
);
let saved = bundle_manifest::load(&args.output);
let mut interrupt = Interrupt::watch();
let mut run = ctx
.pull_all(
&manifest.entries,
&args.output,
args.overwrite,
saved,
&mut interrupt,
)
.await;
let replaced = replaced_pool(&run);
if let Some(new) = replaced {
eprintln!("note: {new}");
ctx.ledgers = LaneLedgers::new();
ctx.funding = fetch::RunFunding::default();
ctx.recovery.start_next_pass();
let saved = bundle_manifest::load(&args.output);
let second = ctx
.pull_all(
&manifest.entries,
&args.output,
args.overwrite,
saved,
&mut interrupt,
)
.await;
run = merge_passes(run, second);
eprintln!(
"note: pool {} accepts no more funds and may still hold a balance; once its \
dispute window ends, reclaim it with: decdn pool reclaim --pool {}",
new.closed, new.closed
);
}
let PullRun {
outcomes,
mut warnings,
transfer,
interrupted,
stopped,
} = run;
let stopped = stopped.map(|err| match (replaced, err.downcast_ref::<PoolReplaced>()) {
(Some(_), Some(_)) => err.context(
"funding needed: the pool this pull opened accepts no more funds either; run the \
pull again",
),
_ => err,
});
ctx.progress.finish();
warn_leftover_partials(&args.output, &manifest.entries, args.hash.as_deref());
warnings.extend(ctx.funding.shortfall());
let dedup = DedupSummary {
spliced_bytes: ctx.dedup_stats.spliced_bytes.load(Ordering::Relaxed),
hints_ignored: ctx.dedup_stats.hints_ignored.load(Ordering::Relaxed),
};
let reported = report(
&outcomes,
&warnings,
transfer,
dedup,
excluded,
&args.output,
args.json,
);
if interrupted {
return Err(Interrupted.into());
}
if let Some(err) = stopped {
return Err(err);
}
reported
}
const POLL_WATCH_TICK: std::time::Duration = std::time::Duration::from_secs(1);
const POLL_WATCH_LATE: std::time::Duration = std::time::Duration::from_secs(2);
async fn drive_until_interrupted(
drive: impl std::future::Future<Output = ()>,
flush: impl std::future::Future<Output = ()>,
interrupt: &mut Interrupt,
) -> bool {
let drive = async {
tokio::select! {
() = drive => false,
() = interrupt.wait() => true,
never = warn_when_unpolled() => match never {},
}
};
tokio::join!(drive, flush).0
}
async fn warn_when_unpolled() -> std::convert::Infallible {
let mut tick = tokio::time::interval(POLL_WATCH_TICK);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
let due = tick.tick().await;
let late = due.elapsed();
if late > POLL_WATCH_LATE {
tracing::warn!(
late_ms = late.as_millis(),
"the bundle task was not polled for at least {late:?}: a blocking call on the \
pull task froze every lane (or the system was suspended); run with -vv to see \
which"
);
}
}
}
struct PullRun {
outcomes: Vec<EntryOutcome>,
warnings: Vec<String>,
transfer: Transfer,
interrupted: bool,
stopped: Option<anyhow::Error>,
}
fn replaced_pool(run: &PullRun) -> Option<PoolReplaced> {
if run.interrupted {
return None;
}
run.stopped
.as_ref()
.and_then(|err| err.downcast_ref::<PoolReplaced>())
.copied()
}
fn merge_passes(first: PullRun, second: PullRun) -> PullRun {
let outcomes = if first.outcomes.len() == second.outcomes.len() {
first
.outcomes
.into_iter()
.zip(second.outcomes)
.map(|(was, now)| match (&was, &now) {
(
EntryOutcome::Fetched(_) | EntryOutcome::Linked | EntryOutcome::Deduped(_),
EntryOutcome::Skipped,
) => was,
_ => now,
})
.collect()
} else {
second.outcomes
};
let mut warnings = first.warnings;
warnings.extend(second.warnings);
PullRun {
outcomes,
warnings,
transfer: first.transfer.add(second.transfer),
interrupted: second.interrupted,
stopped: second.stopped,
}
}
fn namespace_id(namespace: Option<u64>) -> [u8; 32] {
namespace.map_or(decdn_protocol::client::NO_NAMESPACE, |n| {
alloy::primitives::U256::from(n).to_be_bytes()
})
}
fn cached_manifest(args: &BundlePullArgs, hash: [u8; 32]) -> Option<Vec<u8>> {
if args.overwrite {
return None;
}
bundle_cache::load(&args.output, hash).unwrap_or_else(|e| {
eprintln!("warning: {e:#}; fetching the bundle manifest");
None
})
}
async fn obtain_manifest<P: Provider + Clone>(
ctx: &PullCtx<'_, P>,
args: &BundlePullArgs,
filter: &EntryFilter,
filters_given: bool,
local_manifest: Option<Kept>,
) -> anyhow::Result<Option<Kept>> {
if let Some(m) = local_manifest {
return Ok(Some(m));
}
let raw = args
.hash
.as_deref()
.ok_or_else(|| anyhow!("no bundle source (expected -i or --hash)"))?;
let hash = fetch::parse_hash(raw)?;
let bytes = if let Some(cached) = cached_manifest(args, hash) {
cached
} else {
let fetched = ctx
.fetch_to_memory(hash, &args.output)
.await
.context("fetch bundle manifest blob")?;
bundle_cache::store(&args.output, hash, &fetched);
fetched
};
let mut m = parse_manifest(&bytes)?;
let raw_empty = m.entries.is_empty();
let (entries, excluded) = filter.apply_and_warn(m.entries);
m.entries = entries;
if m.entries.is_empty() {
report_nothing_to_fetch(NothingReason::from_filter(filters_given, raw_empty));
warn_leftover_partials(&args.output, &[], args.hash.as_deref());
return Ok(None);
}
Ok(Some(Kept {
manifest: m,
excluded,
}))
}
fn settle_group_run(
groups: &mut [SettledGroup],
flush_tx: &tokio::sync::mpsc::UnboundedSender<FlushBatch>,
i: usize,
run: GroupRun,
updates: BTreeMap<String, bundle_manifest::SavedFile>,
warnings: Vec<String>,
) -> Option<anyhow::Error> {
if !updates.is_empty() {
let fetched_bytes = run
.outcomes
.iter()
.map(|o| match o {
EntryOutcome::Fetched(n) => *n,
_ => 0,
})
.sum();
let _ = flush_tx.send(FlushBatch {
updates,
fetched_bytes,
});
}
if let Some(slot) = groups.get_mut(i) {
*slot = SettledGroup {
outcomes: run.outcomes,
warnings,
bytes: run.bytes,
};
}
run.stop
}
#[derive(Clone, Default)]
struct SettledGroup {
outcomes: Vec<EntryOutcome>,
warnings: Vec<String>,
bytes: Option<EntryBytes>,
}
async fn run_groups<G, R, Fut>(
items: Vec<G>,
jobs: usize,
run: impl Fn(G) -> Fut,
mut settle: impl FnMut(usize, R) -> Option<anyhow::Error>,
) -> Option<anyhow::Error>
where
Fut: std::future::Future<Output = R>,
{
let width = jobs.min(items.len()).max(1);
let mut stream = futures_util::stream::iter(items.into_iter().enumerate())
.map(|(i, item)| {
let fut = run(item);
async move { (i, fut.await) }
})
.buffer_unordered(width);
while let Some((i, result)) = stream.next().await {
if let Some(err) = settle(i, result) {
return Some(err);
}
}
None
}
pub(crate) struct LaneStreamCap {
map: tokio::sync::Mutex<HashMap<Address, Arc<ProviderStreams>>>,
n: usize,
next_lane: AtomicU64,
}
impl LaneStreamCap {
pub(crate) fn new(n: usize) -> Self {
Self {
map: tokio::sync::Mutex::new(HashMap::new()),
n: n.max(1),
next_lane: AtomicU64::new(0),
}
}
async fn streams(&self, provider: Address) -> Arc<ProviderStreams> {
let mut map = self.map.lock().await;
Arc::clone(
map.entry(provider)
.or_insert_with(|| Arc::new(ProviderStreams::new(self.n))),
)
}
pub(crate) async fn try_permit(
&self,
provider: Address,
) -> Option<tokio::sync::OwnedSemaphorePermit> {
Arc::clone(&self.streams(provider).await.semaphore)
.try_acquire_owned()
.ok()
}
pub(crate) async fn widen(&self, provider: Address) -> decdn_client::LaneWiden {
let extras = Arc::new(self.extra_permits(provider).await);
let released = Arc::clone(&extras);
decdn_client::LaneWiden::new(
move |kind| extras.grant(kind),
move || released.release_one(),
)
}
async fn extra_permits(&self, provider: Address) -> ExtraPermits {
ExtraPermits {
streams: self.streams(provider).await,
keep: self.extra_keep(),
lane: self.next_lane.fetch_add(1, Ordering::Relaxed),
held: std::sync::Mutex::new(Vec::new()),
}
}
const fn extra_keep(&self) -> usize {
if self.n > 2 { 1 } else { 0 }
}
}
const CLAIM_TTL: std::time::Duration = decdn_client::GROWTH_RETRY.saturating_mul(3);
struct ProviderStreams {
semaphore: Arc<tokio::sync::Semaphore>,
claims: std::sync::Mutex<Vec<Claim>>,
}
struct Claim {
lane: u64,
keep: usize,
bare: bool,
at: tokio::time::Instant,
}
impl ProviderStreams {
fn new(n: usize) -> Self {
Self {
semaphore: Arc::new(tokio::sync::Semaphore::new(n)),
claims: std::sync::Mutex::new(Vec::new()),
}
}
fn take(
&self,
lane: u64,
keep: usize,
bare: bool,
) -> Option<tokio::sync::OwnedSemaphorePermit> {
let now = tokio::time::Instant::now();
let mut claims = self.claims.lock().unwrap_or_else(PoisonError::into_inner);
claims.retain(|claim| now.saturating_duration_since(claim.at) < CLAIM_TTL);
let own = claims.iter().position(|claim| claim.lane == lane);
let (ahead, kept) = claims
.get(..own.unwrap_or(claims.len()))
.unwrap_or_default()
.iter()
.filter(|claim| !bare || claim.bare)
.fold((0_usize, keep), |(n, kept), claim| {
(n.saturating_add(1), kept.max(claim.keep))
});
if self.semaphore.available_permits() <= ahead.saturating_add(kept) {
let claim = Claim {
lane,
keep,
bare,
at: now,
};
match own.and_then(|i| claims.get_mut(i)) {
Some(renewed) => *renewed = claim,
None => claims.push(claim),
}
return None;
}
match Arc::clone(&self.semaphore).try_acquire_owned() {
Ok(permit) => {
if let Some(i) = own {
claims.remove(i);
}
Some(permit)
}
Err(tokio::sync::TryAcquireError::NoPermits) => None,
Err(tokio::sync::TryAcquireError::Closed) => {
tracing::warn!("a lane's stream semaphore is closed; it grants no extra stream");
None
}
}
}
fn forget(&self, lane: u64) {
self.claims
.lock()
.unwrap_or_else(PoisonError::into_inner)
.retain(|claim| claim.lane != lane);
}
}
struct ExtraPermits {
streams: Arc<ProviderStreams>,
keep: usize,
lane: u64,
held: std::sync::Mutex<Vec<tokio::sync::OwnedSemaphorePermit>>,
}
impl ExtraPermits {
fn grant(&self, kind: decdn_client::GrowFor) -> bool {
let (keep, bare) = match kind {
decdn_client::GrowFor::Restart => (0, self.lock_held().is_empty()),
decdn_client::GrowFor::Extra => (self.keep, false),
};
let Some(permit) = self.streams.take(self.lane, keep, bare) else {
return false;
};
self.lock_held().push(permit);
true
}
fn release_one(&self) {
let permit = self.lock_held().pop();
debug_assert!(
permit.is_some(),
"a stream was given back that was never granted"
);
if permit.is_none() {
tracing::warn!("a lane gave back a stream permit it does not hold");
}
drop(permit);
}
fn lock_held(&self) -> std::sync::MutexGuard<'_, Vec<tokio::sync::OwnedSemaphorePermit>> {
self.held.lock().unwrap_or_else(PoisonError::into_inner)
}
}
impl Drop for ExtraPermits {
fn drop(&mut self) {
self.streams.forget(self.lane);
}
}
struct JobGate {
permits: tokio::sync::Semaphore,
admission: tokio::sync::Mutex<()>,
}
impl JobGate {
fn new(jobs: usize) -> Self {
Self {
permits: tokio::sync::Semaphore::new(jobs),
admission: tokio::sync::Mutex::new(()),
}
}
async fn permit(&self) -> anyhow::Result<tokio::sync::SemaphorePermit<'_>> {
self.permits
.acquire()
.await
.map_err(|_| anyhow!("bundle pull concurrency gate closed"))
}
}
struct FetchSlot<'g> {
gate: &'g JobGate,
permit: Mutex<Option<tokio::sync::SemaphorePermit<'g>>>,
}
impl<'g> FetchSlot<'g> {
async fn acquire(gate: &'g JobGate) -> anyhow::Result<Self> {
let admitted = gate.admission.lock().await;
let permit = gate.permit().await?;
drop(admitted);
Ok(Self {
gate,
permit: Mutex::new(Some(permit)),
})
}
fn release(&self) {
drop(
self.permit
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take(),
);
}
async fn ensure(&self) -> anyhow::Result<()> {
if self.held() {
return Ok(());
}
let permit = self.gate.permit().await?;
let mut slot = self.permit.lock().unwrap_or_else(PoisonError::into_inner);
if slot.is_none() {
*slot = Some(permit);
}
Ok(())
}
fn held(&self) -> bool {
self.permit
.lock()
.unwrap_or_else(PoisonError::into_inner)
.is_some()
}
}
struct PullCtx<'a, P: Provider + Clone> {
endpoint: &'a Endpoint,
store: &'a Arc<RedbBuyerPoolStore>,
contract: &'a PaymentPool::PaymentPoolInstance<P>,
rpc: &'a P,
signer: &'a Arc<PrivateKeySigner>,
self_address: Address,
token: Address,
voucher_dom: &'a Eip712Domain,
slash_dom: &'a Eip712Domain,
chain: &'a fetch::ResolvedChain,
relays: &'a [RelayUrl],
explicit: Option<FetchTarget>,
registry: Option<Vec<NodeCandidate>>,
common: &'a ClientFetchArgs,
grant: Option<decdn_incentive::CapabilityGrant>,
namespace_id: [u8; 32],
ledgers: LaneLedgers,
funding: fetch::RunFunding,
recovery: Arc<RecoveryGate>,
dedup_stats: DedupStats,
open_lock: tokio::sync::Mutex<()>,
gate: JobGate,
lane_cap: LaneStreamCap,
health: Arc<PeerHealth>,
connections: Connections,
writes: &'a OrderedWrites,
stop: StopPolicy,
progress: PullProgress,
}
impl<P: Provider + Clone> PullCtx<'_, P> {
fn drive_deps(&self, max_blob_bytes: u64) -> anyhow::Result<fetch::DriveFetchDeps<'_, P>> {
Ok(fetch::DriveFetchDeps {
timings: None,
endpoint: self.endpoint,
store: self.store,
contract: self.contract,
rpc: self.rpc,
slash_dom: self.slash_dom,
self_address: self.self_address,
token: self.token,
chain: self.chain,
namespace_id: self.namespace_id,
max_rate_per_mb: self.common.max_rate_per_mb,
max_blob_bytes,
deadlines: PullDeadlines::new(fetch::STALL_WINDOW, fetch::STALL_WINDOW, 0)?,
connections: &self.connections,
writes: self.writes,
funding: &self.funding,
})
}
async fn fetch_to_staging(
&self,
hash: [u8; 32],
staging: &Path,
total: Option<u64>,
progress: Option<&ProgressCallback>,
slot: &FetchSlot<'_>,
) -> anyhow::Result<bool> {
slot.ensure().await?;
let targets = self.entry_targets(hash).await?;
self.acquire_entry(&targets, hash, staging, total, None, progress)
.await
}
async fn entry_targets(&self, hash: [u8; 32]) -> anyhow::Result<fetch::ResolvedTargets> {
fetch::holders_or_none(self.resolve_entry_targets(hash).await)
}
async fn resolve_entry_targets(
&self,
hash: [u8; 32],
) -> anyhow::Result<fetch::ResolvedTargets> {
if self.explicit.is_some() {
return fetch::resolve_target_node(
self.common,
self.chain,
self.endpoint,
self.relays,
hash,
fetch::ProbeOpts {
timings: None,
round: fetch::ProbeRound::Stream,
},
)
.await;
}
let candidates = self.entry_candidates()?;
fetch::probe_and_order(
self.endpoint,
&candidates,
self.relays.first(),
hash,
fetch::ProxyWarmingParams::from_args(self.common),
self.slash_dom,
fetch::ProbeRound::Stream,
)
.await
}
async fn acquire_entry(
&self,
targets: &fetch::ResolvedTargets,
hash: [u8; 32],
staging: &Path,
total: Option<u64>,
ranges: Option<&[(u64, u64)]>,
progress: Option<&ProgressCallback>,
) -> anyhow::Result<bool> {
let max_blob_bytes = self.common.max_blob_mb.saturating_mul(1024 * 1024);
let deps = self.drive_deps(max_blob_bytes)?;
let sources = CliSources::new(
&deps,
self.common,
self.relays,
self.grant.as_ref(),
self.signer,
self.voucher_dom,
Some(&self.open_lock),
Some(&self.ledgers),
Some(&self.lane_cap),
&self.recovery,
);
let holders = sources.holders_from(targets);
let on_drop = fetch::SettleOnDrop::new(|| sources.persist_watermarks_detached());
let result = async {
let (total_bytes, holders) = if let Some(total) = total {
(total, holders)
} else {
let claim = sources
.first_claim(hash, holders, &self.health, &self.stop)
.await?;
(claim.total_bytes, claim.holders)
};
let mut downloader = Downloader::new(&sources, sources.funder())
.holders(holders)
.health(Arc::clone(&self.health))
.recovery(Arc::clone(&self.recovery))
.max_lanes(self.common.max_sources)
.max_blob_bytes(max_blob_bytes);
if let Some(slot) = sources.credentials() {
downloader = downloader.credentials(slot);
}
Box::pin(downloader.fetch_to_paths_shared(
&[DownloadTarget {
hash,
total_bytes,
dest: staging,
ranges,
}],
&self.ledgers,
progress,
&self.stop,
))
.await
.map(|_paths| downloader.refetched_targets() > 0)
}
.await;
on_drop.disarm();
sources.persist_watermarks().await;
result.map_err(|err| sources.annotate(err))
}
fn entry_candidates(&self) -> anyhow::Result<Vec<NodeCandidate>> {
let registry = self
.registry
.as_deref()
.ok_or_else(|| anyhow!("no discovery candidates available"))?;
Ok(entry_candidates(registry, self.chain.region.as_deref()))
}
async fn fetch_to_memory(&self, hash: [u8; 32], out_root: &Path) -> anyhow::Result<Vec<u8>> {
let staging = staging_path(out_root, hash)?;
let slot = FetchSlot::acquire(&self.gate).await?;
self.fetch_to_staging(hash, &staging, None, None, &slot)
.await?;
let bytes =
std::fs::read(&staging).with_context(|| format!("read {}", staging.display()))?;
remove_staging_off_runtime(&staging).await;
Ok(bytes)
}
async fn pull_all(
&self,
entries: &[ManifestEntry],
out_root: &Path,
overwrite: bool,
saved: SavedManifest,
interrupt: &mut Interrupt,
) -> PullRun {
let index = ChunkIndex::default();
let refs: Vec<&ManifestEntry> = entries.iter().collect();
let fetch_plan = build_fetch_plan(entries);
let hold = self.stop.clock.hold();
let disk = resolve_disk_state(entries, &saved, out_root, overwrite).await;
drop(hold);
for d in &disk.seed {
index.seed_disk(d.hash, &d.source, d.offset, d.len);
}
let run = self
.pull_plain(
&refs,
out_root,
overwrite,
&index,
&fetch_plan,
&disk,
saved,
interrupt,
)
.await;
sweep_donor_sources(&index).await;
run
}
#[allow(clippy::too_many_arguments)]
async fn pull_plain<'e>(
&self,
entries: &[&'e ManifestEntry],
out_root: &Path,
overwrite: bool,
index: &ChunkIndex,
fetch_plan: &FetchPlan,
disk: &DiskState,
saved: SavedManifest,
interrupt: &mut Interrupt,
) -> PullRun {
let groups_by_hash = order_groups_smallest_first(group_by_hash(entries));
let group_count = groups_by_hash.len().max(1);
let (flush_tx, flush_rx) = tokio::sync::mpsc::unbounded_channel::<FlushBatch>();
let mut groups: Vec<SettledGroup> = vec![SettledGroup::default(); groups_by_hash.len()];
let mut stopped: Option<anyhow::Error> = None;
let drive = async {
let run = |group: HashGroup<'e>| {
let group_entries: Vec<&'e ManifestEntry> = group.entries.clone();
async move {
let run = self
.fetch_group(group, out_root, overwrite, index, fetch_plan, disk)
.await;
if let Some(en) = group_entries.first()
&& let Ok(whole) = fetch::parse_hash(&en.hash)
{
index.mark_finished(whole);
}
debug_assert_eq!(
group_entries.len(),
run.outcomes.len(),
"fetch_group must return one outcome per entry"
);
let updates = build_completed_updates(&group_entries, &run.outcomes, out_root);
let warnings = size_warnings(&group_entries, &updates);
(run, updates, warnings)
}
};
stopped = run_groups(
groups_by_hash,
group_count,
run,
|i, (run, updates, warnings)| {
settle_group_run(&mut groups, &flush_tx, i, run, updates, warnings)
},
)
.await;
drop(flush_tx);
};
let flush = flush_task(out_root, saved, flush_rx);
let interrupted = drive_until_interrupted(drive, flush, interrupt).await;
let transfer = groups
.iter()
.map(|g| group_transfer(&g.outcomes, g.bytes))
.fold(Transfer::default(), Transfer::add);
let warnings = groups
.iter_mut()
.flat_map(|g| std::mem::take(&mut g.warnings))
.collect();
let outcomes = groups.into_iter().flat_map(|g| g.outcomes).collect();
PullRun {
outcomes,
warnings,
transfer,
interrupted,
stopped,
}
}
#[allow(clippy::too_many_arguments)]
async fn pull_entry(
&self,
hash: [u8; 32],
hints: Option<&[Hint]>,
total: Option<u64>,
staging: &Path,
index: &ChunkIndex,
fetch_plan: &FetchPlan,
file: Option<&pull_progress::FileBar>,
slot: &FetchSlot<'_>,
) -> anyhow::Result<EntryBytes> {
let started = std::time::Instant::now();
self.pull_entry_untimed(hash, hints, total, staging, index, fetch_plan, file, slot)
.await
.inspect(|&bytes| log_entry_done(hash, staging, bytes, started.elapsed()))
}
#[allow(clippy::too_many_arguments)]
async fn pull_entry_untimed(
&self,
hash: [u8; 32],
hints: Option<&[Hint]>,
total: Option<u64>,
staging: &Path,
index: &ChunkIndex,
fetch_plan: &FetchPlan,
file: Option<&pull_progress::FileBar>,
slot: &FetchSlot<'_>,
) -> anyhow::Result<EntryBytes> {
let progress = file.and_then(|f| f.callback());
if staging.try_exists().unwrap_or(false) {
let staging_buf = staging.to_path_buf();
let verified = tokio::task::spawn_blocking(move || {
hash_partial(&staging_buf).is_ok_and(|got| got == hash)
})
.await
.map_err(|e| anyhow!("staging verify task: {e}"))?;
if verified {
if let (Some(cb), Some(total)) = (progress, total) {
cb(total, total);
}
index.register(hints, staging);
return Ok(EntryBytes::default());
}
remove_staging_off_runtime(staging).await;
}
let resumed = resumed_spans_off_runtime(staging, hash).await;
if let Some(f) = file {
f.set_discovering();
}
let plan = match (hints, total) {
(Some(hints), Some(total)) if !hints.is_empty() => {
let guard = index.map.lock().unwrap_or_else(PoisonError::into_inner);
let plan = plan_reassembly(hints, &guard, fetch_plan, hash, total);
drop(guard);
(!plan.donor.is_empty() || !plan.deferred.is_empty()).then_some((plan, total))
}
_ => None,
};
let Some((plan, total)) = plan else {
let refetched = self
.fetch_to_staging(hash, staging, total, progress, slot)
.await?;
index.register(hints, staging);
let len = tokio::fs::metadata(staging).await.map_or(0, |m| m.len());
let resumed = if refetched { 0 } else { span_bytes(&resumed) };
return Ok(EntryBytes::whole_blob(len, resumed));
};
slot.ensure().await?;
let targets = self.entry_targets(hash).await?;
let driver = AcquireRangeDriver {
ctx: self,
targets: &targets,
hash,
staging,
total,
progress,
refetched: AtomicBool::new(false),
slot,
};
let finish_progress = || {
if let Some(cb) = progress {
cb(total, total);
}
};
let outcome = reassemble_dedup(
&driver,
&plan,
total,
&resumed,
hints,
index,
fetch_plan,
file,
&finish_progress,
)
.await?
.refetched_if(driver.refetched.load(Ordering::Relaxed));
self.dedup_stats
.spliced_bytes
.fetch_add(outcome.spliced_bytes, Ordering::Relaxed);
self.dedup_stats
.hints_ignored
.fetch_add(outcome.hints_ignored, Ordering::Relaxed);
Ok(EntryBytes::dedup(total, outcome))
}
async fn fetch_group(
&self,
group: HashGroup<'_>,
out_root: &Path,
overwrite: bool,
index: &ChunkIndex,
fetch_plan: &FetchPlan,
disk: &DiskState,
) -> GroupRun {
let slot = match fetch::parse_hash(group.hash) {
Ok(h) => FetchSlot::acquire(&self.gate).await.map(|slot| (h, slot)),
Err(e) => Err(e),
};
let (hash, slot) = match slot {
Ok(ok) => ok,
Err(e) => {
return GroupRun::done(
group
.entries
.iter()
.map(|en| EntryOutcome::failed(&en.path, &e))
.collect(),
);
}
};
let first = group.entries.first();
let hints = first.and_then(|e| hints_of(e));
if let Some(e) = first
&& hints.is_none()
&& let Some(chunks) = e.chunks.as_ref()
{
self.dedup_stats.hints_ignored.fetch_add(
u64::try_from(chunks.len()).unwrap_or(u64::MAX),
Ordering::Relaxed,
);
}
let total = group.entries.iter().find_map(|e| e.size);
let is_donor = hints.as_ref().is_some_and(|h| !h.is_empty());
let slots = plan_slots(&group.entries, out_root, overwrite, &disk.skip);
if !slots.iter().any(|s| matches!(s, Slot::Write { .. })) {
return GroupRun::done(
slots
.into_iter()
.map(|s| match s {
Slot::Failed(o) => o,
_ => EntryOutcome::Skipped,
})
.collect(),
);
}
if let Some(donor) = disk.whole_file.get(&hash)
&& let Some(len) = verified_donor_len(donor, hash).await
{
return GroupRun::done(materialize_from_donor(slots, donor, len).await);
}
let staging = match staging_path(out_root, hash) {
Ok(p) => p,
Err(e) => return GroupRun::done(fail_all(slots, &e)),
};
let paths: Vec<String> = group.entries.iter().map(|e| e.path.clone()).collect();
let (download_bytes_of_blob, reconstruct_bytes_of_blob) =
blob_download_reconstruct(&group, fetch_plan, &HashMap::new());
let file_bar = self.progress.file_bar(
&pull_progress::file_label(&paths),
download_bytes_of_blob,
reconstruct_bytes_of_blob,
);
let fetched = self
.pull_entry(
hash,
hints.as_deref(),
total,
&staging,
index,
fetch_plan,
Some(&file_bar),
&slot,
)
.await;
file_bar.finish();
let bytes = match fetched {
Ok(bytes) => bytes,
Err(e) => {
return GroupRun::fetch_failed(slots, e);
}
};
let outcomes = materialize_group(
slots,
|dest| {
let staging = staging.clone();
off_runtime("materialize", move || {
first_write(&staging, &dest, is_donor)
})
},
link_off_runtime,
)
.await;
let any_failed = outcomes
.iter()
.any(|o| matches!(o, EntryOutcome::Failed { .. }));
if !is_donor && !any_failed {
remove_staging_off_runtime(&staging).await;
} else if is_donor && any_failed {
index.mark_retained(&staging);
}
GroupRun::landed(outcomes, bytes)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct EntryBytes {
paid: u64,
spliced: u64,
resumed: u64,
}
impl EntryBytes {
fn whole_blob(len: u64, resumed: u64) -> Self {
let resumed = resumed.min(len);
Self {
paid: len.saturating_sub(resumed),
spliced: 0,
resumed,
}
}
const fn dedup(total: u64, outcome: DedupOutcome) -> Self {
Self {
paid: total
.saturating_sub(outcome.spliced_bytes)
.saturating_sub(outcome.resumed_bytes),
spliced: outcome.spliced_bytes,
resumed: outcome.resumed_bytes,
}
}
}
fn resumed_spans(staging: &Path, hash: [u8; 32]) -> Vec<(u64, u64)> {
let read = || -> anyhow::Result<Vec<(u64, u64)>> {
let (dir, stem) = fetch::ranged_store_location(staging)?;
if !ClientRangedStore::has_record(&dir, &stem)? {
return Ok(Vec::new());
}
Ok(ClientRangedStore::open(&dir, &stem, hash)?.present_byte_ranges())
};
read().unwrap_or_else(|e| {
tracing::debug!(
"could not read the resume record beside {}: {e:#}",
staging.display()
);
Vec::new()
})
}
async fn resumed_spans_off_runtime(staging: &Path, hash: [u8; 32]) -> Vec<(u64, u64)> {
let staging = staging.to_path_buf();
tokio::task::spawn_blocking(move || resumed_spans(&staging, hash))
.await
.unwrap_or_else(|e| {
tracing::debug!("resume record task: {e}");
Vec::new()
})
}
fn span_bytes(spans: &[(u64, u64)]) -> u64 {
spans
.iter()
.fold(0u64, |acc, &(_, len)| acc.saturating_add(len))
}
fn log_entry_done(hash: [u8; 32], staging: &Path, bytes: EntryBytes, elapsed: std::time::Duration) {
let size = std::fs::metadata(staging).map_or_else(
|e| {
tracing::debug!(
"could not stat {} for its entry line: {e}",
staging.display()
);
0
},
|m| m.len(),
);
tracing::info!("{}", entry_done_line(hash, size, bytes, elapsed));
}
fn entry_done_line(
hash: [u8; 32],
size: u64,
bytes: EntryBytes,
elapsed: std::time::Duration,
) -> String {
let secs = elapsed.as_secs_f64();
let rate = if secs > 0.0 {
fetch::fmt_rate(fetch::bytes_as_f64(bytes.paid) / secs)
} else {
fetch::fmt_rate(0.0)
};
let detail = if bytes.spliced == 0 && bytes.resumed == 0 {
rate
} else {
let mut parts = vec![format!(
"{} downloaded at {rate}",
indicatif::HumanBytes(bytes.paid)
)];
if bytes.resumed > 0 {
parts.push(format!("{} resumed", indicatif::HumanBytes(bytes.resumed)));
}
if bytes.spliced > 0 {
parts.push(format!(
"{} spliced from disk",
indicatif::HumanBytes(bytes.spliced)
));
}
parts.join(", ")
};
format!(
"bundle pull: {}: {} in {secs:.1}s ({detail})",
blake3::Hash::from_bytes(hash).to_hex(),
indicatif::HumanBytes(size),
)
}
trait RangeDriver {
fn hash(&self) -> [u8; 32];
fn staging(&self) -> &Path;
fn drive<'a>(
&'a self,
ranges: &'a [(u64, u64)],
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + 'a>>;
fn release_slot(&self) {}
fn retake_slot(
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + '_>> {
Box::pin(async { Ok(()) })
}
}
struct AcquireRangeDriver<'a, 'g, P: Provider + Clone> {
ctx: &'a PullCtx<'a, P>,
targets: &'a fetch::ResolvedTargets,
hash: [u8; 32],
staging: &'a Path,
total: u64,
progress: Option<&'a ProgressCallback>,
refetched: AtomicBool,
slot: &'a FetchSlot<'g>,
}
impl<P: Provider + Clone> RangeDriver for AcquireRangeDriver<'_, '_, P> {
fn hash(&self) -> [u8; 32] {
self.hash
}
fn staging(&self) -> &Path {
self.staging
}
fn drive<'a>(
&'a self,
ranges: &'a [(u64, u64)],
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + 'a>> {
Box::pin(async move {
self.slot.ensure().await?;
let refetched = self
.ctx
.acquire_entry(
self.targets,
self.hash,
self.staging,
Some(self.total),
Some(ranges),
self.progress,
)
.await?;
if refetched {
self.refetched.store(true, Ordering::Relaxed);
}
Ok(())
})
}
fn release_slot(&self) {
self.slot.release();
}
fn retake_slot(
&self,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + '_>> {
Box::pin(self.slot.ensure())
}
}
async fn ensure_partial_off_runtime(
staging: &Path,
hash: [u8; 32],
total: u64,
) -> anyhow::Result<PathBuf> {
let staging = staging.to_path_buf();
tokio::task::spawn_blocking(move || ensure_partial(&staging, hash, total))
.await
.map_err(|e| anyhow!("ensure partial task: {e}"))?
}
fn ensure_partial(staging: &Path, hash: [u8; 32], total: u64) -> anyhow::Result<PathBuf> {
let (dir, stem) = fetch::ranged_store_location(staging)?;
ClientRangedStore::open_or_create(&dir, &stem, hash, total)
.with_context(|| format!("open the ranged store for {}", staging.display()))?;
let partial = partial_path(staging);
let f = std::fs::OpenOptions::new()
.write(true)
.open(&partial)
.with_context(|| format!("open {}", partial.display()))?;
if f.metadata()
.with_context(|| format!("stat {}", partial.display()))?
.len()
< total
{
f.set_len(total)
.with_context(|| format!("size {}", partial.display()))?;
}
Ok(partial)
}
#[allow(clippy::too_many_arguments)]
async fn reassemble_dedup(
driver: &dyn RangeDriver,
plan: &ReassemblePlan,
total: u64,
resumed: &[(u64, u64)],
hints: Option<&[Hint]>,
index: &ChunkIndex,
fetch_plan: &FetchPlan,
file: Option<&pull_progress::FileBar>,
finish_progress: &dyn Fn(),
) -> anyhow::Result<DedupOutcome> {
let hash = driver.hash();
let staging = driver.staging();
if !plan.drive.is_empty() {
driver.drive(&plan.drive).await?;
if staging.try_exists()? {
finish_progress();
index.register(hints, staging);
return Ok(DedupOutcome::of(&[], resumed, total, 0));
}
}
let partial = ensure_partial_off_runtime(staging, hash, total).await?;
let mut ledger = SpliceLedger::default();
let refetch = splice_off_runtime(&partial, plan.donor.clone(), &mut ledger).await?;
let mut hints_ignored = u64::try_from(refetch.len()).unwrap_or(u64::MAX);
if !refetch.is_empty() {
driver.drive(&refetch).await?;
ledger.driven.extend_from_slice(&refetch);
if staging.try_exists()? {
finish_progress();
index.register(hints, staging);
return Ok(DedupOutcome::of(
&ledger.spliced_spans(total),
resumed,
total,
hints_ignored,
));
}
}
let tail_ignored = reconcile_deferred(
driver,
&partial,
&plan.deferred,
index,
fetch_plan,
file,
&mut ledger,
)
.await?;
hints_ignored = hints_ignored.saturating_add(tail_ignored);
let mut spliced = ledger.spliced_spans(total);
if staging.try_exists()? {
finish_progress();
index.register(hints, staging);
return Ok(DedupOutcome::of(&spliced, resumed, total, hints_ignored));
}
let reporter = file.and_then(pull_progress::FileBar::reconstruct_reporter);
let partial_for_hash = partial.clone();
let got = tokio::task::spawn_blocking(move || {
hash_partial_with_progress(&partial_for_hash, reporter.as_deref())
})
.await
.map_err(|e| anyhow!("whole-file hash task: {e}"))??;
if got != hash {
spliced = Vec::new();
hints_ignored =
u64::try_from(plan.donor.len().saturating_add(plan.deferred.len())).unwrap_or(u64::MAX);
tracing::warn!(
"bundle pull: entry {} failed its whole-file hash after range-dedup; \
re-fetching the whole blob",
blake3::Hash::from_bytes(hash).to_hex()
);
driver.drive(&[(0, total)]).await?;
if staging.try_exists()? {
finish_progress();
index.register(hints, staging);
return Ok(DedupOutcome::of(&spliced, resumed, total, hints_ignored));
}
let partial_for_hash = partial.clone();
let got = tokio::task::spawn_blocking(move || hash_partial(&partial_for_hash))
.await
.map_err(|e| anyhow!("whole-file hash task: {e}"))??;
if got != hash {
bail!(
"reconstructed blob {} does not match its whole-file hash after a full re-fetch",
blake3::Hash::from_bytes(hash).to_hex()
);
}
}
let partial_for_promote = partial.clone();
let staging_for_promote = staging.to_path_buf();
tokio::task::spawn_blocking(move || {
promote_partial(&partial_for_promote, &staging_for_promote)
})
.await
.map_err(|e| anyhow!("promote task: {e}"))??;
finish_progress();
index.register(hints, staging);
Ok(DedupOutcome::of(&spliced, resumed, total, hints_ignored))
}
async fn reconcile_deferred(
driver: &dyn RangeDriver,
partial: &Path,
deferred: &[DeferredChunk],
index: &ChunkIndex,
fetch_plan: &FetchPlan,
file: Option<&pull_progress::FileBar>,
ledger: &mut SpliceLedger,
) -> anyhow::Result<u64> {
let mut waiting: Vec<DeferredChunk> = deferred.to_vec();
let mut ignored = 0u64;
if waiting.is_empty() {
return Ok(0);
}
let notified = index.progress.notified();
tokio::pin!(notified);
loop {
notified.as_mut().enable();
let mut ready: Vec<DonorRange> = Vec::new();
let mut fallback: Vec<(u64, u64)> = Vec::new();
let mut still: Vec<DeferredChunk> = Vec::new();
{
let guard = index.map.lock().unwrap_or_else(PoisonError::into_inner);
for c in &waiting {
let h = &c.hint;
if let Some(m) = guard.get(&h.hash).filter(|m| m.len == h.len) {
ready.push(DonorRange::new(h, m, c.dst, c.refetch));
continue;
}
let present_but_untrusted = guard.contains_key(&h.hash);
let fetcher_running = fetch_plan
.assigned
.get(&h.hash)
.copied()
.is_some_and(|a| !index.is_finished(a));
if fetcher_running && !present_but_untrusted {
still.push(*c);
} else {
fallback.push(c.refetch);
ignored = ignored.saturating_add(1);
}
}
}
if !ready.is_empty() {
let refetch = splice_off_runtime(partial, ready, ledger).await?;
ignored = ignored.saturating_add(u64::try_from(refetch.len()).unwrap_or(u64::MAX));
fallback.extend(refetch);
}
if !fallback.is_empty() {
driver.drive(&fallback).await?;
ledger.driven.extend_from_slice(&fallback);
if driver.staging().try_exists()? {
return Ok(ignored);
}
}
waiting = still;
if waiting.is_empty() {
return Ok(ignored);
}
if let Some(f) = file {
f.set_pending();
}
driver.release_slot();
notified.as_mut().await;
driver.retake_slot().await?;
notified.set(index.progress.notified());
}
}
async fn sweep_donor_sources(index: &ChunkIndex) {
let doomed: Vec<PathBuf> = index
.sources()
.into_iter()
.filter(|source| !index.retained(source))
.collect();
if let Err(e) = tokio::task::spawn_blocking(move || {
for source in &doomed {
remove_staging(source);
}
})
.await
{
tracing::warn!("donor staging sweep task: {e}");
}
}
fn materialize(staging: &Path, dest: &Path) -> anyhow::Result<u64> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let mut tmp =
fetch::temp_in_parent(dest).with_context(|| format!("stage {}", dest.display()))?;
let mut src =
std::fs::File::open(staging).with_context(|| format!("open {}", staging.display()))?;
let written = std::io::copy(&mut src, tmp.as_file_mut())
.with_context(|| format!("copy {} -> {}", staging.display(), dest.display()))?;
tmp.as_file()
.sync_all()
.with_context(|| format!("sync staged copy for {}", dest.display()))?;
tmp.persist(dest)
.map_err(|e| e.error)
.with_context(|| format!("write {}", dest.display()))?;
Ok(written)
}
fn first_write(staging: &Path, dest: &Path, is_donor: bool) -> anyhow::Result<u64> {
if !is_donor {
return move_into_place(staging, dest);
}
let len = std::fs::metadata(staging)
.with_context(|| format!("stat {}", staging.display()))?
.len();
link_or_copy_atomic(staging, dest).map(|()| len)
}
fn move_into_place(staging: &Path, dest: &Path) -> anyhow::Result<u64> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let len = std::fs::metadata(staging)
.with_context(|| format!("stat {}", staging.display()))?
.len();
match std::fs::rename(staging, dest) {
Ok(()) => Ok(len),
Err(_) => materialize(staging, dest),
}
}
#[derive(Debug, Clone, Copy)]
struct Hint {
hash: [u8; 32],
offset: u64,
len: u64,
}
struct MaterializedRange {
source: PathBuf,
offset: u64,
len: u64,
}
#[derive(Default)]
struct ChunkIndex {
map: std::sync::Mutex<HashMap<[u8; 32], MaterializedRange>>,
retain: std::sync::Mutex<HashSet<PathBuf>>,
seeded: std::sync::Mutex<HashSet<PathBuf>>,
finished: std::sync::Mutex<HashSet<[u8; 32]>>,
progress: tokio::sync::Notify,
}
impl ChunkIndex {
fn register(&self, hints: Option<&[Hint]>, source: &Path) {
let Some(hints) = hints else {
return;
};
{
let mut map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
for h in hints {
map.entry(h.hash).or_insert_with(|| MaterializedRange {
source: source.to_path_buf(),
offset: h.offset,
len: h.len,
});
}
}
self.progress.notify_waiters();
}
fn sources(&self) -> Vec<PathBuf> {
let map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
let seeded = self.seeded.lock().unwrap_or_else(PoisonError::into_inner);
let mut seen: HashSet<&Path> = HashSet::new();
let mut out = Vec::new();
for m in map.values() {
if !seeded.contains(&m.source) && seen.insert(m.source.as_path()) {
out.push(m.source.clone());
}
}
out
}
fn seed_disk(&self, hash: [u8; 32], source: &Path, offset: u64, len: u64) {
{
let mut map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
map.entry(hash).or_insert_with(|| MaterializedRange {
source: source.to_path_buf(),
offset,
len,
});
}
{
let mut seeded = self.seeded.lock().unwrap_or_else(PoisonError::into_inner);
seeded.insert(source.to_path_buf());
}
self.progress.notify_waiters();
}
fn mark_finished(&self, whole: [u8; 32]) {
{
let mut finished = self.finished.lock().unwrap_or_else(PoisonError::into_inner);
finished.insert(whole);
}
self.progress.notify_waiters();
}
fn is_finished(&self, whole: [u8; 32]) -> bool {
self.finished
.lock()
.unwrap_or_else(PoisonError::into_inner)
.contains(&whole)
}
fn mark_retained(&self, source: &Path) {
let mut retain = self.retain.lock().unwrap_or_else(PoisonError::into_inner);
retain.insert(source.to_path_buf());
}
fn retained(&self, source: &Path) -> bool {
let retain = self.retain.lock().unwrap_or_else(PoisonError::into_inner);
retain.contains(source)
}
}
#[derive(Debug, Clone)]
struct DonorRange {
dst: (u64, u64),
refetch: (u64, u64),
source: PathBuf,
src_offset: u64,
chunk_hash: [u8; 32],
chunk_src_offset: u64,
chunk_len: u64,
}
fn hints_of(entry: &ManifestEntry) -> Option<Vec<Hint>> {
let chunks = entry.chunks.as_ref()?;
let total = entry.size?;
let mut hints = Vec::with_capacity(chunks.len());
let mut offset = 0u64;
for c in chunks {
let hash = fetch::parse_hash(&c.hash).ok()?;
hints.push(Hint {
hash,
offset,
len: c.size,
});
offset = offset.checked_add(c.size)?;
}
if offset != total {
tracing::debug!(
"bundle entry {:?}: chunk sizes sum to {offset}, expected whole-file size {total}; \
ignoring range-dedup hints",
entry.path
);
return None;
}
Some(hints)
}
impl DonorRange {
fn new(h: &Hint, m: &MaterializedRange, dst: (u64, u64), refetch: (u64, u64)) -> Self {
Self {
dst,
refetch,
source: m.source.clone(),
src_offset: m.offset.saturating_add(dst.0.saturating_sub(h.offset)),
chunk_hash: h.hash,
chunk_src_offset: m.offset,
chunk_len: m.len,
}
}
}
#[derive(Debug, Clone, Copy)]
struct DeferredChunk {
hint: Hint,
dst: (u64, u64),
refetch: (u64, u64),
}
fn outward_groups((offset, len): (u64, u64), total: u64) -> (u64, u64) {
let group = CHUNK_GROUP_BYTES;
let start = (offset / group).saturating_mul(group);
let end = offset
.saturating_add(len)
.div_ceil(group)
.saturating_mul(group)
.min(total);
(start, end.saturating_sub(start))
}
#[derive(Debug, Default)]
struct FetchPlan {
assigned: HashMap<[u8; 32], [u8; 32]>,
}
fn build_fetch_plan(entries: &[ManifestEntry]) -> FetchPlan {
struct Occurrence {
count: u32,
best_size: u64,
best_whole: [u8; 32],
}
let mut seen: HashMap<[u8; 32], Occurrence> = HashMap::new();
for e in entries {
let Ok(whole) = fetch::parse_hash(&e.hash) else {
continue;
};
let Some(hints) = hints_of(e) else {
continue;
};
let size = e.size.unwrap_or(u64::MAX);
for h in &hints {
seen.entry(h.hash)
.and_modify(|o| {
o.count = o.count.saturating_add(1);
if (size, whole) < (o.best_size, o.best_whole) {
o.best_size = size;
o.best_whole = whole;
}
})
.or_insert(Occurrence {
count: 1,
best_size: size,
best_whole: whole,
});
}
}
let assigned = seen
.into_iter()
.filter(|(_, o)| o.count >= 2)
.map(|(hash, o)| (hash, o.best_whole))
.collect();
FetchPlan { assigned }
}
struct ReassemblePlan {
donor: Vec<DonorRange>,
deferred: Vec<DeferredChunk>,
drive: Vec<(u64, u64)>,
}
fn plan_reassembly(
hints: &[Hint],
index: &HashMap<[u8; 32], MaterializedRange>,
fetch_plan: &FetchPlan,
whole: [u8; 32],
total: u64,
) -> ReassemblePlan {
let mut candidates: Vec<(&Hint, Option<&MaterializedRange>)> = Vec::new();
for h in hints {
match index.get(&h.hash) {
Some(m) if m.len == h.len => candidates.push((h, Some(m))),
_ => {
let assigned_elsewhere = fetch_plan
.assigned
.get(&h.hash)
.is_some_and(|owner| *owner != whole);
if assigned_elsewhere {
candidates.push((h, None));
}
}
}
}
let spans: Vec<(u64, u64)> = candidates.iter().map(|(h, _)| (h.offset, h.len)).collect();
let runs = spliced_runs(&spans, total);
let mut donor = Vec::new();
let mut deferred = Vec::new();
for (h, m) in candidates {
let Some(dst) = span_in_runs(&runs, h.offset, h.len) else {
continue;
};
match m {
Some(m) => donor.push(DonorRange::new(h, m, dst, outward_groups(dst, total))),
None => deferred.push(DeferredChunk {
hint: *h,
dst,
refetch: outward_groups(dst, total),
}),
}
}
let covered: Vec<(u64, u64)> = runs.iter().map(|&(s, e)| (s, e - s)).collect();
ReassemblePlan {
drive: complement_runs(&covered, total),
donor,
deferred,
}
}
fn spliced_runs(spans: &[(u64, u64)], total: u64) -> Vec<(u64, u64)> {
let group = CHUNK_GROUP_BYTES;
coalesce_runs(spans, total)
.into_iter()
.filter_map(|(start, end)| {
let start = start.div_ceil(group).saturating_mul(group);
let end = if end == total {
end
} else {
(end / group).saturating_mul(group)
};
(start < end).then_some((start, end))
})
.collect()
}
fn span_in_runs(runs: &[(u64, u64)], offset: u64, len: u64) -> Option<(u64, u64)> {
let end = offset.saturating_add(len);
let i = runs.partition_point(|&(_, run_end)| run_end <= offset);
let &(run_start, run_end) = runs.get(i)?;
let (start, end) = (offset.max(run_start), end.min(run_end));
(start < end).then_some((start, end - start))
}
fn partial_path(staging: &Path) -> PathBuf {
staging.with_extension("partial")
}
fn splice_donors(partial: &Path, donors: &[DonorRange]) -> anyhow::Result<Vec<DonorRange>> {
use std::io::{Seek, SeekFrom};
let mut refetch = Vec::new();
let mut out = std::fs::OpenOptions::new()
.write(true)
.open(partial)
.with_context(|| format!("open {}", partial.display()))?;
for d in donors {
let (dst_offset, len) = d.dst;
let Ok(mut src) = std::fs::File::open(&d.source) else {
refetch.push(d.clone());
continue;
};
if !chunk_verified(&mut src, d.chunk_src_offset, d.chunk_len, d.chunk_hash) {
refetch.push(d.clone());
continue;
}
let copied = src
.seek(SeekFrom::Start(d.src_offset))
.and_then(|_| out.seek(SeekFrom::Start(dst_offset)))
.and_then(|_| copy_exact(&mut src, &mut out, len));
if copied.is_err() {
refetch.push(d.clone());
}
}
out.sync_all()
.with_context(|| format!("sync {}", partial.display()))?;
Ok(refetch)
}
async fn splice_off_runtime(
partial: &Path,
donors: Vec<DonorRange>,
ledger: &mut SpliceLedger,
) -> anyhow::Result<Vec<(u64, u64)>> {
let partial = partial.to_path_buf();
let planned: Vec<((u64, u64), (u64, u64))> =
donors.iter().map(|d| (d.dst, d.refetch)).collect();
let failed = off_runtime("donor splice", move || splice_donors(&partial, &donors)).await?;
let failed_dst: HashSet<(u64, u64)> = failed.iter().map(|d| d.dst).collect();
ledger.spliced.extend(
planned
.iter()
.filter(|(dst, _)| !failed_dst.contains(dst))
.map(|&(dst, _)| dst),
);
Ok(failed.iter().map(|d| d.refetch).collect())
}
#[derive(Debug, Default)]
struct SpliceLedger {
spliced: Vec<(u64, u64)>,
driven: Vec<(u64, u64)>,
}
impl SpliceLedger {
fn spliced_spans(&self, total: u64) -> Vec<(u64, u64)> {
uncovered_runs(&self.spliced, &self.driven, total)
}
}
fn uncovered_runs(spans: &[(u64, u64)], cover: &[(u64, u64)], total: u64) -> Vec<(u64, u64)> {
let kept = complement_runs(cover, total);
let mut kept = kept
.iter()
.map(|&(offset, len)| (offset, offset.saturating_add(len)));
let mut current = kept.next();
let mut out = Vec::new();
for (start, end) in coalesce_runs(spans, total) {
while let Some((k_start, k_end)) = current {
if k_end <= start {
current = kept.next();
continue;
}
if k_start >= end {
break;
}
let (from, to) = (start.max(k_start), end.min(k_end));
if to > from {
out.push((from, to - from));
}
if k_end <= end {
current = kept.next();
} else {
break;
}
}
}
out
}
fn chunk_verified(src: &mut std::fs::File, offset: u64, len: u64, expected: [u8; 32]) -> bool {
use std::io::{Read, Seek, SeekFrom};
if src.seek(SeekFrom::Start(offset)).is_err() {
return false;
}
let mut hasher = blake3::Hasher::new();
let mut remaining = len;
let mut buf = vec![0u8; 1 << 20];
while remaining > 0 {
let want = usize::try_from(remaining.min(1 << 20)).unwrap_or(1 << 20);
let Some(slice) = buf.get_mut(..want) else {
return false;
};
if src.read_exact(slice).is_err() {
return false;
}
hasher.update(slice);
remaining = remaining.saturating_sub(u64::try_from(want).unwrap_or(remaining));
}
*hasher.finalize().as_bytes() == expected
}
fn copy_exact(src: &mut std::fs::File, out: &mut std::fs::File, len: u64) -> std::io::Result<()> {
use std::io::{Read, Write};
let mut remaining = len;
let mut buf = vec![0u8; 1 << 20];
while remaining > 0 {
let want = usize::try_from(remaining.min(1 << 20)).unwrap_or(1 << 20);
let slice = buf
.get_mut(..want)
.ok_or_else(|| std::io::Error::other("copy buffer slice"))?;
src.read_exact(slice)?;
out.write_all(slice)?;
remaining = remaining.saturating_sub(u64::try_from(want).unwrap_or(remaining));
}
Ok(())
}
fn hash_partial(partial: &Path) -> anyhow::Result<[u8; 32]> {
hash_partial_with_progress(partial, None)
}
fn hash_partial_with_progress(
partial: &Path,
progress: Option<&(dyn Fn(u64) + Send + Sync)>,
) -> anyhow::Result<[u8; 32]> {
use std::io::Read;
let mut f =
std::fs::File::open(partial).with_context(|| format!("open {}", partial.display()))?;
let mut hasher = blake3::Hasher::new();
let mut buf = vec![0u8; 1 << 20];
let mut hashed_bytes = 0u64;
loop {
let n = f
.read(&mut buf)
.with_context(|| format!("read {}", partial.display()))?;
if n == 0 {
break;
}
let slice = buf
.get(..n)
.ok_or_else(|| anyhow!("short read buffer slice"))?;
hasher.update(slice);
if let Some(cb) = progress {
hashed_bytes = hashed_bytes.saturating_add(u64::try_from(n).unwrap_or(0));
cb(hashed_bytes);
}
}
Ok(*hasher.finalize().as_bytes())
}
fn promote_partial(partial: &Path, staging: &Path) -> anyhow::Result<()> {
std::fs::rename(partial, staging)
.with_context(|| format!("promote {} -> {}", partial.display(), staging.display()))?;
let sidecar = staging.with_extension("partial.ranges");
if let Err(e) = std::fs::remove_file(&sidecar)
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
"failed to remove staging sidecar {}: {e}",
sidecar.display()
);
}
Ok(())
}
const STAGING_DIR: &str = ".decdn-partial";
fn staging_path(out_root: &Path, hash: [u8; 32]) -> anyhow::Result<PathBuf> {
let dir = out_root.join(STAGING_DIR);
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let name = blake3::Hash::from_bytes(hash).to_hex().to_string();
Ok(dir.join(name))
}
pub(crate) fn complement_runs(donor_aligned: &[(u64, u64)], total: u64) -> Vec<(u64, u64)> {
if total == 0 {
return Vec::new();
}
let coalesced = coalesce_runs(donor_aligned, total);
let mut gaps = Vec::with_capacity(coalesced.len() + 1);
let mut cursor = 0u64;
for (start, end) in coalesced {
if start > cursor {
gaps.push((cursor, start - cursor));
}
cursor = cursor.max(end);
}
if cursor < total {
gaps.push((cursor, total - cursor));
}
gaps
}
fn coalesce_runs(ranges: &[(u64, u64)], total: u64) -> Vec<(u64, u64)> {
let mut runs: Vec<(u64, u64)> = ranges
.iter()
.filter_map(|&(offset, len)| {
let start = offset.min(total);
let end = offset.checked_add(len).unwrap_or(total).min(total);
(end > start).then_some((start, end))
})
.collect();
runs.sort_unstable_by_key(|&(start, _)| start);
let mut coalesced: Vec<(u64, u64)> = Vec::with_capacity(runs.len());
for (start, end) in runs {
match coalesced.last_mut() {
Some((_, last_end)) if start <= *last_end => {
*last_end = (*last_end).max(end);
}
_ => coalesced.push((start, end)),
}
}
coalesced
}
async fn remove_staging_off_runtime(staging: &Path) {
let staging = staging.to_path_buf();
if let Err(e) = tokio::task::spawn_blocking(move || remove_staging(&staging)).await {
tracing::warn!("staging removal task: {e}");
}
}
fn remove_staging(staging: &Path) {
let paths_to_remove = [
staging.to_path_buf(),
staging.with_extension("partial"),
staging.with_extension("partial.ranges"),
];
for path in paths_to_remove {
if let Err(e) = std::fs::remove_file(&path)
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!("failed to remove staging file {}: {e}", path.display());
}
}
}
fn fail_all(slots: Vec<Slot<'_>>, e: &anyhow::Error) -> Vec<EntryOutcome> {
slots
.into_iter()
.map(|s| match s {
Slot::Failed(o) => o,
Slot::Skip => EntryOutcome::Skipped,
Slot::Write { label, .. } => EntryOutcome::failed(label, e),
})
.collect()
}
impl EntryOutcome {
fn failed(path: &str, err: &anyhow::Error) -> Self {
Self::Failed {
path: path.to_string(),
err: sanitize_err_chain(err),
}
}
}
fn explicit_target(common: &ClientFetchArgs) -> anyhow::Result<Option<(PublicKey, Address)>> {
let Some(raw) = &common.node_id else {
return Ok(None);
};
let node_id =
PublicKey::from_str(raw).map_err(|e| anyhow!("invalid --node-id {raw:?}: {e}"))?;
let provider_raw = common
.provider_address
.as_deref()
.ok_or_else(|| anyhow!("--provider-address is required with --node-id"))?;
let provider = chain_ctx::parse_address(provider_raw, "--provider-address")?;
Ok(Some((node_id, provider)))
}
fn read_local_manifest(path: &Path) -> anyhow::Result<Manifest> {
let bytes =
std::fs::read(path).with_context(|| format!("read bundle file {}", path.display()))?;
parse_manifest(&bytes)
}
fn parse_manifest(bytes: &[u8]) -> anyhow::Result<Manifest> {
let manifest: Manifest =
serde_json::from_slice(bytes).context("parse bundle JSON (expected v1 manifest)")?;
if manifest.version != 1 {
bail!(
"unsupported bundle version {} (this build supports v1)",
manifest.version
);
}
Ok(manifest)
}
fn safe_join(root: &Path, rel: &str) -> anyhow::Result<PathBuf> {
if rel.is_empty() {
bail!("empty entry path");
}
let mut out = root.to_path_buf();
for seg in rel.split('/') {
let mut comps = Path::new(seg).components();
match (comps.next(), comps.next()) {
(Some(Component::Normal(s)), None) => out.push(s),
_ => bail!("rejected unsafe component {seg:?} in entry path {rel:?}"),
}
}
Ok(out)
}
fn record_one(out_root: &Path, en: &ManifestEntry) -> Option<bundle_manifest::SavedFile> {
let dest = safe_join(out_root, &en.path).ok()?;
let meta = std::fs::symlink_metadata(&dest).ok()?;
if !meta.is_file() {
return None; }
let mtime = SavedMtime::of(&meta)?; let chunks = en.chunks.as_ref().map(|cs| {
cs.iter()
.map(|c| bundle_manifest::SavedChunk {
hash: c.hash.clone(),
size: c.size,
})
.collect()
});
Some(bundle_manifest::SavedFile {
hash: en.hash.clone(),
size: meta.len(),
mtime,
chunks,
})
}
fn size_warnings(
entries: &[&ManifestEntry],
updates: &BTreeMap<String, bundle_manifest::SavedFile>,
) -> Vec<String> {
entries
.iter()
.filter_map(|en| {
let manifest = en.size?;
let landed = updates.get(&en.path)?.size;
(landed != manifest).then(|| {
format!(
"{}: manifest says {manifest} bytes, the blob is {landed} bytes",
en.path
)
})
})
.collect()
}
fn build_completed_updates(
entries: &[&ManifestEntry],
outcomes: &[EntryOutcome],
out_root: &Path,
) -> BTreeMap<String, bundle_manifest::SavedFile> {
let mut updates = BTreeMap::new();
for (en, outcome) in entries.iter().zip(outcomes) {
let recordable = matches!(
outcome,
EntryOutcome::Fetched(_) | EntryOutcome::Linked | EntryOutcome::Skipped
);
if recordable && let Some(rec) = record_one(out_root, en) {
updates.insert(en.path.clone(), rec);
}
}
updates
}
const FLUSH_FILES: usize = 64;
const FLUSH_BYTES: u64 = 1 << 30;
struct FlushBatch {
updates: BTreeMap<String, bundle_manifest::SavedFile>,
fetched_bytes: u64,
}
async fn flush_task(
out_root: &Path,
mut acc: SavedManifest,
mut rx: tokio::sync::mpsc::UnboundedReceiver<FlushBatch>,
) {
let mut files_since = 0usize;
let mut bytes_since = 0u64;
let mut dirty = false;
while let Some(batch) = rx.recv().await {
files_since += batch.updates.len();
bytes_since = bytes_since.saturating_add(batch.fetched_bytes);
bundle_manifest::merge(&mut acc, batch.updates);
dirty = true;
if (files_since >= FLUSH_FILES || bytes_since >= FLUSH_BYTES)
&& flush_now(out_root, &acc).await
{
files_since = 0;
bytes_since = 0;
dirty = false;
}
}
if dirty {
flush_now(out_root, &acc).await;
}
}
async fn flush_now(out_root: &Path, acc: &SavedManifest) -> bool {
let bytes = match bundle_manifest::serialize(acc) {
Ok(b) => b,
Err(e) => {
tracing::warn!(
"failed to serialize {}: {e}",
bundle_manifest::SAVED_MANIFEST_NAME
);
return false;
}
};
let root = out_root.to_path_buf();
match tokio::task::spawn_blocking(move || bundle_manifest::write_bytes(&root, &bytes)).await {
Ok(Ok(())) => true,
Ok(Err(e)) => {
tracing::warn!(
"failed to write {}: {e}",
bundle_manifest::SAVED_MANIFEST_NAME
);
false
}
Err(e) => {
tracing::warn!("skip-cache flush task panicked: {e}");
false
}
}
}
#[derive(Clone, Copy)]
enum NothingReason {
EmptyBundle,
Filtered,
Deselected,
}
impl NothingReason {
const fn from_filter(filters_given: bool, raw_empty: bool) -> Self {
if filters_given && !raw_empty {
Self::Filtered
} else {
Self::EmptyBundle
}
}
}
fn report_nothing_to_fetch(reason: NothingReason) {
let msg = match reason {
NothingReason::EmptyBundle => "bundle has no entries; nothing to fetch",
NothingReason::Filtered => {
"no bundle entries match the include/exclude filters; nothing to fetch"
}
NothingReason::Deselected => "every file was deselected; nothing to fetch",
};
println!("{msg}");
}
fn dry_run_manifest(args: &BundlePullArgs) -> anyhow::Result<Option<Manifest>> {
match (&args.input, &args.hash) {
(Some(path), _) => read_local_manifest(path).map(Some),
(None, Some(h)) => {
let hash = fetch::parse_hash(h)?;
bundle_cache::load(&args.output, hash)?
.map(|bytes| {
parse_manifest(&bytes).with_context(|| {
format!(
"cached bundle manifest {}",
bundle_cache::cache_path(&args.output, hash).display()
)
})
})
.transpose()
}
(None, None) => bail!("no bundle source (expected -i or --hash)"),
}
}
fn dry_run(args: &BundlePullArgs, filter: &EntryFilter, filters_given: bool) -> anyhow::Result<()> {
let out = args.output.display();
let Some(mut manifest) = dry_run_manifest(args)? else {
let h = args.hash.as_deref().unwrap_or_default();
if args.json {
let plan = serde_json::json!({
"output": out.to_string(),
"hash": h,
"count": null,
"entries": null,
});
println!("{plan}");
return Ok(());
}
let filter_note = if filters_given {
"; the include/exclude patterns are matched against them then"
} else {
""
};
println!(
"--dry-run with --hash: would fetch bundle {h} then its entries into {out} \
(no cached manifest: entries are listed once a pull into this directory \
caches it{filter_note})"
);
return Ok(());
};
let raw_empty = manifest.entries.is_empty();
manifest.entries = filter.apply_and_warn(manifest.entries).0;
if args.json {
let plan = serde_json::json!({
"output": args.output.display().to_string(),
"count": manifest.entries.len(),
"entries": manifest.entries.iter().map(|e| serde_json::json!({
"path": e.path, "hash": e.hash, "size": e.size,
"chunks": e.chunks.as_ref().map(Vec::len),
})).collect::<Vec<_>>(),
});
println!("{plan}");
} else if manifest.entries.is_empty() {
report_nothing_to_fetch(NothingReason::from_filter(filters_given, raw_empty));
} else {
println!(
"would fetch {} entr(ies) into {out}:",
manifest.entries.len()
);
for e in &manifest.entries {
let chunks = e
.chunks
.as_ref()
.map(|c| format!(", {} chunks", c.len()))
.unwrap_or_default();
match e.size {
Some(n) => println!(" {} ({n} bytes{chunks})", e.path),
None => println!(" {}{}", e.path, chunks),
}
}
}
Ok(())
}
fn report(
outcomes: &[EntryOutcome],
warnings: &[String],
transfer: Transfer,
dedup: DedupSummary,
excluded: u64,
output: &Path,
json: bool,
) -> anyhow::Result<()> {
for o in outcomes {
if let EntryOutcome::Failed { path, err } = o {
eprintln!("failed: {path}: {err}");
}
}
for line in warnings {
eprintln!("warning: {line}");
}
let rep = pull_report(outcomes, transfer, dedup, excluded, output);
let (reused, reused_bytes, failed) = (rep.reused, rep.reused_bytes, rep.failed);
if json {
let line = serde_json::to_string(&rep).map_err(|e| anyhow!("serialize report: {e}"))?;
println!("{line}");
} else {
println!("{}", counts_line(&rep));
println!("{}", transfer_line(transfer));
if transfer.resumed > 0 {
println!(
"resumed {} from earlier partials",
human_bytes(transfer.resumed)
);
}
if reused > 0 {
println!(
"whole-file dedup: reused {} from disk ({reused} file(s))",
human_bytes(reused_bytes)
);
}
if dedup.spliced_bytes > 0 || dedup.hints_ignored > 0 {
println!(
"range-dedup: spliced {} from disk, {} hint(s) ignored",
human_bytes(dedup.spliced_bytes),
dedup.hints_ignored
);
}
}
if failed > 0 {
bail!("{failed} entr(ies) failed to fetch");
}
Ok(())
}
fn pull_report(
outcomes: &[EntryOutcome],
transfer: Transfer,
dedup: DedupSummary,
excluded: u64,
output: &Path,
) -> PullReport {
let mut rep = PullReport {
output: output.display().to_string(),
fetched: 0,
linked: 0,
skipped: 0,
excluded,
failed: 0,
downloaded: transfer.downloaded,
reconstructed: transfer.reconstructed,
spliced_bytes: dedup.spliced_bytes,
hints_ignored: dedup.hints_ignored,
resumed_bytes: transfer.resumed,
reused: 0,
reused_bytes: 0,
};
for o in outcomes {
match o {
EntryOutcome::Fetched(_) => rep.fetched += 1,
EntryOutcome::Linked => rep.linked += 1,
EntryOutcome::Skipped => rep.skipped += 1,
EntryOutcome::Deduped(n) => {
rep.reused += 1;
rep.reused_bytes = rep.reused_bytes.saturating_add(*n);
}
EntryOutcome::Failed { .. } => rep.failed += 1,
}
}
rep
}
fn counts_line(rep: &PullReport) -> String {
format!(
"pulled into {} ({} fetched, {} linked, {} skipped, {} reused, {} excluded, {} failed)",
rep.output, rep.fetched, rep.linked, rep.skipped, rep.reused, rep.excluded, rep.failed
)
}
fn leftover_partials(out_root: &Path, keep: &HashSet<[u8; 32]>) -> Leftovers {
let Ok(dir) = std::fs::read_dir(out_root.join(STAGING_DIR)) else {
return Leftovers::default();
};
let mut blobs: HashSet<[u8; 32]> = HashSet::new();
let mut bytes = 0u64;
for entry in dir.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let stem = name
.strip_suffix(".partial.ranges")
.or_else(|| name.strip_suffix(".partial"))
.unwrap_or(name);
let Ok(hash) = blake3::Hash::from_hex(stem) else {
continue;
};
let hash = *hash.as_bytes();
if keep.contains(&hash) {
continue;
}
let Ok(meta) = entry.metadata() else {
continue;
};
if meta.is_file() {
blobs.insert(hash);
bytes = bytes.saturating_add(allocated_bytes(&meta));
}
}
Leftovers {
blobs: u64::try_from(blobs.len()).unwrap_or(u64::MAX),
bytes,
}
}
fn allocated_bytes(meta: &std::fs::Metadata) -> u64 {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
meta.blocks().saturating_mul(512)
}
#[cfg(not(unix))]
{
meta.len()
}
}
#[derive(Debug, Default, PartialEq, Eq)]
struct Leftovers {
blobs: u64,
bytes: u64,
}
fn leftover_warning(dir: &Path, leftovers: &Leftovers) -> Option<String> {
let size = human_bytes(leftovers.bytes);
let dir = dir.display();
match leftovers.blobs {
0 => None,
1 => Some(format!(
"1 partial download ({size} on disk) that this run did not use remains in \
{dir}; delete it to reclaim the space"
)),
n => Some(format!(
"{n} partial downloads ({size} on disk) that this run did not use remain in \
{dir}; delete them to reclaim the space"
)),
}
}
fn warn_leftover_partials(out_root: &Path, entries: &[ManifestEntry], bundle_hash: Option<&str>) {
let keep: HashSet<[u8; 32]> = entries
.iter()
.map(|e| e.hash.as_str())
.chain(bundle_hash)
.filter_map(|h| fetch::parse_hash(h).ok())
.collect();
let leftovers = leftover_partials(out_root, &keep);
if let Some(line) = leftover_warning(&out_root.join(STAGING_DIR), &leftovers) {
eprintln!("warning: {line}");
}
}
fn human_bytes(n: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
if n < 1000 {
return format!("{n} B");
}
#[allow(
clippy::cast_precision_loss,
reason = "display-only size label; exact integer value is not needed"
)]
let mut value = n as f64;
let mut unit = 0usize;
while value >= 1000.0 && unit + 1 < UNITS.len() {
value /= 1000.0;
unit += 1;
}
let label = UNITS.get(unit).copied().unwrap_or("B");
format!("{value:.1} {label}")
}
fn transfer_line(t: Transfer) -> String {
if t.downloaded == t.reconstructed {
format!("downloaded {}", human_bytes(t.downloaded))
} else {
format!(
"downloaded {} → reconstructed {}",
human_bytes(t.downloaded),
human_bytes(t.reconstructed)
)
}
}
fn group_transfer(outcomes: &[EntryOutcome], bytes: Option<EntryBytes>) -> Transfer {
let paid_size = outcomes.iter().find_map(|o| match o {
EntryOutcome::Fetched(n) => Some(*n),
_ => None,
});
let reused_size = outcomes.iter().find_map(|o| match o {
EntryOutcome::Deduped(n) => Some(*n),
_ => None,
});
match paid_size.or(reused_size) {
Some(n) => {
let copies = outcomes
.iter()
.filter(|o| {
matches!(
o,
EntryOutcome::Fetched(_) | EntryOutcome::Linked | EntryOutcome::Deduped(_)
)
})
.count();
let copies = u64::try_from(copies).unwrap_or(u64::MAX);
Transfer {
downloaded: paid_size.map_or(0, |size| bytes.map_or(size, |b| b.paid)),
reconstructed: n.saturating_mul(copies),
resumed: paid_size.and(bytes).map_or(0, |b| b.resumed),
}
}
None => Transfer::default(),
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
mod tests;