use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
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_bao_range::CHUNK_GROUP_BYTES;
use decdn_common::redact::sanitize_err_chain;
use decdn_incentive::buyer_pool::BuyerPoolStore;
use decdn_incentive::payment_pool::PaymentPool;
use futures_util::StreamExt as _;
use iroh::{Endpoint, RelayUrl};
use tokio_util::sync::CancellationToken;
use super::skip_cache::{self, SavedManifest, SavedMtime};
use super::{
BundleEnd, BundleObserver, BundleOutcome, BundleReport, EntryFailure, EntryLanded, FaultHints,
FileObserver, ManifestEntry, NodeSelection, STAGING_DIR, manifest_cache, parse_hash,
};
use crate::discovery::{self, NodeCandidate};
use crate::funding::RunFunding;
use crate::lane_sources::LaneSources;
use crate::lanes::{LaneDeps, LanePayer, STALL_WINDOW, SettleOnDrop};
use crate::ordered_writes::OrderedWrites;
use crate::session::{ChainConfig, DepositPolicy, DiscoveryOptions, PullSite};
use crate::stream_cap::LaneStreamCap;
use crate::targets::{
ProbeOpts, ProbeRound, ResolvedTargets, holders_or_none, identity_fresh_candidates, now_secs,
probe_and_order, resolve_target_node,
};
use crate::{
ClientRangedStore, Connections, CredentialSlot, DownloadTarget, Downloader, FundingNeeded,
LaneLedgers, PeerHealth, PoolReplaced, ProgressCallback, PullDeadlines, RecoveryGate,
RunDemand, StopPolicy,
};
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>,
work: HashMap<[u8; 32], PlannedWork>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct PlannedWork {
download: u64,
resumed: u64,
}
async fn planned_work(
entries: &[ManifestEntry],
out_root: &Path,
overwrite: bool,
fetch_plan: &FetchPlan,
disk: &DiskState,
) -> HashMap<[u8; 32], PlannedWork> {
let refs: Vec<&ManifestEntry> = entries.iter().collect();
let empty = HashMap::new();
let mut work = HashMap::new();
for group in group_by_hash(&refs) {
let Ok(hash) = parse_hash(group.hash) else {
continue;
};
let writes = plan_slots(&group.entries, out_root, overwrite, &disk.skip)
.iter()
.any(|slot| matches!(slot, Slot::Write { .. }));
if !writes || disk.whole_file.contains_key(&hash) {
continue;
}
let (download, _) = blob_download_reconstruct(&group, fetch_plan, &empty);
if download == 0 {
continue;
}
let resumed = match staging_path(out_root, hash) {
Ok(staging) => span_bytes(&resumed_spans_off_runtime(&staging, hash).await),
Err(_) => 0,
};
work.insert(
hash,
PlannedWork {
download: download.saturating_sub(resumed),
resumed,
},
);
}
work
}
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) = 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) = skip_cache::saved_hints(rec)
{
for (chash, offset, len) in old {
if let Ok(h) = 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) = parse_hash(&rec.hash)
{
state.whole_file.entry(h).or_insert_with(|| src.clone());
}
if let Some(hints) = skip_cache::saved_hints(rec) {
for (chash, offset, len) in hints {
if let Ok(h) = 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()
}
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| 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 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(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) -> crate::FatalScope {
match crate::classify(err) {
crate::Fault::Fatal(scope) => scope,
_ => crate::FatalScope::Item,
}
}
fn ends_the_pull(err: &anyhow::Error) -> bool {
entry_scope(err) == crate::FatalScope::Command || err.downcast_ref::<crate::GaveUp>().is_some()
}
#[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)]
pub(super) struct DedupStats {
spliced_bytes: AtomicU64,
hints_ignored: AtomicU64,
}
impl DedupStats {
fn take(&self) -> DedupSummary {
DedupSummary {
spliced_bytes: self.spliced_bytes.swap(0, Ordering::Relaxed),
hints_ignored: self.hints_ignored.swap(0, Ordering::Relaxed),
}
}
}
#[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),
}
}
}
pub(super) async fn discover_candidates(
chain: &ChainConfig,
site: &PullSite,
discovery: &DiscoveryOptions,
) -> anyhow::Result<Vec<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 !discovery.rediscover
&& let Some(cached) = cached_registry(&site.data_dir, now_secs())
{
return Ok(cached);
}
let bootstrap = crate::discovery::bootstrap_nodes(
&chain.rpc_url,
capacity_bond,
&site.data_dir,
discovery.registry_timeout,
)
.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 = crate::PeerStore::open(data_dir);
let cfg = crate::StoreConfig::default();
let cached = 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)
}
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 = ()>,
cancel: &CancellationToken,
) -> bool {
let drive = async {
tokio::select! {
() = drive => false,
() = cancel.cancelled() => 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 settle_group_run(
groups: &mut [SettledGroup],
flush_tx: &tokio::sync::mpsc::UnboundedSender<FlushBatch>,
i: usize,
run: GroupRun,
updates: BTreeMap<String, skip_cache::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(super) struct JobGate {
permits: tokio::sync::Semaphore,
admission: tokio::sync::Mutex<()>,
}
impl JobGate {
pub(super) 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()
}
}
pub(super) struct PullCtx<P: Provider + Clone> {
pub(super) endpoint: Endpoint,
pub(super) store: Arc<dyn BuyerPoolStore>,
pub(super) contract: PaymentPool::PaymentPoolInstance<P>,
pub(super) rpc: P,
pub(super) signer: Arc<PrivateKeySigner>,
pub(super) self_address: Address,
pub(super) token: Address,
pub(super) voucher_dom: Eip712Domain,
pub(super) slash_dom: Eip712Domain,
pub(super) chain: ChainConfig,
pub(super) site: PullSite,
pub(super) relays: Vec<RelayUrl>,
pub(super) nodes: NodeSelection,
pub(super) discovery: DiscoveryOptions,
pub(super) max_rate_per_mb: u64,
pub(super) max_blob_bytes: u64,
pub(super) deposit: DepositPolicy,
pub(super) slot: Option<CredentialSlot>,
pub(super) namespace_id: [u8; 32],
pub(super) ledgers: LaneLedgers,
pub(super) funding: RunFunding,
pub(super) recovery: Arc<RecoveryGate>,
pub(super) dedup_stats: DedupStats,
pub(super) open_lock: tokio::sync::Mutex<()>,
pub(super) gate: JobGate,
pub(super) lane_cap: LaneStreamCap,
pub(super) health: Arc<PeerHealth>,
pub(super) connections: Connections,
pub(super) writes: OrderedWrites,
pub(super) stop: StopPolicy,
pub(super) demand: Arc<RunDemand>,
pub(super) observer: Option<Arc<dyn BundleObserver>>,
pub(super) hints: Option<Arc<dyn FaultHints>>,
}
impl<P: Provider + Clone> PullCtx<P> {
fn drive_deps(&self, max_blob_bytes: u64) -> anyhow::Result<LaneDeps<'_, P>> {
Ok(LaneDeps {
marks: 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,
site: &self.site,
deposit: self.deposit,
namespace_id: self.namespace_id,
max_rate_per_mb: self.max_rate_per_mb,
max_blob_bytes,
deadlines: PullDeadlines::new(STALL_WINDOW, 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<ResolvedTargets> {
holders_or_none(self.resolve_entry_targets(hash).await)
}
async fn resolve_entry_targets(&self, hash: [u8; 32]) -> anyhow::Result<ResolvedTargets> {
if self.nodes.pinned.is_some() {
return resolve_target_node(
&self.discovery,
(&self.chain, &self.site),
&self.endpoint,
&self.relays,
hash,
ProbeOpts {
marks: None,
round: ProbeRound::Stream,
},
)
.await;
}
let candidates = self.entry_candidates()?;
probe_and_order(
&self.endpoint,
&candidates,
self.relays.first(),
hash,
self.discovery.proxy_warming,
&self.slash_dom,
ProbeRound::Stream,
)
.await
}
async fn acquire_entry(
&self,
targets: &ResolvedTargets,
hash: [u8; 32],
staging: &Path,
total: Option<u64>,
ranges: Option<&[(u64, u64)]>,
progress: Option<&ProgressCallback>,
) -> anyhow::Result<bool> {
let max_blob_bytes = self.max_blob_bytes;
let deps = self.drive_deps(max_blob_bytes)?;
let sources = LaneSources::new(
&deps,
&self.discovery,
&self.relays,
match &self.slot {
Some(slot) => LanePayer::Delegated(slot),
None => LanePayer::Owned(&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 = 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.discovery.max_sources)
.max_blob_bytes(max_blob_bytes)
.run_demand(Arc::clone(&self.demand));
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| match &self.hints {
Some(hints) => hints.annotate(err, &sources.fault_context()),
None => err,
})
}
fn entry_candidates(&self) -> anyhow::Result<Vec<NodeCandidate>> {
let registry = self
.nodes
.registry
.as_deref()
.ok_or_else(|| anyhow!("no discovery candidates available"))?;
Ok(entry_candidates(registry, self.site.region.as_deref()))
}
pub(super) async fn fetch_manifest(
&self,
hash: [u8; 32],
out_root: &Path,
use_cache: bool,
) -> anyhow::Result<super::BundleManifest> {
let bytes = if let Some(cached) =
cached_manifest(out_root, hash, use_cache, |warning| self.warn(warning))
{
cached
} else {
let fetched = self
.fetch_to_memory(hash, out_root)
.await
.context("fetch bundle manifest blob")?;
if let Some(warning) = manifest_cache::store(out_root, hash, &fetched) {
self.warn(&warning);
}
fetched
};
super::BundleManifest::parse(&bytes)
}
fn warn(&self, warning: &str) {
if let Some(observer) = &self.observer {
observer.warning(warning);
}
}
pub(super) async fn run(
&mut self,
entries: &[ManifestEntry],
out_root: &Path,
overwrite: bool,
cancel: &CancellationToken,
) -> BundleOutcome {
let fetch_plan = build_fetch_plan(entries);
let planned = download_bytes(entries, &fetch_plan);
if let Some(observer) = &self.observer {
observer.run_started(planned, total_content_bytes(entries));
}
let saved = skip_cache::load(out_root);
let paid_before = self.ledgers.paid_bytes();
let mut run = self
.pull_all(entries, out_root, overwrite, saved, cancel)
.await;
let mut paid = self.ledgers.paid_bytes().saturating_sub(paid_before);
let replaced = replaced_pool(&run);
if let Some(new) = replaced {
if let Some(observer) = &self.observer {
observer.pool_replaced(&new);
}
self.ledgers = LaneLedgers::new();
self.funding = RunFunding::reporting_opens_to(self.funding.opens().clone());
self.recovery.start_next_pass();
let saved = skip_cache::load(out_root);
let second = self
.pull_all(entries, out_root, overwrite, saved, cancel)
.await;
run = merge_passes(run, second);
paid = paid.saturating_add(self.ledgers.paid_bytes());
}
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,
});
warnings.extend(self.funding.shortfall());
let dedup = self.dedup_stats.take();
let mut report = bundle_report(&outcomes, transfer, dedup, warnings);
report.paid_bytes = u64::try_from(paid).unwrap_or(u64::MAX);
let end = if interrupted {
BundleEnd::Interrupted
} else {
match stopped {
None => BundleEnd::Complete,
Some(error) => match error.downcast_ref::<FundingNeeded>().copied() {
Some(needed) => BundleEnd::FundingNeeded { needed, error },
None => BundleEnd::Stopped(error),
},
}
};
BundleOutcome {
report,
end,
replaced,
}
}
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,
cancel: &CancellationToken,
) -> 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 mut disk = resolve_disk_state(entries, &saved, out_root, overwrite).await;
disk.work = planned_work(entries, out_root, overwrite, &fetch_plan, &disk).await;
let outstanding = disk
.work
.values()
.fold(0u64, |sum, work| sum.saturating_add(work.download));
self.demand.plan(outstanding);
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,
cancel,
)
.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,
cancel: &CancellationToken,
) -> 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) = 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, cancel).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<&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| self.report_landed(hash, staging, bytes, started.elapsed()))
}
fn report_landed(
&self,
hash: [u8; 32],
staging: &Path,
bytes: EntryBytes,
elapsed: std::time::Duration,
) {
let Some(observer) = &self.observer else {
return;
};
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(),
);
observer.entry_landed(&EntryLanded {
hash,
size,
paid: bytes.paid,
spliced: bytes.spliced,
resumed: bytes.resumed,
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<&FileBar>,
slot: &FetchSlot<'_>,
) -> anyhow::Result<EntryBytes> {
let progress = file.map(FileBar::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))
}
fn work_share(
&self,
group: &HashGroup<'_>,
fetch_plan: &FetchPlan,
disk: &DiskState,
) -> (Arc<WorkShare>, u64, u64) {
let (download, reconstruct) = blob_download_reconstruct(group, fetch_plan, &HashMap::new());
let work = parse_hash(group.hash)
.ok()
.and_then(|hash| disk.work.get(&hash).copied())
.unwrap_or_default();
let share = Arc::new(WorkShare::new(Arc::clone(&self.demand), work));
(share, download, reconstruct)
}
async fn fetch_group(
&self,
group: HashGroup<'_>,
out_root: &Path,
overwrite: bool,
index: &ChunkIndex,
fetch_plan: &FetchPlan,
disk: &DiskState,
) -> GroupRun {
let (share, download_bytes_of_blob, reconstruct_bytes_of_blob) =
self.work_share(&group, fetch_plan, disk);
let slot = match 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 file_bar = FileBar::of(
self.observer.as_ref().map(|observer| {
observer.file_started(&paths, download_bytes_of_blob, reconstruct_bytes_of_blob)
}),
share,
);
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)
}
}
struct WorkShare {
demand: Arc<RunDemand>,
planned: u64,
resumed: u64,
taken: AtomicU64,
}
impl WorkShare {
const fn new(demand: Arc<RunDemand>, work: PlannedWork) -> Self {
Self {
demand,
planned: work.download,
resumed: work.resumed,
taken: AtomicU64::new(0),
}
}
fn delivered(&self, received: u64) {
let capped = received.saturating_sub(self.resumed).min(self.planned);
let before = self.taken.fetch_max(capped, Ordering::Relaxed);
if capped > before {
let gone = capped - before;
self.demand.done(gone);
}
}
}
impl Drop for WorkShare {
fn drop(&mut self) {
self.delivered(self.resumed.saturating_add(self.planned));
}
}
pub(super) struct FileBar {
file: Option<Arc<dyn FileObserver>>,
cb: Box<ProgressCallback>,
}
impl FileBar {
fn of(file: Option<Arc<dyn FileObserver>>, share: Arc<WorkShare>) -> Self {
let observer = file.clone();
let cb = Box::new(move |received: u64, expected: u64| {
share.delivered(received);
if let Some(file) = &observer {
file.delivered(received, expected);
}
}) as Box<ProgressCallback>;
Self { file, cb }
}
fn callback(&self) -> &ProgressCallback {
self.cb.as_ref()
}
fn set_discovering(&self) {
if let Some(file) = &self.file {
file.discovering();
}
}
fn set_pending(&self) {
if let Some(file) = &self.file {
file.pending();
}
}
fn reconstruct_reporter(&self) -> Option<Box<dyn Fn(u64) + Send + Sync>> {
let file = self.file.clone()?;
file.reconstructing();
Some(Box::new(move |hashed: u64| file.hashed(hashed)))
}
fn finish(self) {
if let Some(file) = self.file {
file.finished();
}
}
}
#[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) = 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))
}
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<P>,
targets: &'a 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) = 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<&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(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<&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 = 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 = 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) = 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(())
}
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 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<skip_cache::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| skip_cache::SavedChunk {
hash: c.hash.clone(),
size: c.size,
})
.collect()
});
Some(skip_cache::SavedFile {
hash: en.hash.clone(),
size: meta.len(),
mtime,
chunks,
})
}
fn size_warnings(
entries: &[&ManifestEntry],
updates: &BTreeMap<String, skip_cache::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, skip_cache::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, skip_cache::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);
skip_cache::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 skip_cache::serialize(acc) {
Ok(b) => b,
Err(e) => {
tracing::warn!(
"failed to serialize {}: {e}",
skip_cache::SAVED_MANIFEST_NAME
);
return false;
}
};
let root = out_root.to_path_buf();
match tokio::task::spawn_blocking(move || skip_cache::write_bytes(&root, &bytes)).await {
Ok(Ok(())) => true,
Ok(Err(e)) => {
tracing::warn!("failed to write {}: {e}", skip_cache::SAVED_MANIFEST_NAME);
false
}
Err(e) => {
tracing::warn!("skip-cache flush task panicked: {e}");
false
}
}
}
fn cached_manifest(
out_root: &Path,
hash: [u8; 32],
use_cache: bool,
warn: impl FnOnce(&str),
) -> Option<Vec<u8>> {
if !use_cache {
return None;
}
manifest_cache::load(out_root, hash).unwrap_or_else(|e| {
warn(&format!("{e:#}; fetching the bundle manifest"));
None
})
}
fn bundle_report(
outcomes: &[EntryOutcome],
transfer: Transfer,
dedup: DedupSummary,
warnings: Vec<String>,
) -> BundleReport {
let mut report = BundleReport {
downloaded: transfer.downloaded,
reconstructed: transfer.reconstructed,
resumed_bytes: transfer.resumed,
spliced_bytes: dedup.spliced_bytes,
hints_ignored: dedup.hints_ignored,
warnings,
..BundleReport::default()
};
for o in outcomes {
match o {
EntryOutcome::Fetched(_) => report.fetched += 1,
EntryOutcome::Linked => report.linked += 1,
EntryOutcome::Skipped => report.skipped += 1,
EntryOutcome::Deduped(n) => {
report.reused += 1;
report.reused_bytes = report.reused_bytes.saturating_add(*n);
}
EntryOutcome::Failed { path, err } => {
report.failed += 1;
report.failures.push(EntryFailure {
path: path.clone(),
error: err.clone(),
});
}
}
}
report
}
pub(super) fn temp_in_parent(target: &Path) -> std::io::Result<tempfile::NamedTempFile> {
match target.parent().filter(|p| !p.as_os_str().is_empty()) {
Some(p) => tempfile::NamedTempFile::new_in(p),
None => tempfile::NamedTempFile::new_in("."),
}
}
fn ranged_store_location(output: &Path) -> anyhow::Result<(PathBuf, String)> {
let dir = match output.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
};
let stem = output
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow!("{} has no usable file name", output.display()))?
.to_string();
Ok((dir, stem))
}
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;