decdn_cli/commands/bundle_pull.rs
1//! `decdn bundle pull` — fetch every blob a bundle manifest references into an
2//! output directory over the paid `cdn/client/v1` path (issue #391).
3//!
4//! The manifest comes from a local file (`-i`) or is fetched first by its own
5//! BLAKE3 hash (`--hash`); either way entries are then fetched per *distinct*
6//! blob hash. Entries naming the same blob (one file at two paths) are fetched —
7//! and paid for — once and hard-linked (or copied) to each path (#1306). Node selection is
8//! per blob (#936): with an explicit `--node-id` every entry is pulled from that
9//! one node, otherwise each distinct blob discovers its own holder among the
10//! region-nearest active nodes. Every group's fetch — a plain whole-file blob,
11//! or a hint-carrying entry's pay-now-range fetch — fans out concurrently,
12//! bounded by one global `--jobs` cap (`PullCtx.gate`). An entry that only
13//! waits for a sibling's donor chunks holds no slot while it waits. A manifest `chunks`
14//! entry is never fetched or stored as its own blob: its hints only let a
15//! byte range shared with another entry be recognized and spliced from disk
16//! instead of paid for again.
17//!
18//! **Shared chunks are fetched once.** When a chunk hint hash appears in two or
19//! more entries, the run assigns that chunk to one entry — its smallest holder,
20//! computed up front from the whole manifest by `build_fetch_plan` — and only
21//! that entry pays to fetch it. Groups are scheduled smallest-first, so a holder
22//! starts before the larger entries that share its chunks. Each entry drives its
23//! own ranges (unique chunks, chunks assigned to it, and the partial groups at
24//! the two ends of each spliced run) up front and defers the rest; at its tail it splices each deferred range as the
25//! assigned holder registers it, waiting on the holder — not a clock — and driving
26//! a deferred range itself only if that holder finishes without producing it.
27//!
28//! **One shared pool.** The whole bundle pulls from the caller's single
29//! `PaymentPool` deposit (ADR 003) — opened once and reused across every
30//! provider the manifest touches. Two concurrency guards follow from that: the
31//! run's shared `LaneLedgers` (ADR 039) give each `(pool_id, signer, provider)`
32//! lane one monotonic voucher issuer, so concurrent fetches sharing a lane —
33//! including every leg of a multi-source entry's admitted provider set — draw
34//! from the same watermark instead of racing it, while their transfers still
35//! run concurrently; and a single global mutex serializes every open-or-reuse
36//! call — the pool's on-chain state (deposit, allowance) is one shared resource,
37//! regardless of which provider an entry is bound for.
38//!
39//! **Incremental re-runs.** A run reads `<out_root>/.decdn-manifest.json`
40//! before fetching and folds each completed file's record back into it as the
41//! pull progresses — rewriting the merged cache atomically whenever a batch of
42//! files or a gigabyte of new bytes has landed, and once more when the pull
43//! ends. An interrupted pull therefore leaves the files it already landed
44//! recorded, so the next run skips them rather than re-hashing the whole tree.
45//! Only a file that completed successfully this run is recorded; a still-in-flight
46//! or not-yet-fetched entry is never written from bytes this run has not landed.
47//! An in-scope path is
48//! skipped when the saved record's hash, size, and mtime match the new
49//! manifest. A path with no matching saved record is still skipped when
50//! re-hashing its on-disk bytes matches the new manifest hash. A path whose
51//! on-disk content no longer matches the new manifest hash is re-fetched, not
52//! silently kept. Every skipped or freshly written path also seeds its
53//! on-disk byte ranges as chunk donors, so a later run's or bundle's
54//! complement-range fetch can splice an unchanged range from disk instead of
55//! paying for it again.
56//!
57//! **Whole-root content reuse.** A run also reuses a byte-identical file
58//! already anywhere in the output root, not only at its own manifest path. It
59//! consults a whole-root content index built from `.decdn-manifest.json`'s
60//! saved records: target blob hash → an on-disk regular file the manifest
61//! records with that hash. When a blob's hash hits that index, the candidate
62//! is confirmed by a whole-file re-hash, then the destination is materialized
63//! by hard link (or copy) with no download and no payment. A file that only
64//! partially matches an on-disk candidate still splices its common byte
65//! ranges from disk the same way, through the donor mechanism described
66//! above.
67
68use std::collections::{BTreeMap, HashMap, HashSet};
69use std::io::IsTerminal as _;
70use std::path::{Component, Path, PathBuf};
71use std::str::FromStr;
72use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
73use std::sync::{Arc, Mutex, PoisonError};
74
75use alloy::dyn_abi::Eip712Domain;
76use alloy::primitives::Address;
77use alloy::providers::Provider;
78use alloy::signers::local::PrivateKeySigner;
79use anyhow::{Context as _, anyhow, bail};
80use decdn_common::cli::{BundlePullArgs, ClientFetchArgs};
81use decdn_common::config::load_file_config;
82use decdn_common::redact::sanitize_err_chain;
83use decdn_incentive::buyer_pool_redb::RedbBuyerPoolStore;
84use decdn_incentive::eth_identity::{PasswordUse, load_signer};
85use decdn_incentive::payment_pool::PaymentPool;
86use decdn_incentive::{slash_judge_domain, voucher_domain};
87use futures_util::StreamExt as _;
88use iroh::{Endpoint, PublicKey, RelayUrl};
89use serde::{Deserialize, Serialize};
90
91use super::bundle_cache;
92use super::bundle_manifest::{self, SavedManifest, SavedMtime};
93use super::buyer_store::open_client_store_for_buy;
94use super::chain_ctx;
95use super::fetch;
96use super::interrupt::{Interrupt, Interrupted};
97use super::manifest::build_glob_set;
98use super::ordered_writes::OrderedWrites;
99use super::pull_progress::{self, PullProgress};
100use decdn_bao_range::CHUNK_GROUP_BYTES;
101use decdn_client::discovery::{self, NodeCandidate};
102use decdn_client::endpoint as client_endpoint;
103use decdn_client::provider;
104use decdn_client::{
105 ClientRangedStore, Connections, DownloadTarget, Downloader, LaneLedgers, PeerHealth,
106 PoolReplaced, ProgressCallback, ProgressClock, PullDeadlines, RecoveryGate, StopPolicy,
107};
108
109use super::cli_sources::CliSources;
110
111type FetchTarget = (PublicKey, Address);
112
113/// Group manifest entries by their blob `hash`, preserving first-seen order both
114/// across groups and within each group. Entries that name the same blob (one
115/// file published at two paths) land in one group so it is fetched once (#1306).
116fn group_by_hash<'a>(entries: &[&'a ManifestEntry]) -> Vec<HashGroup<'a>> {
117 let mut index: HashMap<&str, usize> = HashMap::new();
118 let mut groups: Vec<HashGroup<'a>> = Vec::new();
119 for entry in entries {
120 let next = groups.len();
121 let at = *index.entry(entry.hash.as_str()).or_insert(next);
122 if at == next {
123 groups.push(HashGroup {
124 hash: entry.hash.as_str(),
125 entries: vec![*entry],
126 });
127 } else if let Some(group) = groups.get_mut(at) {
128 group.entries.push(*entry);
129 }
130 }
131 groups
132}
133
134/// Order hash-groups smallest whole-file first so a shared chunk's assigned
135/// fetcher (the smallest containing entry, per [`build_fetch_plan`]) starts — and
136/// finishes — before the larger entries that defer to it, keeping the tail-reconcile
137/// wait short. A group's entries share one blob, so the group's size is any entry's
138/// declared `size` (the same OR-across-same-hash-entries rule as
139/// [`total_content_bytes`], so a group with one unsized duplicate path still sorts
140/// by its real size); a group no entry sizes sorts last (it can never be a sized
141/// donor). Ties break on the group hash for a deterministic order.
142fn order_groups_smallest_first(mut groups: Vec<HashGroup<'_>>) -> Vec<HashGroup<'_>> {
143 groups.sort_by(|a, b| {
144 let a_size = a.entries.iter().find_map(|e| e.size);
145 let b_size = b.entries.iter().find_map(|e| e.size);
146 // A group no entry sizes sorts last: map to the max sentinel for the compare.
147 let key = |s: Option<u64>| s.unwrap_or(u64::MAX);
148 key(a_size)
149 .cmp(&key(b_size))
150 .then_with(|| a.hash.cmp(b.hash))
151 });
152 groups
153}
154
155/// Run blocking file work `f` on tokio's blocking pool and return its result.
156/// Every group of a bundle pull is a future polled by ONE task, so a blocking
157/// call on that task stops every other group's streams from reading or paying —
158/// a serving node then ends them when its proof wait faults. `what` names the
159/// work in a join error.
160async fn off_runtime<T, F>(what: &'static str, f: F) -> anyhow::Result<T>
161where
162 T: Send + 'static,
163 F: FnOnce() -> anyhow::Result<T> + Send + 'static,
164{
165 tokio::task::spawn_blocking(f)
166 .await
167 .map_err(|e| anyhow!("{what} task: {e}"))?
168}
169
170/// [`link_or_copy_atomic`] on the blocking pool: its copy fallback moves a whole
171/// blob.
172async fn link_off_runtime(src: PathBuf, dest: PathBuf) -> anyhow::Result<()> {
173 off_runtime("link", move || link_or_copy_atomic(&src, &dest)).await
174}
175
176/// Materialize `src`'s content at `dest` without re-reading it over the network:
177/// a hard link where the filesystem allows it, else a full copy (cross-device
178/// `EXDEV`, or a filesystem that can't link). Staged in `dest`'s parent and
179/// renamed into place so `dest` is only ever absent or complete — the same
180/// atomic-replace invariant [`materialize`] upholds, which [`resolve_disk_state`]
181/// and [`plan_slots`] rely on when deciding a path is skip-safe.
182fn link_or_copy_atomic(src: &Path, dest: &Path) -> anyhow::Result<()> {
183 let parent = dest
184 .parent()
185 .filter(|p| !p.as_os_str().is_empty())
186 .unwrap_or_else(|| Path::new("."));
187 // A duplicate path may live in a subdir the canonical path never created.
188 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
189 let staged = tempfile::Builder::new()
190 .prefix(".decdn-link-")
191 .make_in(parent, |candidate| {
192 match std::fs::hard_link(src, candidate) {
193 Ok(()) => Ok(()),
194 // A fresh random candidate colliding is make_in's signal to retry.
195 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
196 // EXDEV or an unsupported link: fall back to a full byte copy.
197 Err(_) => std::fs::copy(src, candidate).map(|_| ()),
198 }
199 })
200 .with_context(|| {
201 format!(
202 "stage link/copy of {} for {}",
203 src.display(),
204 dest.display()
205 )
206 })?;
207 staged
208 .persist(dest)
209 .map_err(|e| e.error)
210 .with_context(|| format!("write {}", dest.display()))?;
211 Ok(())
212}
213
214/// A disk-seeded chunk donor: a byte range already on disk whose bytes back a
215/// chunk `hash`. Fed into [`ChunkIndex`] before fetching so a splice can reuse
216/// on-disk bytes across runs. The source is an OUTPUT path (not a staging blob),
217/// verified by the existing per-chunk re-hash before any splice trusts it.
218struct SeedDonor {
219 /// The chunk's BLAKE3 hash — the [`ChunkIndex`] key.
220 hash: [u8; 32],
221 /// The on-disk output file that holds the chunk's bytes.
222 source: PathBuf,
223 /// Byte offset of the chunk within `source`.
224 offset: u64,
225 /// Chunk length in bytes.
226 len: u64,
227}
228
229/// The result of inspecting the output tree against the new manifest and the
230/// saved skip-cache before fetching: which in-scope paths to skip, and the
231/// on-disk chunk donors to seed.
232#[derive(Default)]
233struct DiskState {
234 /// In-scope manifest paths whose on-disk file already matches the new
235 /// manifest hash (fast-skip or re-hash-confirmed) — not fetched.
236 skip: HashSet<String>,
237 /// On-disk chunk donors to seed into the run's [`ChunkIndex`].
238 seed: Vec<SeedDonor>,
239 /// Whole-file donors already on disk anywhere in the root: target blob hash
240 /// → an on-disk regular file the saved manifest records with that hash. A hit
241 /// lets a group materialize by link/copy instead of fetching — but only after
242 /// the candidate is re-hashed and confirmed (done at the use site).
243 whole_file: HashMap<[u8; 32], PathBuf>,
244}
245
246/// Classify every in-scope entry against the output tree and the saved
247/// skip-cache: fast-skip on a matching saved record (hash + size + mtime), else
248/// re-hash the on-disk bytes against the new manifest hash, else fetch. With
249/// `overwrite` nothing is skipped. The whole-file `hash` is authoritative, so a
250/// stale or absent saved record only costs a re-hash, never correctness.
251async fn resolve_disk_state(
252 entries: &[ManifestEntry],
253 saved: &SavedManifest,
254 out_root: &Path,
255 overwrite: bool,
256) -> DiskState {
257 let mut state = DiskState::default();
258 if overwrite {
259 return state;
260 }
261 for en in entries {
262 let Ok(dest) = safe_join(out_root, &en.path) else {
263 continue; // a bad path fails later in plan_slots; not skippable
264 };
265 if dest.starts_with(out_root.join(STAGING_DIR)) {
266 continue;
267 }
268 // `symlink_metadata` does not follow links: a symlink or any
269 // non-regular file is never skipped, re-hashed, or seeded as a donor —
270 // it is left to the fetch path, whose atomic materialize replaces it
271 // with the manifest's regular file. This avoids hashing a symlink target
272 // and avoids keeping a non-regular file in place on a fast-skip.
273 let Ok(meta) = std::fs::symlink_metadata(&dest) else {
274 continue; // absent/unreadable → fetch
275 };
276 if !meta.is_file() {
277 continue; // symlink / dir / non-regular → not skippable, not a donor
278 }
279 // Fast-skip: the saved record agrees with the file on hash, size, and
280 // mtime. The file is accepted by its hash: the manifest's `size` is only
281 // a first claim, and a blob whose true size differs from it is still
282 // the entry's blob, so it is not fetched again on every run.
283 let fast = saved.get(&en.path).is_some_and(|rec| {
284 rec.hash == en.hash
285 && rec.size == meta.len()
286 && SavedMtime::of(&meta).as_ref() == Some(&rec.mtime)
287 });
288 if fast {
289 state.skip.insert(en.path.clone());
290 seed_new_chunks(&mut state, en, &dest);
291 continue;
292 }
293 // Re-hash gate: confirm the on-disk bytes against the new manifest hash.
294 let Ok(want) = fetch::parse_hash(&en.hash) else {
295 continue;
296 };
297 let dest_buf = dest.clone();
298 let got = tokio::task::spawn_blocking(move || hash_partial(&dest_buf)).await;
299 if let Ok(Ok(got)) = got
300 && got == want
301 {
302 state.skip.insert(en.path.clone());
303 seed_new_chunks(&mut state, en, &dest);
304 continue;
305 }
306 // Present but mismatched: this path will be fetched. Its current bytes
307 // are the OLD file and survive on disk until this entry's own group
308 // atomically materializes (temp + rename), so they are a safe donor for
309 // any other entry that shares an old chunk in the meantime.
310 if let Some(rec) = saved.get(&en.path)
311 && let Some(old) = bundle_manifest::saved_hints(rec)
312 {
313 for (chash, offset, len) in old {
314 if let Ok(h) = fetch::parse_hash(&chash) {
315 state.seed.push(SeedDonor {
316 hash: h,
317 source: dest.clone(),
318 offset,
319 len,
320 });
321 }
322 }
323 }
324 }
325 // Widen reuse to the whole root: every saved record whose file still exists
326 // as a regular file is a donor keyed by content — a whole-file donor (for a
327 // no-download link, verified by re-hash at the use site) and a chunk donor
328 // (spliced under the existing per-chunk re-hash guard). This is what lets a
329 // file shared across bundles at different paths be reused.
330 //
331 // A path this run will overwrite (an in-scope entry not already in
332 // `state.skip`) is excluded from `whole_file` only: its bytes can be
333 // atomically replaced by its own group between the whole-file link's
334 // re-hash and its link/copy (TOCTOU), which would silently corrupt the
335 // link's destination with no post-link verification to catch it. Chunk
336 // donors from the same record stay in `state.seed` — a spliced range is
337 // always covered by the final whole-file re-verification, so the race
338 // there is harmless.
339 let will_write: HashSet<&str> = entries
340 .iter()
341 .map(|e| e.path.as_str())
342 .filter(|p| !state.skip.contains(*p))
343 .collect();
344 for (path, rec) in saved.records() {
345 let Ok(src) = safe_join(out_root, path) else {
346 continue;
347 };
348 if src.starts_with(out_root.join(STAGING_DIR)) {
349 continue;
350 }
351 let Ok(meta) = std::fs::symlink_metadata(&src) else {
352 continue; // gone / unreadable
353 };
354 if !meta.is_file() {
355 continue; // symlink / non-regular → never a donor
356 }
357 if !will_write.contains(path)
358 && let Ok(h) = fetch::parse_hash(&rec.hash)
359 {
360 state.whole_file.entry(h).or_insert_with(|| src.clone());
361 }
362 if let Some(hints) = bundle_manifest::saved_hints(rec) {
363 for (chash, offset, len) in hints {
364 if let Ok(h) = fetch::parse_hash(&chash) {
365 state.seed.push(SeedDonor {
366 hash: h,
367 source: src.clone(),
368 offset,
369 len,
370 });
371 }
372 }
373 }
374 }
375 state
376}
377
378/// Seed `state.seed` with the NEW manifest entry's chunk donors, sourced from
379/// `dest` — the entry's own output file, confirmed unchanged (skipped) this
380/// run. Seeding never gates skip/fetch; it only supplies optional splice
381/// donors for other entries.
382fn seed_new_chunks(state: &mut DiskState, en: &ManifestEntry, dest: &Path) {
383 if let Some(hints) = hints_of(en) {
384 for h in hints {
385 state.seed.push(SeedDonor {
386 hash: h.hash,
387 source: dest.to_path_buf(),
388 offset: h.offset,
389 len: h.len,
390 });
391 }
392 }
393}
394
395/// Resolve each entry's on-disk destination and classify it — a resolve failure,
396/// a path to skip, or a path to write — before any fetch. `skip` is the
397/// [`resolve_disk_state`] pre-pass result: a path lands in it on a matching
398/// saved-manifest record (fast-skip) or, failing that, on a re-hash of the
399/// on-disk bytes against the new manifest hash. A path whose content changed
400/// is written, not silently kept. Evaluated **per destination**, so one path
401/// of a duplicated blob can be skipped while another is written. With
402/// `overwrite` set, `skip` is empty and every destination is written.
403fn plan_slots<'a>(
404 entries: &[&'a ManifestEntry],
405 out_root: &Path,
406 overwrite: bool,
407 skip: &HashSet<String>,
408) -> Vec<Slot<'a>> {
409 entries
410 .iter()
411 .map(|en| match safe_join(out_root, &en.path) {
412 Err(e) => Slot::Failed(EntryOutcome::failed(&en.path, &e)),
413 // Reserve the staging dir: an entry resolving inside
414 // `<out_root>/.decdn-partial/` would collide with a per-hash staging
415 // file, and `remove_staging` could then delete a materialized output.
416 Ok(dest) if dest.starts_with(out_root.join(STAGING_DIR)) => {
417 Slot::Failed(EntryOutcome::failed(
418 &en.path,
419 &anyhow::anyhow!(
420 "manifest path {:?} is inside the reserved staging directory {STAGING_DIR}/",
421 en.path
422 ),
423 ))
424 }
425 Ok(_) if !overwrite && skip.contains(en.path.as_str()) => Slot::Skip,
426 Ok(dest) => Slot::Write {
427 label: en.path.as_str(),
428 dest,
429 },
430 })
431 .collect()
432}
433
434/// Turn classified [`Slot`]s into outcomes: materialize the blob at the first
435/// writable destination and hard-link/copy it to the rest. `materialize` is the
436/// paid path and runs **at most once** per group — every later destination goes
437/// through the free `link`, reported as [`EntryOutcome::Linked`] so the summary
438/// never implies a second paid fetch. If the first materialize fails, the next
439/// writable path retries it from the same already-fetched staging file (no
440/// re-fetch), so one bad path can't doom the group.
441///
442/// Parameterized over the two operations so the fetch-once / link-rest invariant
443/// — the core of #1306 — is unit-testable without a live endpoint or pool. Both
444/// are futures: a copy of a multi-GB blob must run off the runtime (see
445/// [`off_runtime`]), because every group of a run shares one task.
446async fn materialize_group<MF, MFut, LF, LFut>(
447 slots: Vec<Slot<'_>>,
448 mut materialize: MF,
449 mut link: LF,
450) -> Vec<EntryOutcome>
451where
452 MF: FnMut(PathBuf) -> MFut,
453 MFut: std::future::Future<Output = anyhow::Result<u64>>,
454 LF: FnMut(PathBuf, PathBuf) -> LFut,
455 LFut: std::future::Future<Output = anyhow::Result<()>>,
456{
457 let mut canonical: Option<PathBuf> = None;
458 let mut outcomes = Vec::with_capacity(slots.len());
459 for slot in slots {
460 let outcome = match slot {
461 Slot::Failed(o) => o,
462 Slot::Skip => EntryOutcome::Skipped,
463 Slot::Write { label, dest } => match &canonical {
464 Some(src) => match link(src.clone(), dest).await {
465 Ok(()) => EntryOutcome::Linked,
466 Err(e) => EntryOutcome::failed(label, &e),
467 },
468 None => match materialize(dest.clone()).await {
469 Ok(n) => {
470 canonical = Some(dest);
471 EntryOutcome::Fetched(n)
472 }
473 Err(e) => EntryOutcome::failed(label, &e),
474 },
475 },
476 };
477 outcomes.push(outcome);
478 }
479 outcomes
480}
481
482/// Re-hash an on-disk whole-file `donor` candidate against `hash` on the
483/// blocking pool and, on a match, return its on-disk length — the authoritative
484/// size of what is about to be linked, rather than the manifest's optional
485/// `size` (absent on the wire for some bundles). `None` covers a hash mismatch,
486/// a read or metadata failure, and a failed join.
487async fn verified_donor_len(donor: &Path, hash: [u8; 32]) -> Option<u64> {
488 let target = donor.to_path_buf();
489 tokio::task::spawn_blocking(move || {
490 let got = hash_partial(&target).ok()?;
491 (got == hash)
492 .then(|| std::fs::metadata(&target).ok())
493 .flatten()
494 .map(|m| m.len())
495 })
496 .await
497 .unwrap_or(None)
498}
499
500/// Materialize every write slot from an on-disk whole-file `donor` (a byte-
501/// identical file already in the root), by link/copy — no fetch, no payment.
502/// The first write is [`EntryOutcome::Deduped`] (the reused blob, counted once);
503/// each further duplicate path is [`EntryOutcome::Linked`], mirroring the
504/// fetch-once/link-rest accounting of #1306. The donor is verified by the
505/// caller (a re-hash against the target hash) before this is called.
506async fn materialize_from_donor(
507 slots: Vec<Slot<'_>>,
508 donor: &Path,
509 size: u64,
510) -> Vec<EntryOutcome> {
511 materialize_group(
512 slots,
513 |dest| {
514 let donor = donor.to_path_buf();
515 async move { link_off_runtime(donor, dest).await.map(|()| size) }
516 },
517 link_off_runtime,
518 )
519 .await
520 .into_iter()
521 .map(|o| match o {
522 // materialize_group tags the first materialize Fetched(n); relabel it
523 // Deduped since no payment or download occurred.
524 EntryOutcome::Fetched(n) => EntryOutcome::Deduped(n),
525 other => other,
526 })
527 .collect()
528}
529
530/// Read-side bundle manifest (the write-side lives in [`super::bundle`]). `size`
531/// is optional on the wire (per `appendix-bundles.md`); it is informational for
532/// the dry-run plan and not required to fetch.
533#[derive(Debug, Deserialize)]
534struct Manifest {
535 version: u32,
536 entries: Vec<ManifestEntry>,
537}
538
539/// A manifest cut down to the entries this run pulls, and how many entries
540/// the `--include`/`--exclude` filter and the `--select` editor dropped.
541#[derive(Debug)]
542struct Kept {
543 manifest: Manifest,
544 /// Entries the run does not pull: filtered out or deselected.
545 excluded: u64,
546}
547
548#[derive(Debug, Deserialize)]
549struct ManifestEntry {
550 path: String,
551 hash: String,
552 #[serde(default)]
553 size: Option<u64>,
554 /// Optional ordered chunk decomposition (a range-dedup hint, per
555 /// `appendix-bundles.md`). The whole file is always fetched and verified by
556 /// the authoritative whole-file `hash`; when this list is present its chunk
557 /// `(hash, size)` pairs let a fetch splice byte ranges already materialized
558 /// on disk by a sibling entry that shares a chunk, and pay only for the
559 /// complement. When absent the file is fetched as one blob by `hash`.
560 #[serde(default)]
561 chunks: Option<Vec<ManifestChunk>>,
562}
563
564/// One chunk of a hint-carrying [`ManifestEntry`]: an independently
565/// BLAKE3-addressed run of the file's bytes. Both fields are read — `hash`
566/// identifies the chunk in the in-run [`ChunkIndex`], and `size` places it at a
567/// byte offset (the running sum of prior chunk sizes) and bounds the range a
568/// sibling may splice.
569#[derive(Debug, Deserialize)]
570struct ManifestChunk {
571 /// The chunk's BLAKE3 content address (`b3:`hex).
572 hash: String,
573 /// The chunk's length in bytes; the chunk sizes sum to the entry's
574 /// whole-file `size`.
575 size: u64,
576}
577
578/// The run's whole-download content size — the fixed denominator for the total
579/// progress bar. Every entry declares its whole-file `size` and is deduped by
580/// `hash` (a blob fetched once and materialized to several paths counts once,
581/// matching the fetch-once grouping), whether or not it carries range-dedup
582/// chunk hints. A blob whose manifest `size` is absent contributes nothing,
583/// exactly as it then moves the total bar not at all, so the numerator and
584/// denominator stay consistent. `None` when nothing kept declares a size — the
585/// total bar is then omitted and only per-file bars render.
586fn total_content_bytes(entries: &[ManifestEntry]) -> Option<u64> {
587 // Entries keyed by whole-file hash, OR-ing in a declared size wherever one of
588 // the same-hash entries carries it (the file bar picks its size the same way).
589 let mut by_hash: HashMap<&str, Option<u64>> = HashMap::new();
590 for entry in entries {
591 let slot = by_hash.entry(entry.hash.as_str()).or_insert(None);
592 *slot = slot.or(entry.size);
593 }
594 let mut sum: u64 = 0;
595 let mut any_sized = false;
596 for size in by_hash.values().flatten() {
597 sum = sum.saturating_add(*size);
598 any_sized = true;
599 }
600 any_sized.then_some(sum)
601}
602
603/// One blob's static **download** (pay-now) bytes and its **reconstruct**
604/// (spliced-from-disk) bytes, planned against `index` under the run's
605/// [`FetchPlan`]: `download` is the sum of the blob's drive ranges (its unique and
606/// self-assigned chunks — a shared chunk it defers to a sibling is spliced, not
607/// downloaded) and `reconstruct` is the rest of its declared `size`. A plain
608/// (unhinted) blob downloads its whole `size` and reconstructs nothing. Returns
609/// `(0, 0)` when the blob declares no size. Passing an empty `index` gives the
610/// fresh-pull figures; a resumed run's pre-seeded donors only shrink `download`.
611fn blob_download_reconstruct(
612 group: &HashGroup<'_>,
613 fetch_plan: &FetchPlan,
614 index: &HashMap<[u8; 32], MaterializedRange>,
615) -> (u64, u64) {
616 let Some(total) = group.entries.iter().find_map(|e| e.size) else {
617 return (0, 0);
618 };
619 let hints = group.entries.first().and_then(|e| hints_of(e));
620 let whole = group
621 .entries
622 .first()
623 .and_then(|e| fetch::parse_hash(&e.hash).ok());
624 let download = match (hints, whole) {
625 (Some(hints), Some(whole)) => plan_reassembly(&hints, index, fetch_plan, whole, total)
626 .drive
627 .iter()
628 .map(|r| r.1)
629 .fold(0u64, u64::saturating_add),
630 // No usable hints (or an unparseable hash) → the whole blob is downloaded.
631 _ => total,
632 };
633 (download, total.saturating_sub(download))
634}
635
636/// The run's static **download** total — the content bytes it pays to fetch, deduped
637/// by whole-file `hash` and by shared chunk (each shared chunk counted once, under
638/// its assigned fetcher). This is the total progress bar's denominator; the run
639/// finishes downloading exactly these bytes and splices the rest from disk. `None`
640/// when nothing kept declares a size (no total bar). Computed against an empty index
641/// (the fresh-pull figure), so a resumed run downloads at most this.
642/// The run label for the progress header: the output directory's final component,
643/// or its whole path when it has none (a bare root).
644fn run_label(output: &Path) -> String {
645 output.file_name().map_or_else(
646 || output.to_string_lossy().into_owned(),
647 |n| n.to_string_lossy().into_owned(),
648 )
649}
650
651fn download_bytes(entries: &[ManifestEntry], fetch_plan: &FetchPlan) -> Option<u64> {
652 let refs: Vec<&ManifestEntry> = entries.iter().collect();
653 let empty = HashMap::new();
654 let mut sum = 0u64;
655 let mut any = false;
656 for group in group_by_hash(&refs) {
657 if group.entries.iter().find_map(|e| e.size).is_none() {
658 continue;
659 }
660 any = true;
661 let (download, _) = blob_download_reconstruct(&group, fetch_plan, &empty);
662 sum = sum.saturating_add(download);
663 }
664 any.then_some(sum)
665}
666
667/// The compiled `--include`/`--exclude` globs that select which manifest entries
668/// a pull run fetches. Both sets match an entry's POSIX relative `path` — the
669/// manifest field (`models/a.bin`), never the on-disk absolute path — with the
670/// same gitignore glob dialect `origin import --exclude` uses (`*` does not
671/// cross `/`, `**` recurses), via [`build_glob_set`].
672///
673/// An entry is kept iff it passes the include gate AND matches no exclude. The
674/// include gate is open when no `--include` was given (every entry passes) and
675/// otherwise requires a match against at least one include pattern; `--exclude`
676/// always wins over `--include`.
677///
678/// Every pattern matches the whole path from the bundle root, so `metal/*`
679/// matches `metal/model.bin` but not `gpt/metal/model.bin`; `**/metal/*`
680/// matches both. A pattern that matches no entry is almost always this mistake,
681/// so [`Self::apply_and_warn`] names each one.
682#[derive(Debug)]
683struct EntryFilter {
684 include: globset::GlobSet,
685 /// The `--include` patterns as given, in the order [`build_glob_set`]
686 /// compiled them, so a [`globset::GlobSet::matches`] index names its
687 /// pattern.
688 include_patterns: Vec<String>,
689 exclude: globset::GlobSet,
690 /// The `--exclude` patterns as given, indexed like `include_patterns`.
691 exclude_patterns: Vec<String>,
692}
693
694/// A `--include` or `--exclude` pattern that matched no entry of the manifest.
695#[derive(Debug, Clone, PartialEq, Eq)]
696struct Unmatched {
697 /// The flag the pattern came from: `--include` or `--exclude`.
698 flag: &'static str,
699 /// The pattern as given.
700 pattern: String,
701 /// The same pattern under a leading `**/`, when that form matches at least
702 /// one entry: the likely intent of a pattern written as if it matched at
703 /// any depth.
704 suggestion: Option<String>,
705}
706
707impl Unmatched {
708 /// The warning line for this pattern, without the `warning: ` prefix.
709 fn warning(&self) -> String {
710 let hint = self
711 .suggestion
712 .as_ref()
713 .map_or_else(String::new, |suggestion| {
714 format!(
715 " (a pattern matches the whole path from the bundle root; did you mean \
716 '{suggestion}'?)"
717 )
718 });
719 format!("{} '{}' matched no entries{hint}", self.flag, self.pattern)
720 }
721}
722
723/// What [`EntryFilter::apply_reporting`] hands back.
724#[derive(Debug)]
725struct FilterPass {
726 /// The entries the filter keeps, in manifest order.
727 kept: Vec<ManifestEntry>,
728 /// How many entries the filter dropped.
729 excluded: u64,
730 /// Each pattern that matched no entry, `--include` ones first.
731 unmatched: Vec<Unmatched>,
732}
733
734impl EntryFilter {
735 /// Compile the run's `--include`/`--exclude` patterns. A malformed glob is a
736 /// hard error naming the flag it came from.
737 fn compile(include: &[String], exclude: &[String]) -> anyhow::Result<Self> {
738 Ok(Self {
739 include: build_glob_set(include, "--include")?,
740 include_patterns: include.to_vec(),
741 exclude: build_glob_set(exclude, "--exclude")?,
742 exclude_patterns: exclude.to_vec(),
743 })
744 }
745
746 /// Retain only the entries the filter keeps, preserving manifest order, and
747 /// report the patterns that matched no entry. A pattern's hits are counted
748 /// over every entry of `entries`, before either gate drops any, so an
749 /// `--exclude` that matches only entries the include gate already dropped
750 /// still counts as a match.
751 fn apply_reporting(&self, entries: Vec<ManifestEntry>) -> FilterPass {
752 let mut include_hits = vec![false; self.include_patterns.len()];
753 let mut exclude_hits = vec![false; self.exclude_patterns.len()];
754 let keep: Vec<bool> = entries
755 .iter()
756 .map(|e| {
757 let p = Path::new(&e.path);
758 let included = mark_hits(&self.include, p, &mut include_hits);
759 let excluded = mark_hits(&self.exclude, p, &mut exclude_hits);
760 (self.include_patterns.is_empty() || included) && !excluded
761 })
762 .collect();
763 let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
764 let unmatched = unmatched_patterns("--include", &self.include_patterns, &include_hits)
765 .chain(unmatched_patterns(
766 "--exclude",
767 &self.exclude_patterns,
768 &exclude_hits,
769 ))
770 .map(|mut u| {
771 u.suggestion = anywhere_suggestion(&u.pattern, &paths);
772 u
773 })
774 .collect();
775 let raw = entries.len();
776 let kept: Vec<ManifestEntry> = entries
777 .into_iter()
778 .zip(keep)
779 .filter_map(|(e, keep)| keep.then_some(e))
780 .collect();
781 let excluded = u64::try_from(raw.saturating_sub(kept.len())).unwrap_or(u64::MAX);
782 FilterPass {
783 kept,
784 excluded,
785 unmatched,
786 }
787 }
788
789 /// [`Self::apply_reporting`], with one `warning:` line on stderr for each
790 /// pattern that matched no entry. Returns the kept entries and how many the
791 /// filter dropped.
792 fn apply_and_warn(&self, entries: Vec<ManifestEntry>) -> (Vec<ManifestEntry>, u64) {
793 let pass = self.apply_reporting(entries);
794 for u in &pass.unmatched {
795 eprintln!("warning: {}", u.warning());
796 }
797 (pass.kept, pass.excluded)
798 }
799}
800
801/// Mark in `hits` every pattern of `set` that matches `path`, and return
802/// whether any did.
803fn mark_hits(set: &globset::GlobSet, path: &Path, hits: &mut [bool]) -> bool {
804 let matched = set.matches(path);
805 for i in &matched {
806 if let Some(hit) = hits.get_mut(*i) {
807 *hit = true;
808 }
809 }
810 !matched.is_empty()
811}
812
813/// The patterns of `flag` whose `hits` slot is unset, with no suggestion yet.
814fn unmatched_patterns<'a>(
815 flag: &'static str,
816 patterns: &'a [String],
817 hits: &'a [bool],
818) -> impl Iterator<Item = Unmatched> + 'a {
819 patterns
820 .iter()
821 .zip(hits)
822 .filter(|(_, hit)| !**hit)
823 .map(move |(pattern, _)| Unmatched {
824 flag,
825 pattern: pattern.clone(),
826 suggestion: None,
827 })
828}
829
830/// `**/<pattern>`, when `pattern` does not start with `**` or `/` and that form
831/// matches at least one of `paths`.
832fn anywhere_suggestion(pattern: &str, paths: &[&str]) -> Option<String> {
833 if pattern.starts_with("**") || pattern.starts_with('/') {
834 return None;
835 }
836 let anywhere = format!("**/{pattern}");
837 let set = build_glob_set(std::slice::from_ref(&anywhere), "suggestion").ok()?;
838 paths
839 .iter()
840 .any(|p| set.is_match(Path::new(p)))
841 .then_some(anywhere)
842}
843
844/// Header written above the file list in the `--select` editor buffer. Explains
845/// the git-rebase-style convention: commented or deleted lines are skipped.
846const SELECT_HEADER: &str = "\
847# decdn bundle pull --select — choose which files to pull.
848#
849# Comment out (prefix with #) or delete any line to SKIP that file.
850# Save and exit to pull every file still listed below; delete them all to pull
851# nothing. Reordering has no effect, and you cannot add files here.
852#
853# Each line is <path>\t<size> — only the path (before the tab) is read.
854";
855
856/// Render the `--select` editor buffer: the header, then one line per entry as
857/// `<path>\t<human size>`. The size is a right-hand annotation for context only;
858/// [`parse_selection`] reads just the path before the first tab.
859fn render_selection(entries: &[ManifestEntry]) -> String {
860 let mut out = String::from(SELECT_HEADER);
861 for e in entries {
862 let size = e.size.map_or_else(|| "?".to_string(), human_bytes);
863 out.push_str(&e.path);
864 out.push('\t');
865 out.push_str(&size);
866 out.push('\n');
867 }
868 out
869}
870
871/// Reject a bundle whose paths cannot round-trip through the `--select` editor
872/// buffer exactly. [`parse_selection`] reads a kept line as the text before the
873/// first tab, trimmed, and skips a line whose first non-whitespace character is
874/// `#`. A path therefore fails to round-trip when it:
875/// - differs from its trimmed form (leading/trailing whitespace, which
876/// [`parse_selection`] strips — including whitespace before a `#`, which would
877/// make the line read as a comment and silently drop the file), or
878/// - has `#` as its first non-whitespace character (read as a comment), or
879/// - contains a tab, newline, or carriage return (splits or breaks its line).
880///
881/// Such paths are pathological for a POSIX relative filename; `--select` refuses
882/// them loudly rather than silently dropping a file the user meant to keep.
883/// `--include`/`--exclude` still handle these bundles.
884fn check_selectable(entries: &[ManifestEntry]) -> anyhow::Result<()> {
885 if let Some(bad) = entries.iter().find(|e| {
886 let p = e.path.as_str();
887 p != p.trim() || p.trim_start().starts_with('#') || p.contains(['\t', '\n', '\r'])
888 }) {
889 bail!(
890 "--select cannot represent the path {:?} (paths with leading/trailing \
891 whitespace, a leading '#', or a tab/newline are unsupported); use \
892 --include/--exclude instead",
893 bad.path
894 );
895 }
896 Ok(())
897}
898
899/// Apply the edited `--select` buffer back onto the entry list: keep only entries
900/// whose path still appears on a non-comment, non-blank line, preserving the
901/// original manifest order. A kept line whose path matches no bundle entry is a
902/// hard error — the editor only removes files, so an unmatched line is a typo,
903/// not an addition, and silently dropping it would be worse than failing.
904fn parse_selection(
905 edited: &str,
906 entries: Vec<ManifestEntry>,
907) -> anyhow::Result<Vec<ManifestEntry>> {
908 let known: HashSet<&str> = entries.iter().map(|e| e.path.as_str()).collect();
909 let mut kept: HashSet<String> = HashSet::new();
910 let mut unknown: Vec<&str> = Vec::new();
911 for line in edited.lines() {
912 if line.trim_start().is_empty() || line.trim_start().starts_with('#') {
913 continue;
914 }
915 // The path is the text before the size annotation (first tab), trimmed.
916 let path = line.split_once('\t').map_or(line, |(p, _)| p).trim();
917 if path.is_empty() {
918 continue;
919 }
920 if known.contains(path) {
921 kept.insert(path.to_string());
922 } else {
923 unknown.push(path);
924 }
925 }
926 if !unknown.is_empty() {
927 bail!(
928 "--select: these lines match no bundle entry (editing only removes files, \
929 it cannot add them): {}",
930 unknown.join(", ")
931 );
932 }
933 Ok(entries
934 .into_iter()
935 .filter(|e| kept.contains(&e.path))
936 .collect())
937}
938
939/// Apply the interactive `--select` step to a finalized manifest, or pass it
940/// through unchanged when `--select` is off. Each deselected entry adds to the
941/// excluded count. `Ok(None)` means the user deselected every file:
942/// [`report_nothing_to_fetch`] was already called, so the run is done.
943fn maybe_select(args: &BundlePullArgs, mut kept: Kept) -> anyhow::Result<Option<Kept>> {
944 if args.select {
945 kept = apply_selection(kept, select_entries)?;
946 if kept.manifest.entries.is_empty() {
947 report_nothing_to_fetch(NothingReason::Deselected);
948 warn_leftover_partials(&args.output, &[], args.hash.as_deref());
949 return Ok(None);
950 }
951 }
952 Ok(Some(kept))
953}
954
955/// Replace `kept`'s entries with the ones `select` keeps, and add each entry it
956/// drops to the excluded count.
957fn apply_selection(
958 mut kept: Kept,
959 select: impl FnOnce(Vec<ManifestEntry>) -> anyhow::Result<Vec<ManifestEntry>>,
960) -> anyhow::Result<Kept> {
961 let offered = kept.manifest.entries.len();
962 kept.manifest.entries = select(kept.manifest.entries)?;
963 let dropped = offered.saturating_sub(kept.manifest.entries.len());
964 kept.excluded = kept
965 .excluded
966 .saturating_add(u64::try_from(dropped).unwrap_or(u64::MAX));
967 Ok(kept)
968}
969
970/// Reject a `--select` invocation that cannot work: it opens an editor, so it
971/// needs an interactive terminal (both stdin and stdout) and is incompatible with
972/// the non-interactive `--json` and `--dry-run` modes. A no-op when `--select` is
973/// not set.
974fn check_select_flags(args: &BundlePullArgs) -> anyhow::Result<()> {
975 if !args.select {
976 return Ok(());
977 }
978 if args.json {
979 bail!("--select cannot be combined with --json (it needs an interactive editor)");
980 }
981 if args.dry_run {
982 bail!("--select cannot be combined with --dry-run (it needs an interactive editor)");
983 }
984 if !(std::io::stdin().is_terminal() && std::io::stdout().is_terminal()) {
985 bail!("--select needs an interactive terminal; use --include/--exclude instead");
986 }
987 Ok(())
988}
989
990/// Run the interactive `--select` step: render the entry list, open it in the
991/// user's editor, and apply the edited buffer back. All decision logic lives in
992/// the tested [`render_selection`]/[`parse_selection`]/[`check_selectable`]; this
993/// only wires them to the editor. The caller has already verified an interactive
994/// terminal and that `--json`/`--dry-run` are not set.
995fn select_entries(entries: Vec<ManifestEntry>) -> anyhow::Result<Vec<ManifestEntry>> {
996 check_selectable(&entries)?;
997 let edited = edit_in_editor(&render_selection(&entries))?;
998 parse_selection(&edited, entries)
999}
1000
1001/// Resolve the editor command from `$VISUAL`/`$EDITOR` into program + arguments,
1002/// falling back to `vi`. `$VISUAL` wins over `$EDITOR`; a missing or
1003/// blank/whitespace-only value is treated as unset. The value may carry arguments
1004/// (`code --wait`): it is split on ASCII whitespace, with no shell quoting or
1005/// escaping — an argument cannot itself contain a space. The scratch file path is
1006/// appended by the caller, not here. Never empty — the fallback guarantees at
1007/// least `["vi"]`.
1008fn editor_command(visual: Option<&str>, editor: Option<&str>) -> Vec<String> {
1009 let chosen = [visual, editor]
1010 .into_iter()
1011 .flatten()
1012 .map(str::trim)
1013 .find(|s| !s.is_empty())
1014 .unwrap_or("vi");
1015 chosen.split_whitespace().map(String::from).collect()
1016}
1017
1018/// Open `buffer` in the user's editor and return the saved contents. The editor
1019/// is `$VISUAL`, then `$EDITOR`, then `vi`; the value may carry arguments
1020/// (`code --wait`), split on ASCII whitespace with no shell quoting (see
1021/// [`editor_command`]). The buffer is staged in a temporary file that is removed
1022/// when this returns.
1023fn edit_in_editor(buffer: &str) -> anyhow::Result<String> {
1024 let mut file = tempfile::Builder::new()
1025 .prefix("decdn-select-")
1026 .suffix(".txt")
1027 .tempfile()
1028 .context("create --select scratch file")?;
1029 std::io::Write::write_all(file.as_file_mut(), buffer.as_bytes())
1030 .context("write --select scratch file")?;
1031 file.as_file()
1032 .sync_all()
1033 .context("sync --select scratch file")?;
1034
1035 let command = editor_command(
1036 std::env::var("VISUAL").ok().as_deref(),
1037 std::env::var("EDITOR").ok().as_deref(),
1038 );
1039 let (program, rest) = command
1040 .split_first()
1041 .ok_or_else(|| anyhow!("empty editor command"))?;
1042
1043 let status = std::process::Command::new(program)
1044 .args(rest)
1045 .arg(file.path())
1046 .status()
1047 .with_context(|| format!("launch editor {program:?}"))?;
1048 if !status.success() {
1049 bail!("editor {program:?} exited with {status}; aborting --select");
1050 }
1051
1052 std::fs::read_to_string(file.path()).context("read back --select scratch file")
1053}
1054
1055/// A group of manifest entries that all name the same blob `hash` — one file
1056/// published at two (or more) bundle paths. Non-empty by construction; the
1057/// shared `hash` is carried explicitly so consumers never re-derive it from an
1058/// arbitrary member.
1059#[derive(Clone)]
1060struct HashGroup<'a> {
1061 hash: &'a str,
1062 entries: Vec<&'a ManifestEntry>,
1063}
1064
1065/// Per-destination classification within a hash-group, resolved before any
1066/// fetch: a resolve failure, an already-present file to skip, or a path to
1067/// write. `label` (the entry's manifest-relative path) is retained only to tag
1068/// a later failure.
1069enum Slot<'a> {
1070 Failed(EntryOutcome),
1071 Skip,
1072 Write { label: &'a str, dest: PathBuf },
1073}
1074
1075/// Per-entry result, kept (never short-circuited) so one failure doesn't abort
1076/// the others — re-running resumes via skip-existing. `Linked` is a duplicate
1077/// destination materialized from an already-fetched sibling (hard link or copy),
1078/// kept distinct from `Fetched` (a paid network pull) so the summary never
1079/// implies a blob was paid for twice — the whole point of #1306.
1080#[derive(Clone)]
1081enum EntryOutcome {
1082 Fetched(u64),
1083 Linked,
1084 Skipped,
1085 /// A destination materialized from an on-disk whole-file donor (a byte-
1086 /// identical file already in the root, verified by re-hash) — no download,
1087 /// no payment. The `u64` is the file's size, counted as reused, never
1088 /// downloaded.
1089 Deduped(u64),
1090 Failed {
1091 path: String,
1092 err: String,
1093 },
1094}
1095
1096/// One hash-group's result.
1097struct GroupRun {
1098 /// One outcome per entry in the group, in order.
1099 outcomes: Vec<EntryOutcome>,
1100 /// The content bytes the group paid for and resumed, when its blob fetch
1101 /// landed ([`PullCtx::pull_entry_untimed`]). `None` when it fetched nothing.
1102 bytes: Option<EntryBytes>,
1103 /// The fault that ends the whole pull ([`ends_the_pull`]), when the
1104 /// group's blob fetch failed with one.
1105 stop: Option<anyhow::Error>,
1106}
1107
1108impl GroupRun {
1109 /// A result that fetched nothing.
1110 const fn done(outcomes: Vec<EntryOutcome>) -> Self {
1111 Self {
1112 outcomes,
1113 bytes: None,
1114 stop: None,
1115 }
1116 }
1117
1118 /// The group's blob fetch landed with `bytes`, and `outcomes` is how each
1119 /// destination then materialized.
1120 const fn landed(outcomes: Vec<EntryOutcome>, bytes: EntryBytes) -> Self {
1121 Self {
1122 outcomes,
1123 bytes: Some(bytes),
1124 stop: None,
1125 }
1126 }
1127
1128 /// The group's blob fetch failed with `err`: every writable slot fails,
1129 /// and `err` ends the whole pull when [`ends_the_pull`] says so.
1130 fn fetch_failed(slots: Vec<Slot<'_>>, err: anyhow::Error) -> Self {
1131 Self {
1132 outcomes: fail_all(slots, &err),
1133 bytes: None,
1134 stop: ends_the_pull(&err).then_some(err),
1135 }
1136 }
1137}
1138
1139/// What a failed entry fetch stops (ADR 039 § Failure handling: reassign-only
1140/// tail): a fault
1141/// only the user can fix stops every entry. Every other fault belongs to its
1142/// entry alone.
1143fn entry_scope(err: &anyhow::Error) -> decdn_client::FatalScope {
1144 match decdn_client::classify(err) {
1145 decdn_client::Fault::Fatal(scope) => scope,
1146 _ => decdn_client::FatalScope::Item,
1147 }
1148}
1149
1150/// Whether a failed entry fetch ends the whole pull: a command-scope fault
1151/// ([`entry_scope`]), or a give-up. A give-up is item-scoped, but every entry
1152/// shares the command's progress clock, so every other entry gives up at the
1153/// same moment.
1154fn ends_the_pull(err: &anyhow::Error) -> bool {
1155 entry_scope(err) == decdn_client::FatalScope::Command
1156 || err.downcast_ref::<decdn_client::GaveUp>().is_some()
1157}
1158
1159/// One-line `--json` summary. `fetched`/`linked`/`skipped`/`failed` are entry
1160/// counts; `downloaded` is the content bytes this run fetched and paid for across
1161/// the distinct blobs fetched (a blob shared across several paths counts once,
1162/// #1306; a range-dedup blob counts only the bytes it did not splice from disk)
1163/// and `reconstructed` is the total bytes written to disk this run — they diverge
1164/// when one blob is materialized to several paths or when range-dedup spliced part
1165/// of a blob. `downloaded` is a content-size tally, not an exact on-wire
1166/// measurement: it excludes bao proof overhead.
1167///
1168/// `resumed_bytes` is the content bytes of a `.partial` prefix that an earlier,
1169/// interrupted run fetched and paid for, which this run resumed rather than
1170/// fetched again. It is never part of `downloaded`. A byte that this run also
1171/// spliced from disk counts only in `spliced_bytes`.
1172///
1173/// `excluded` counts the manifest entries this run does not pull: the ones the
1174/// `--include`/`--exclude` filter dropped and the ones deselected under
1175/// `--select`.
1176///
1177/// `reused` and `reused_bytes` report the whole-file dedup outcome, counted per
1178/// distinct blob exactly as `fetched`/`downloaded` are (a blob reused at several
1179/// paths counts once here; its extra destinations are `linked`): `reused` is the
1180/// count of distinct blobs materialized from an on-disk whole-file donor (verified
1181/// by re-hash before use), and `reused_bytes` sums those blobs' sizes once each —
1182/// bytes served from disk with no download and no payment.
1183///
1184/// `spliced_bytes` and `hints_ignored` report the range-dedup outcome so a run
1185/// whose hints saved bytes is distinguishable from one whose hints did not:
1186/// `spliced_bytes` is the total bytes served from a local donor splice (bytes NOT
1187/// downloaded or paid for), and `hints_ignored` counts range-dedup hints dropped
1188/// by a fault — a chunk set that failed to parse or whose sizes did not sum to the
1189/// file size, a donor that failed its verification re-hash (or was unreadable), or
1190/// a self-heal whole-file re-drive that discarded already-spliced donors. Both are
1191/// reporting only and never affect payment.
1192#[derive(Serialize)]
1193struct PullReport {
1194 output: String,
1195 fetched: u64,
1196 linked: u64,
1197 skipped: u64,
1198 /// Entries the filter or `--select` dropped.
1199 excluded: u64,
1200 failed: u64,
1201 downloaded: u64,
1202 reconstructed: u64,
1203 spliced_bytes: u64,
1204 hints_ignored: u64,
1205 /// Content bytes of `.partial` prefixes that earlier runs fetched and this
1206 /// run resumed; never part of `downloaded`.
1207 resumed_bytes: u64,
1208 /// Count of distinct blobs materialized from an on-disk whole-file donor;
1209 /// extra destinations of the same blob are counted in `linked`.
1210 reused: u64,
1211 /// Sum of those blobs' sizes (once per blob) — bytes materialized from a
1212 /// whole-file donor with no download or payment.
1213 reused_bytes: u64,
1214}
1215
1216/// The run's range-dedup outcome, read off [`PullCtx::dedup_stats`] once the pull
1217/// finishes and folded into the [`PullReport`] and the human summary. See
1218/// [`PullReport`] for the exact meaning of each field.
1219#[derive(Clone, Copy, Default)]
1220struct DedupSummary {
1221 spliced_bytes: u64,
1222 hints_ignored: u64,
1223}
1224
1225/// One dedup entry's range-dedup outcome, returned by [`reassemble_dedup`].
1226/// [`PullCtx::pull_entry_untimed`] accumulates its spliced bytes and ignored
1227/// hints into [`PullCtx::dedup_stats`] and folds all of it into the entry's
1228/// [`EntryBytes`].
1229#[derive(Clone, Copy, Default, Debug)]
1230struct DedupOutcome {
1231 /// Bytes this entry served from a local donor splice — never downloaded.
1232 spliced_bytes: u64,
1233 /// Bytes this entry's ranged store held from an earlier run before this run
1234 /// drove anything, less the bytes it spliced — neither downloaded nor
1235 /// spliced here.
1236 resumed_bytes: u64,
1237 /// Range-dedup hints this entry dropped by a fault (a donor that failed its
1238 /// verification re-hash or was unreadable, or a self-heal re-drive that
1239 /// discarded every already-spliced donor).
1240 hints_ignored: u64,
1241}
1242
1243impl DedupOutcome {
1244 /// The outcome of an entry of `total` bytes whose net spliced spans are
1245 /// `spliced` and whose store held the `resumed` spans from an earlier run.
1246 /// A byte in both counts as spliced, so no byte counts twice.
1247 fn of(spliced: &[(u64, u64)], resumed: &[(u64, u64)], total: u64, hints_ignored: u64) -> Self {
1248 Self {
1249 spliced_bytes: span_bytes(spliced),
1250 resumed_bytes: span_bytes(&uncovered_runs(resumed, spliced, total)),
1251 hints_ignored,
1252 }
1253 }
1254
1255 /// This outcome, or, when a drive fetched the whole blob again after its
1256 /// finalize failed the hash (`refetched`), one with nothing spliced or
1257 /// resumed. That pass dropped every byte the store held and fetched the
1258 /// blob in full, and it always ends the entry, so this run paid for all
1259 /// of it.
1260 const fn refetched_if(self, refetched: bool) -> Self {
1261 if refetched {
1262 Self {
1263 spliced_bytes: 0,
1264 resumed_bytes: 0,
1265 hints_ignored: self.hints_ignored,
1266 }
1267 } else {
1268 self
1269 }
1270 }
1271}
1272
1273/// Run-scoped range-dedup counters, shared by every concurrent entry via
1274/// `&PullCtx`. Atomics because entries run concurrently under `buffer_unordered`;
1275/// `Relaxed` is enough — the totals are read once, after the pull joins.
1276#[derive(Default)]
1277struct DedupStats {
1278 spliced_bytes: AtomicU64,
1279 hints_ignored: AtomicU64,
1280}
1281
1282/// A pull's byte accounting: `downloaded` is the content bytes this run fetched and
1283/// paid for across the distinct blobs fetched (a blob materialized to several paths
1284/// counts once, #1306; a range-dedup blob counts only the bytes it did not splice
1285/// from disk); `reconstructed` is the total bytes written to disk (every
1286/// materialized copy); `resumed` is the content bytes of `.partial` prefixes that
1287/// earlier runs fetched and this run resumed. `downloaded` is content bytes, not
1288/// exact on-wire bytes: it omits bao proof overhead.
1289#[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
1290struct Transfer {
1291 downloaded: u64,
1292 reconstructed: u64,
1293 resumed: u64,
1294}
1295
1296impl Transfer {
1297 /// Combine two tallies (saturating — a pull never reports a wrapped total).
1298 const fn add(self, other: Transfer) -> Transfer {
1299 Transfer {
1300 downloaded: self.downloaded.saturating_add(other.downloaded),
1301 reconstructed: self.reconstructed.saturating_add(other.reconstructed),
1302 resumed: self.resumed.saturating_add(other.resumed),
1303 }
1304 }
1305}
1306
1307/// The run's candidate list: every active registered node. Each entry draws its
1308/// own candidate sample from the whole list ([`entry_candidates`]), so no
1309/// registered node is hidden from every entry by one draw.
1310///
1311/// The peer store's identity-fresh records serve as that list when enough of
1312/// them qualify ([`cached_registry`]), and the run issues no
1313/// `getRegisteredNodes`. Otherwise the registry is read once. A cached list that
1314/// runs out of holders falls back to a live read through the in-run
1315/// rediscovery ([`CliSources`](super::cli_sources)).
1316async fn discover_candidates(
1317 chain: &fetch::ResolvedChain,
1318 common: &ClientFetchArgs,
1319) -> anyhow::Result<Vec<discovery::NodeCandidate>> {
1320 let capacity_bond = chain.capacity_bond.ok_or_else(|| {
1321 anyhow!(
1322 "auto-discovery needs capacity_bond_address (--capacity-bond-address or \
1323 blockchain.capacity_bond_address), or pass --node-id to pull from one node"
1324 )
1325 })?;
1326 if !common.rediscover
1327 && let Some(cached) = cached_registry(&chain.data_dir, fetch::now_secs_cli())
1328 {
1329 return Ok(cached);
1330 }
1331 let registry_cap = common.discovery_cap();
1332 let bootstrap =
1333 discovery::bootstrap_nodes(&chain.rpc_url, capacity_bond, &chain.data_dir, registry_cap)
1334 .await?;
1335 // See `fetch::discover_provider`: the degraded bootstrap paths reach the
1336 // operator through the return value; surface it here at `warn`.
1337 if let Some(warning) = bootstrap.warning() {
1338 tracing::warn!("{warning}");
1339 }
1340 let all = bootstrap.into_peers();
1341 if all.is_empty() {
1342 bail!("no active nodes in the CapacityBond registry at {capacity_bond}");
1343 }
1344 Ok(all)
1345}
1346
1347/// The peer store's identity-fresh records under `data_dir`, or `None` when
1348/// fewer than [`decdn_client::StoreConfig::min_fresh_candidates`] qualify. The
1349/// records are the last live registry read, confirmed within
1350/// [`decdn_client::StoreConfig::identity_refresh_secs`]. The run only reads
1351/// them: identity refreshes on a live registry read alone, so the refresh
1352/// horizon still expires.
1353fn cached_registry(data_dir: &Path, now_secs: u64) -> Option<Vec<NodeCandidate>> {
1354 let store = decdn_client::PeerStore::open(data_dir);
1355 let cfg = decdn_client::StoreConfig::default();
1356 let cached = fetch::identity_fresh_candidates(&store, &cfg, now_secs);
1357 (cached.len() >= cfg.min_fresh_candidates).then_some(cached)
1358}
1359
1360/// One entry's probe candidates: a fresh sample of `registry`, same-region
1361/// nodes first ([`discovery::select_candidates`]). A sample per entry costs no
1362/// extra probe, because each entry probes its own candidates, and a node that
1363/// one draw leaves out can still reach other entries.
1364fn entry_candidates(registry: &[NodeCandidate], region: Option<&str>) -> Vec<NodeCandidate> {
1365 discovery::select_candidates(registry.to_vec(), region, discovery::SELECT_K)
1366}
1367
1368/// Load the buyer's Ethereum signer (vouchers + any openPool/topUp tx) and its
1369/// address, prompting for the keystore password once. Password from
1370/// `$DECDN_KEYSTORE_PASSWORD`, else `--keystore-password-file`, else a TTY
1371/// prompt.
1372fn load_buyer_signer(
1373 chain: &fetch::ResolvedChain,
1374) -> anyhow::Result<(Arc<PrivateKeySigner>, Address)> {
1375 let password = super::chain_ctx::read_keystore_password(
1376 &super::chain_ctx::password_sources(
1377 chain.keystore_password_file.as_deref(),
1378 PasswordUse::Unlock,
1379 ),
1380 "eth keystore password",
1381 )?
1382 .into_secret();
1383 let signer = Arc::new(load_signer(&chain.keystore, &password)?);
1384 let self_address = signer.address();
1385 Ok((signer, self_address))
1386}
1387
1388/// The resolved node selection and buyer signer for a pull run.
1389struct Selection {
1390 /// `Some((node, provider))` pins every entry to one node; `None` discovers per
1391 /// entry against a sample of `registry`.
1392 explicit: Option<FetchTarget>,
1393 /// Every active registered node, resolved once for the run
1394 /// ([`discover_candidates`]).
1395 registry: Option<Vec<NodeCandidate>>,
1396 signer: Arc<PrivateKeySigner>,
1397 self_address: Address,
1398}
1399
1400/// Resolve the node selection and buyer signer for a pull run.
1401///
1402/// The keystore prompt is deferred until AFTER the registry read on the
1403/// discovery path, so a failed discovery never prompts.
1404async fn resolve_selection(
1405 common: &ClientFetchArgs,
1406 chain: &fetch::ResolvedChain,
1407) -> anyhow::Result<Selection> {
1408 // Explicit single node for every entry, or the active set (cached or read
1409 // from `CapacityBond` once), which each entry samples.
1410 let explicit = explicit_target(common)?;
1411 let registry = match explicit {
1412 Some(_) => None,
1413 // A registry read is bounded by `--timeout-ms` (#1349), inside
1414 // `bootstrap_nodes` so a timeout still falls through to the peer store.
1415 // No sampling or probing happens here: each entry samples and probes
1416 // its own candidates (`PullCtx::entry_candidates`).
1417 None => Some(discover_candidates(chain, common).await?),
1418 };
1419 let (signer, self_address) = load_buyer_signer(chain)?;
1420 Ok(Selection {
1421 explicit,
1422 registry,
1423 signer,
1424 self_address,
1425 })
1426}
1427
1428/// Entry point for `decdn bundle pull`.
1429pub async fn bundle_pull(args: &BundlePullArgs, config_path: Option<&Path>) -> anyhow::Result<()> {
1430 // Validate the flag combination first — BEFORE the dry-run short-circuit — so a
1431 // bad combo (e.g. `--provider-address` without `--node-id`) or an unusable
1432 // timeout pair is rejected even for `--dry-run`. This rule does not run at
1433 // parse time, so `bundle_pull` calls it BEFORE the dry-run short-circuit. It
1434 // is cheap and side-effect-free.
1435 args.common.validate()?;
1436
1437 // Compile the `--include`/`--exclude` entry filter BEFORE the dry-run
1438 // short-circuit — same rationale as `validate()`: a malformed glob is
1439 // rejected before any network, chain, or keystore work, and `--dry-run`
1440 // reflects the filtered plan too.
1441 let filter = EntryFilter::compile(&args.include, &args.exclude)?;
1442 let filters_given = !args.include.is_empty() || !args.exclude.is_empty();
1443
1444 // `--select` opens an editor: reject an incompatible combo or a non-terminal
1445 // up front — before any network, chain, or keystore work — so it fails fast
1446 // rather than after paying to fetch the manifest.
1447 check_select_flags(args)?;
1448
1449 // Dry-run short-circuits before any network/chain/keystore activity.
1450 if args.dry_run {
1451 return dry_run(args, &filter, filters_given);
1452 }
1453
1454 // A local manifest is read up front (no network) and filtered here, so a
1455 // bundle that is empty — or emptied by the filter — needs no endpoint or
1456 // keystore password at all.
1457 let local_manifest = match &args.input {
1458 Some(path) => {
1459 let mut m = read_local_manifest(path)?;
1460 let raw_empty = m.entries.is_empty();
1461 let (entries, excluded) = filter.apply_and_warn(m.entries);
1462 m.entries = entries;
1463 if m.entries.is_empty() {
1464 report_nothing_to_fetch(NothingReason::from_filter(filters_given, raw_empty));
1465 warn_leftover_partials(&args.output, &[], None);
1466 return Ok(());
1467 }
1468 Some(Kept {
1469 manifest: m,
1470 excluded,
1471 })
1472 }
1473 None => None,
1474 };
1475
1476 let common = &args.common;
1477 let relays = client_endpoint::resolve_relays(common.relay_url.as_deref(), config_path)?;
1478 let disc = client_endpoint::client_discovery(config_path)?;
1479 let file = load_file_config(config_path)?;
1480 let mut chain = fetch::resolve_chain(common, &file)?;
1481
1482 // Delegated adoption: `--capability`/`--capability-file` names a pool the
1483 // caller does NOT own and a capability authorizing this client's key to
1484 // spend against it (every entry pulls from that one pool). `None` => the
1485 // unchanged self-owned pool path; a delegated grant also zeroes the working
1486 // deposit in `chain`, so its funding recovery step has nothing to add.
1487 let grant = fetch::resolve_delegation_grant(common, &mut chain)?;
1488
1489 // Same guard as `decdn fetch`, for the same reason and before the same
1490 // password prompt: a node's data dir is a buy target only when the
1491 // operator named it (#2082).
1492 // Shared, so a lane's watermark write runs off the runtime thread.
1493 let store = Arc::new(open_client_store_for_buy(
1494 &chain.data_dir,
1495 chain.data_dir_source,
1496 "pull",
1497 )?);
1498 let endpoint = client_endpoint::client_endpoint(&relays, &disc).await?;
1499 // The command's peer-record and watermark writes, off the runtime thread.
1500 let writes = OrderedWrites::default();
1501 // The body runs in `pull_over`, so the endpoint closes on every exit —
1502 // success, an early return, or an error — and its open connections end
1503 // cleanly instead of being aborted on drop.
1504 let result = pull_over(
1505 args,
1506 &chain,
1507 grant,
1508 (&store, &writes),
1509 &endpoint,
1510 &relays,
1511 (&filter, filters_given),
1512 local_manifest,
1513 )
1514 .await;
1515 // On every exit, a Ctrl-C's included: what the entries queued is durable
1516 // before the command returns.
1517 writes.settle().await;
1518 endpoint.close().await;
1519 result
1520}
1521
1522/// The part of [`bundle_pull`] that runs over its open `endpoint`: resolve the
1523/// providers and the buyer signer, obtain the manifest, and pull it. `filter` is
1524/// the compiled `--include`/`--exclude` filter and whether either flag was given.
1525#[allow(clippy::too_many_arguments)]
1526async fn pull_over(
1527 args: &BundlePullArgs,
1528 chain: &fetch::ResolvedChain,
1529 grant: Option<decdn_incentive::CapabilityGrant>,
1530 (store, writes): (&Arc<RedbBuyerPoolStore>, &OrderedWrites),
1531 endpoint: &Endpoint,
1532 relays: &[RelayUrl],
1533 (filter, filters_given): (&EntryFilter, bool),
1534 local_manifest: Option<Kept>,
1535) -> anyhow::Result<()> {
1536 let common = &args.common;
1537 // Selection + the buyer signer, resolved per path (see `resolve_selection`).
1538 let Selection {
1539 explicit,
1540 registry,
1541 signer,
1542 self_address,
1543 } = resolve_selection(common, chain).await?;
1544
1545 // Chain plumbing for the pull loop, built once from the resolved signer.
1546 let rpc = provider::build_provider(&chain.rpc_url, &signer)?;
1547 let contract = PaymentPool::new(chain.payment_pool, rpc.clone());
1548 let voucher_dom = voucher_domain(chain.chain_id, chain.payment_pool);
1549 let slash_dom = slash_judge_domain(chain.chain_id, chain.slash_judge);
1550 let token = contract
1551 .usdc()
1552 .call()
1553 .await
1554 .map_err(|e| anyhow::Error::new(e).context("read PaymentPool.usdc()"))?;
1555
1556 // `--namespace <id>` → big-endian `uint256`; absent => `NO_NAMESPACE`
1557 // (best-effort cache/DHT). Same conversion as `decdn fetch`.
1558 let namespace_id = namespace_id(args.namespace);
1559
1560 let ctx = PullCtx {
1561 endpoint,
1562 store,
1563 contract: &contract,
1564 rpc: &rpc,
1565 signer: &signer,
1566 self_address,
1567 token,
1568 voucher_dom: &voucher_dom,
1569 slash_dom: &slash_dom,
1570 chain,
1571 relays,
1572 explicit,
1573 registry,
1574 common,
1575 namespace_id,
1576 grant,
1577 ledgers: LaneLedgers::new(),
1578 funding: fetch::RunFunding::default(),
1579 recovery: Arc::new(RecoveryGate::new()),
1580 dedup_stats: DedupStats::default(),
1581 open_lock: tokio::sync::Mutex::new(()),
1582 gate: JobGate::new(args.jobs.max(1)),
1583 lane_cap: LaneStreamCap::new(usize::from(args.max_lane_streams)),
1584 health: Arc::new(PeerHealth::default()),
1585 connections: Connections::new(endpoint.clone()),
1586 writes,
1587 // One clock for the whole command: a stuck entry waits while another
1588 // lands bytes.
1589 stop: StopPolicy::new(
1590 std::io::stderr().is_terminal(),
1591 common.give_up_after(),
1592 Arc::new(ProgressClock::new()),
1593 ),
1594 // Silent during the manifest fetch below (a single blob); replaced once
1595 // the kept entries are known and their sizes decide the total-bar mode.
1596 progress: PullProgress::disabled(),
1597 };
1598
1599 pull_manifest(ctx, args, filter, filters_given, local_manifest).await
1600}
1601
1602/// Print the run's wallet-shortfall warning ([`fetch::RunFunding::shortfall`]),
1603/// if it has one, for a run that ends before its summary.
1604fn warn_shortfall<P: Provider + Clone>(ctx: &PullCtx<'_, P>) {
1605 if let Some(shortfall) = ctx.funding.shortfall() {
1606 eprintln!("warning: {shortfall}");
1607 }
1608}
1609
1610/// Obtain the bundle manifest through `ctx`, pull every kept entry, and print the
1611/// run summary.
1612async fn pull_manifest<P: Provider + Clone>(
1613 mut ctx: PullCtx<'_, P>,
1614 args: &BundlePullArgs,
1615 filter: &EntryFilter,
1616 filters_given: bool,
1617 local_manifest: Option<Kept>,
1618) -> anyhow::Result<()> {
1619 // Obtain the manifest: the pre-read local one, or the `--hash` bundle blob
1620 // fetched and filtered here. `None` => filtered to empty (already reported).
1621 // `--select`: let the user trim the (already glob-filtered) list in their
1622 // editor. Everything deselected ends the run (reported) like an empty filter.
1623 let selected = async {
1624 let Some(kept) = obtain_manifest(&ctx, args, filter, filters_given, local_manifest).await?
1625 else {
1626 return Ok(None);
1627 };
1628 maybe_select(args, kept)
1629 }
1630 .await;
1631 // The `--hash` manifest fetch is paid, so a run that ends here still owes
1632 // the wallet-shortfall warning the summary below prints otherwise.
1633 let Some(Kept { manifest, excluded }) = selected.inspect_err(|_| warn_shortfall(&ctx))? else {
1634 warn_shortfall(&ctx);
1635 return Ok(());
1636 };
1637
1638 // The kept manifest is known: enable the multi-bar renderer (silent off a
1639 // terminal or under `--json`). Its total bar meters the run's **download** — the
1640 // bytes actually fetched after shared-chunk dedup — with the whole on-disk
1641 // content size shown alongside in the header, both fixed now from the manifest.
1642 let manifest_fetch_plan = build_fetch_plan(&manifest.entries);
1643 ctx.progress = PullProgress::new(
1644 args.json,
1645 download_bytes(&manifest.entries, &manifest_fetch_plan),
1646 total_content_bytes(&manifest.entries),
1647 &run_label(&args.output),
1648 );
1649
1650 // Loaded once for the whole run: the pre-pass skip decisions above consult it,
1651 // and it seeds the incremental skip-cache flush inside `pull_all` — a run's
1652 // completed files are folded onto this prior and rewritten as the pull
1653 // progresses, so an interrupted pull leaves the files it landed recorded
1654 // rather than losing the whole index. The skip-cache is advisory, so a flush
1655 // failure is logged (inside the flush) and never fails the pull.
1656 let saved = bundle_manifest::load(&args.output);
1657 // Watched from here on: a Ctrl-C during the pull stops it gracefully (see
1658 // `pull_plain`), and the summary below still reports what landed.
1659 let mut interrupt = Interrupt::watch();
1660 let mut run = ctx
1661 .pull_all(
1662 &manifest.entries,
1663 &args.output,
1664 args.overwrite,
1665 saved,
1666 &mut interrupt,
1667 )
1668 .await;
1669 // A funding recovery step that replaced the pool ends the first pass. The
1670 // remaining entries run once more against the new pool: lanes key on the
1671 // pool, so the pass starts with fresh ledgers and funding facts, and each
1672 // entry resumes the partial data it holds (ADR 003 § Funding recovery).
1673 let replaced = replaced_pool(&run);
1674 if let Some(new) = replaced {
1675 eprintln!("note: {new}");
1676 ctx.ledgers = LaneLedgers::new();
1677 ctx.funding = fetch::RunFunding::default();
1678 ctx.recovery.start_next_pass();
1679 let saved = bundle_manifest::load(&args.output);
1680 let second = ctx
1681 .pull_all(
1682 &manifest.entries,
1683 &args.output,
1684 args.overwrite,
1685 saved,
1686 &mut interrupt,
1687 )
1688 .await;
1689 run = merge_passes(run, second);
1690 eprintln!(
1691 "note: pool {} accepts no more funds and may still hold a balance; once its \
1692 dispute window ends, reclaim it with: decdn pool reclaim --pool {}",
1693 new.closed, new.closed
1694 );
1695 }
1696 let PullRun {
1697 outcomes,
1698 mut warnings,
1699 transfer,
1700 interrupted,
1701 stopped,
1702 } = run;
1703 // A second replacement in one command ends it "funding needed".
1704 let stopped = stopped.map(|err| match (replaced, err.downcast_ref::<PoolReplaced>()) {
1705 (Some(_), Some(_)) => err.context(
1706 "funding needed: the pool this pull opened accepts no more funds either; run the \
1707 pull again",
1708 ),
1709 _ => err,
1710 });
1711 ctx.progress.finish();
1712 warn_leftover_partials(&args.output, &manifest.entries, args.hash.as_deref());
1713 warnings.extend(ctx.funding.shortfall());
1714
1715 // Every entry has joined, so the shared dedup counters are now stable.
1716 let dedup = DedupSummary {
1717 spliced_bytes: ctx.dedup_stats.spliced_bytes.load(Ordering::Relaxed),
1718 hints_ignored: ctx.dedup_stats.hints_ignored.load(Ordering::Relaxed),
1719 };
1720 let reported = report(
1721 &outcomes,
1722 &warnings,
1723 transfer,
1724 dedup,
1725 excluded,
1726 &args.output,
1727 args.json,
1728 );
1729 if interrupted {
1730 return Err(Interrupted.into());
1731 }
1732 // A fault that ended the whole pull is its result, so `main` maps it to
1733 // its exit code (75 for a give-up, 1 otherwise).
1734 if let Some(err) = stopped {
1735 return Err(err);
1736 }
1737 reported
1738}
1739
1740/// How often the pull's poll watch ([`warn_when_unpolled`]) ticks.
1741const POLL_WATCH_TICK: std::time::Duration = std::time::Duration::from_secs(1);
1742
1743/// How late a poll-watch tick may fire before the pull logs that its task was
1744/// not polled.
1745const POLL_WATCH_LATE: std::time::Duration = std::time::Duration::from_secs(2);
1746
1747/// Run `drive` beside `flush` until both finish, or until the first Ctrl-C.
1748/// Returns whether a Ctrl-C stopped it.
1749///
1750/// The Ctrl-C drops `drive`: every in-flight fetch stops where it is (its drop
1751/// guard queues what it paid for recording, and its `.partial` is the next
1752/// run's resume prefix). The flush channel's sender drops with `drive`, so
1753/// `flush` still writes every group that settled before it returns. A poll
1754/// watch ([`warn_when_unpolled`]) runs beside `drive` until `drive` ends or
1755/// Ctrl-C stops it.
1756async fn drive_until_interrupted(
1757 drive: impl std::future::Future<Output = ()>,
1758 flush: impl std::future::Future<Output = ()>,
1759 interrupt: &mut Interrupt,
1760) -> bool {
1761 let drive = async {
1762 tokio::select! {
1763 () = drive => false,
1764 () = interrupt.wait() => true,
1765 never = warn_when_unpolled() => match never {},
1766 }
1767 };
1768 tokio::join!(drive, flush).0
1769}
1770
1771/// Log a WARN each time a tick fires more than [`POLL_WATCH_LATE`] late, that
1772/// is, each time the task that polls this future was not polled for a while.
1773/// Never returns.
1774///
1775/// Every entry and lane of a pull runs on one task, so anything that blocks
1776/// that task stops every lane at once, and the nodes see the lanes' streams
1777/// go quiet together (#2211). A tick's lateness is a lower bound on that
1778/// freeze: a freeze that starts just after a tick misses up to one
1779/// [`POLL_WATCH_TICK`], so only a freeze longer than about 3 s is sure to be
1780/// logged. A system suspend shows up the same way.
1781async fn warn_when_unpolled() -> std::convert::Infallible {
1782 let mut tick = tokio::time::interval(POLL_WATCH_TICK);
1783 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1784 loop {
1785 let due = tick.tick().await;
1786 let late = due.elapsed();
1787 if late > POLL_WATCH_LATE {
1788 tracing::warn!(
1789 late_ms = late.as_millis(),
1790 "the bundle task was not polled for at least {late:?}: a blocking call on the \
1791 pull task froze every lane (or the system was suspended); run with -vv to see \
1792 which"
1793 );
1794 }
1795 }
1796}
1797
1798/// What [`PullCtx::pull_all`] hands back: every settled entry's outcome, the
1799/// run's byte tally, whether a Ctrl-C stopped it, and the fault that ended it
1800/// early. A run that stopped early leaves out the entries that had not
1801/// settled.
1802struct PullRun {
1803 outcomes: Vec<EntryOutcome>,
1804 /// One line per landed entry whose blob differs in size from its
1805 /// manifest ([`size_warnings`]).
1806 warnings: Vec<String>,
1807 transfer: Transfer,
1808 interrupted: bool,
1809 /// The fault that ended the whole pull ([`ends_the_pull`]), if one did.
1810 stopped: Option<anyhow::Error>,
1811}
1812
1813/// The pool replacement that ended `run`, when a funding recovery step opened
1814/// a new pool and the pass was not interrupted.
1815fn replaced_pool(run: &PullRun) -> Option<PoolReplaced> {
1816 if run.interrupted {
1817 return None;
1818 }
1819 run.stopped
1820 .as_ref()
1821 .and_then(|err| err.downcast_ref::<PoolReplaced>())
1822 .copied()
1823}
1824
1825/// One run's result from its `first` pass, which a pool replacement ended,
1826/// and its `second` pass against the new pool. Both passes walk the same
1827/// entries in the same order, so an entry the first pass landed and the
1828/// second found present keeps the first pass's outcome; every other entry
1829/// takes the second's. The two transfers add up.
1830fn merge_passes(first: PullRun, second: PullRun) -> PullRun {
1831 let outcomes = if first.outcomes.len() == second.outcomes.len() {
1832 first
1833 .outcomes
1834 .into_iter()
1835 .zip(second.outcomes)
1836 .map(|(was, now)| match (&was, &now) {
1837 (
1838 EntryOutcome::Fetched(_) | EntryOutcome::Linked | EntryOutcome::Deduped(_),
1839 EntryOutcome::Skipped,
1840 ) => was,
1841 _ => now,
1842 })
1843 .collect()
1844 } else {
1845 second.outcomes
1846 };
1847 let mut warnings = first.warnings;
1848 warnings.extend(second.warnings);
1849 PullRun {
1850 outcomes,
1851 warnings,
1852 transfer: first.transfer.add(second.transfer),
1853 interrupted: second.interrupted,
1854 stopped: second.stopped,
1855 }
1856}
1857
1858/// The bundle-level namespace id (ADR 002) every paid pull in the run carries:
1859/// `--namespace <id>` as a big-endian `uint256`, or `NO_NAMESPACE` when the flag
1860/// was omitted.
1861fn namespace_id(namespace: Option<u64>) -> [u8; 32] {
1862 namespace.map_or(decdn_protocol::client::NO_NAMESPACE, |n| {
1863 alloy::primitives::U256::from(n).to_be_bytes()
1864 })
1865}
1866
1867/// The `--hash` bundle manifest bytes a real pull reads from the output root's
1868/// [`bundle_cache`] instead of paying for a fetch, or `None` to fetch them.
1869/// "Don't pay again": a prior pull of this bundle into the same output dir cached
1870/// the manifest blob, keyed by its hash and self-verifying on read. `--overwrite`
1871/// returns `None` to force a fresh fetch, as it bypasses the file skip-cache. A
1872/// cache file that cannot be read or fails its hash check is fail-open: the error
1873/// prints as a warning and the pull fetches.
1874fn cached_manifest(args: &BundlePullArgs, hash: [u8; 32]) -> Option<Vec<u8>> {
1875 if args.overwrite {
1876 return None;
1877 }
1878 bundle_cache::load(&args.output, hash).unwrap_or_else(|e| {
1879 eprintln!("warning: {e:#}; fetching the bundle manifest");
1880 None
1881 })
1882}
1883
1884/// The kept manifest for the run: the pre-read local one (already filtered up
1885/// front), or the `--hash` bundle blob fetched into memory and filtered here.
1886/// `Ok(None)` means the manifest is empty after filtering — [`report_nothing_to_fetch`]
1887/// was already called, so the caller returns `Ok(())`.
1888async fn obtain_manifest<P: Provider + Clone>(
1889 ctx: &PullCtx<'_, P>,
1890 args: &BundlePullArgs,
1891 filter: &EntryFilter,
1892 filters_given: bool,
1893 local_manifest: Option<Kept>,
1894) -> anyhow::Result<Option<Kept>> {
1895 if let Some(m) = local_manifest {
1896 return Ok(Some(m));
1897 }
1898 let raw = args
1899 .hash
1900 .as_deref()
1901 .ok_or_else(|| anyhow!("no bundle source (expected -i or --hash)"))?;
1902 let hash = fetch::parse_hash(raw)?;
1903 let bytes = if let Some(cached) = cached_manifest(args, hash) {
1904 cached
1905 } else {
1906 let fetched = ctx
1907 .fetch_to_memory(hash, &args.output)
1908 .await
1909 .context("fetch bundle manifest blob")?;
1910 // Advisory: a write failure prints a warning, never fails the pull.
1911 bundle_cache::store(&args.output, hash, &fetched);
1912 fetched
1913 };
1914 let mut m = parse_manifest(&bytes)?;
1915 let raw_empty = m.entries.is_empty();
1916 let (entries, excluded) = filter.apply_and_warn(m.entries);
1917 m.entries = entries;
1918 if m.entries.is_empty() {
1919 report_nothing_to_fetch(NothingReason::from_filter(filters_given, raw_empty));
1920 warn_leftover_partials(&args.output, &[], args.hash.as_deref());
1921 return Ok(None);
1922 }
1923 Ok(Some(Kept {
1924 manifest: m,
1925 excluded,
1926 }))
1927}
1928
1929/// Record one group's run in [`PullCtx::pull_plain`]: forward its recordable
1930/// files to the skip-cache flush, put its outcomes in slot `i`, and return the
1931/// fault that ends the whole pull, if its fetch failed with one.
1932fn settle_group_run(
1933 groups: &mut [SettledGroup],
1934 flush_tx: &tokio::sync::mpsc::UnboundedSender<FlushBatch>,
1935 i: usize,
1936 run: GroupRun,
1937 updates: BTreeMap<String, bundle_manifest::SavedFile>,
1938 warnings: Vec<String>,
1939) -> Option<anyhow::Error> {
1940 if !updates.is_empty() {
1941 // Newly-fetched content bytes drive the byte-cadence flush; a link/skip
1942 // records a file but lands no new bytes.
1943 let fetched_bytes = run
1944 .outcomes
1945 .iter()
1946 .map(|o| match o {
1947 EntryOutcome::Fetched(n) => *n,
1948 _ => 0,
1949 })
1950 .sum();
1951 // Unbounded send: fails only if the flush task is gone, which never
1952 // happens before `drive` drops `flush_tx`.
1953 let _ = flush_tx.send(FlushBatch {
1954 updates,
1955 fetched_bytes,
1956 });
1957 }
1958 if let Some(slot) = groups.get_mut(i) {
1959 *slot = SettledGroup {
1960 outcomes: run.outcomes,
1961 warnings,
1962 bytes: run.bytes,
1963 };
1964 }
1965 run.stop
1966}
1967
1968/// A hash-group's result, kept for the run summary: its per-entry outcomes
1969/// and the content bytes its landed fetch paid for and resumed (see
1970/// [`GroupRun::bytes`]).
1971#[derive(Clone, Default)]
1972struct SettledGroup {
1973 outcomes: Vec<EntryOutcome>,
1974 /// The group's size warnings ([`size_warnings`]).
1975 warnings: Vec<String>,
1976 bytes: Option<EntryBytes>,
1977}
1978
1979/// Run `items` through `run` once, at most `jobs` at a time. A bundle pull
1980/// passes every group: its [`JobGate`] is the `--jobs` cap, and a group waits
1981/// for its slot inside `run`.
1982///
1983/// `settle` sees every result as it lands, with the item's index in `items`,
1984/// and returns the fault that ends the whole run, if the result carries one.
1985/// That fault starts no further item, drops the items in flight, and is what
1986/// this returns. `None` means every item ran and settled.
1987async fn run_groups<G, R, Fut>(
1988 items: Vec<G>,
1989 jobs: usize,
1990 run: impl Fn(G) -> Fut,
1991 mut settle: impl FnMut(usize, R) -> Option<anyhow::Error>,
1992) -> Option<anyhow::Error>
1993where
1994 Fut: std::future::Future<Output = R>,
1995{
1996 let width = jobs.min(items.len()).max(1);
1997 let mut stream = futures_util::stream::iter(items.into_iter().enumerate())
1998 .map(|(i, item)| {
1999 let fut = run(item);
2000 async move { (i, fut.await) }
2001 })
2002 .buffer_unordered(width);
2003 while let Some((i, result)) = stream.next().await {
2004 if let Some(err) = settle(i, result) {
2005 return Some(err);
2006 }
2007 }
2008 None
2009}
2010
2011/// Per-provider cap on concurrent streams to one `(pool, signer, provider)`
2012/// lane. A lane has one shared [`LaneLedgers`] voucher watermark; concurrent
2013/// streams on the same lane draw on it together, and the serving node credits
2014/// each stream's delivered bytes from that shared watermark fairly — a slower
2015/// stream, whose reveal for its own chunk can land below a faster sibling's
2016/// frontier, is paid from the lane headroom the sibling opened rather than
2017/// stalled. So same-lane concurrency is safe; this only bounds how many streams
2018/// touch a given provider at once. `--max-lane-streams` sets the cap (default 4):
2019/// a `Semaphore(N)` admits N concurrent same-lane streams, and N == 1 runs a
2020/// single ordered voucher sequence per lane. Cross-lane parallelism (distinct
2021/// providers) is never bounded here — only by `--jobs`.
2022pub(crate) struct LaneStreamCap {
2023 /// Per-provider streams, created on first use. The `tokio::sync::Mutex`
2024 /// guards the map so the cap is `Sync` and shareable across the entry futures.
2025 map: tokio::sync::Mutex<HashMap<Address, Arc<ProviderStreams>>>,
2026 /// Concurrent-stream permits per provider (at least 1). At 1 a `Semaphore(1)`
2027 /// serializes same-lane streams exactly like a mutex.
2028 n: usize,
2029 /// The id the next lane's [`ExtraPermits`] gets.
2030 next_lane: AtomicU64,
2031}
2032
2033impl LaneStreamCap {
2034 /// A cap admitting `n` concurrent streams per provider (clamped to at least 1).
2035 pub(crate) fn new(n: usize) -> Self {
2036 Self {
2037 map: tokio::sync::Mutex::new(HashMap::new()),
2038 n: n.max(1),
2039 next_lane: AtomicU64::new(0),
2040 }
2041 }
2042
2043 /// The per-provider streams, created on first use.
2044 async fn streams(&self, provider: Address) -> Arc<ProviderStreams> {
2045 let mut map = self.map.lock().await;
2046 Arc::clone(
2047 map.entry(provider)
2048 .or_insert_with(|| Arc::new(ProviderStreams::new(self.n))),
2049 )
2050 }
2051
2052 /// One stream permit for `provider` if one is free now, else `None`. It
2053 /// never waits, so a caller that already holds another provider's permit
2054 /// cannot deadlock against a sibling that holds this one. It does not
2055 /// look at the provider's claims ([`ExtraPermits::grant`]): a lane's first
2056 /// stream takes any free permit, and a lane that finds none backs off and
2057 /// holds nothing.
2058 pub(crate) async fn try_permit(
2059 &self,
2060 provider: Address,
2061 ) -> Option<tokio::sync::OwnedSemaphorePermit> {
2062 Arc::clone(&self.streams(provider).await.semaphore)
2063 .try_acquire_owned()
2064 .ok()
2065 }
2066
2067 /// How a lane to `provider` takes a stream beyond its lease
2068 /// ([`decdn_client::LaneWiden`]): an extra stream for a queued range no
2069 /// idle lane takes, or the stream it starts again on. `grow` takes only
2070 /// a permit of `provider` that is free now and never waits, and `release`
2071 /// gives one back. The lane's first stream holds its permit as its lease,
2072 /// so the extra streams fit in the rest of the cap. At a cap of 3 or more,
2073 /// an extra stream never takes the provider's last free permit, so a
2074 /// sibling entry's first stream to `provider` can still open; the extra
2075 /// stream holds its permit for a whole range. A smaller cap has no room
2076 /// for both, and an extra stream takes any free permit. The acquire gives
2077 /// the lease back when the lane's own worker ends, and the lane starts
2078 /// again only on a permit `grow` takes; that start may take the last
2079 /// permit that no claim holds back.
2080 ///
2081 /// A lane that `grow` refuses claims the provider's next free permit,
2082 /// ahead of every later `grow` ask, and a lane that runs no stream goes
2083 /// ahead of the claims of lanes that run one ([`ProviderStreams::take`],
2084 /// #2341). A lane's first stream ([`Self::try_permit`]) still takes any
2085 /// free permit.
2086 pub(crate) async fn widen(&self, provider: Address) -> decdn_client::LaneWiden {
2087 let extras = Arc::new(self.extra_permits(provider).await);
2088 let released = Arc::clone(&extras);
2089 decdn_client::LaneWiden::new(
2090 move |kind| extras.grant(kind),
2091 move || released.release_one(),
2092 )
2093 }
2094
2095 /// A new lane's grants of `provider`'s permits beside its lease, under a
2096 /// lane id no other lane of this cap has.
2097 async fn extra_permits(&self, provider: Address) -> ExtraPermits {
2098 ExtraPermits {
2099 streams: self.streams(provider).await,
2100 keep: self.extra_keep(),
2101 lane: self.next_lane.fetch_add(1, Ordering::Relaxed),
2102 held: std::sync::Mutex::new(Vec::new()),
2103 }
2104 }
2105
2106 /// The free permits of a provider an extra stream leaves for a sibling
2107 /// entry's first stream: one at a cap of 3 or more, where the lease, an
2108 /// extra stream and the kept permit fit, and none below.
2109 const fn extra_keep(&self) -> usize {
2110 if self.n > 2 { 1 } else { 0 }
2111 }
2112}
2113
2114/// How long a lane's claim on a provider's next free permit holds without a
2115/// new ask. The acquire asks a refused `grow` again within
2116/// [`decdn_client::GROWTH_RETRY`] while it still wants the stream, and each
2117/// ask renews the claim. So a lane that still waits keeps its claim, and the
2118/// claim of a lane that stopped asking, such as one whose own worker took
2119/// the range, ends soon after. A lane whose node refused its extra streams
2120/// is not asked while it waits out that refusal, so its claim lapses too:
2121/// the node would refuse the stream anyway.
2122const CLAIM_TTL: std::time::Duration = decdn_client::GROWTH_RETRY.saturating_mul(3);
2123
2124/// One provider's stream permits and the claims of the lanes that wait for
2125/// one.
2126struct ProviderStreams {
2127 /// The provider's per-lane stream semaphore.
2128 semaphore: Arc<tokio::sync::Semaphore>,
2129 /// The lanes [`Self::take`] refused, at most one claim per lane, in the
2130 /// order they first claimed. A renewal keeps a lane's place.
2131 claims: std::sync::Mutex<Vec<Claim>>,
2132}
2133
2134/// A lane's claim on its provider's next free permit.
2135struct Claim {
2136 /// The claiming lane's [`ExtraPermits::lane`].
2137 lane: u64,
2138 /// The free permits the lane's last ask leaves after its own.
2139 keep: usize,
2140 /// Whether the lane ran no stream at its last ask.
2141 bare: bool,
2142 /// When the lane last asked.
2143 at: tokio::time::Instant,
2144}
2145
2146impl ProviderStreams {
2147 /// `n` permits and no claims.
2148 fn new(n: usize) -> Self {
2149 Self {
2150 semaphore: Arc::new(tokio::sync::Semaphore::new(n)),
2151 claims: std::sync::Mutex::new(Vec::new()),
2152 }
2153 }
2154
2155 /// A permit for `lane` if one is free now that no claim holds back,
2156 /// never waiting. `keep` is the free permits the ask leaves after its
2157 /// own, and `bare` says the lane runs no stream.
2158 ///
2159 /// Each claim ahead of `lane`'s own, or every claim when it has none,
2160 /// holds back one free permit, and the largest `keep` among the ask and
2161 /// those claims stays free after them. A bare ask counts only the bare
2162 /// claims ahead, so a lane that runs no stream goes ahead of a lane that
2163 /// waits for one more: every lane gets its first stream before any lane
2164 /// gets a second. So a lane that asks the instant a permit frees, such
2165 /// as one whose short legs restart beside its extra streams, cannot take
2166 /// it from a lane that asks only once per growth pass (#2341).
2167 ///
2168 /// A refused ask claims the next free permit, or renews the lane's claim
2169 /// in its place. A grant ends the claim. Claims older than [`CLAIM_TTL`]
2170 /// do not count. Every entry is polled on one task and this never
2171 /// awaits, so no sibling takes a permit between the count and the take.
2172 fn take(
2173 &self,
2174 lane: u64,
2175 keep: usize,
2176 bare: bool,
2177 ) -> Option<tokio::sync::OwnedSemaphorePermit> {
2178 let now = tokio::time::Instant::now();
2179 let mut claims = self.claims.lock().unwrap_or_else(PoisonError::into_inner);
2180 claims.retain(|claim| now.saturating_duration_since(claim.at) < CLAIM_TTL);
2181 let own = claims.iter().position(|claim| claim.lane == lane);
2182 let (ahead, kept) = claims
2183 .get(..own.unwrap_or(claims.len()))
2184 .unwrap_or_default()
2185 .iter()
2186 .filter(|claim| !bare || claim.bare)
2187 .fold((0_usize, keep), |(n, kept), claim| {
2188 (n.saturating_add(1), kept.max(claim.keep))
2189 });
2190 if self.semaphore.available_permits() <= ahead.saturating_add(kept) {
2191 let claim = Claim {
2192 lane,
2193 keep,
2194 bare,
2195 at: now,
2196 };
2197 match own.and_then(|i| claims.get_mut(i)) {
2198 Some(renewed) => *renewed = claim,
2199 None => claims.push(claim),
2200 }
2201 return None;
2202 }
2203 match Arc::clone(&self.semaphore).try_acquire_owned() {
2204 Ok(permit) => {
2205 if let Some(i) = own {
2206 claims.remove(i);
2207 }
2208 Some(permit)
2209 }
2210 Err(tokio::sync::TryAcquireError::NoPermits) => None,
2211 Err(tokio::sync::TryAcquireError::Closed) => {
2212 // Nothing closes the semaphore: a closed one grants no
2213 // stream, and says so.
2214 tracing::warn!("a lane's stream semaphore is closed; it grants no extra stream");
2215 None
2216 }
2217 }
2218 }
2219
2220 /// End `lane`'s claim, if it has one.
2221 fn forget(&self, lane: u64) {
2222 self.claims
2223 .lock()
2224 .unwrap_or_else(PoisonError::into_inner)
2225 .retain(|claim| claim.lane != lane);
2226 }
2227}
2228
2229/// The stream permits one lane holds beside its lease: its extra streams',
2230/// and its own stream's once it starts again without the lease
2231/// ([`LaneStreamCap::widen`]).
2232struct ExtraPermits {
2233 /// The provider's permits and claims.
2234 streams: Arc<ProviderStreams>,
2235 /// The free permits an extra stream leaves for a sibling entry's first
2236 /// stream.
2237 keep: usize,
2238 /// The lane's id in its provider's claims.
2239 lane: u64,
2240 /// The permits granted and not yet given back.
2241 held: std::sync::Mutex<Vec<tokio::sync::OwnedSemaphorePermit>>,
2242}
2243
2244impl ExtraPermits {
2245 /// Take one permit if it is free now and no claim holds it back
2246 /// ([`ProviderStreams::take`]), never waiting, and return whether one
2247 /// was taken. An extra stream leaves `keep` free permits for a sibling
2248 /// entry's first stream; a restart may take the last one. The acquire
2249 /// asks a restart only once the lane's lease is given back, so a lane
2250 /// that asks one and holds no grant runs no stream.
2251 fn grant(&self, kind: decdn_client::GrowFor) -> bool {
2252 let (keep, bare) = match kind {
2253 decdn_client::GrowFor::Restart => (0, self.lock_held().is_empty()),
2254 decdn_client::GrowFor::Extra => (self.keep, false),
2255 };
2256 let Some(permit) = self.streams.take(self.lane, keep, bare) else {
2257 return false;
2258 };
2259 self.lock_held().push(permit);
2260 true
2261 }
2262
2263 /// Give back one held permit. The acquire gives back only what `grant`
2264 /// granted, so a call with none held breaks that rule: it does nothing
2265 /// and says so.
2266 fn release_one(&self) {
2267 let permit = self.lock_held().pop();
2268 debug_assert!(
2269 permit.is_some(),
2270 "a stream was given back that was never granted"
2271 );
2272 if permit.is_none() {
2273 tracing::warn!("a lane gave back a stream permit it does not hold");
2274 }
2275 drop(permit);
2276 }
2277
2278 /// The held permits, locked.
2279 fn lock_held(&self) -> std::sync::MutexGuard<'_, Vec<tokio::sync::OwnedSemaphorePermit>> {
2280 self.held.lock().unwrap_or_else(PoisonError::into_inner)
2281 }
2282}
2283
2284impl Drop for ExtraPermits {
2285 /// An ended lane claims nothing.
2286 fn drop(&mut self) {
2287 self.streams.forget(self.lane);
2288 }
2289}
2290
2291/// The run's `--jobs` cap ([`PullCtx::gate`]): its permits, and the admission
2292/// queue a new group's first permit waits in.
2293///
2294/// A new group waits in `admission` before it waits for a permit, so at most
2295/// one new group queues on `permits` at a time. A group that gave its permit
2296/// back while it waited for donors ([`FetchSlot::ensure`]) queues on `permits`
2297/// directly, so it waits behind at most that one new group, not behind every
2298/// group of the bundle still to start.
2299struct JobGate {
2300 permits: tokio::sync::Semaphore,
2301 admission: tokio::sync::Mutex<()>,
2302}
2303
2304impl JobGate {
2305 /// A gate of `jobs` permits.
2306 fn new(jobs: usize) -> Self {
2307 Self {
2308 permits: tokio::sync::Semaphore::new(jobs),
2309 admission: tokio::sync::Mutex::new(()),
2310 }
2311 }
2312
2313 /// Wait for a permit.
2314 async fn permit(&self) -> anyhow::Result<tokio::sync::SemaphorePermit<'_>> {
2315 self.permits
2316 .acquire()
2317 .await
2318 .map_err(|_| anyhow!("bundle pull concurrency gate closed"))
2319 }
2320}
2321
2322/// One `--jobs` permit of a [`JobGate`], held by one hash group's fetch or by
2323/// the manifest fetch.
2324///
2325/// A range-dedup entry gives it back only while it waits for a sibling to
2326/// register its donor chunks ([`RangeDriver::release_slot`]): a waiter moves
2327/// no bytes, and holding the permit would keep a queued entry from fetching.
2328/// The wait takes a permit again as it ends ([`RangeDriver::retake_slot`]), so
2329/// the splice, the whole-file hash and the materialize after it run under one,
2330/// and each drive takes one again first ([`Self::ensure`]). One entry uses a
2331/// slot sequentially: it never drives twice at once.
2332struct FetchSlot<'g> {
2333 gate: &'g JobGate,
2334 permit: Mutex<Option<tokio::sync::SemaphorePermit<'g>>>,
2335}
2336
2337impl<'g> FetchSlot<'g> {
2338 /// Wait in `gate`'s admission queue, then for a permit.
2339 async fn acquire(gate: &'g JobGate) -> anyhow::Result<Self> {
2340 let admitted = gate.admission.lock().await;
2341 let permit = gate.permit().await?;
2342 drop(admitted);
2343 Ok(Self {
2344 gate,
2345 permit: Mutex::new(Some(permit)),
2346 })
2347 }
2348
2349 /// Give the permit back, if this slot holds it.
2350 fn release(&self) {
2351 drop(
2352 self.permit
2353 .lock()
2354 .unwrap_or_else(PoisonError::into_inner)
2355 .take(),
2356 );
2357 }
2358
2359 /// Hold a permit: wait for one if [`Self::release`] gave it back, behind
2360 /// at most the one new group in admission. A permit that arrives after
2361 /// another already filled the slot goes straight back.
2362 async fn ensure(&self) -> anyhow::Result<()> {
2363 if self.held() {
2364 return Ok(());
2365 }
2366 let permit = self.gate.permit().await?;
2367 let mut slot = self.permit.lock().unwrap_or_else(PoisonError::into_inner);
2368 if slot.is_none() {
2369 *slot = Some(permit);
2370 }
2371 Ok(())
2372 }
2373
2374 /// Whether this slot holds a permit now.
2375 fn held(&self) -> bool {
2376 self.permit
2377 .lock()
2378 .unwrap_or_else(PoisonError::into_inner)
2379 .is_some()
2380 }
2381}
2382
2383/// Shared, by-reference state for the entry fetch loop. Borrowed by every
2384/// in-flight entry future, and every entry must be polled on one task, as
2385/// `buffer_unordered` does: each entry reads its lanes' ledgers and queues
2386/// their watermark writes with no await between
2387/// ([`fetch::queue_face_watermarks`]), and only one task keeps another entry's
2388/// read from falling between them.
2389struct PullCtx<'a, P: Provider + Clone> {
2390 endpoint: &'a Endpoint,
2391 store: &'a Arc<RedbBuyerPoolStore>,
2392 contract: &'a PaymentPool::PaymentPoolInstance<P>,
2393 rpc: &'a P,
2394 signer: &'a Arc<PrivateKeySigner>,
2395 self_address: Address,
2396 /// The pool's settlement token (USDC), read once via `PaymentPool.usdc()`.
2397 token: Address,
2398 voucher_dom: &'a Eip712Domain,
2399 slash_dom: &'a Eip712Domain,
2400 chain: &'a fetch::ResolvedChain,
2401 relays: &'a [RelayUrl],
2402 /// `Some((node_id, provider))` pins every entry to one node (`--node-id`);
2403 /// `None` discovers per entry against a sample of `registry`.
2404 explicit: Option<FetchTarget>,
2405 /// Every active registered node, read once for the run; `None` when
2406 /// `explicit` pins the node.
2407 registry: Option<Vec<NodeCandidate>>,
2408 common: &'a ClientFetchArgs,
2409 /// Delegated capability adopted for every entry (`--capability`), or `None`
2410 /// for the self-owned pool path. When `Some`, every entry presents this
2411 /// owner-signed grant instead of opening/reusing the caller's own pool, and
2412 /// a funding recovery step has no funding path (the delegate owns no pool
2413 /// to fund).
2414 grant: Option<decdn_incentive::CapabilityGrant>,
2415 /// Bundle-level namespace id (ADR 002) applied to every paid pull in the run —
2416 /// `--namespace <id>` as a big-endian `uint256`; `NO_NAMESPACE` when the flag
2417 /// was omitted.
2418 namespace_id: [u8; 32],
2419 /// The run's shared per-lane voucher ledgers (ADR 039): every entry and
2420 /// chunk on a `(pool_id, signer, provider)` lane draws on one monotonic
2421 /// issuer, so concurrent same-lane fetches never race the cumulative
2422 /// watermark.
2423 ledgers: LaneLedgers,
2424 /// The run's funding facts ([`fetch::RunFunding`]): the pool's spend
2425 /// outside the run's lanes, and a wallet too short of USDC to top it up.
2426 funding: fetch::RunFunding,
2427 /// The run's funding recovery gate (ADR 003 § Funding recovery). A bundle
2428 /// pull is one fetch for every entry, so its entries share one progress
2429 /// rule, and so does the pass that runs the remaining entries again
2430 /// against a replaced pool.
2431 recovery: Arc<RecoveryGate>,
2432 /// Run-scoped range-dedup counters (bytes spliced from disk, hints dropped by
2433 /// a fault), accumulated by every entry and reported once the pull finishes.
2434 dedup_stats: DedupStats,
2435 /// Serializes every pool open-or-reuse across the whole bundle. The
2436 /// bundle's every entry shares ONE `PaymentPool` deposit (ADR 003), so
2437 /// distinct providers cannot open concurrently: its on-chain state (deposit,
2438 /// standing USDC allowance) is one resource regardless of which provider an
2439 /// entry is bound for. Taken around the whole open-or-reuse call (which may
2440 /// also perform a low-water top-up) and released before streaming, so
2441 /// delivery itself still runs concurrently once each entry has its context.
2442 open_lock: tokio::sync::Mutex<()>,
2443 /// Global `--jobs` cap (min 1) on hash groups at work, one permit per group
2444 /// ([`FetchSlot`]), taken before the group's file bar: a plain whole-file
2445 /// fetch, or a hint-carrying entry's complement drive, donor splice and any
2446 /// re-fetch. A range-dedup entry gives its permit back while it only waits
2447 /// for a sibling's donor chunks, and takes one again to drive. A byte range
2448 /// a sibling entry already holds is spliced from disk instead of fetched,
2449 /// so it never takes a permit of its own.
2450 gate: JobGate,
2451 /// Per-provider cap on concurrent same-lane streams (`--max-lane-streams`,
2452 /// default 4). Every lane an entry's fetch builds holds one of its
2453 /// provider's permits, taken without waiting ([`CliSources`]). Bounds only
2454 /// per-provider concurrency; `--jobs` still bounds cross-lane parallelism.
2455 lane_cap: LaneStreamCap,
2456 /// The command-wide holder health every entry's fetch records into, so a
2457 /// holder that faults one entry cools for every entry.
2458 health: Arc<PeerHealth>,
2459 /// The command's one connection per node: every entry's lanes, the
2460 /// range-dedup entries' included, open their streams on it.
2461 connections: Connections,
2462 /// The command's blocking state writes, in queue order across every
2463 /// entry ([`OrderedWrites`]).
2464 writes: &'a OrderedWrites,
2465 /// The command's stop policy. Its progress clock is shared by every entry
2466 /// and the manifest fetch, so the pull gives up only once no entry has
2467 /// landed a verified byte for the whole limit.
2468 stop: StopPolicy,
2469 /// The run's multi-bar progress renderer: one per-file bar per active pull
2470 /// above a bottom total bar (silent off a terminal or under `--json`). Set
2471 /// once the kept manifest is known — its entries decide the total-bar mode —
2472 /// so it starts [disabled](PullProgress::disabled) during the manifest fetch.
2473 progress: PullProgress,
2474}
2475
2476impl<P: Provider + Clone> PullCtx<'_, P> {
2477 /// The shared lane deps, assembled from `PullCtx`'s borrowed chain
2478 /// plumbing plus this run's per-fetch budgets — the same shape `decdn fetch`
2479 /// builds, for every fetch of an entry ([`Self::acquire_entry`]).
2480 fn drive_deps(&self, max_blob_bytes: u64) -> anyhow::Result<fetch::DriveFetchDeps<'_, P>> {
2481 Ok(fetch::DriveFetchDeps {
2482 timings: None,
2483 endpoint: self.endpoint,
2484 store: self.store,
2485 contract: self.contract,
2486 rpc: self.rpc,
2487 slash_dom: self.slash_dom,
2488 self_address: self.self_address,
2489 token: self.token,
2490 chain: self.chain,
2491 // Bundle-level `--namespace` (ADR 002): routes any cache-miss origin
2492 // pull to that namespace's authorized origins. Applies uniformly to the
2493 // manifest blob and every entry — all funnel through here.
2494 namespace_id: self.namespace_id,
2495 max_rate_per_mb: self.common.max_rate_per_mb,
2496 max_blob_bytes,
2497 // Same shape as `fetch` (#1134): a node that accepts the connection and
2498 // never answers is as dead as one that stops mid-stream, so the same
2499 // budget bounds both stages. The pull carries no overall wall-clock cap —
2500 // `drive` never consults one — so it completes for any blob size as long
2501 // as the upstream keeps feeding it bytes.
2502 deadlines: PullDeadlines::new(fetch::STALL_WINDOW, fetch::STALL_WINDOW, 0)?,
2503 connections: &self.connections,
2504 writes: self.writes,
2505 funding: &self.funding,
2506 })
2507 }
2508
2509 /// Fetch one whole blob into `staging` through the acquire loop (ADR 039),
2510 /// across the entry's holders ([`Self::entry_targets`]). See
2511 /// [`Self::acquire_entry`] for the fetch itself and what it returns.
2512 ///
2513 /// `slot` is the caller's `--jobs` permit; the fetch runs under it.
2514 async fn fetch_to_staging(
2515 &self,
2516 hash: [u8; 32],
2517 staging: &Path,
2518 total: Option<u64>,
2519 progress: Option<&ProgressCallback>,
2520 slot: &FetchSlot<'_>,
2521 ) -> anyhow::Result<bool> {
2522 slot.ensure().await?;
2523 let targets = self.entry_targets(hash).await?;
2524 self.acquire_entry(&targets, hash, staging, total, None, progress)
2525 .await
2526 }
2527
2528 /// One entry's holders: the pinned `--node-id`, or a fresh probe of the
2529 /// entry's own registry sample (proxy-warming non-holders first when they
2530 /// help, then holders nearest RTT first). An entry resolves them once and
2531 /// every fetch of the entry reuses them. A resolution that fails for any
2532 /// reason but the configuration ([`fetch::holders_or_none`]) starts the
2533 /// entry with no holder, and its acquire loop discovers them.
2534 async fn entry_targets(&self, hash: [u8; 32]) -> anyhow::Result<fetch::ResolvedTargets> {
2535 fetch::holders_or_none(self.resolve_entry_targets(hash).await)
2536 }
2537
2538 /// [`Self::entry_targets`] before a failure is sorted into a configuration
2539 /// fault or an empty start.
2540 async fn resolve_entry_targets(
2541 &self,
2542 hash: [u8; 32],
2543 ) -> anyhow::Result<fetch::ResolvedTargets> {
2544 if self.explicit.is_some() {
2545 return fetch::resolve_target_node(
2546 self.common,
2547 self.chain,
2548 self.endpoint,
2549 self.relays,
2550 hash,
2551 fetch::ProbeOpts {
2552 timings: None,
2553 round: fetch::ProbeRound::Stream,
2554 },
2555 )
2556 .await;
2557 }
2558 // The entry's round streams: its fetch starts at the first verified
2559 // holder, and the holders that answer later join it.
2560 let candidates = self.entry_candidates()?;
2561 fetch::probe_and_order(
2562 self.endpoint,
2563 &candidates,
2564 self.relays.first(),
2565 hash,
2566 fetch::ProxyWarmingParams::from_args(self.common),
2567 self.slash_dom,
2568 fetch::ProbeRound::Stream,
2569 )
2570 .await
2571 }
2572
2573 /// Fill `ranges` of blob `hash` (`None` is the whole blob) into the ranged
2574 /// store beside `staging` through the acquire loop, across `targets`' holders.
2575 /// The store is promoted to `staging` once every byte is present.
2576 ///
2577 /// `total` is the entry's first size claim: the manifest's `size` when it
2578 /// gives one. The fetch grows or shrinks it as bytes land, so a manifest
2579 /// size that differs from the blob never fails the entry. With no manifest
2580 /// size (the manifest blob itself, or an unsized entry), the first claim
2581 /// comes from the probe's size hint, or else from a header-only first open.
2582 ///
2583 /// A holder that faults cools in the command-wide [`PeerHealth`] and
2584 /// returns inside the same loop, so a holder that fails one entry is
2585 /// rested for every entry. The fetch ends on done, a fault only the user
2586 /// can fix, a unanimous verdict of the holders, or the command's
2587 /// [`StopPolicy`]. Every lane draws vouchers from the run's shared
2588 /// `LaneLedgers` (ADR 039), serializes its pool open-or-reuse on
2589 /// `open_lock`, and holds one of its provider's stream permits
2590 /// ([`LaneStreamCap`]). Each lane's voucher watermark is persisted before
2591 /// the result returns, and the `.partial` beside `staging` stays for a
2592 /// resume.
2593 ///
2594 /// It returns `true` when the store's finalized file failed its hash and
2595 /// the fetch dropped every byte the store held and fetched the blob again
2596 /// ([`Downloader::refetched_targets`]). A prefix an earlier run left is
2597 /// then fetched and paid again.
2598 async fn acquire_entry(
2599 &self,
2600 targets: &fetch::ResolvedTargets,
2601 hash: [u8; 32],
2602 staging: &Path,
2603 total: Option<u64>,
2604 ranges: Option<&[(u64, u64)]>,
2605 progress: Option<&ProgressCallback>,
2606 ) -> anyhow::Result<bool> {
2607 let max_blob_bytes = self.common.max_blob_mb.saturating_mul(1024 * 1024);
2608 let deps = self.drive_deps(max_blob_bytes)?;
2609 let sources = CliSources::new(
2610 &deps,
2611 self.common,
2612 self.relays,
2613 self.grant.as_ref(),
2614 self.signer,
2615 self.voucher_dom,
2616 Some(&self.open_lock),
2617 Some(&self.ledgers),
2618 Some(&self.lane_cap),
2619 &self.recovery,
2620 );
2621 let holders = sources.holders_from(targets);
2622 // A fetch dropped by Ctrl-C, or by a sibling's command-wide fault,
2623 // queues every lane's vouchers for recording too.
2624 let on_drop = fetch::SettleOnDrop::new(|| sources.persist_watermarks_detached());
2625 let result = async {
2626 // The manifest's size is the entry's first claim; without one, the
2627 // probe's hint or a header-only open gives it.
2628 let (total_bytes, holders) = if let Some(total) = total {
2629 (total, holders)
2630 } else {
2631 let claim = sources
2632 .first_claim(hash, holders, &self.health, &self.stop)
2633 .await?;
2634 (claim.total_bytes, claim.holders)
2635 };
2636 // `--max-sources` caps the lanes the entry stripes across at
2637 // once; every other holder waits as a reserve.
2638 let mut downloader = Downloader::new(&sources, sources.funder())
2639 .holders(holders)
2640 .health(Arc::clone(&self.health))
2641 .recovery(Arc::clone(&self.recovery))
2642 .max_lanes(self.common.max_sources)
2643 .max_blob_bytes(max_blob_bytes);
2644 if let Some(slot) = sources.credentials() {
2645 downloader = downloader.credentials(slot);
2646 }
2647 Box::pin(downloader.fetch_to_paths_shared(
2648 &[DownloadTarget {
2649 hash,
2650 total_bytes,
2651 dest: staging,
2652 ranges,
2653 }],
2654 &self.ledgers,
2655 progress,
2656 &self.stop,
2657 ))
2658 .await
2659 .map(|_paths| downloader.refetched_targets() > 0)
2660 }
2661 .await;
2662 on_drop.disarm();
2663 sources.persist_watermarks().await;
2664 result.map_err(|err| sources.annotate(err))
2665 }
2666
2667 /// This entry's probe candidates: a fresh sample of the run's registry read
2668 /// ([`entry_candidates`]).
2669 fn entry_candidates(&self) -> anyhow::Result<Vec<NodeCandidate>> {
2670 let registry = self
2671 .registry
2672 .as_deref()
2673 .ok_or_else(|| anyhow!("no discovery candidates available"))?;
2674 Ok(entry_candidates(registry, self.chain.region.as_deref()))
2675 }
2676
2677 /// Fetch one blob fully into memory — used only for the bundle manifest
2678 /// itself when named by `--hash` rather than read locally via `-i`.
2679 /// Manifests are small (unlike bundle entries, which stream straight to
2680 /// their destination and never buffer the whole blob), so streaming into a
2681 /// staging file under `out_root` and reading it back is cheap; it also
2682 /// means a manifest fetch runs the same acquire loop, under the same
2683 /// health table and progress clock, as every entry. A manifest has no
2684 /// manifest size, so the fetch takes the first size claim (the probe hint,
2685 /// or a header-only open when there is none). The staging
2686 /// file is removed once read back — a manifest fetch has nothing further
2687 /// to resume once its bytes are safely in memory.
2688 async fn fetch_to_memory(&self, hash: [u8; 32], out_root: &Path) -> anyhow::Result<Vec<u8>> {
2689 let staging = staging_path(out_root, hash)?;
2690 // The manifest blob fetch is silent (no bar): `progress` is disabled here
2691 // anyway, and the per-file bars belong to the entries, not the manifest.
2692 let slot = FetchSlot::acquire(&self.gate).await?;
2693 self.fetch_to_staging(hash, &staging, None, None, &slot)
2694 .await?;
2695 let bytes =
2696 std::fs::read(&staging).with_context(|| format!("read {}", staging.display()))?;
2697 remove_staging_off_runtime(&staging).await;
2698 Ok(bytes)
2699 }
2700
2701 /// Fetch every entry into `out_root`, one unit of work per *distinct* blob
2702 /// hash: entries sharing a hash (one file at two bundle paths) are fetched and
2703 /// reconstructed once, then materialized at each path (#1306) — never fetched,
2704 /// nor *paid for*, twice. Each unit of work runs via `buffer_unordered` (no
2705 /// `tokio::spawn`, by choice — nothing here is `!Send`), every one in flight
2706 /// at once and waiting in start order for its slot of `PullCtx.gate`, the
2707 /// one `--jobs` cap; a group's file bar appears only once it holds a slot.
2708 /// Parallelism comes from concurrent in-flight network I/O, while the run's
2709 /// shared `LaneLedgers` keep same-lane voucher issuance monotonic across
2710 /// every entry.
2711 ///
2712 /// A run-wide [`ChunkIndex`] threads through every group: an entry that
2713 /// carries chunk hints and completes registers its chunks, and a later entry
2714 /// that shares a chunk splices those already-materialized byte ranges from
2715 /// disk instead of paying to fetch them (range-dedup). A donor's finalized
2716 /// staging blob is therefore kept until the whole run finishes, then swept.
2717 async fn pull_all(
2718 &self,
2719 entries: &[ManifestEntry],
2720 out_root: &Path,
2721 overwrite: bool,
2722 saved: SavedManifest,
2723 interrupt: &mut Interrupt,
2724 ) -> PullRun {
2725 // Every entry declares an authoritative whole-file `hash`, so the by-hash
2726 // grouping path (fetch-once + link-duplicates, #1306) covers plain and
2727 // hint-carrying entries alike — the chunk hints only change HOW a group's
2728 // one blob is assembled, never that it is one paid unit per distinct hash.
2729 let index = ChunkIndex::default();
2730 let refs: Vec<&ManifestEntry> = entries.iter().collect();
2731 // Assign every chunk shared by two or more entries to its smallest holder,
2732 // so that shared chunk is fetched once (by that holder) and spliced into
2733 // every other entry — computed once from the whole manifest.
2734 let fetch_plan = build_fetch_plan(entries);
2735 // Pre-pass: classify every entry against the output tree and the saved
2736 // skip-cache before any fetch, so an updated file at an already-present
2737 // path is written rather than silently skipped. The re-hash is this
2738 // command's own work, so the progress clock holds while it runs, and
2739 // the time since the manifest landed (an editor under `--select`
2740 // included) ends when it does.
2741 let hold = self.stop.clock.hold();
2742 let disk = resolve_disk_state(entries, &saved, out_root, overwrite).await;
2743 drop(hold);
2744 for d in &disk.seed {
2745 index.seed_disk(d.hash, &d.source, d.offset, d.len);
2746 }
2747 // `saved` moves on to seed the incremental skip-cache flush: each
2748 // completed group's records fold onto it and rewrite the cache as the
2749 // pull progresses.
2750 let run = self
2751 .pull_plain(
2752 &refs,
2753 out_root,
2754 overwrite,
2755 &index,
2756 &fetch_plan,
2757 &disk,
2758 saved,
2759 interrupt,
2760 )
2761 .await;
2762
2763 // A donor entry's finalized staging blob is the source a recipient splices
2764 // from, so it is kept past its own group's cleanup. With the run over,
2765 // every registered donor source is safe to remove — EXCEPT one a
2766 // `mark_retained` flagged: its blob is paid for but a destination failed to
2767 // materialize, so the finalized `<hex>` is the resume prefix a rerun needs
2768 // (deleting it would force a full re-fetch and re-payment).
2769 //
2770 // Disk cost: a donor's first destination is a hard link to its staging
2771 // blob, so the two share storage until this sweep drops the staging name
2772 // (a copy only when the destination is on another filesystem).
2773 sweep_donor_sources(&index).await;
2774 run
2775 }
2776
2777 /// Fetch every distinct blob once (grouped by hash) and materialize it at each
2778 /// destination path (#1306), routing each group through [`Self::pull_entry`] so a
2779 /// blob whose chunk hints overlap an already-materialized sibling pays only for
2780 /// the complement. `PullCtx.gate` caps the groups at work at `--jobs`; a
2781 /// range-dedup entry waiting for donors holds no slot, so a queued group
2782 /// starts in its place.
2783 // The run-wide context (index, fetch plan, disk pre-pass, flush cache) all
2784 // threads through here; bundling it into a struct would only rename the fields.
2785 #[allow(clippy::too_many_arguments)]
2786 async fn pull_plain<'e>(
2787 &self,
2788 entries: &[&'e ManifestEntry],
2789 out_root: &Path,
2790 overwrite: bool,
2791 index: &ChunkIndex,
2792 fetch_plan: &FetchPlan,
2793 disk: &DiskState,
2794 saved: SavedManifest,
2795 interrupt: &mut Interrupt,
2796 ) -> PullRun {
2797 let groups_by_hash = order_groups_smallest_first(group_by_hash(entries));
2798 let group_count = groups_by_hash.len().max(1);
2799 // Completed groups' skip-cache records flow to the flush task over this
2800 // channel. Unbounded so a `send` from the fetch-driving side never blocks
2801 // (the flush write must never stall a fetch); the messages are one small
2802 // batch per completed group, so the channel stays shallow.
2803 let (flush_tx, flush_rx) = tokio::sync::mpsc::unbounded_channel::<FlushBatch>();
2804
2805 // The fetch-driving side: run every group through `fetch_group` once,
2806 // every one in flight and each waiting there for its `--jobs` slot. As
2807 // each run completes, forward its recordable files (built here while
2808 // the group's entries are in scope) to the flush task and keep its
2809 // outcomes for the run summary. A fault that ends the whole pull
2810 // ([`ends_the_pull`]) drops every group still in flight, so none that
2811 // waits for a slot starts.
2812 let mut groups: Vec<SettledGroup> = vec![SettledGroup::default(); groups_by_hash.len()];
2813 let mut stopped: Option<anyhow::Error> = None;
2814 let drive = async {
2815 let run = |group: HashGroup<'e>| {
2816 // Cheap clone of the group's entry refs so `fetch_group` can consume
2817 // `group` while we still correlate outcomes to entries.
2818 let group_entries: Vec<&'e ManifestEntry> = group.entries.clone();
2819 async move {
2820 let run = self
2821 .fetch_group(group, out_root, overwrite, index, fetch_plan, disk)
2822 .await;
2823 // Mark this group's blob finished (whatever the outcome) so a
2824 // tail-reconcile waiter deferring a chunk assigned to it stops
2825 // waiting and pays for the range itself if the group failed to
2826 // register that chunk.
2827 if let Some(en) = group_entries.first()
2828 && let Ok(whole) = fetch::parse_hash(&en.hash)
2829 {
2830 index.mark_finished(whole);
2831 }
2832 // `fetch_group` returns one outcome per entry, in order;
2833 // `build_completed_updates` relies on that to correlate a path
2834 // to its outcome by position. Assert the invariant so a future
2835 // divergence is caught in debug builds rather than silently
2836 // truncating the zip.
2837 debug_assert_eq!(
2838 group_entries.len(),
2839 run.outcomes.len(),
2840 "fetch_group must return one outcome per entry"
2841 );
2842 let updates = build_completed_updates(&group_entries, &run.outcomes, out_root);
2843 let warnings = size_warnings(&group_entries, &updates);
2844 (run, updates, warnings)
2845 }
2846 };
2847 stopped = run_groups(
2848 groups_by_hash,
2849 // Every group in flight at once: each waits for its slot of the
2850 // global gate, the one `--jobs` cap, in start order.
2851 group_count,
2852 run,
2853 |i, (run, updates, warnings)| {
2854 settle_group_run(&mut groups, &flush_tx, i, run, updates, warnings)
2855 },
2856 )
2857 .await;
2858 // Closing the channel tells the flush task to do its final write.
2859 drop(flush_tx);
2860 };
2861 // The flush task: fold each batch onto the prior skip-cache and rewrite it
2862 // atomically at the cadence, plus a final write when the channel closes.
2863 // Running here (joined, not awaited inside `drive`) keeps the blocking
2864 // write off the fetch-driving path — `join!` keeps polling `drive` while
2865 // this side awaits its write.
2866 let flush = flush_task(out_root, saved, flush_rx);
2867
2868 let interrupted = drive_until_interrupted(drive, flush, interrupt).await;
2869 // Byte tally is per-group (a blob pulled once, materialized to N paths),
2870 // so sum it before flattening away the group boundaries.
2871 let transfer = groups
2872 .iter()
2873 .map(|g| group_transfer(&g.outcomes, g.bytes))
2874 .fold(Transfer::default(), Transfer::add);
2875 let warnings = groups
2876 .iter_mut()
2877 .flat_map(|g| std::mem::take(&mut g.warnings))
2878 .collect();
2879 let outcomes = groups.into_iter().flat_map(|g| g.outcomes).collect();
2880 PullRun {
2881 outcomes,
2882 warnings,
2883 transfer,
2884 interrupted,
2885 stopped,
2886 }
2887 }
2888
2889 /// Run [`Self::pull_entry_untimed`] and, when the entry lands, log one `-v`
2890 /// line with its size, the time the whole entry took, and its download rate
2891 /// (#2120, #2189). Returns the entry's [`EntryBytes`] (see
2892 /// [`Self::pull_entry_untimed`]).
2893 #[allow(clippy::too_many_arguments)]
2894 async fn pull_entry(
2895 &self,
2896 hash: [u8; 32],
2897 hints: Option<&[Hint]>,
2898 total: Option<u64>,
2899 staging: &Path,
2900 index: &ChunkIndex,
2901 fetch_plan: &FetchPlan,
2902 file: Option<&pull_progress::FileBar>,
2903 slot: &FetchSlot<'_>,
2904 ) -> anyhow::Result<EntryBytes> {
2905 let started = std::time::Instant::now();
2906 self.pull_entry_untimed(hash, hints, total, staging, index, fetch_plan, file, slot)
2907 .await
2908 .inspect(|&bytes| log_entry_done(hash, staging, bytes, started.elapsed()))
2909 }
2910
2911 /// Reconstruct one entry's blob into `staging` (the finalized per-hash staging
2912 /// file [`Self::fetch_group`] then materializes to each destination), using chunk
2913 /// hints to dedup byte ranges against the run's [`ChunkIndex`] when they help.
2914 ///
2915 /// - No hints (or `total` unknown, or the blob is already finalized at
2916 /// `staging`), or no chunk overlaps a materialized sibling → the plain
2917 /// whole-file [`Self::fetch_to_staging`] path.
2918 /// - Otherwise the dedup path: pay through an [`AcquireRangeDriver`] for only
2919 /// the *complement* (the group-aligned bytes no donor covers) into `staging`'s
2920 /// `.partial`, then splice each donor range from its sibling's on-disk blob.
2921 /// Each donor chunk is confirmed present at the recorded source offset by
2922 /// re-hashing the whole chunk against its hint hash before its bytes are
2923 /// trusted; a chunk that fails (a lying donor hint, a short read) is added to
2924 /// a re-fetch list and driven normally. The reassembled `.partial` is then
2925 /// verified whole against the authoritative `hash`; on a match it is promoted
2926 /// to `staging`, and on a mismatch (a lying *recipient* hint mis-placed a
2927 /// chunk) the whole blob is re-driven and re-verified before the entry fails.
2928 ///
2929 /// On success the entry's chunks are registered into `index` so later entries
2930 /// can splice from this blob, and its [`EntryBytes`] are returned. Before
2931 /// either path drives anything, the entry reads the bytes its ranged store
2932 /// already holds from an earlier, interrupted run ([`resumed_spans`]); those
2933 /// are resumed, not paid. The paid count is the whole blob less its resumed
2934 /// bytes on the plain path, the blob less its spliced and resumed bytes on the
2935 /// dedup path, and 0 for an already-finalized staging blob. It is a content
2936 /// count: bao proof overhead is not in it.
2937 #[allow(clippy::too_many_arguments)]
2938 async fn pull_entry_untimed(
2939 &self,
2940 hash: [u8; 32],
2941 hints: Option<&[Hint]>,
2942 total: Option<u64>,
2943 staging: &Path,
2944 index: &ChunkIndex,
2945 fetch_plan: &FetchPlan,
2946 file: Option<&pull_progress::FileBar>,
2947 slot: &FetchSlot<'_>,
2948 ) -> anyhow::Result<EntryBytes> {
2949 // The byte-delivery callback drives the file bar's download and the total
2950 // bar's download meter; the `file` handle also carries the phase transitions
2951 // (discovering / pending / reconstructing) the byte callback cannot express.
2952 let progress = file.and_then(|f| f.callback());
2953 // An already-finalized `<hex>` staging blob — a prior run promoted it, or
2954 // this run finalized it and crashed in the promote-to-materialize window —
2955 // is the complete blob, BLAKE3-verified when it was promoted. Materialize
2956 // straight from it: `fetch_group` copies `staging` to each destination, so
2957 // re-hash it once as a cheap guard and return. It must NEVER be re-driven:
2958 // the ranged store keys resume on the `.ranges` sidecar, which promotion
2959 // deletes, so `open_or_create` on a sidecar-less `<hex>` would `create`
2960 // (truncate) it and re-pay for the whole blob. On a mismatch (a corrupt
2961 // leftover) drop it and fall through to a normal fetch.
2962 if staging.try_exists().unwrap_or(false) {
2963 let staging_buf = staging.to_path_buf();
2964 let verified = tokio::task::spawn_blocking(move || {
2965 hash_partial(&staging_buf).is_ok_and(|got| got == hash)
2966 })
2967 .await
2968 .map_err(|e| anyhow!("staging verify task: {e}"))?;
2969 if verified {
2970 if let (Some(cb), Some(total)) = (progress, total) {
2971 cb(total, total);
2972 }
2973 index.register(hints, staging);
2974 return Ok(EntryBytes::default());
2975 }
2976 remove_staging_off_runtime(staging).await;
2977 }
2978
2979 // What earlier runs already fetched into this entry's `.partial`: read now,
2980 // before any drive of this run adds to it. `staging` is absent here, so
2981 // the read opens the record alone and never hashes a final file.
2982 let resumed = resumed_spans_off_runtime(staging, hash).await;
2983
2984 // Until the first byte lands, the entry is discovering holders — surfaced so
2985 // a slow or stalling probe (a blob no probed node answers for) does not look
2986 // like a frozen empty bar. The delivery callback switches the row to its
2987 // download counts on the first chunk.
2988 if let Some(f) = file {
2989 f.set_discovering();
2990 }
2991
2992 // Plan the reassembly only when there is something to dedup against: hints
2993 // and a known total. The finalized-staging fast path above already
2994 // returned, so `staging` does not exist here. A chunk already materialized
2995 // (a pre-seeded on-disk donor, or a sibling that already finished) becomes a
2996 // splice `donor`; a chunk this run assigns to another entry becomes
2997 // `deferred` (waited for at the tail); the rest is driven. When neither a
2998 // donor nor a deferral applies, there is nothing to dedup — fall through to
2999 // the plain whole-file fetch.
3000 let plan = match (hints, total) {
3001 (Some(hints), Some(total)) if !hints.is_empty() => {
3002 let guard = index.map.lock().unwrap_or_else(PoisonError::into_inner);
3003 let plan = plan_reassembly(hints, &guard, fetch_plan, hash, total);
3004 drop(guard);
3005 (!plan.donor.is_empty() || !plan.deferred.is_empty()).then_some((plan, total))
3006 }
3007 _ => None,
3008 };
3009
3010 let Some((plan, total)) = plan else {
3011 // No donor overlap and nothing deferred — pay for the whole file, then
3012 // register its chunks so a *later* entry can dedup against it. The
3013 // manifest's `size`, when it gives one, is the entry's first size claim,
3014 // a hint; the paid count is the verified blob's length.
3015 let refetched = self
3016 .fetch_to_staging(hash, staging, total, progress, slot)
3017 .await?;
3018 index.register(hints, staging);
3019 let len = tokio::fs::metadata(staging).await.map_or(0, |m| m.len());
3020 let resumed = if refetched { 0 } else { span_bytes(&resumed) };
3021 return Ok(EntryBytes::whole_blob(len, resumed));
3022 };
3023
3024 // Dedup path. The group's `slot` covers every drive below; the entry
3025 // gives it back only while it waits for a sibling's donor chunks
3026 // ([`RangeDriver::release_slot`]), and each drive takes it again.
3027 slot.ensure().await?;
3028
3029 // Resolve the entry's holders ONCE and reuse them across every drive
3030 // below (complement, donor re-fetch, whole-blob re-drive), so the entry
3031 // probes exactly once.
3032 let targets = self.entry_targets(hash).await?;
3033 let driver = AcquireRangeDriver {
3034 ctx: self,
3035 targets: &targets,
3036 hash,
3037 staging,
3038 total,
3039 progress,
3040 refetched: AtomicBool::new(false),
3041 slot,
3042 };
3043
3044 // On any dedup-path success, true up the file + total progress bars to
3045 // 100%: donor bytes are spliced from disk and never flow through `drive`'s
3046 // progress callback, so a mostly-spliced entry would otherwise leave its
3047 // bars short of the blob's full size. Both bars are monotonic, so a path
3048 // that already reached 100% (a whole-blob re-drive) is unaffected.
3049 let finish_progress = || {
3050 if let Some(cb) = progress {
3051 cb(total, total);
3052 }
3053 };
3054
3055 let outcome = reassemble_dedup(
3056 &driver,
3057 &plan,
3058 total,
3059 &resumed,
3060 hints,
3061 index,
3062 fetch_plan,
3063 file,
3064 &finish_progress,
3065 )
3066 .await?
3067 .refetched_if(driver.refetched.load(Ordering::Relaxed));
3068 self.dedup_stats
3069 .spliced_bytes
3070 .fetch_add(outcome.spliced_bytes, Ordering::Relaxed);
3071 self.dedup_stats
3072 .hints_ignored
3073 .fetch_add(outcome.hints_ignored, Ordering::Relaxed);
3074 Ok(EntryBytes::dedup(total, outcome))
3075 }
3076
3077 /// Fetch the blob shared by one hash-group and write it under `out_root` at
3078 /// each entry's path. The blob is fetched — and paid for — **once**; the first
3079 /// writable destination receives the materialized bytes and every other is a
3080 /// hard link (or copy) of it (#1306). Returns one [`EntryOutcome`] per input
3081 /// entry, in order. Never panics or short-circuits — every failure becomes an
3082 /// [`EntryOutcome::Failed`], and the [`GroupRun`] carries a fetch fault that
3083 /// ends the whole pull ([`ends_the_pull`]).
3084 async fn fetch_group(
3085 &self,
3086 group: HashGroup<'_>,
3087 out_root: &Path,
3088 overwrite: bool,
3089 index: &ChunkIndex,
3090 fetch_plan: &FetchPlan,
3091 disk: &DiskState,
3092 ) -> GroupRun {
3093 // The group's shared hash is carried explicitly; parse it once, and a bad
3094 // hash fails every path in the group. Then wait for the group's `--jobs`
3095 // slot: every group of the run is in flight at once, and this is the cap.
3096 let slot = match fetch::parse_hash(group.hash) {
3097 Ok(h) => FetchSlot::acquire(&self.gate).await.map(|slot| (h, slot)),
3098 Err(e) => Err(e),
3099 };
3100 let (hash, slot) = match slot {
3101 Ok(ok) => ok,
3102 Err(e) => {
3103 return GroupRun::done(
3104 group
3105 .entries
3106 .iter()
3107 .map(|en| EntryOutcome::failed(&en.path, &e))
3108 .collect(),
3109 );
3110 }
3111 };
3112
3113 // Every entry in the group shares this blob (same whole-file hash), so its
3114 // chunk decomposition is identical; take the first entry's hints. `None`
3115 // (no `chunks`, an unparseable chunk hash, or chunk sizes that do not sum
3116 // to the whole-file size) drops back to a plain whole-file fetch.
3117 let first = group.entries.first();
3118 let hints = first.and_then(|e| hints_of(e));
3119 // A first entry that carries `chunks` but yields no usable hints had its
3120 // hint set dropped by a fault — an unparseable chunk hash, or chunk sizes
3121 // that do not sum to the whole-file size. Report the dropped hints so the
3122 // outcome is not silent; the whole-file `hash` still fetches the blob.
3123 if let Some(e) = first
3124 && hints.is_none()
3125 && let Some(chunks) = e.chunks.as_ref()
3126 {
3127 self.dedup_stats.hints_ignored.fetch_add(
3128 u64::try_from(chunks.len()).unwrap_or(u64::MAX),
3129 Ordering::Relaxed,
3130 );
3131 }
3132 let total = group.entries.iter().find_map(|e| e.size);
3133 // An entry that registers chunks can serve as a donor, so its finalized
3134 // staging blob must outlive this group's cleanup — the run-end sweep in
3135 // `pull_all` removes it once every recipient has had its chance.
3136 let is_donor = hints.as_ref().is_some_and(|h| !h.is_empty());
3137
3138 let slots = plan_slots(&group.entries, out_root, overwrite, &disk.skip);
3139
3140 // Every destination already present (or failed to resolve) → no fetch, no
3141 // payment. This is the whole point of the group: a duplicate path that is
3142 // already on disk costs nothing. It downloads nothing, so it contributes
3143 // nothing to the total bar's download meter — no credit, no bar.
3144 if !slots.iter().any(|s| matches!(s, Slot::Write { .. })) {
3145 return GroupRun::done(
3146 slots
3147 .into_iter()
3148 .map(|s| match s {
3149 Slot::Failed(o) => o,
3150 _ => EntryOutcome::Skipped,
3151 })
3152 .collect(),
3153 );
3154 }
3155
3156 // Whole-file dedup: an identical file already sits somewhere in the root
3157 // (recorded by a prior bundle). Verify that candidate by re-hashing it,
3158 // then materialize every destination by link/copy — no fetch, no payment.
3159 // A failed re-hash (stale/edited/removed donor) falls through to the
3160 // normal fetch path below; `slots` is untouched until this decision is
3161 // made, so falling through loses nothing. `materialize_group` (inside
3162 // `materialize_from_donor`) already passes a `Slot::Failed` through as its
3163 // own failure and a `Slot::Skip` through as `Skipped`, so handing it the
3164 // whole `slots` vector — not just the `Write` entries — is correct.
3165 if let Some(donor) = disk.whole_file.get(&hash)
3166 && let Some(len) = verified_donor_len(donor, hash).await
3167 {
3168 // A whole-file link downloads nothing, so it adds nothing to the
3169 // total bar's download meter.
3170 return GroupRun::done(materialize_from_donor(slots, donor, len).await);
3171 }
3172
3173 // Staged once per group: the entry's fetch finalizes to
3174 // `out_root/.decdn-partial/<hex>` (#1497), streaming its `<hex>.partial`
3175 // + sidecars there — rather than buffering the blob — which is what lets a
3176 // large entry top up mid-fetch. The staging path derived from `out_root`
3177 // is only created once a group actually has something to write, never for
3178 // an all-skipped group.
3179 let staging = match staging_path(out_root, hash) {
3180 Ok(p) => p,
3181 Err(e) => return GroupRun::done(fail_all(slots, &e)),
3182 };
3183
3184 // One per-file bar for this group's single pull, labeled by its destination
3185 // path(s) — a fetch-once hash group shows one bar for every path it lands at.
3186 // The bar meters the blob's download (pay-now) bytes and shows its
3187 // reconstruct (spliced-from-disk) bytes in parentheses; both come from the
3188 // static plan against the run's fetch plan (an empty donor index — the
3189 // fresh-pull split), matching the header's download total.
3190 let paths: Vec<String> = group.entries.iter().map(|e| e.path.clone()).collect();
3191 let (download_bytes_of_blob, reconstruct_bytes_of_blob) =
3192 blob_download_reconstruct(&group, fetch_plan, &HashMap::new());
3193 let file_bar = self.progress.file_bar(
3194 &pull_progress::file_label(&paths),
3195 download_bytes_of_blob,
3196 reconstruct_bytes_of_blob,
3197 );
3198 let fetched = self
3199 .pull_entry(
3200 hash,
3201 hints.as_deref(),
3202 total,
3203 &staging,
3204 index,
3205 fetch_plan,
3206 Some(&file_bar),
3207 &slot,
3208 )
3209 .await;
3210 file_bar.finish();
3211 let bytes = match fetched {
3212 Ok(bytes) => bytes,
3213 Err(e) => {
3214 // `pull_entry` (whole-file or dedup) leaves the `<hex>.partial` +
3215 // `.ranges` record in place on error: they are what the
3216 // next run resumes from rather than re-paying for bytes already landed
3217 // (same contract as `fetch`'s `<output>.partial` store).
3218 return GroupRun::fetch_failed(slots, e);
3219 }
3220 };
3221
3222 // A failed first write leaves `staging` in place, so the next writable
3223 // path retries from it. Off the runtime: a multi-GB copy on this task
3224 // would stall every other group's streams.
3225 let outcomes = materialize_group(
3226 slots,
3227 |dest| {
3228 let staging = staging.clone();
3229 off_runtime("materialize", move || {
3230 first_write(&staging, &dest, is_donor)
3231 })
3232 },
3233 link_off_runtime,
3234 )
3235 .await;
3236
3237 // Remove the paid staging blob (and its sidecars) ONLY when every
3238 // destination landed. If any materialize failed (unwritable dir, ENOSPC, a
3239 // link failure), keep it: the blob is fully fetched and paid for, and
3240 // `pull_entry` already finalized `<hex>` — a rerun sees the finalized
3241 // staging file and re-pulls only the still-missing ranges (typically
3242 // none), never the whole blob. Deleting staging here would force a full
3243 // re-fetch — and re-payment — of an unrefunded blob in *every* case;
3244 // `fetch`'s single-blob path gets this free from its own ranged store, so
3245 // the copy-based fan-out must gate it. A blob that moved into its first
3246 // destination leaves no staging blob behind; that destination is recorded
3247 // for the skip cache, so a rerun reuses it as a whole-file donor.
3248 //
3249 // A donor blob (`is_donor`) is a splice source a later entry may still
3250 // read, so it is kept here regardless and swept once at run end by
3251 // `pull_all`.
3252 let any_failed = outcomes
3253 .iter()
3254 .any(|o| matches!(o, EntryOutcome::Failed { .. }));
3255 if !is_donor && !any_failed {
3256 // A non-donor whose every destination landed: its content is safely on
3257 // disk (the blob itself usually moved there), so drop what is left of
3258 // staging now. A failed non-donor keeps its `.partial` resume prefix; a
3259 // donor is kept for splicing and swept at run end.
3260 remove_staging_off_runtime(&staging).await;
3261 } else if is_donor && any_failed {
3262 // A donor whose blob is fully fetched and paid for but whose
3263 // materialize failed: retain its finalized `<hex>` from the run-end
3264 // sweep so a rerun resumes from it instead of re-paying the whole blob.
3265 index.mark_retained(&staging);
3266 }
3267
3268 GroupRun::landed(outcomes, bytes)
3269 }
3270}
3271
3272/// The content bytes one landed entry paid for, spliced from disk, and resumed
3273/// from an earlier run, as [`PullCtx::pull_entry_untimed`] counts them. No byte
3274/// counts in two of them.
3275#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
3276struct EntryBytes {
3277 /// Content bytes this run fetched and paid for (see
3278 /// [`PullCtx::pull_entry_untimed`]).
3279 paid: u64,
3280 /// Bytes the entry spliced from a local donor on disk rather than
3281 /// downloading them itself.
3282 spliced: u64,
3283 /// Bytes an earlier, interrupted run fetched into the entry's `.partial`,
3284 /// which this run resumed rather than fetched again.
3285 resumed: u64,
3286}
3287
3288impl EntryBytes {
3289 /// A whole-blob fetch that landed a `len`-byte blob into a store that
3290 /// already held `resumed` bytes of it.
3291 fn whole_blob(len: u64, resumed: u64) -> Self {
3292 let resumed = resumed.min(len);
3293 Self {
3294 paid: len.saturating_sub(resumed),
3295 spliced: 0,
3296 resumed,
3297 }
3298 }
3299
3300 /// A range-dedup entry of `total` bytes with `outcome`: every byte it did
3301 /// not splice or resume, this run paid for.
3302 const fn dedup(total: u64, outcome: DedupOutcome) -> Self {
3303 Self {
3304 paid: total
3305 .saturating_sub(outcome.spliced_bytes)
3306 .saturating_sub(outcome.resumed_bytes),
3307 spliced: outcome.spliced_bytes,
3308 resumed: outcome.resumed_bytes,
3309 }
3310 }
3311}
3312
3313/// The byte spans of `staging`'s ranged store that earlier runs already
3314/// fetched: the present set of its `.partial.ranges` record, as sorted,
3315/// disjoint `(offset, len)` pairs. The store marks only fetched bytes present,
3316/// never the donor bytes a splice writes, so these spans are bytes an earlier
3317/// run paid for. No record (a fresh entry) gives no spans.
3318///
3319/// The caller reads this before it drives anything, with `staging` absent, so
3320/// the read opens the record alone and never hashes a final file. The spans
3321/// are for reporting only: a record it cannot read logs at `debug` and gives
3322/// no spans, and the fetch that follows surfaces the fault.
3323fn resumed_spans(staging: &Path, hash: [u8; 32]) -> Vec<(u64, u64)> {
3324 let read = || -> anyhow::Result<Vec<(u64, u64)>> {
3325 let (dir, stem) = fetch::ranged_store_location(staging)?;
3326 if !ClientRangedStore::has_record(&dir, &stem)? {
3327 return Ok(Vec::new());
3328 }
3329 Ok(ClientRangedStore::open(&dir, &stem, hash)?.present_byte_ranges())
3330 };
3331 read().unwrap_or_else(|e| {
3332 tracing::debug!(
3333 "could not read the resume record beside {}: {e:#}",
3334 staging.display()
3335 );
3336 Vec::new()
3337 })
3338}
3339
3340/// [`resumed_spans`] on the blocking pool. A failed join gives no spans.
3341async fn resumed_spans_off_runtime(staging: &Path, hash: [u8; 32]) -> Vec<(u64, u64)> {
3342 let staging = staging.to_path_buf();
3343 tokio::task::spawn_blocking(move || resumed_spans(&staging, hash))
3344 .await
3345 .unwrap_or_else(|e| {
3346 tracing::debug!("resume record task: {e}");
3347 Vec::new()
3348 })
3349}
3350
3351/// The total length of `(offset, len)` spans, saturating. The caller passes
3352/// disjoint spans, so no byte counts twice.
3353fn span_bytes(spans: &[(u64, u64)]) -> u64 {
3354 spans
3355 .iter()
3356 .fold(0u64, |acc, &(_, len)| acc.saturating_add(len))
3357}
3358
3359/// Log a finished entry's line at `-v` (#2120), so a run's slow entries can be
3360/// told from its fast ones. See [`entry_done_line`]. A staging file it cannot
3361/// stat logs its size as 0, after a debug line naming the path and the error.
3362fn log_entry_done(hash: [u8; 32], staging: &Path, bytes: EntryBytes, elapsed: std::time::Duration) {
3363 let size = std::fs::metadata(staging).map_or_else(
3364 |e| {
3365 tracing::debug!(
3366 "could not stat {} for its entry line: {e}",
3367 staging.display()
3368 );
3369 0
3370 },
3371 |m| m.len(),
3372 );
3373 tracing::info!("{}", entry_done_line(hash, size, bytes, elapsed));
3374}
3375
3376/// A finished entry's size, the time it took, and its rate over the bytes this
3377/// run paid for (see [`PullCtx::pull_entry_untimed`]). When the entry resumed
3378/// bytes from an earlier run or spliced bytes from disk, the line reports each
3379/// non-zero one apart from the paid bytes (#2189, #2236). The time covers the
3380/// whole entry: probing, every drive, any splice and the whole-file check.
3381fn entry_done_line(
3382 hash: [u8; 32],
3383 size: u64,
3384 bytes: EntryBytes,
3385 elapsed: std::time::Duration,
3386) -> String {
3387 let secs = elapsed.as_secs_f64();
3388 let rate = if secs > 0.0 {
3389 fetch::fmt_rate(fetch::bytes_as_f64(bytes.paid) / secs)
3390 } else {
3391 fetch::fmt_rate(0.0)
3392 };
3393 let detail = if bytes.spliced == 0 && bytes.resumed == 0 {
3394 rate
3395 } else {
3396 let mut parts = vec![format!(
3397 "{} downloaded at {rate}",
3398 indicatif::HumanBytes(bytes.paid)
3399 )];
3400 if bytes.resumed > 0 {
3401 parts.push(format!("{} resumed", indicatif::HumanBytes(bytes.resumed)));
3402 }
3403 if bytes.spliced > 0 {
3404 parts.push(format!(
3405 "{} spliced from disk",
3406 indicatif::HumanBytes(bytes.spliced)
3407 ));
3408 }
3409 parts.join(", ")
3410 };
3411 format!(
3412 "bundle pull: {}: {} in {secs:.1}s ({detail})",
3413 blake3::Hash::from_bytes(hash).to_hex(),
3414 indicatif::HumanBytes(size),
3415 )
3416}
3417
3418/// The range-drive step [`reassemble_dedup`] performs against one entry: drive
3419/// exactly `ranges` into the entry's `.partial` (bao-verified against the
3420/// whole-file hash), or let the ranged store finalize `staging` when they complete
3421/// it. Abstracted from the reassembly control flow so that flow — which must treat
3422/// a drive that finalized the blob as done, rather than open a now-renamed
3423/// `.partial` — is unit-testable without a live endpoint or pool.
3424trait RangeDriver {
3425 /// The entry's authoritative whole-file BLAKE3 hash.
3426 fn hash(&self) -> [u8; 32];
3427
3428 /// The entry's finalized staging path (`<out_root>/.decdn-partial/<hex>`); its
3429 /// `.partial` is what the splice writes into.
3430 fn staging(&self) -> &Path;
3431
3432 /// Drive `ranges` for the entry. On return the entry's `staging` is either
3433 /// finalized (the store completed and renamed `<hex>.partial` -> `<hex>`) or
3434 /// still a `.partial` the caller splices into.
3435 fn drive<'a>(
3436 &'a self,
3437 ranges: &'a [(u64, u64)],
3438 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + 'a>>;
3439
3440 /// Give back the entry's `--jobs` slot while it only waits for a sibling's
3441 /// donor chunks. A driver that holds no slot keeps the default, which does
3442 /// nothing.
3443 fn release_slot(&self) {}
3444
3445 /// Take the entry's `--jobs` slot again as a donor wait ends
3446 /// ([`Self::release_slot`]). The default does nothing.
3447 fn retake_slot(
3448 &self,
3449 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + '_>> {
3450 Box::pin(async { Ok(()) })
3451 }
3452}
3453
3454/// The production [`RangeDriver`]: each drive fills its ranges through the
3455/// acquire loop ([`PullCtx::acquire_entry`]) across the entry's holders, which
3456/// the entry resolved once.
3457///
3458/// Every drive of the entry (the pay-now complement, a donor re-fetch, each
3459/// deferred fallback, the self-heal re-drive) runs the same loop as a
3460/// whole-file entry: it stripes the ranges across the holders, a holder that
3461/// faults cools in the command-wide [`PeerHealth`] and returns, and the drive
3462/// ends on done, a fault only the user can fix, a unanimous verdict of the
3463/// holders, or the command's [`StopPolicy`]. The manifest's size is the
3464/// drive's first claim, a hint: a leg that verifies the final chunk proves
3465/// the true size. The spliced donor bytes are never marked present in the
3466/// store, so a drive promotes the entry only once fetched bytes fill the
3467/// whole store: a resumed store that already held the rest, or the self-heal
3468/// re-drive of the whole blob.
3469struct AcquireRangeDriver<'a, 'g, P: Provider + Clone> {
3470 ctx: &'a PullCtx<'a, P>,
3471 targets: &'a fetch::ResolvedTargets,
3472 hash: [u8; 32],
3473 staging: &'a Path,
3474 /// The manifest's size: the entry's first size claim.
3475 total: u64,
3476 progress: Option<&'a ProgressCallback>,
3477 /// Set once a drive's finalize failed its hash and the drive fetched the
3478 /// whole blob again ([`PullCtx::acquire_entry`]).
3479 refetched: AtomicBool,
3480 /// The entry's `--jobs` slot: held for each drive, given back while the
3481 /// entry waits for donors.
3482 slot: &'a FetchSlot<'g>,
3483}
3484
3485impl<P: Provider + Clone> RangeDriver for AcquireRangeDriver<'_, '_, P> {
3486 fn hash(&self) -> [u8; 32] {
3487 self.hash
3488 }
3489
3490 fn staging(&self) -> &Path {
3491 self.staging
3492 }
3493
3494 fn drive<'a>(
3495 &'a self,
3496 ranges: &'a [(u64, u64)],
3497 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + 'a>> {
3498 Box::pin(async move {
3499 self.slot.ensure().await?;
3500 let refetched = self
3501 .ctx
3502 .acquire_entry(
3503 self.targets,
3504 self.hash,
3505 self.staging,
3506 Some(self.total),
3507 Some(ranges),
3508 self.progress,
3509 )
3510 .await?;
3511 if refetched {
3512 self.refetched.store(true, Ordering::Relaxed);
3513 }
3514 Ok(())
3515 })
3516 }
3517
3518 fn release_slot(&self) {
3519 self.slot.release();
3520 }
3521
3522 fn retake_slot(
3523 &self,
3524 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + '_>> {
3525 Box::pin(self.slot.ensure())
3526 }
3527}
3528
3529/// Ensure `staging`'s ranged store exists with its `.partial` at the whole-file
3530/// size, so a splice can seek and write into it.
3531///
3532/// A drive normally creates the store; an entry with nothing to drive (every
3533/// chunk is a donor or deferred) has no drive, so this creates it. It always
3534/// makes a real store with its `.ranges` record, never a bare `.partial`: a
3535/// later drive into the same entry (a donor re-fetch, a deferred fallback, the
3536/// self-heal re-drive) calls `open_or_create`, which keys resume on the
3537/// `.ranges` record and truncates a `.partial` that lacks one, which wipes
3538/// every byte already spliced into it. A store that already has its record (a drive
3539/// made it, or a resumed run) is reopened as is. The data file is then extended
3540/// to `total`, sparsely, because the whole-file hash reads its full length.
3541///
3542/// It runs on the blocking pool: reopening a store can hash a final file.
3543async fn ensure_partial_off_runtime(
3544 staging: &Path,
3545 hash: [u8; 32],
3546 total: u64,
3547) -> anyhow::Result<PathBuf> {
3548 let staging = staging.to_path_buf();
3549 tokio::task::spawn_blocking(move || ensure_partial(&staging, hash, total))
3550 .await
3551 .map_err(|e| anyhow!("ensure partial task: {e}"))?
3552}
3553
3554/// [`ensure_partial_off_runtime`]'s body, on the blocking pool.
3555fn ensure_partial(staging: &Path, hash: [u8; 32], total: u64) -> anyhow::Result<PathBuf> {
3556 let (dir, stem) = fetch::ranged_store_location(staging)?;
3557 ClientRangedStore::open_or_create(&dir, &stem, hash, total)
3558 .with_context(|| format!("open the ranged store for {}", staging.display()))?;
3559 let partial = partial_path(staging);
3560 let f = std::fs::OpenOptions::new()
3561 .write(true)
3562 .open(&partial)
3563 .with_context(|| format!("open {}", partial.display()))?;
3564 if f.metadata()
3565 .with_context(|| format!("stat {}", partial.display()))?
3566 .len()
3567 < total
3568 {
3569 f.set_len(total)
3570 .with_context(|| format!("size {}", partial.display()))?;
3571 }
3572 Ok(partial)
3573}
3574
3575/// Reassemble one dedup entry's blob into `staging`: drive the pay-now ranges,
3576/// splice the donor ranges from disk, reconcile the deferred (sibling-assigned)
3577/// ranges at the tail, verify the whole-file BLAKE3, and promote — using `driver`
3578/// for every paid range drive.
3579///
3580/// The authoritative gate throughout is the whole-file BLAKE3; a bad or lying hint
3581/// only ever costs a re-download, never a corrupt output or a spuriously-failed
3582/// entry. After EVERY range drive (pay-now, a donor re-fetch, a deferred fallback,
3583/// and the whole-blob re-drive) the store may have completed — `drive` then
3584/// finalized and renamed `<hex>.partial` -> `<hex>`. Each such point checks
3585/// `staging.try_exists()` and returns success rather than opening a `.partial` that
3586/// no longer exists: a resumed run whose prior `.partial` already held every range
3587/// hits this on the very FIRST pay-now drive.
3588///
3589/// The deferred ranges are chunks this run assigned to a smaller sibling
3590/// ([`build_fetch_plan`]). They are NOT driven up front; the tail loop waits on the
3591/// [`ChunkIndex`] progress signal and splices each one as its assigned fetcher
3592/// registers it — so the shared bytes are fetched once, by that sibling, and copied
3593/// here from disk. The wait is never a fixed timeout (the deferred bytes are exactly
3594/// what the sibling is still downloading): a deferred range is driven-and-paid here
3595/// only if its assigned fetcher FINISHES without registering the chunk (a failed
3596/// fetcher), which keeps the run live and never worse than fetching it directly.
3597///
3598/// `finish_progress` trues the file + total bars up to 100% on success: donor bytes
3599/// are spliced from disk and never flow through `drive`'s progress callback, so a
3600/// mostly-spliced entry would otherwise leave its bars short.
3601///
3602/// `resumed` is the byte spans the entry's store held from an earlier run before
3603/// this run drove anything ([`resumed_spans`]).
3604///
3605/// Returns the entry's [`DedupOutcome`] — bytes actually spliced from disk, bytes
3606/// resumed from an earlier run, and hints dropped by a fault — for the run-level
3607/// report. A byte both resumed and spliced counts as spliced. A resume that
3608/// finalized on the first pay-now drive spliced nothing this run; a self-heal
3609/// whole-blob re-drive discards every splice, so it reports zero spliced bytes and
3610/// counts all its donors as ignored.
3611#[allow(clippy::too_many_arguments)]
3612async fn reassemble_dedup(
3613 driver: &dyn RangeDriver,
3614 plan: &ReassemblePlan,
3615 total: u64,
3616 resumed: &[(u64, u64)],
3617 hints: Option<&[Hint]>,
3618 index: &ChunkIndex,
3619 fetch_plan: &FetchPlan,
3620 file: Option<&pull_progress::FileBar>,
3621 finish_progress: &dyn Fn(),
3622) -> anyhow::Result<DedupOutcome> {
3623 let hash = driver.hash();
3624 let staging = driver.staging();
3625
3626 // Pay only for the pay-now ranges (this entry's unique + self-assigned chunks
3627 // and the partial groups at spliced-run ends); donors are spliced and deferred
3628 // ranges wait.
3629 // An entry with nothing to drive (every chunk is a donor or deferred) skips the
3630 // drive entirely and splices into a freshly-sized `.partial`.
3631 if !plan.drive.is_empty() {
3632 driver.drive(&plan.drive).await?;
3633 // A resumed run may already hold every range in `.partial`, so this first
3634 // drive can COMPLETE the store: `drive` then ran its whole-file hash
3635 // against `hash` and renamed `<hex>.partial` -> `<hex>`. The blob is
3636 // finalized and verified; splicing would open a `.partial` that no longer
3637 // exists. Register the donor chunks and return. No splice ran this run,
3638 // so every resumed byte counts as resumed.
3639 if staging.try_exists()? {
3640 finish_progress();
3641 index.register(hints, staging);
3642 return Ok(DedupOutcome::of(&[], resumed, total, 0));
3643 }
3644 }
3645
3646 let partial = ensure_partial_off_runtime(staging, hash, total).await?;
3647
3648 // Verify + splice each initially-available donor range off the executor. A donor
3649 // whose chunk no longer hashes to its hint (a lying donor hint, or a short read)
3650 // is re-fetched normally rather than trusted.
3651 // Each re-fetched donor is a dropped hint.
3652 let mut ledger = SpliceLedger::default();
3653 let refetch = splice_off_runtime(&partial, plan.donor.clone(), &mut ledger).await?;
3654 let mut hints_ignored = u64::try_from(refetch.len()).unwrap_or(u64::MAX);
3655 if !refetch.is_empty() {
3656 driver.drive(&refetch).await?;
3657 ledger.driven.extend_from_slice(&refetch);
3658 if staging.try_exists()? {
3659 finish_progress();
3660 index.register(hints, staging);
3661 return Ok(DedupOutcome::of(
3662 &ledger.spliced_spans(total),
3663 resumed,
3664 total,
3665 hints_ignored,
3666 ));
3667 }
3668 }
3669
3670 // Tail reconcile: splice each deferred (sibling-assigned) range as its fetcher
3671 // registers it, waiting on the index's progress signal. A deferred chunk whose
3672 // assigned fetcher finishes WITHOUT producing it (a failed fetcher) is driven
3673 // and paid here instead, so the run never hangs.
3674 let tail_ignored = reconcile_deferred(
3675 driver,
3676 &partial,
3677 &plan.deferred,
3678 index,
3679 fetch_plan,
3680 file,
3681 &mut ledger,
3682 )
3683 .await?;
3684 hints_ignored = hints_ignored.saturating_add(tail_ignored);
3685 let mut spliced = ledger.spliced_spans(total);
3686 if staging.try_exists()? {
3687 finish_progress();
3688 index.register(hints, staging);
3689 return Ok(DedupOutcome::of(&spliced, resumed, total, hints_ignored));
3690 }
3691
3692 // The authoritative check: the whole reassembled blob must hash to `hash`. This
3693 // hash of the full blob is the slow tail of a mostly-spliced entry, so it drives
3694 // the file row's `reconstructing…` bar with its running byte count — otherwise
3695 // the row would sit frozen while a multi-GB blob verifies.
3696 let reporter = file.and_then(pull_progress::FileBar::reconstruct_reporter);
3697 let partial_for_hash = partial.clone();
3698 let got = tokio::task::spawn_blocking(move || {
3699 hash_partial_with_progress(&partial_for_hash, reporter.as_deref())
3700 })
3701 .await
3702 .map_err(|e| anyhow!("whole-file hash task: {e}"))??;
3703 if got != hash {
3704 // A lying recipient hint placed a chunk at the wrong offset. Drop the
3705 // spliced ranges by re-driving the whole blob (the ranged store fetches
3706 // exactly the bytes the splice wrote, bao-verified against `hash`) and
3707 // re-verify. Every donor is discarded, so nothing was saved and all of them
3708 // count as ignored. The resumed bytes stay present in the store, so the
3709 // re-drive does not fetch them again.
3710 spliced = Vec::new();
3711 hints_ignored =
3712 u64::try_from(plan.donor.len().saturating_add(plan.deferred.len())).unwrap_or(u64::MAX);
3713 tracing::warn!(
3714 "bundle pull: entry {} failed its whole-file hash after range-dedup; \
3715 re-fetching the whole blob",
3716 blake3::Hash::from_bytes(hash).to_hex()
3717 );
3718 driver.drive(&[(0, total)]).await?;
3719 // The whole-blob re-drive covers `[0, total)`, so `drive` finalized it: its
3720 // whole-file hash verified the bytes against `hash` and renamed `.partial` ->
3721 // `staging`. The blob is verified — do not re-hash a `.partial` that no
3722 // longer exists; register the donor chunks and return.
3723 if staging.try_exists()? {
3724 finish_progress();
3725 index.register(hints, staging);
3726 return Ok(DedupOutcome::of(&spliced, resumed, total, hints_ignored));
3727 }
3728 let partial_for_hash = partial.clone();
3729 let got = tokio::task::spawn_blocking(move || hash_partial(&partial_for_hash))
3730 .await
3731 .map_err(|e| anyhow!("whole-file hash task: {e}"))??;
3732 if got != hash {
3733 bail!(
3734 "reconstructed blob {} does not match its whole-file hash after a full re-fetch",
3735 blake3::Hash::from_bytes(hash).to_hex()
3736 );
3737 }
3738 }
3739
3740 // Promote the verified `.partial` to the plain staging file and clean up the
3741 // ranged-store sidecars, then register this blob's chunks as donors.
3742 let partial_for_promote = partial.clone();
3743 let staging_for_promote = staging.to_path_buf();
3744 tokio::task::spawn_blocking(move || {
3745 promote_partial(&partial_for_promote, &staging_for_promote)
3746 })
3747 .await
3748 .map_err(|e| anyhow!("promote task: {e}"))??;
3749 finish_progress();
3750 index.register(hints, staging);
3751 Ok(DedupOutcome::of(&spliced, resumed, total, hints_ignored))
3752}
3753
3754/// Tail reconcile for one entry's deferred (sibling-assigned) chunks: splice each
3755/// as its assigned fetcher registers it in `index`, waiting on the index progress
3756/// signal between scans rather than polling on a clock. A deferred chunk whose
3757/// assigned fetcher FINISHES without registering it (a failed fetcher, or one with
3758/// no recorded assignee) — or one registered under a length that disagrees with the
3759/// hint — is driven and paid here instead, over the whole groups its span touches,
3760/// so the entry always completes and the run never hangs. Records each splice and
3761/// each driven span in `ledger`, and returns the count of deferred chunks that fell
3762/// back to a paid drive.
3763async fn reconcile_deferred(
3764 driver: &dyn RangeDriver,
3765 partial: &Path,
3766 deferred: &[DeferredChunk],
3767 index: &ChunkIndex,
3768 fetch_plan: &FetchPlan,
3769 file: Option<&pull_progress::FileBar>,
3770 ledger: &mut SpliceLedger,
3771) -> anyhow::Result<u64> {
3772 let mut waiting: Vec<DeferredChunk> = deferred.to_vec();
3773 let mut ignored = 0u64;
3774 if waiting.is_empty() {
3775 return Ok(0);
3776 }
3777 let notified = index.progress.notified();
3778 tokio::pin!(notified);
3779 loop {
3780 // Register as a waiter BEFORE scanning so a registration/finish between the
3781 // scan and the await is not lost (`notify_waiters` stores no permit).
3782 notified.as_mut().enable();
3783
3784 let mut ready: Vec<DonorRange> = Vec::new();
3785 let mut fallback: Vec<(u64, u64)> = Vec::new();
3786 let mut still: Vec<DeferredChunk> = Vec::new();
3787 {
3788 let guard = index.map.lock().unwrap_or_else(PoisonError::into_inner);
3789 for c in &waiting {
3790 let h = &c.hint;
3791 // A trustworthy donor for this chunk is available now — splice it.
3792 // A registered length that disagrees with the hint is untrusted.
3793 if let Some(m) = guard.get(&h.hash).filter(|m| m.len == h.len) {
3794 ready.push(DonorRange::new(h, m, c.dst, c.refetch));
3795 continue;
3796 }
3797 // Otherwise keep waiting ONLY while the assigned fetcher is still
3798 // running and might yet register a good donor. Stop waiting — and pay
3799 // for the interior here — once that fetcher has finished without
3800 // producing it, or a registered donor's length disagreed (present but
3801 // untrusted), or there is no recorded assignee at all.
3802 let present_but_untrusted = guard.contains_key(&h.hash);
3803 let fetcher_running = fetch_plan
3804 .assigned
3805 .get(&h.hash)
3806 .copied()
3807 .is_some_and(|a| !index.is_finished(a));
3808 if fetcher_running && !present_but_untrusted {
3809 still.push(*c);
3810 } else {
3811 fallback.push(c.refetch);
3812 ignored = ignored.saturating_add(1);
3813 }
3814 }
3815 }
3816
3817 if !ready.is_empty() {
3818 let refetch = splice_off_runtime(partial, ready, ledger).await?;
3819 ignored = ignored.saturating_add(u64::try_from(refetch.len()).unwrap_or(u64::MAX));
3820 fallback.extend(refetch);
3821 }
3822
3823 if !fallback.is_empty() {
3824 driver.drive(&fallback).await?;
3825 ledger.driven.extend_from_slice(&fallback);
3826 // A fallback drive may complete the store (`drive` renames
3827 // `.partial` -> `<hex>`); no more splicing is then possible or needed.
3828 if driver.staging().try_exists()? {
3829 return Ok(ignored);
3830 }
3831 }
3832
3833 waiting = still;
3834 if waiting.is_empty() {
3835 return Ok(ignored);
3836 }
3837
3838 // Nothing to do this pass but wait on a sibling — surface it on the file row
3839 // (its download is done; the total bar shows the run is still moving).
3840 if let Some(f) = file {
3841 f.set_pending();
3842 }
3843 // Block until the next registration or group-finish, then rescan. A
3844 // waiter moves no bytes, so its `--jobs` slot goes to a queued entry
3845 // meanwhile; a fallback drive takes one again.
3846 driver.release_slot();
3847 notified.as_mut().await;
3848 driver.retake_slot().await?;
3849 notified.set(index.progress.notified());
3850 }
3851}
3852
3853/// The run-end sweep of donor staging blobs: remove every registered donor
3854/// source EXCEPT one [`ChunkIndex::mark_retained`] flagged (its blob is paid for
3855/// but a destination failed to materialize, so its finalized `<hex>` is the resume
3856/// prefix a rerun needs — deleting it would force a full re-fetch and re-payment).
3857/// The removal runs on the blocking pool (see [`remove_staging_off_runtime`]).
3858async fn sweep_donor_sources(index: &ChunkIndex) {
3859 let doomed: Vec<PathBuf> = index
3860 .sources()
3861 .into_iter()
3862 .filter(|source| !index.retained(source))
3863 .collect();
3864 if let Err(e) = tokio::task::spawn_blocking(move || {
3865 for source in &doomed {
3866 remove_staging(source);
3867 }
3868 })
3869 .await
3870 {
3871 tracing::warn!("donor staging sweep task: {e}");
3872 }
3873}
3874
3875/// Copy the already-fetched, already-verified blob at `staging` to `dest`,
3876/// returning the byte count. `staging` is read-only here (not consumed) — a
3877/// retry after a failed first write calls this again for the next writable
3878/// path — so the copy goes through a unique temp file beside `dest` and an
3879/// atomic rename, the same source-must-survive shape [`link_or_copy_atomic`]
3880/// uses for every later duplicate — built from `fetch::temp_in_parent`, the
3881/// staging primitive the bundle cache and manifest writers share.
3882fn materialize(staging: &Path, dest: &Path) -> anyhow::Result<u64> {
3883 if let Some(parent) = dest.parent() {
3884 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
3885 }
3886 let mut tmp =
3887 fetch::temp_in_parent(dest).with_context(|| format!("stage {}", dest.display()))?;
3888 let mut src =
3889 std::fs::File::open(staging).with_context(|| format!("open {}", staging.display()))?;
3890 let written = std::io::copy(&mut src, tmp.as_file_mut())
3891 .with_context(|| format!("copy {} -> {}", staging.display(), dest.display()))?;
3892 tmp.as_file()
3893 .sync_all()
3894 .with_context(|| format!("sync staged copy for {}", dest.display()))?;
3895 tmp.persist(dest)
3896 .map_err(|e| e.error)
3897 .with_context(|| format!("write {}", dest.display()))?;
3898 Ok(written)
3899}
3900
3901/// Land a group's finalized `staging` blob at its first destination, returning
3902/// its byte count: a non-donor moves ([`move_into_place`]); a donor — a splice
3903/// source the run-end sweep still owns — is hard-linked (a copy only across
3904/// filesystems), so its staging name survives for later recipients.
3905fn first_write(staging: &Path, dest: &Path, is_donor: bool) -> anyhow::Result<u64> {
3906 if !is_donor {
3907 return move_into_place(staging, dest);
3908 }
3909 let len = std::fs::metadata(staging)
3910 .with_context(|| format!("stat {}", staging.display()))?
3911 .len();
3912 link_or_copy_atomic(staging, dest).map(|()| len)
3913}
3914
3915/// Move the finalized `staging` blob to `dest`, returning its byte count. A
3916/// non-donor blob has no reader after its first destination lands, so a rename
3917/// replaces the full copy [`materialize`] makes — no second write of the blob and
3918/// no second copy on disk. `staging` is already durable: the ranged store's
3919/// finalize, and the dedup splice before promotion, sync it. A rename that fails
3920/// (a destination on another filesystem, `EXDEV`) falls back to [`materialize`],
3921/// which leaves `staging` in place for the next writable path.
3922fn move_into_place(staging: &Path, dest: &Path) -> anyhow::Result<u64> {
3923 if let Some(parent) = dest.parent() {
3924 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
3925 }
3926 let len = std::fs::metadata(staging)
3927 .with_context(|| format!("stat {}", staging.display()))?
3928 .len();
3929 match std::fs::rename(staging, dest) {
3930 Ok(()) => Ok(len),
3931 Err(_) => materialize(staging, dest),
3932 }
3933}
3934
3935/// One chunk of a hint-carrying entry, resolved to an absolute byte placement in
3936/// the whole file: `offset` is the running sum of prior chunk sizes and `len` the
3937/// chunk's own size, so `[offset, offset + len)` is the chunk's span.
3938#[derive(Debug, Clone, Copy)]
3939struct Hint {
3940 /// The chunk's BLAKE3 content address.
3941 hash: [u8; 32],
3942 /// Byte offset of the chunk within the whole file.
3943 offset: u64,
3944 /// Chunk length in bytes.
3945 len: u64,
3946}
3947
3948/// Where one chunk's verified bytes are already materialized on disk this run.
3949struct MaterializedRange {
3950 /// The finalized staging file (a completed entry's whole-file blob) that
3951 /// contains the chunk.
3952 source: PathBuf,
3953 /// Byte offset of the chunk within `source`.
3954 offset: u64,
3955 /// Chunk length in bytes.
3956 len: u64,
3957}
3958
3959/// In-run index from a chunk's BLAKE3 hash to where its verified bytes live on
3960/// disk. Shared across the run's concurrent entries behind a mutex; an entry
3961/// registers its chunks only after it fully completes and its staging blob is
3962/// finalized, so a donor is always fully written before a recipient reads it.
3963#[derive(Default)]
3964struct ChunkIndex {
3965 /// First-writer-wins map; a chunk registered by one completed entry serves
3966 /// every later entry that shares it.
3967 map: std::sync::Mutex<HashMap<[u8; 32], MaterializedRange>>,
3968 /// Donor staging blobs the run-end sweep in [`PullCtx::pull_all`] must NOT
3969 /// delete: a donor group whose blob is fully fetched and registered but
3970 /// whose materialize to disk failed (ENOSPC, an unwritable dest). Its
3971 /// finalized `<hex>` is the resume prefix a rerun needs — deleting it would
3972 /// force a full re-fetch and re-payment of an unrefunded blob.
3973 retain: std::sync::Mutex<HashSet<PathBuf>>,
3974 /// Output-file donor sources seeded from disk before the run. Excluded from
3975 /// [`Self::sources`] so the run-end staging sweep never deletes a
3976 /// materialized output.
3977 seeded: std::sync::Mutex<HashSet<PathBuf>>,
3978 /// Whole-file hashes of groups whose fetch has FINISHED this run, success or
3979 /// failure. A tail-reconcile waiter watches this: once the entry a deferred
3980 /// chunk is assigned to has finished without registering that chunk, the waiter
3981 /// stops waiting and drives the range itself (the assigned fetcher failed). It
3982 /// only ever grows.
3983 finished: std::sync::Mutex<HashSet<[u8; 32]>>,
3984 /// Pinged whenever a donor is registered/seeded or a group finishes, so a
3985 /// tail-reconcile waiter re-checks its deferred chunks without polling.
3986 progress: tokio::sync::Notify,
3987}
3988
3989impl ChunkIndex {
3990 /// Register a completed entry's chunks, all pointing at its finalized
3991 /// `source` blob. First writer wins, so a chunk shared by several entries
3992 /// keeps the first donor. A `None` hint list (a plain entry) registers
3993 /// nothing.
3994 fn register(&self, hints: Option<&[Hint]>, source: &Path) {
3995 let Some(hints) = hints else {
3996 return;
3997 };
3998 {
3999 let mut map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
4000 for h in hints {
4001 map.entry(h.hash).or_insert_with(|| MaterializedRange {
4002 source: source.to_path_buf(),
4003 offset: h.offset,
4004 len: h.len,
4005 });
4006 }
4007 }
4008 // New donors may unblock a tail-reconcile waiter.
4009 self.progress.notify_waiters();
4010 }
4011
4012 /// The distinct donor staging blobs registered this run, for the run-end
4013 /// sweep in [`PullCtx::pull_all`]. Excludes any [`Self::seed_disk`] source —
4014 /// an on-disk OUTPUT file the sweep must never delete.
4015 fn sources(&self) -> Vec<PathBuf> {
4016 // Acquire `map` before `seeded` — the single lock order every site follows
4017 // (`register`/`seed_disk` touch `map` first), so no pair of these methods
4018 // can ever invert and deadlock.
4019 let map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
4020 let seeded = self.seeded.lock().unwrap_or_else(PoisonError::into_inner);
4021 let mut seen: HashSet<&Path> = HashSet::new();
4022 let mut out = Vec::new();
4023 for m in map.values() {
4024 if !seeded.contains(&m.source) && seen.insert(m.source.as_path()) {
4025 out.push(m.source.clone());
4026 }
4027 }
4028 out
4029 }
4030
4031 /// Seed a donor whose bytes live in an on-disk OUTPUT file (a skipped
4032 /// entry's current file, or a changed entry's old copy still present until
4033 /// its atomic materialize). First-writer-wins, like [`Self::register`]; the
4034 /// source is recorded as seeded so the sweep never removes it. The existing
4035 /// per-chunk re-hash guard verifies the bytes before any splice trusts them,
4036 /// so a stale offset or edited/removed file simply falls back to a fetch.
4037 fn seed_disk(&self, hash: [u8; 32], source: &Path, offset: u64, len: u64) {
4038 {
4039 let mut map = self.map.lock().unwrap_or_else(PoisonError::into_inner);
4040 map.entry(hash).or_insert_with(|| MaterializedRange {
4041 source: source.to_path_buf(),
4042 offset,
4043 len,
4044 });
4045 }
4046 {
4047 let mut seeded = self.seeded.lock().unwrap_or_else(PoisonError::into_inner);
4048 seeded.insert(source.to_path_buf());
4049 }
4050 // A pre-seeded on-disk donor may unblock a tail-reconcile waiter.
4051 self.progress.notify_waiters();
4052 }
4053
4054 /// Record that a group's fetch has finished this run (success or failure) and
4055 /// wake any tail-reconcile waiter. A waiter watching a deferred chunk assigned
4056 /// to `whole` stops waiting once `whole` is finished but the chunk never
4057 /// registered — the assigned fetcher failed to produce it, so the waiter drives
4058 /// the range itself.
4059 fn mark_finished(&self, whole: [u8; 32]) {
4060 {
4061 let mut finished = self.finished.lock().unwrap_or_else(PoisonError::into_inner);
4062 finished.insert(whole);
4063 }
4064 self.progress.notify_waiters();
4065 }
4066
4067 /// Whether the group with whole-file hash `whole` has finished fetching this
4068 /// run (see [`Self::mark_finished`]).
4069 fn is_finished(&self, whole: [u8; 32]) -> bool {
4070 self.finished
4071 .lock()
4072 .unwrap_or_else(PoisonError::into_inner)
4073 .contains(&whole)
4074 }
4075
4076 /// Mark a donor `source` as a resume prefix the run-end sweep must keep: its
4077 /// blob is fully fetched and paid for, but at least one destination failed to
4078 /// materialize, so a rerun needs the finalized `<hex>` rather than re-paying.
4079 fn mark_retained(&self, source: &Path) {
4080 let mut retain = self.retain.lock().unwrap_or_else(PoisonError::into_inner);
4081 retain.insert(source.to_path_buf());
4082 }
4083
4084 /// Whether `source` was retained by [`Self::mark_retained`] and so must
4085 /// survive the run-end sweep.
4086 fn retained(&self, source: &Path) -> bool {
4087 let retain = self.retain.lock().unwrap_or_else(PoisonError::into_inner);
4088 retain.contains(source)
4089 }
4090}
4091
4092/// One splice source for the dedup path: the part of one chunk of the recipient
4093/// blob that its spliced run covers, and the materialized donor that holds it.
4094#[derive(Debug, Clone)]
4095struct DonorRange {
4096 /// `(offset, len)` in the recipient blob to write: the chunk's span inside its
4097 /// spliced run ([`plan_reassembly`]). It need not sit on chunk-group
4098 /// boundaries — adjacent spliced chunks tile a run whose ends do, so the runs
4099 /// and the ranged store's complement (fetched on group boundaries) tile the
4100 /// blob without overlap.
4101 dst: (u64, u64),
4102 /// The whole chunk groups `dst` touches, capped at the blob end — what a paid
4103 /// re-fetch drives when this donor cannot be trusted. It may take in a
4104 /// neighbour's bytes of a shared boundary group; the drive writes verified
4105 /// bytes over them.
4106 refetch: (u64, u64),
4107 /// The donor blob to read from.
4108 source: PathBuf,
4109 /// Source offset of the `dst` span within `source`.
4110 src_offset: u64,
4111 /// The whole chunk's hash — the `dst` span is trusted only after the
4112 /// whole chunk at `[chunk_src_offset, chunk_src_offset + chunk_len)` in
4113 /// `source` re-hashes to this.
4114 chunk_hash: [u8; 32],
4115 /// Whole chunk start in `source` (for the verification re-hash).
4116 chunk_src_offset: u64,
4117 /// Whole chunk length in bytes (for the verification re-hash).
4118 chunk_len: u64,
4119}
4120
4121/// Turn an entry's optional chunk list into placed [`Hint`]s, or `None` when the
4122/// entry should be fetched as a plain whole-file blob: no `chunks`, no whole-file
4123/// `size` to validate against, an unparseable chunk hash, or chunk sizes that do
4124/// not sum to the whole-file `size` (a malformed hint set, logged at debug). The
4125/// whole-file `hash` is authoritative in every case, so ignoring hints only ever
4126/// costs dedup, never correctness.
4127fn hints_of(entry: &ManifestEntry) -> Option<Vec<Hint>> {
4128 let chunks = entry.chunks.as_ref()?;
4129 let total = entry.size?;
4130 let mut hints = Vec::with_capacity(chunks.len());
4131 let mut offset = 0u64;
4132 for c in chunks {
4133 let hash = fetch::parse_hash(&c.hash).ok()?;
4134 hints.push(Hint {
4135 hash,
4136 offset,
4137 len: c.size,
4138 });
4139 offset = offset.checked_add(c.size)?;
4140 }
4141 if offset != total {
4142 tracing::debug!(
4143 "bundle entry {:?}: chunk sizes sum to {offset}, expected whole-file size {total}; \
4144 ignoring range-dedup hints",
4145 entry.path
4146 );
4147 return None;
4148 }
4149 Some(hints)
4150}
4151
4152impl DonorRange {
4153 /// The splice of chunk `h`'s span `dst` from the materialized donor `m`,
4154 /// which holds the whole chunk at `m.offset`; `refetch` is `dst`'s
4155 /// [`outward_groups`].
4156 fn new(h: &Hint, m: &MaterializedRange, dst: (u64, u64), refetch: (u64, u64)) -> Self {
4157 Self {
4158 dst,
4159 refetch,
4160 source: m.source.clone(),
4161 src_offset: m.offset.saturating_add(dst.0.saturating_sub(h.offset)),
4162 chunk_hash: h.hash,
4163 chunk_src_offset: m.offset,
4164 chunk_len: m.len,
4165 }
4166 }
4167}
4168
4169/// A deferred chunk of a [`ReassemblePlan`]: a chunk this run assigns to another
4170/// entry, spliced over `dst` once that entry registers it, or driven over
4171/// `refetch` (see [`DonorRange`]) if it never does.
4172#[derive(Debug, Clone, Copy)]
4173struct DeferredChunk {
4174 hint: Hint,
4175 dst: (u64, u64),
4176 refetch: (u64, u64),
4177}
4178
4179/// The whole chunk groups the byte span `(offset, len)` touches, as
4180/// `(offset, len)`, capped at `total`.
4181fn outward_groups((offset, len): (u64, u64), total: u64) -> (u64, u64) {
4182 let group = CHUNK_GROUP_BYTES;
4183 let start = (offset / group).saturating_mul(group);
4184 let end = offset
4185 .saturating_add(len)
4186 .div_ceil(group)
4187 .saturating_mul(group)
4188 .min(total);
4189 (start, end.saturating_sub(start))
4190}
4191
4192/// The run-level shared-chunk fetch plan: which entry pays to fetch each chunk that
4193/// appears in two or more entry positions across the whole manifest. A chunk in one
4194/// position only is absent — its sole entry pays for it as part of its complement.
4195/// Built once per run, so every entry derives the same assignment with no
4196/// coordination.
4197#[derive(Debug, Default)]
4198struct FetchPlan {
4199 /// Shared chunk hash → the whole-file hash of the entry assigned to fetch it.
4200 /// The assignee is the smallest containing entry by whole-file size, tie-broken
4201 /// by whole-file hash bytes, so the fetcher finishes soonest and every waiter is
4202 /// a larger entry whose own tail comes later.
4203 assigned: HashMap<[u8; 32], [u8; 32]>,
4204}
4205
4206/// Build the run-level [`FetchPlan`]: assign every chunk shared by two or more entry
4207/// positions to its smallest containing entry (tie-break by whole-file hash), so a
4208/// shared chunk is fetched and paid for exactly once per run and spliced into every
4209/// other entry that names it. An entry without usable hints or an unparseable
4210/// whole-file hash takes no part. An entry with no declared `size` ranks as
4211/// `u64::MAX`, so a sized sharer — the only kind that can be spliced from anyway —
4212/// always wins the assignment.
4213fn build_fetch_plan(entries: &[ManifestEntry]) -> FetchPlan {
4214 struct Occurrence {
4215 count: u32,
4216 best_size: u64,
4217 best_whole: [u8; 32],
4218 }
4219 let mut seen: HashMap<[u8; 32], Occurrence> = HashMap::new();
4220 for e in entries {
4221 let Ok(whole) = fetch::parse_hash(&e.hash) else {
4222 continue;
4223 };
4224 let Some(hints) = hints_of(e) else {
4225 continue;
4226 };
4227 let size = e.size.unwrap_or(u64::MAX);
4228 for h in &hints {
4229 seen.entry(h.hash)
4230 .and_modify(|o| {
4231 o.count = o.count.saturating_add(1);
4232 if (size, whole) < (o.best_size, o.best_whole) {
4233 o.best_size = size;
4234 o.best_whole = whole;
4235 }
4236 })
4237 .or_insert(Occurrence {
4238 count: 1,
4239 best_size: size,
4240 best_whole: whole,
4241 });
4242 }
4243 }
4244 let assigned = seen
4245 .into_iter()
4246 .filter(|(_, o)| o.count >= 2)
4247 .map(|(hash, o)| (hash, o.best_whole))
4248 .collect();
4249 FetchPlan { assigned }
4250}
4251
4252/// One entry's reassembly plan, computed from the live [`ChunkIndex`] snapshot and
4253/// the run-level [`FetchPlan`]. The three range sets tile the blob:
4254///
4255/// - `donor`: chunks already materialized somewhere (a pre-seeded on-disk file, or
4256/// a sibling that already finished) — spliced immediately, no fetch.
4257/// - `deferred`: chunks assigned to a DIFFERENT entry and not yet available —
4258/// waited for and spliced at the tail once the assigned fetcher registers them
4259/// (or driven as a fallback if that fetcher finishes without producing them).
4260/// - `drive`: everything else — this entry's unique and self-assigned chunks, plus
4261/// the partial chunk groups at the edges of each spliced run — driven and paid
4262/// for now.
4263///
4264/// Donor and deferred chunks that touch form one spliced run, and only the run's
4265/// two ends round inward to chunk-group boundaries. A boundary between two
4266/// spliced chunks therefore costs nothing, wherever it falls: its group is
4267/// spliced from both sides rather than driven as a separate 16 KiB request.
4268///
4269/// With an empty [`FetchPlan`] (no shared chunks) `deferred` is empty and the plan
4270/// reduces to a donor/complement split.
4271struct ReassemblePlan {
4272 donor: Vec<DonorRange>,
4273 deferred: Vec<DeferredChunk>,
4274 drive: Vec<(u64, u64)>,
4275}
4276
4277/// Build an entry's [`ReassemblePlan`] for the entry whose whole-file hash is
4278/// `whole`. A chunk currently in `index` under its hinted length becomes a
4279/// `donor` (spliced now); a chunk the [`FetchPlan`] assigns to another entry, not
4280/// yet available, becomes `deferred`. Their spans merge into runs
4281/// ([`spliced_runs`]); a chunk with no bytes inside a run is dropped to the
4282/// drive, and everything outside the runs is driven. `total` is the entry's
4283/// whole-file size.
4284fn plan_reassembly(
4285 hints: &[Hint],
4286 index: &HashMap<[u8; 32], MaterializedRange>,
4287 fetch_plan: &FetchPlan,
4288 whole: [u8; 32],
4289 total: u64,
4290) -> ReassemblePlan {
4291 // Each candidate chunk, with its donor when one is materialized now.
4292 let mut candidates: Vec<(&Hint, Option<&MaterializedRange>)> = Vec::new();
4293 for h in hints {
4294 match index.get(&h.hash) {
4295 // A donor whose claimed chunk length disagrees with the hint is one
4296 // side's manifest lying about the chunk — never spliced from.
4297 Some(m) if m.len == h.len => candidates.push((h, Some(m))),
4298 _ => {
4299 let assigned_elsewhere = fetch_plan
4300 .assigned
4301 .get(&h.hash)
4302 .is_some_and(|owner| *owner != whole);
4303 if assigned_elsewhere {
4304 candidates.push((h, None));
4305 }
4306 }
4307 }
4308 }
4309 let spans: Vec<(u64, u64)> = candidates.iter().map(|(h, _)| (h.offset, h.len)).collect();
4310 let runs = spliced_runs(&spans, total);
4311
4312 let mut donor = Vec::new();
4313 let mut deferred = Vec::new();
4314 for (h, m) in candidates {
4315 let Some(dst) = span_in_runs(&runs, h.offset, h.len) else {
4316 continue;
4317 };
4318 match m {
4319 Some(m) => donor.push(DonorRange::new(h, m, dst, outward_groups(dst, total))),
4320 None => deferred.push(DeferredChunk {
4321 hint: *h,
4322 dst,
4323 refetch: outward_groups(dst, total),
4324 }),
4325 }
4326 }
4327 let covered: Vec<(u64, u64)> = runs.iter().map(|&(s, e)| (s, e - s)).collect();
4328 ReassemblePlan {
4329 drive: complement_runs(&covered, total),
4330 donor,
4331 deferred,
4332 }
4333}
4334
4335/// Merge the spliceable chunk spans `(offset, len)` into sorted, disjoint
4336/// `(start, end)` runs — spans that overlap or touch join one run — then round
4337/// each run inward to chunk-group boundaries, the blob end counting as one. A
4338/// run with no whole group left is dropped. The ranged store fetches whole
4339/// groups, so only a run's two ends can leave a partial group to drive.
4340fn spliced_runs(spans: &[(u64, u64)], total: u64) -> Vec<(u64, u64)> {
4341 let group = CHUNK_GROUP_BYTES;
4342 coalesce_runs(spans, total)
4343 .into_iter()
4344 .filter_map(|(start, end)| {
4345 let start = start.div_ceil(group).saturating_mul(group);
4346 let end = if end == total {
4347 end
4348 } else {
4349 (end / group).saturating_mul(group)
4350 };
4351 (start < end).then_some((start, end))
4352 })
4353 .collect()
4354}
4355
4356/// The part of the span `(offset, len)` inside the sorted, disjoint `runs`, as
4357/// `(offset, len)`, or `None` when no run covers any of it. A span from the same
4358/// set the runs were built from meets at most one run.
4359fn span_in_runs(runs: &[(u64, u64)], offset: u64, len: u64) -> Option<(u64, u64)> {
4360 let end = offset.saturating_add(len);
4361 let i = runs.partition_point(|&(_, run_end)| run_end <= offset);
4362 let &(run_start, run_end) = runs.get(i)?;
4363 let (start, end) = (offset.max(run_start), end.min(run_end));
4364 (start < end).then_some((start, end - start))
4365}
4366
4367/// The `.partial` data file the ranged store keeps beside a finalized `staging`
4368/// blob — `<hex>.partial` — where driven ranges and spliced donor bytes land
4369/// before promotion.
4370fn partial_path(staging: &Path) -> PathBuf {
4371 staging.with_extension("partial")
4372}
4373
4374/// Verify and splice every donor range into `partial`, returning the donors that
4375/// could NOT be trusted (a donor whose chunk no longer hashes to its hint, or an
4376/// unreadable source): the caller re-fetches and pays for each one's
4377/// [`DonorRange::refetch`] span.
4378///
4379/// A donor's bytes are trusted only after the WHOLE chunk at its recorded source
4380/// offset re-hashes to the chunk hash — a donor's own chunk placement is an
4381/// unverified manifest claim until then. The trusted `dst` span is then written
4382/// at the recipient offset; the whole-file BLAKE3 the caller runs next is the
4383/// authoritative backstop against a lying recipient placement. A span that lands
4384/// in a group the ranged store already holds (a resumed run, or a neighbour's
4385/// re-fetch) writes the same verified bytes; if a lying placement wrote others,
4386/// the store's finalize verify drops that group and a drive fetches it again.
4387fn splice_donors(partial: &Path, donors: &[DonorRange]) -> anyhow::Result<Vec<DonorRange>> {
4388 use std::io::{Seek, SeekFrom};
4389
4390 let mut refetch = Vec::new();
4391 let mut out = std::fs::OpenOptions::new()
4392 .write(true)
4393 .open(partial)
4394 .with_context(|| format!("open {}", partial.display()))?;
4395 for d in donors {
4396 let (dst_offset, len) = d.dst;
4397 // An unreadable donor source is not trusted — pay to fetch it.
4398 let Ok(mut src) = std::fs::File::open(&d.source) else {
4399 refetch.push(d.clone());
4400 continue;
4401 };
4402 if !chunk_verified(&mut src, d.chunk_src_offset, d.chunk_len, d.chunk_hash) {
4403 refetch.push(d.clone());
4404 continue;
4405 }
4406 // The chunk is confirmed present at `chunk_src_offset`; copy its `dst`
4407 // span into the recipient's `.partial`. A seek/copy error here (a
4408 // truncated or racing donor file, an I/O fault) is not fatal: the donor
4409 // bytes are simply not trusted, so queue the donor for a paid,
4410 // bao-verified re-fetch that overwrites every group it touches — nothing
4411 // torn by a partial copy survives, and the whole-file BLAKE3 the caller
4412 // runs next is the authoritative backstop.
4413 let copied = src
4414 .seek(SeekFrom::Start(d.src_offset))
4415 .and_then(|_| out.seek(SeekFrom::Start(dst_offset)))
4416 .and_then(|_| copy_exact(&mut src, &mut out, len));
4417 if copied.is_err() {
4418 refetch.push(d.clone());
4419 }
4420 }
4421 out.sync_all()
4422 .with_context(|| format!("sync {}", partial.display()))?;
4423 Ok(refetch)
4424}
4425
4426/// [`splice_donors`] on the blocking pool. Records the `dst` span of each donor
4427/// it spliced in `ledger`, and returns the [`DonorRange::refetch`] span of each
4428/// untrusted donor, which the caller drives and pays for.
4429async fn splice_off_runtime(
4430 partial: &Path,
4431 donors: Vec<DonorRange>,
4432 ledger: &mut SpliceLedger,
4433) -> anyhow::Result<Vec<(u64, u64)>> {
4434 let partial = partial.to_path_buf();
4435 let planned: Vec<((u64, u64), (u64, u64))> =
4436 donors.iter().map(|d| (d.dst, d.refetch)).collect();
4437 let failed = off_runtime("donor splice", move || splice_donors(&partial, &donors)).await?;
4438 let failed_dst: HashSet<(u64, u64)> = failed.iter().map(|d| d.dst).collect();
4439 ledger.spliced.extend(
4440 planned
4441 .iter()
4442 .filter(|(dst, _)| !failed_dst.contains(dst))
4443 .map(|&(dst, _)| dst),
4444 );
4445 Ok(failed.iter().map(|d| d.refetch).collect())
4446}
4447
4448/// The byte spans one entry's reassembly spliced from disk and the spans it
4449/// drove to replace an untrusted or missing donor. A driven span is widened to
4450/// whole chunk groups, so it can cover part of a neighbour's spliced span, and
4451/// the drive then writes and pays for those bytes.
4452#[derive(Debug, Default)]
4453struct SpliceLedger {
4454 /// The `dst` span of each donor that spliced cleanly.
4455 spliced: Vec<(u64, u64)>,
4456 /// Each re-fetch or fallback span that a drive covered.
4457 driven: Vec<(u64, u64)>,
4458}
4459
4460impl SpliceLedger {
4461 /// The spans served from a splice: the spliced spans less every driven
4462 /// span, as sorted, disjoint `(offset, len)` pairs.
4463 fn spliced_spans(&self, total: u64) -> Vec<(u64, u64)> {
4464 uncovered_runs(&self.spliced, &self.driven, total)
4465 }
4466}
4467
4468/// The parts of `spans` that no `cover` span covers, over `[0, total)`, as
4469/// sorted, disjoint `(offset, len)` pairs. Both inputs are `(offset, len)`
4470/// spans, in any order, and may overlap.
4471fn uncovered_runs(spans: &[(u64, u64)], cover: &[(u64, u64)], total: u64) -> Vec<(u64, u64)> {
4472 let kept = complement_runs(cover, total);
4473 let mut kept = kept
4474 .iter()
4475 .map(|&(offset, len)| (offset, offset.saturating_add(len)));
4476 let mut current = kept.next();
4477 let mut out = Vec::new();
4478 for (start, end) in coalesce_runs(spans, total) {
4479 while let Some((k_start, k_end)) = current {
4480 if k_end <= start {
4481 current = kept.next();
4482 continue;
4483 }
4484 if k_start >= end {
4485 break;
4486 }
4487 let (from, to) = (start.max(k_start), end.min(k_end));
4488 if to > from {
4489 out.push((from, to - from));
4490 }
4491 if k_end <= end {
4492 current = kept.next();
4493 } else {
4494 break;
4495 }
4496 }
4497 }
4498 out
4499}
4500
4501/// Whether the `len` bytes at `offset` in `src` hash to `expected`. Any read
4502/// failure (a short source, an I/O error) returns `false`: the bytes are simply
4503/// not trusted, and the caller re-fetches them rather than splicing.
4504fn chunk_verified(src: &mut std::fs::File, offset: u64, len: u64, expected: [u8; 32]) -> bool {
4505 use std::io::{Read, Seek, SeekFrom};
4506 if src.seek(SeekFrom::Start(offset)).is_err() {
4507 return false;
4508 }
4509 let mut hasher = blake3::Hasher::new();
4510 let mut remaining = len;
4511 let mut buf = vec![0u8; 1 << 20];
4512 while remaining > 0 {
4513 let want = usize::try_from(remaining.min(1 << 20)).unwrap_or(1 << 20);
4514 let Some(slice) = buf.get_mut(..want) else {
4515 return false;
4516 };
4517 if src.read_exact(slice).is_err() {
4518 return false;
4519 }
4520 hasher.update(slice);
4521 remaining = remaining.saturating_sub(u64::try_from(want).unwrap_or(remaining));
4522 }
4523 *hasher.finalize().as_bytes() == expected
4524}
4525
4526/// Copy exactly `len` bytes from `src` to `out`, both already positioned, in
4527/// bounded chunks so a large range never loads into memory at once.
4528fn copy_exact(src: &mut std::fs::File, out: &mut std::fs::File, len: u64) -> std::io::Result<()> {
4529 use std::io::{Read, Write};
4530 let mut remaining = len;
4531 let mut buf = vec![0u8; 1 << 20];
4532 while remaining > 0 {
4533 let want = usize::try_from(remaining.min(1 << 20)).unwrap_or(1 << 20);
4534 let slice = buf
4535 .get_mut(..want)
4536 .ok_or_else(|| std::io::Error::other("copy buffer slice"))?;
4537 src.read_exact(slice)?;
4538 out.write_all(slice)?;
4539 remaining = remaining.saturating_sub(u64::try_from(want).unwrap_or(remaining));
4540 }
4541 Ok(())
4542}
4543
4544/// BLAKE3 of the whole `partial` data file, streamed so an arbitrarily large blob
4545/// never loads into memory at once.
4546fn hash_partial(partial: &Path) -> anyhow::Result<[u8; 32]> {
4547 hash_partial_with_progress(partial, None)
4548}
4549
4550/// BLAKE3 of the whole `partial` data file, streamed, reporting the cumulative bytes
4551/// hashed to `progress` after each read — so a caller can advance a progress bar
4552/// through a multi-GB verify instead of showing a frozen row.
4553fn hash_partial_with_progress(
4554 partial: &Path,
4555 progress: Option<&(dyn Fn(u64) + Send + Sync)>,
4556) -> anyhow::Result<[u8; 32]> {
4557 use std::io::Read;
4558 let mut f =
4559 std::fs::File::open(partial).with_context(|| format!("open {}", partial.display()))?;
4560 let mut hasher = blake3::Hasher::new();
4561 let mut buf = vec![0u8; 1 << 20];
4562 let mut hashed_bytes = 0u64;
4563 loop {
4564 let n = f
4565 .read(&mut buf)
4566 .with_context(|| format!("read {}", partial.display()))?;
4567 if n == 0 {
4568 break;
4569 }
4570 let slice = buf
4571 .get(..n)
4572 .ok_or_else(|| anyhow!("short read buffer slice"))?;
4573 hasher.update(slice);
4574 if let Some(cb) = progress {
4575 hashed_bytes = hashed_bytes.saturating_add(u64::try_from(n).unwrap_or(0));
4576 cb(hashed_bytes);
4577 }
4578 }
4579 Ok(*hasher.finalize().as_bytes())
4580}
4581
4582/// Promote a verified `.partial` to the plain `staging` blob (an atomic
4583/// same-directory rename) and best-effort remove the ranged-store sidecars the
4584/// dedup path no longer needs. `staging` is then the finalized blob `fetch_group`
4585/// materializes at each destination — the same shape the whole-file path's
4586/// ranged store leaves on its own finalize.
4587fn promote_partial(partial: &Path, staging: &Path) -> anyhow::Result<()> {
4588 std::fs::rename(partial, staging)
4589 .with_context(|| format!("promote {} -> {}", partial.display(), staging.display()))?;
4590 let sidecar = staging.with_extension("partial.ranges");
4591 if let Err(e) = std::fs::remove_file(&sidecar)
4592 && e.kind() != std::io::ErrorKind::NotFound
4593 {
4594 tracing::warn!(
4595 "failed to remove staging sidecar {}: {e}",
4596 sidecar.display()
4597 );
4598 }
4599 Ok(())
4600}
4601
4602/// Reserved subdirectory of `out_root` holding per-hash staging files and their
4603/// sidecars. Manifest entry paths that resolve inside it are rejected in
4604/// [`plan_slots`], so a manifest can never collide with a staging file — nor
4605/// trick `remove_staging` into deleting a materialized output.
4606const STAGING_DIR: &str = ".decdn-partial";
4607
4608/// Per-hash staging file an entry's fetch finalizes to before `fetch_group`
4609/// materializes it at the manifest's destination path(s) —
4610/// `<out_root>/.decdn-partial/<hex>` (#1497). This is the *plain* final path:
4611/// the entry's ranged store owns the `.partial` suffix and the `.ranges`
4612/// record itself, streaming into `<hex>.partial` beside this and renaming to
4613/// `<hex>` on `finalize`. Those sidecars are what survives
4614/// an interrupted pull — a rerun resumes from them rather than re-paying for
4615/// bytes already landed. Creates the staging directory (idempotent, and only
4616/// ever called once a group has real work to do — an all-skipped group never
4617/// creates one) but not the file itself; the ranged store does that.
4618fn staging_path(out_root: &Path, hash: [u8; 32]) -> anyhow::Result<PathBuf> {
4619 let dir = out_root.join(STAGING_DIR);
4620 std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
4621 let name = blake3::Hash::from_bytes(hash).to_hex().to_string();
4622 Ok(dir.join(name))
4623}
4624
4625/// The `(offset, len)` runs of `[0, total)` NOT covered by `donor_aligned`.
4626///
4627/// `donor_aligned` holds chunk-group-aligned ranges already available from a
4628/// donor (a sibling entry's already-pulled bytes) — possibly unsorted and
4629/// possibly overlapping. This sorts and coalesces them first, then emits the
4630/// gaps between (and around) the coalesced runs as the complement to drive:
4631/// the bytes a donor does NOT cover, which still need a paid pull.
4632///
4633/// A donor range past `total`, or one that overlaps `total`, is clamped to
4634/// `total` — `total` is the whole blob's authoritative length. `total == 0`
4635/// always returns an empty complement (there is nothing to cover).
4636pub(crate) fn complement_runs(donor_aligned: &[(u64, u64)], total: u64) -> Vec<(u64, u64)> {
4637 if total == 0 {
4638 return Vec::new();
4639 }
4640 let coalesced = coalesce_runs(donor_aligned, total);
4641
4642 // Walk the coalesced donor runs, emitting the gap before each one and,
4643 // at the end, the gap after the last one up to `total`.
4644 let mut gaps = Vec::with_capacity(coalesced.len() + 1);
4645 let mut cursor = 0u64;
4646 for (start, end) in coalesced {
4647 if start > cursor {
4648 gaps.push((cursor, start - cursor));
4649 }
4650 cursor = cursor.max(end);
4651 }
4652 if cursor < total {
4653 gaps.push((cursor, total - cursor));
4654 }
4655 gaps
4656}
4657
4658/// Sort and coalesce `(offset, len)` byte ranges into disjoint `(start, end)`
4659/// runs, clamped to `[0, total)`: ranges that overlap or touch join one run, and
4660/// an empty or out-of-range one is dropped.
4661fn coalesce_runs(ranges: &[(u64, u64)], total: u64) -> Vec<(u64, u64)> {
4662 // Clamp each range to `[0, total)` and drop empty/out-of-range ones, then
4663 // sort by start so overlapping/adjacent runs coalesce in one pass.
4664 let mut runs: Vec<(u64, u64)> = ranges
4665 .iter()
4666 .filter_map(|&(offset, len)| {
4667 let start = offset.min(total);
4668 let end = offset.checked_add(len).unwrap_or(total).min(total);
4669 (end > start).then_some((start, end))
4670 })
4671 .collect();
4672 runs.sort_unstable_by_key(|&(start, _)| start);
4673
4674 let mut coalesced: Vec<(u64, u64)> = Vec::with_capacity(runs.len());
4675 for (start, end) in runs {
4676 match coalesced.last_mut() {
4677 Some((_, last_end)) if start <= *last_end => {
4678 *last_end = (*last_end).max(end);
4679 }
4680 _ => coalesced.push((start, end)),
4681 }
4682 }
4683 coalesced
4684}
4685
4686/// [`remove_staging`] on the blocking pool: unlinking a multi-GB blob can take
4687/// long enough on some filesystems to stall the task every group shares. A
4688/// failed join only leaves harmless clutter, the same as a failed removal.
4689async fn remove_staging_off_runtime(staging: &Path) {
4690 let staging = staging.to_path_buf();
4691 if let Err(e) = tokio::task::spawn_blocking(move || remove_staging(&staging)).await {
4692 tracing::warn!("staging removal task: {e}");
4693 }
4694}
4695
4696/// Best-effort cleanup of a finalized staging blob whose content is safely
4697/// elsewhere (materialized to disk, or read into memory) and so has nothing left
4698/// to resume. Removes the plain `<hex>` finalized blob itself and, best-effort,
4699/// any leftover `<hex>.partial{,.ranges}` sidecars: the ranged store's
4700/// `finalize` normally clears those on success, but this is the belt to that
4701/// brace and never runs on an errored entry (whose sidecars are the resume
4702/// prefix a rerun needs). A leftover here is harmless clutter, not a
4703/// correctness issue, so a removal failure is reported and swallowed rather
4704/// than propagated.
4705fn remove_staging(staging: &Path) {
4706 let paths_to_remove = [
4707 staging.to_path_buf(),
4708 staging.with_extension("partial"),
4709 staging.with_extension("partial.ranges"),
4710 ];
4711 for path in paths_to_remove {
4712 if let Err(e) = std::fs::remove_file(&path)
4713 && e.kind() != std::io::ErrorKind::NotFound
4714 {
4715 tracing::warn!("failed to remove staging file {}: {e}", path.display());
4716 }
4717 }
4718}
4719
4720/// Fail every `Write` slot with `e`, preserving `Failed`/`Skip` classification —
4721/// the shared tail of `fetch_group`'s two pre-materialize failure paths (a
4722/// staging-dir create error, or the fetch itself failing).
4723fn fail_all(slots: Vec<Slot<'_>>, e: &anyhow::Error) -> Vec<EntryOutcome> {
4724 slots
4725 .into_iter()
4726 .map(|s| match s {
4727 Slot::Failed(o) => o,
4728 Slot::Skip => EntryOutcome::Skipped,
4729 Slot::Write { label, .. } => EntryOutcome::failed(label, e),
4730 })
4731 .collect()
4732}
4733
4734impl EntryOutcome {
4735 fn failed(path: &str, err: &anyhow::Error) -> Self {
4736 Self::Failed {
4737 path: path.to_string(),
4738 // Sanitize here, at the single construction site: a per-entry fetch
4739 // failure can wrap a chain-RPC error whose source carries the
4740 // `rpc_url` (API key in path/query). This outcome is both printed to
4741 // stderr and serialized into the `--json` report, neither of which
4742 // passes through `main()`'s sanitizer (issue #954).
4743 err: sanitize_err_chain(err),
4744 }
4745 }
4746}
4747
4748/// `--node-id` (with its clap-required `--provider-address`) → a pinned target
4749/// for every entry; otherwise `None` (discover per entry).
4750fn explicit_target(common: &ClientFetchArgs) -> anyhow::Result<Option<(PublicKey, Address)>> {
4751 let Some(raw) = &common.node_id else {
4752 return Ok(None);
4753 };
4754 let node_id =
4755 PublicKey::from_str(raw).map_err(|e| anyhow!("invalid --node-id {raw:?}: {e}"))?;
4756 let provider_raw = common
4757 .provider_address
4758 .as_deref()
4759 .ok_or_else(|| anyhow!("--provider-address is required with --node-id"))?;
4760 let provider = chain_ctx::parse_address(provider_raw, "--provider-address")?;
4761 Ok(Some((node_id, provider)))
4762}
4763
4764/// Read + parse a local bundle manifest file.
4765fn read_local_manifest(path: &Path) -> anyhow::Result<Manifest> {
4766 let bytes =
4767 std::fs::read(path).with_context(|| format!("read bundle file {}", path.display()))?;
4768 parse_manifest(&bytes)
4769}
4770
4771/// Parse + version-gate bundle manifest bytes.
4772fn parse_manifest(bytes: &[u8]) -> anyhow::Result<Manifest> {
4773 let manifest: Manifest =
4774 serde_json::from_slice(bytes).context("parse bundle JSON (expected v1 manifest)")?;
4775 if manifest.version != 1 {
4776 bail!(
4777 "unsupported bundle version {} (this build supports v1)",
4778 manifest.version
4779 );
4780 }
4781 Ok(manifest)
4782}
4783
4784/// Resolve a bundle entry's POSIX relative `path` to a filesystem path under
4785/// `root`, rejecting anything that isn't a sequence of plain filename
4786/// components — absolute paths, `.`/`..`, drive prefixes, empty components
4787/// (leading/trailing/double slash). With `..` rejected, the result cannot
4788/// escape `root`. (`appendix-bundles.md` § Path-safety rules.)
4789fn safe_join(root: &Path, rel: &str) -> anyhow::Result<PathBuf> {
4790 if rel.is_empty() {
4791 bail!("empty entry path");
4792 }
4793 let mut out = root.to_path_buf();
4794 for seg in rel.split('/') {
4795 // Each `/`-split segment must be exactly one `Normal` path component.
4796 // This rejects "", ".", "..", "/", and a Windows drive prefix uniformly
4797 // and cross-platform.
4798 let mut comps = Path::new(seg).components();
4799 match (comps.next(), comps.next()) {
4800 (Some(Component::Normal(s)), None) => out.push(s),
4801 _ => bail!("rejected unsafe component {seg:?} in entry path {rel:?}"),
4802 }
4803 }
4804 Ok(out)
4805}
4806
4807/// The skip-cache record for one entry, built from its FINAL on-disk state, or
4808/// `None` when this run has nothing safe to record for it. A record is produced
4809/// only when a regular file is present at the entry's path with a usable mtime —
4810/// the gates that keep a symlink, a non-regular file, a missing file, or an
4811/// unreadable mtime out of the later fast-skip path. `materialize` renames new
4812/// content into place only on success, so a present regular file at the path is
4813/// this run's landed bytes; pairing the entry's hash with them is sound.
4814fn record_one(out_root: &Path, en: &ManifestEntry) -> Option<bundle_manifest::SavedFile> {
4815 let dest = safe_join(out_root, &en.path).ok()?;
4816 // `symlink_metadata` does not follow links: only a regular file this run
4817 // landed is recorded, so a symlink or non-regular file at `dest` is never
4818 // written into the skip-cache (its later fast-skip would trust a target
4819 // this run never verified).
4820 let meta = std::fs::symlink_metadata(&dest).ok()?;
4821 if !meta.is_file() {
4822 return None; // only record regular files
4823 }
4824 let mtime = SavedMtime::of(&meta)?; // no usable mtime → omit the unverifiable gate
4825 let chunks = en.chunks.as_ref().map(|cs| {
4826 cs.iter()
4827 .map(|c| bundle_manifest::SavedChunk {
4828 hash: c.hash.clone(),
4829 size: c.size,
4830 })
4831 .collect()
4832 });
4833 Some(bundle_manifest::SavedFile {
4834 hash: en.hash.clone(),
4835 size: meta.len(),
4836 mtime,
4837 chunks,
4838 })
4839}
4840
4841/// One line for each entry whose landed file differs in size from its
4842/// manifest's `size`: `<path>: manifest says X bytes, the blob is Y bytes`. A
4843/// manifest size is only the fetch's first claim, so such an entry succeeds;
4844/// the line tells the user the manifest is stale. `updates` holds the landed
4845/// files' records ([`build_completed_updates`]).
4846fn size_warnings(
4847 entries: &[&ManifestEntry],
4848 updates: &BTreeMap<String, bundle_manifest::SavedFile>,
4849) -> Vec<String> {
4850 entries
4851 .iter()
4852 .filter_map(|en| {
4853 let manifest = en.size?;
4854 let landed = updates.get(&en.path)?.size;
4855 (landed != manifest).then(|| {
4856 format!(
4857 "{}: manifest says {manifest} bytes, the blob is {landed} bytes",
4858 en.path
4859 )
4860 })
4861 })
4862 .collect()
4863}
4864
4865/// Build the skip-cache updates for a batch of `entries` paired with THIS run's
4866/// `outcomes` for them, in order — `fetch_group` returns one outcome per entry,
4867/// in order, so position correlates a path to its outcome. Only an entry whose
4868/// outcome is a success this run (`Fetched`, `Linked`, or `Skipped`) is
4869/// eligible; a `Failed` entry is omitted, because its fetch left the OLD bytes in
4870/// place (materialize renames new content only on success) and pairing the *new*
4871/// manifest hash with them would let a later mtime/hash fast path wrongly treat
4872/// the stale file as up to date and never re-fetch it.
4873///
4874/// Correlating by position — rather than "every entry not in the failed set" —
4875/// is what makes this safe to call MID-RUN: an entry whose group has not
4876/// completed yet is simply absent from `outcomes`, so it is never recorded from
4877/// bytes this run has not landed. A batch may be one completed group or the whole
4878/// run's outcomes; the result is the same records either way.
4879fn build_completed_updates(
4880 entries: &[&ManifestEntry],
4881 outcomes: &[EntryOutcome],
4882 out_root: &Path,
4883) -> BTreeMap<String, bundle_manifest::SavedFile> {
4884 let mut updates = BTreeMap::new();
4885 for (en, outcome) in entries.iter().zip(outcomes) {
4886 let recordable = matches!(
4887 outcome,
4888 EntryOutcome::Fetched(_) | EntryOutcome::Linked | EntryOutcome::Skipped
4889 );
4890 if recordable && let Some(rec) = record_one(out_root, en) {
4891 updates.insert(en.path.clone(), rec);
4892 }
4893 }
4894 updates
4895}
4896
4897/// Completed files may accumulate up to this many before the skip-cache is
4898/// rewritten, bounding how much a later run re-hashes to recover after an
4899/// interruption (and how often a many-file pull rewrites the cache).
4900const FLUSH_FILES: usize = 64;
4901/// Newly-fetched bytes may land up to this many before the skip-cache is
4902/// rewritten, so a long single-file transfer still checkpoints its completed
4903/// siblings without waiting for [`FLUSH_FILES`].
4904const FLUSH_BYTES: u64 = 1 << 30; // 1 GiB
4905
4906/// One completed group's contribution to the skip-cache flush: the records to
4907/// fold in, and the new content bytes it fetched (which drive the byte cadence —
4908/// a link or skip records a file but lands no new bytes).
4909struct FlushBatch {
4910 updates: BTreeMap<String, bundle_manifest::SavedFile>,
4911 fetched_bytes: u64,
4912}
4913
4914/// Fold each completed-group [`FlushBatch`] onto `acc` and rewrite the skip-cache
4915/// atomically whenever ≥[`FLUSH_FILES`] files or ≥[`FLUSH_BYTES`] new bytes have
4916/// accumulated since the last write, plus one final write when the channel closes
4917/// so the last partial batch is always persisted. This is the sole writer of the
4918/// skip-cache for the run: a single consumer, so its rewrites never race.
4919///
4920/// The skip-cache is advisory — a flush failure is logged and never propagated.
4921/// The blocking filesystem write runs on a blocking task with owned bytes, so the
4922/// caller's `join!` keeps driving fetches while a flush is in flight.
4923async fn flush_task(
4924 out_root: &Path,
4925 mut acc: SavedManifest,
4926 mut rx: tokio::sync::mpsc::UnboundedReceiver<FlushBatch>,
4927) {
4928 let mut files_since = 0usize;
4929 let mut bytes_since = 0u64;
4930 let mut dirty = false;
4931 while let Some(batch) = rx.recv().await {
4932 files_since += batch.updates.len();
4933 bytes_since = bytes_since.saturating_add(batch.fetched_bytes);
4934 bundle_manifest::merge(&mut acc, batch.updates);
4935 dirty = true;
4936 if (files_since >= FLUSH_FILES || bytes_since >= FLUSH_BYTES)
4937 && flush_now(out_root, &acc).await
4938 {
4939 // Only clear on a successful write. A failed cadence flush keeps
4940 // `dirty` set and the counters over threshold, so the next batch
4941 // retries and the final write below still runs at close — the last
4942 // accumulated updates are never dropped by a transient write error.
4943 files_since = 0;
4944 bytes_since = 0;
4945 dirty = false;
4946 }
4947 }
4948 // Persist whatever landed since the last successful flush (or the only batch of
4949 // a small run). `dirty` stays false only when the last cadence flush already
4950 // wrote everything, so a clean run adds no redundant final write.
4951 if dirty {
4952 flush_now(out_root, &acc).await;
4953 }
4954}
4955
4956/// Serialize `acc` (fast, in-memory) and write it atomically on a blocking task.
4957/// Returns whether the write succeeded so the caller can retry a failed cadence
4958/// flush at close. Advisory: every failure is logged, never returned as an error.
4959async fn flush_now(out_root: &Path, acc: &SavedManifest) -> bool {
4960 let bytes = match bundle_manifest::serialize(acc) {
4961 Ok(b) => b,
4962 Err(e) => {
4963 tracing::warn!(
4964 "failed to serialize {}: {e}",
4965 bundle_manifest::SAVED_MANIFEST_NAME
4966 );
4967 return false;
4968 }
4969 };
4970 let root = out_root.to_path_buf();
4971 match tokio::task::spawn_blocking(move || bundle_manifest::write_bytes(&root, &bytes)).await {
4972 Ok(Ok(())) => true,
4973 Ok(Err(e)) => {
4974 tracing::warn!(
4975 "failed to write {}: {e}",
4976 bundle_manifest::SAVED_MANIFEST_NAME
4977 );
4978 false
4979 }
4980 Err(e) => {
4981 tracing::warn!("skip-cache flush task panicked: {e}");
4982 false
4983 }
4984 }
4985}
4986
4987/// Why a pull ended with no entries to fetch — picks the message
4988/// [`report_nothing_to_fetch`] prints, so the operator learns the actual cause
4989/// (an empty bundle, globs that matched nothing, or a fully deselected list)
4990/// rather than mistaking one for another.
4991#[derive(Clone, Copy)]
4992enum NothingReason {
4993 /// The bundle manifest itself is empty.
4994 EmptyBundle,
4995 /// `--include`/`--exclude` removed every entry.
4996 Filtered,
4997 /// The user deselected every entry in the `--select` editor.
4998 Deselected,
4999}
5000
5001impl NothingReason {
5002 /// The reason for an empty result of a filter pass: [`Filtered`] when a filter
5003 /// was given and the bundle was non-empty before it, else [`EmptyBundle`].
5004 ///
5005 /// [`Filtered`]: NothingReason::Filtered
5006 /// [`EmptyBundle`]: NothingReason::EmptyBundle
5007 const fn from_filter(filters_given: bool, raw_empty: bool) -> Self {
5008 if filters_given && !raw_empty {
5009 Self::Filtered
5010 } else {
5011 Self::EmptyBundle
5012 }
5013 }
5014}
5015
5016fn report_nothing_to_fetch(reason: NothingReason) {
5017 let msg = match reason {
5018 NothingReason::EmptyBundle => "bundle has no entries; nothing to fetch",
5019 NothingReason::Filtered => {
5020 "no bundle entries match the include/exclude filters; nothing to fetch"
5021 }
5022 NothingReason::Deselected => "every file was deselected; nothing to fetch",
5023 };
5024 println!("{msg}");
5025}
5026
5027/// The manifest a dry run can read with no network: the `-i` file, or the
5028/// `--hash` bundle's copy in the output root's [`bundle_cache`]. `None` when
5029/// `--hash` has no cached copy. Errors on a malformed `--hash`, an unreadable
5030/// `-i` file, a cache file that cannot be read or fails its hash check (naming
5031/// its path), or bytes that do not parse as a v1 manifest. The cache is read even under
5032/// `--overwrite`, which makes a real pull fetch the manifest again: the cache
5033/// is content-addressed, so its bytes are exactly what that fetch returns.
5034fn dry_run_manifest(args: &BundlePullArgs) -> anyhow::Result<Option<Manifest>> {
5035 match (&args.input, &args.hash) {
5036 (Some(path), _) => read_local_manifest(path).map(Some),
5037 (None, Some(h)) => {
5038 let hash = fetch::parse_hash(h)?;
5039 bundle_cache::load(&args.output, hash)?
5040 .map(|bytes| {
5041 parse_manifest(&bytes).with_context(|| {
5042 format!(
5043 "cached bundle manifest {}",
5044 bundle_cache::cache_path(&args.output, hash).display()
5045 )
5046 })
5047 })
5048 .transpose()
5049 }
5050 (None, None) => bail!("no bundle source (expected -i or --hash)"),
5051 }
5052}
5053
5054/// Print the would-fetch plan and exit (no network/chain/keystore activity).
5055/// The plan lists the entries of the manifest [`dry_run_manifest`] reads: the
5056/// `-i` file, or a `--hash` bundle's cached manifest. The `--include`/`--exclude`
5057/// `filter` applies to those entries, so the plan is the entry set a real run
5058/// starts from and the unmatched-pattern warnings are the ones it prints. A real
5059/// run then skips files already present in the output root (unless
5060/// `--overwrite`) and warns on leftover partials; the dry run does neither. A
5061/// `--hash` bundle with no cached manifest reports only the bundle and the
5062/// output root, with `entries: null` under `--json`.
5063fn dry_run(args: &BundlePullArgs, filter: &EntryFilter, filters_given: bool) -> anyhow::Result<()> {
5064 let out = args.output.display();
5065 let Some(mut manifest) = dry_run_manifest(args)? else {
5066 let h = args.hash.as_deref().unwrap_or_default();
5067 if args.json {
5068 let plan = serde_json::json!({
5069 "output": out.to_string(),
5070 "hash": h,
5071 "count": null,
5072 "entries": null,
5073 });
5074 println!("{plan}");
5075 return Ok(());
5076 }
5077 let filter_note = if filters_given {
5078 "; the include/exclude patterns are matched against them then"
5079 } else {
5080 ""
5081 };
5082 println!(
5083 "--dry-run with --hash: would fetch bundle {h} then its entries into {out} \
5084 (no cached manifest: entries are listed once a pull into this directory \
5085 caches it{filter_note})"
5086 );
5087 return Ok(());
5088 };
5089 let raw_empty = manifest.entries.is_empty();
5090 manifest.entries = filter.apply_and_warn(manifest.entries).0;
5091 if args.json {
5092 // The `--json` plan stays machine-readable — an empty set is
5093 // `count: 0` with an empty `entries` array, no prose line. A `--hash`
5094 // bundle with no cached manifest gives `count`/`entries` of `null`.
5095 let plan = serde_json::json!({
5096 "output": args.output.display().to_string(),
5097 "count": manifest.entries.len(),
5098 "entries": manifest.entries.iter().map(|e| serde_json::json!({
5099 "path": e.path, "hash": e.hash, "size": e.size,
5100 "chunks": e.chunks.as_ref().map(Vec::len),
5101 })).collect::<Vec<_>>(),
5102 });
5103 println!("{plan}");
5104 } else if manifest.entries.is_empty() {
5105 // Match the real run's empty-result message rather than printing a
5106 // "would fetch 0 entr(ies)" plan, so `--dry-run` and a live pull
5107 // agree on what an emptied set looks like.
5108 report_nothing_to_fetch(NothingReason::from_filter(filters_given, raw_empty));
5109 } else {
5110 println!(
5111 "would fetch {} entr(ies) into {out}:",
5112 manifest.entries.len()
5113 );
5114 for e in &manifest.entries {
5115 let chunks = e
5116 .chunks
5117 .as_ref()
5118 .map(|c| format!(", {} chunks", c.len()))
5119 .unwrap_or_default();
5120 match e.size {
5121 Some(n) => println!(" {} ({n} bytes{chunks})", e.path),
5122 None => println!(" {}{}", e.path, chunks),
5123 }
5124 }
5125 }
5126 Ok(())
5127}
5128
5129/// Summarize outcomes; return an error if any entry failed (after reporting all).
5130/// Each of `warnings` ([`size_warnings`]) prints one line on stderr and never
5131/// changes the result. `excluded` is the count of entries the run does not pull
5132/// ([`PullReport`]).
5133fn report(
5134 outcomes: &[EntryOutcome],
5135 warnings: &[String],
5136 transfer: Transfer,
5137 dedup: DedupSummary,
5138 excluded: u64,
5139 output: &Path,
5140 json: bool,
5141) -> anyhow::Result<()> {
5142 for o in outcomes {
5143 if let EntryOutcome::Failed { path, err } = o {
5144 // A per-entry failure is a command result the user needs, not
5145 // routing narration: keep it on stderr (unconditional, and clear
5146 // of the `--json` report on stdout) rather than behind logging.
5147 eprintln!("failed: {path}: {err}");
5148 }
5149 }
5150 for line in warnings {
5151 eprintln!("warning: {line}");
5152 }
5153
5154 let rep = pull_report(outcomes, transfer, dedup, excluded, output);
5155 let (reused, reused_bytes, failed) = (rep.reused, rep.reused_bytes, rep.failed);
5156 if json {
5157 let line = serde_json::to_string(&rep).map_err(|e| anyhow!("serialize report: {e}"))?;
5158 println!("{line}");
5159 } else {
5160 println!("{}", counts_line(&rep));
5161 // `downloaded X → reconstructed Y` only when dedup made them differ;
5162 // otherwise a single `downloaded X`.
5163 println!("{}", transfer_line(transfer));
5164 // The bytes earlier, interrupted runs fetched and this run resumed, shown
5165 // only when a run resumed any: they are in neither total above.
5166 if transfer.resumed > 0 {
5167 println!(
5168 "resumed {} from earlier partials",
5169 human_bytes(transfer.resumed)
5170 );
5171 }
5172 // The whole-file dedup outcome, shown only when it mattered: a run that
5173 // materialized any destination from an on-disk donor instead of fetching.
5174 if reused > 0 {
5175 println!(
5176 "whole-file dedup: reused {} from disk ({reused} file(s))",
5177 human_bytes(reused_bytes)
5178 );
5179 }
5180 // The range-dedup outcome, shown only when a hint mattered: a run that
5181 // spliced bytes from disk, or one whose hints were dropped by a fault. A
5182 // plain pull with no usable hints stays silent (both are zero), so a
5183 // hint-carrying bundle whose hints all lie (spliced 0, some ignored) no
5184 // longer prints the same summary as an unhinted one.
5185 if dedup.spliced_bytes > 0 || dedup.hints_ignored > 0 {
5186 println!(
5187 "range-dedup: spliced {} from disk, {} hint(s) ignored",
5188 human_bytes(dedup.spliced_bytes),
5189 dedup.hints_ignored
5190 );
5191 }
5192 }
5193
5194 if failed > 0 {
5195 bail!("{failed} entr(ies) failed to fetch");
5196 }
5197 Ok(())
5198}
5199
5200/// The run's [`PullReport`]: the entry counts tallied from `outcomes`, beside
5201/// the byte tallies and the `excluded` count the caller already holds.
5202fn pull_report(
5203 outcomes: &[EntryOutcome],
5204 transfer: Transfer,
5205 dedup: DedupSummary,
5206 excluded: u64,
5207 output: &Path,
5208) -> PullReport {
5209 let mut rep = PullReport {
5210 output: output.display().to_string(),
5211 fetched: 0,
5212 linked: 0,
5213 skipped: 0,
5214 excluded,
5215 failed: 0,
5216 downloaded: transfer.downloaded,
5217 reconstructed: transfer.reconstructed,
5218 spliced_bytes: dedup.spliced_bytes,
5219 hints_ignored: dedup.hints_ignored,
5220 resumed_bytes: transfer.resumed,
5221 reused: 0,
5222 reused_bytes: 0,
5223 };
5224 for o in outcomes {
5225 match o {
5226 EntryOutcome::Fetched(_) => rep.fetched += 1,
5227 EntryOutcome::Linked => rep.linked += 1,
5228 EntryOutcome::Skipped => rep.skipped += 1,
5229 EntryOutcome::Deduped(n) => {
5230 rep.reused += 1;
5231 rep.reused_bytes = rep.reused_bytes.saturating_add(*n);
5232 }
5233 EntryOutcome::Failed { .. } => rep.failed += 1,
5234 }
5235 }
5236 rep
5237}
5238
5239/// The summary's first line: where the run pulled to, and every entry count.
5240fn counts_line(rep: &PullReport) -> String {
5241 format!(
5242 "pulled into {} ({} fetched, {} linked, {} skipped, {} reused, {} excluded, {} failed)",
5243 rep.output, rep.fetched, rep.linked, rep.skipped, rep.reused, rep.excluded, rep.failed
5244 )
5245}
5246
5247/// Scan `<out_root>/.decdn-partial` for leftover per-hash staging files —
5248/// `<hex>`, `<hex>.partial`, `<hex>.partial.ranges` — whose hash is not in
5249/// `keep`, and total the disk space they take ([`allocated_bytes`]). Other
5250/// names (a record's temporary file) are not counted. A missing or unreadable
5251/// directory holds nothing.
5252fn leftover_partials(out_root: &Path, keep: &HashSet<[u8; 32]>) -> Leftovers {
5253 let Ok(dir) = std::fs::read_dir(out_root.join(STAGING_DIR)) else {
5254 return Leftovers::default();
5255 };
5256 let mut blobs: HashSet<[u8; 32]> = HashSet::new();
5257 let mut bytes = 0u64;
5258 for entry in dir.flatten() {
5259 let name = entry.file_name();
5260 let Some(name) = name.to_str() else {
5261 continue;
5262 };
5263 let stem = name
5264 .strip_suffix(".partial.ranges")
5265 .or_else(|| name.strip_suffix(".partial"))
5266 .unwrap_or(name);
5267 let Ok(hash) = blake3::Hash::from_hex(stem) else {
5268 continue;
5269 };
5270 let hash = *hash.as_bytes();
5271 if keep.contains(&hash) {
5272 continue;
5273 }
5274 let Ok(meta) = entry.metadata() else {
5275 continue;
5276 };
5277 if meta.is_file() {
5278 blobs.insert(hash);
5279 bytes = bytes.saturating_add(allocated_bytes(&meta));
5280 }
5281 }
5282 Leftovers {
5283 blobs: u64::try_from(blobs.len()).unwrap_or(u64::MAX),
5284 bytes,
5285 }
5286}
5287
5288/// The disk space a file takes. A range-dedup `.partial` is sized to the whole
5289/// blob before any byte lands, so its length can be far above the space that
5290/// deleting it frees. On Unix the allocated blocks give that space; elsewhere
5291/// the length is the best figure.
5292fn allocated_bytes(meta: &std::fs::Metadata) -> u64 {
5293 #[cfg(unix)]
5294 {
5295 use std::os::unix::fs::MetadataExt;
5296 meta.blocks().saturating_mul(512)
5297 }
5298 #[cfg(not(unix))]
5299 {
5300 meta.len()
5301 }
5302}
5303
5304/// Staging files in the staging directory that a run did not use: how many
5305/// distinct blobs, and the disk space their files take.
5306#[derive(Debug, Default, PartialEq, Eq)]
5307struct Leftovers {
5308 blobs: u64,
5309 bytes: u64,
5310}
5311
5312/// The warning line for `leftovers` in `dir`, without the `warning: ` prefix,
5313/// or `None` when there are none.
5314fn leftover_warning(dir: &Path, leftovers: &Leftovers) -> Option<String> {
5315 let size = human_bytes(leftovers.bytes);
5316 let dir = dir.display();
5317 match leftovers.blobs {
5318 0 => None,
5319 1 => Some(format!(
5320 "1 partial download ({size} on disk) that this run did not use remains in \
5321 {dir}; delete it to reclaim the space"
5322 )),
5323 n => Some(format!(
5324 "{n} partial downloads ({size} on disk) that this run did not use remain in \
5325 {dir}; delete them to reclaim the space"
5326 )),
5327 }
5328}
5329
5330/// Warn on stderr about leftover staging files of blobs this run did not use
5331/// ([`leftover_partials`]): a partial of an entry an earlier run started,
5332/// which this run's filter or selection left out, or one another bundle
5333/// pulled into the same output directory left. They stay for a later run that
5334/// selects the entry again, so the warning only names them and never deletes
5335/// them. `entries` are the run's selected entries (none when the filter or
5336/// `--select` emptied the run), and `bundle_hash` is the `--hash` manifest
5337/// blob, which is also this run's own.
5338fn warn_leftover_partials(out_root: &Path, entries: &[ManifestEntry], bundle_hash: Option<&str>) {
5339 let keep: HashSet<[u8; 32]> = entries
5340 .iter()
5341 .map(|e| e.hash.as_str())
5342 .chain(bundle_hash)
5343 .filter_map(|h| fetch::parse_hash(h).ok())
5344 .collect();
5345 let leftovers = leftover_partials(out_root, &keep);
5346 if let Some(line) = leftover_warning(&out_root.join(STAGING_DIR), &leftovers) {
5347 eprintln!("warning: {line}");
5348 }
5349}
5350
5351/// Format a byte count as a short decimal-unit label (`13.8 GB`). SI (1000-based)
5352/// units match how file and model sizes are usually quoted.
5353fn human_bytes(n: u64) -> String {
5354 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
5355 if n < 1000 {
5356 return format!("{n} B");
5357 }
5358 // Precision loss is irrelevant here: the result is a one-decimal human label.
5359 #[allow(
5360 clippy::cast_precision_loss,
5361 reason = "display-only size label; exact integer value is not needed"
5362 )]
5363 let mut value = n as f64;
5364 let mut unit = 0usize;
5365 while value >= 1000.0 && unit + 1 < UNITS.len() {
5366 value /= 1000.0;
5367 unit += 1;
5368 }
5369 let label = UNITS.get(unit).copied().unwrap_or("B");
5370 format!("{value:.1} {label}")
5371}
5372
5373/// The end-of-pull transfer line. When dedup saved a transfer the two totals
5374/// differ and both are shown with an arrow; otherwise a single `downloaded X`
5375/// (the `→ reconstructed` half is omitted rather than repeating the same figure).
5376fn transfer_line(t: Transfer) -> String {
5377 if t.downloaded == t.reconstructed {
5378 format!("downloaded {}", human_bytes(t.downloaded))
5379 } else {
5380 format!(
5381 "downloaded {} → reconstructed {}",
5382 human_bytes(t.downloaded),
5383 human_bytes(t.reconstructed)
5384 )
5385 }
5386}
5387
5388/// The [`Transfer`] for one whole-file hash-group: the blob is either paid for
5389/// once (`downloaded` = the content bytes its fetch paid for this run, from
5390/// `bytes`, else the size of the single `Fetched`; `resumed` = the bytes it
5391/// resumed from an earlier run) or reused from an on-disk donor with no download
5392/// (`Deduped`, contributing 0 to `downloaded`) — a group never mixes the two,
5393/// since [`PullCtx::fetch_group`] takes one path or the other. Every materialized
5394/// copy — the canonical (`Fetched` or `Deduped`) plus each `Linked` duplicate
5395/// path — is a full file on disk (`reconstructed` = size × copies). A group with
5396/// nothing written (all skipped or failed) contributes nothing.
5397fn group_transfer(outcomes: &[EntryOutcome], bytes: Option<EntryBytes>) -> Transfer {
5398 let paid_size = outcomes.iter().find_map(|o| match o {
5399 EntryOutcome::Fetched(n) => Some(*n),
5400 _ => None,
5401 });
5402 let reused_size = outcomes.iter().find_map(|o| match o {
5403 EntryOutcome::Deduped(n) => Some(*n),
5404 _ => None,
5405 });
5406 match paid_size.or(reused_size) {
5407 Some(n) => {
5408 let copies = outcomes
5409 .iter()
5410 .filter(|o| {
5411 matches!(
5412 o,
5413 EntryOutcome::Fetched(_) | EntryOutcome::Linked | EntryOutcome::Deduped(_)
5414 )
5415 })
5416 .count();
5417 let copies = u64::try_from(copies).unwrap_or(u64::MAX);
5418 Transfer {
5419 downloaded: paid_size.map_or(0, |size| bytes.map_or(size, |b| b.paid)),
5420 reconstructed: n.saturating_mul(copies),
5421 resumed: paid_size.and(bytes).map_or(0, |b| b.resumed),
5422 }
5423 }
5424 None => Transfer::default(),
5425 }
5426}
5427
5428#[cfg(test)]
5429#[allow(
5430 clippy::unwrap_used,
5431 clippy::expect_used,
5432 clippy::indexing_slicing,
5433 clippy::panic
5434)]
5435mod tests;