decdn_client/source_set.rs
1//! The sources of one blob (ADR 039 § Source set and selection).
2//!
3//! A `SourceSet` holds every provider known to hold the blob, the lane built
4//! for each, and when each may be tried again. It never drops a source: a
5//! delivery fault cools it (in the command-wide [`PeerHealth`]), a price
6//! refusal parks it until the deposit rises, and a lane-build or discovery
7//! error backs off and retries. A partial holder that keeps refusing ranges
8//! outside its coverage is barred from pull-through and serves only the
9//! blocks it covers. A source that declines this fetch is excluded for this
10//! blob. A refusal never ends a fetch on its own (ADR 039 §Failure handling):
11//! the fetch ends when every known source declined it, or on a unanimous
12//! verdict after a fresh discovery found nothing new: every known source is
13//! priced out (funding needed), or every one says it does not hold the blob,
14//! declined it, or is barred from the only work left (not-found).
15//!
16//! The state is synchronous. The acquire loop runs the `connect` and `discover`
17//! futures itself, so lanes keep streaming while a lane builds or discovery
18//! runs.
19
20use std::collections::{HashMap, HashSet};
21use std::sync::Arc;
22
23use alloy::primitives::{Address, U256};
24use decdn_protocol::Coverage;
25use decdn_protocol::client::{StreamError, VoucherRejectReason};
26use tokio::time::{Duration, Instant};
27
28use crate::UpstreamRefused;
29use crate::fault::{Fault, LaneBuildFault, classify};
30use crate::health::PeerHealth;
31use crate::source::{BlobSource, SourceFuture, SourceStream};
32use crate::streamer::StreamCandidate;
33
34/// The first wait before a failed lane build is retried.
35pub(crate) const BUILD_RETRY_BASE: Duration = Duration::from_secs(1);
36/// The longest wait before a failed lane build is retried.
37pub(crate) const BUILD_RETRY_CAP: Duration = Duration::from_secs(30);
38/// The first wait between discoveries.
39pub(crate) const DISCOVERY_BASE: Duration = Duration::from_secs(5);
40/// The longest wait between discoveries.
41pub(crate) const DISCOVERY_CAP: Duration = Duration::from_mins(5);
42
43/// One provider that holds (part of) the blob.
44#[derive(Debug, Clone)]
45pub struct Holder {
46 /// The provider's on-chain address: the payment lane's payee and the key
47 /// of its health.
48 pub provider: Address,
49 /// The blocks it serves: its advertised coverage less any block dropped
50 /// after repeated refusals inside it, or
51 /// `None` for the whole blob.
52 pub coverage: Option<Coverage>,
53 /// The probed round-trip time. Lower starts first.
54 pub rtt_ms: f64,
55 /// Whether a probe reported that this provider holds the blob. A probed
56 /// holder is never marked absent: on the wire a `NotFound` also means load
57 /// shed, a per-signer cap, or a pool the node cannot confirm yet. To a
58 /// whole holder it is a delivery fault only. A partial holder's counts
59 /// toward dropping the refused blocks from its coverage (inside it) or
60 /// barring it from pull-through (outside it), each for a while. A provider the probe did not report as a holder (a
61 /// pull-through or proxy-warming target) counts as absent after
62 /// [`ABSENT_AFTER_NOT_FOUND`] such answers with no verified byte between
63 /// them.
64 pub probed_holder: bool,
65}
66
67/// The range a lane held when it faulted ([`SourceSet::record_fault`]).
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69#[doc(hidden)]
70pub struct LaneRange {
71 /// The range's first byte.
72 pub offset: u64,
73 /// The range's length in bytes.
74 pub len: u64,
75 /// The bytes of the range that verified before the fault.
76 pub landed: u64,
77 /// The piece that faulted starts at or past the end the fetch knows: the
78 /// proven size, or with none proven the smaller of the first size claim
79 /// and the bound. The bound can overshoot the blob, so a `NotFound` for
80 /// such a piece is an overshoot refusal: it cools the provider and never
81 /// counts toward marking it absent ([`ABSENT_AFTER_NOT_FOUND`]).
82 pub past_end: bool,
83 /// The range holds a chunk outside the coverage of the lane that held
84 /// it: the lane took it for its node to serve that part by pull-through,
85 /// as a pending chunk no running lane covers or as a slow-victim steal's
86 /// tail. A `NotFound` for such a range from a probed partial holder
87 /// counts toward barring that holder from pull-through
88 /// ([`SourceSet::no_pull_through`]).
89 ///
90 /// Only a lane built with measured coverage can hold an uncovered range.
91 /// A lane built without it (a pinned holder, or a provider the probe did
92 /// not report as a holder) covers the whole blob, so this is always
93 /// `false` there, and `false` never says that the node holds the range.
94 pub uncovered: bool,
95}
96
97/// How many `NotFound` answers in a row, with no verified byte between them,
98/// mark a provider that is not a probed holder as absent. The same count of
99/// `NotFound` answers for ranges outside a probed partial holder's coverage
100/// bars it from pull-through ([`SourceSet::no_pull_through`]), and for one
101/// block inside it drops that block from its coverage for a while.
102pub const ABSENT_AFTER_NOT_FOUND: u32 = 3;
103
104/// How long a bar from pull-through set by `NotFound` answers lasts. A node
105/// also answers `NotFound` when it sheds miss load or its chain view is stale,
106/// so the bar ends after the longest cooldown and the holder takes one
107/// uncovered range again as a probe. It is also how long a block dropped from
108/// a partial holder's coverage stays out, and the quiet time after which a
109/// block's count of covered refusals starts again.
110pub(crate) const PULL_THROUGH_BAR: Duration = crate::health::COOL_CAP;
111
112/// Where a [`SourceSet`] finds holders and builds their lanes.
113#[doc(hidden)]
114pub trait SourceProvider: Send + Sync {
115 /// The paid source a built lane fetches from.
116 type Source: BlobSource;
117
118 /// Find and probe the current holders of `hash`.
119 fn discover(&self, hash: [u8; 32]) -> SourceFuture<'_, Vec<Holder>>;
120
121 /// Holders found after the set started, pushed as they are found. The
122 /// stream ends when no more will come. The acquire loop takes it once,
123 /// when it starts, and asks for no discovery while it is open. `None`,
124 /// the default, is a provider with nothing to push.
125 fn arrivals(&self) -> Option<SourceStream<'_, Holder>> {
126 None
127 }
128
129 /// Build the paid lane to `holder`. An error here is chain-side: the set
130 /// retries it later and never blames the holder.
131 fn connect<'a>(&'a self, holder: &'a Holder)
132 -> SourceFuture<'a, StreamCandidate<Self::Source>>;
133
134 /// Called once for each delivery fault `holder` raises.
135 fn on_source_fault(&self, _holder: &Holder) {}
136}
137
138impl<P: SourceProvider> SourceProvider for &P {
139 type Source = P::Source;
140
141 fn discover(&self, hash: [u8; 32]) -> SourceFuture<'_, Vec<Holder>> {
142 (**self).discover(hash)
143 }
144
145 fn arrivals(&self) -> Option<SourceStream<'_, Holder>> {
146 (**self).arrivals()
147 }
148
149 fn connect<'a>(
150 &'a self,
151 holder: &'a Holder,
152 ) -> SourceFuture<'a, StreamCandidate<Self::Source>> {
153 (**self).connect(holder)
154 }
155
156 fn on_source_fault(&self, holder: &Holder) {
157 (**self).on_source_fault(holder);
158 }
159}
160
161/// Funding needed: no known source serves at the pool's current funding, a
162/// fresh discovery found no other source, and no funding recovery step could
163/// raise the deposit (ADR 003 § Funding recovery).
164#[derive(Debug)]
165pub struct NoAffordableSource {
166 /// The pool deposit every source refused at.
167 pub deposit: U256,
168}
169
170impl std::fmt::Display for NoAffordableSource {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 write!(
173 f,
174 "funding needed: no provider serves at the pool's deposit of {} (micro-USDC)",
175 self.deposit
176 )
177 }
178}
179
180impl std::error::Error for NoAffordableSource {}
181
182/// Every known source says it does not hold the blob, and a fresh discovery
183/// found no other holder. The acquire loop raises it as context on the last
184/// refusal that marked a source absent, so the error chain also holds that
185/// [`UpstreamRefused`].
186#[derive(Debug)]
187#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
188pub struct NoSourceHasBlob;
189
190impl std::fmt::Display for NoSourceHasBlob {
191 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192 f.write_str("no provider holds this blob; check the hash")
193 }
194}
195
196impl std::error::Error for NoSourceHasBlob {}
197
198/// Why one source declined this fetch (ADR 005 §Open-time refusal classes).
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum DeclineReason {
201 /// The node refused the stream `Declined`.
202 Refused,
203 /// The node rejected this client's voucher mid-stream for a reason no
204 /// watermark bundle heals, and that is not a funding reason.
205 Voucher(VoucherRejectReason),
206}
207
208/// Every known source declined this fetch (ADR 039 §Failure handling). The
209/// fetch ends at once, without a fresh discovery. `reasons` are the reasons the
210/// client collected, each once, in the order it first saw them.
211#[derive(Debug, Clone, PartialEq, Eq)]
212#[cfg_attr(not(feature = "test-util"), non_exhaustive)]
213pub struct NoNodeWillServe {
214 /// The reasons the sources declined, each once.
215 pub reasons: Vec<DeclineReason>,
216}
217
218impl std::fmt::Display for NoNodeWillServe {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 let vouchers: Vec<String> = self
221 .reasons
222 .iter()
223 .filter_map(|r| match r {
224 DeclineReason::Voucher(reason) => Some(format!("{reason:?}")),
225 DeclineReason::Refused => None,
226 })
227 .collect();
228 let refused = self.reasons.contains(&DeclineReason::Refused);
229 match (refused, vouchers.is_empty()) {
230 (_, true) => {
231 f.write_str("no node will serve this: every node declined this blob for this fetch")
232 }
233 (false, false) => write!(
234 f,
235 "no node will serve this: every node rejected this client's vouchers ({})",
236 vouchers.join(", ")
237 ),
238 (true, false) => write!(
239 f,
240 "no node will serve this: every node declined this blob or rejected this \
241 client's vouchers ({})",
242 vouchers.join(", ")
243 ),
244 }
245 }
246}
247
248impl std::error::Error for NoNodeWillServe {}
249
250/// A backoff that doubles from `base` to `cap`.
251#[derive(Debug, Clone, Copy)]
252pub(crate) struct Backoff {
253 /// When the next try may run.
254 pub(crate) next_at: Instant,
255 /// The failures in a row so far.
256 pub(crate) attempts: u32,
257}
258
259impl Backoff {
260 /// No failure yet: the next try may run at `now`.
261 pub(crate) const fn new(now: Instant) -> Self {
262 Self {
263 next_at: now,
264 attempts: 0,
265 }
266 }
267
268 /// One more failure at `now`: wait `base`, doubled for each earlier
269 /// failure in a row, at most `cap`.
270 pub(crate) fn fail(self, now: Instant, base: Duration, cap: Duration) -> Self {
271 let attempts = self.attempts.saturating_add(1);
272 let wait = base
273 .saturating_mul(2u32.saturating_pow(attempts.saturating_sub(1)))
274 .min(cap);
275 Self {
276 next_at: now + wait,
277 attempts,
278 }
279 }
280}
281
282/// The sources of one blob.
283#[doc(hidden)]
284pub struct SourceSet<'p, P: SourceProvider> {
285 provider: &'p P,
286 hash: [u8; 32],
287 health: Arc<PeerHealth>,
288 holders: Vec<Holder>,
289 lanes: HashMap<Address, Arc<StreamCandidate<P::Source>>>,
290 build_retry: HashMap<Address, Backoff>,
291 /// When each built lane that found no free stream may start again
292 /// ([`Self::start_refused`]).
293 start_retry: HashMap<Address, Instant>,
294 absent: HashSet<Address>,
295 /// Each non-holder's `NotFound` answers since its last verified byte.
296 not_found: HashMap<Address, u32>,
297 /// Each probed partial holder's `NotFound` answers for ranges outside its
298 /// coverage since it last served such a range.
299 pull_through_not_found: HashMap<Address, u32>,
300 /// When each probed partial holder last said `NotFound` to a range
301 /// outside its coverage. A verified byte outside its coverage clears its
302 /// count and bar only when it lands after this, so a worker that ends
303 /// late cannot lift a bar set after its byte.
304 pull_through_refused_at: HashMap<Address, Instant>,
305 /// When each probed partial holder last served a verified byte outside its
306 /// coverage. A `NotFound` for such a range that came before this is stale
307 /// and does not count, so the order in which the loop takes worker ends
308 /// cannot change the bar. A tie goes to the refusal.
309 pull_through_served_at: HashMap<Address, Instant>,
310 /// Probed partial holders that said `NotFound` to
311 /// [`ABSENT_AFTER_NOT_FOUND`] ranges outside their coverage, each with
312 /// the time its bar ends ([`PULL_THROUGH_BAR`] after the refusal that set
313 /// it). Each one serves only the blocks it covers until then.
314 no_pull_through: HashMap<Address, Instant>,
315 /// Each probed partial holder's `NotFound` answers for ranges inside its
316 /// coverage, keyed by every discovery block the refused part of a range
317 /// touches, with the time of the last one counted. Answers within
318 /// [`crate::health::COOL_BASE`] of the last one count once, a count whose
319 /// last answer is [`PULL_THROUGH_BAR`] old starts again, and a verified
320 /// byte in the block clears it ([`SourceSet::record_covered_served`]). So
321 /// transient refusals (load shed, a stale chain view) do not add up.
322 covered_not_found: HashMap<(Address, u32), (u32, Option<Instant>)>,
323 /// Blocks each probed partial holder advertised but refused
324 /// [`ABSENT_AFTER_NOT_FOUND`] times, each with the time its drop ends
325 /// ([`PULL_THROUGH_BAR`] after the refusal that set it). Its coverage
326 /// claim for them is likely stale (evicted, or a record wider than its
327 /// store), so they leave its coverage until then, rediscovery included.
328 stale_blocks: HashMap<Address, HashMap<u32, Instant>>,
329 /// The coverage each holder with a dropped block last advertised: what
330 /// its coverage returns to as each drop ends.
331 advertised: HashMap<Address, Coverage>,
332 /// How many times in a row each probed partial holder has been barred
333 /// from pull-through. A first bar can come from transient refusals that
334 /// also read as `NotFound` (load shed, a stale chain view), so only a
335 /// holder barred again after its probe counts as one that cannot serve
336 /// the work left.
337 pull_through_bars: HashMap<Address, u32>,
338 /// Probed partial holders that refused a range outside their coverage
339 /// `Declined` (ADR 005 §Open-time refusal classes), most often for their
340 /// size ceiling, which a node applies only to a pull-through. Neither a
341 /// verified byte nor a rediscovery lifts this bar for the fetch. Each one
342 /// serves only the blocks it covers.
343 declined_pull_through: HashSet<Address>,
344 /// Providers that declined this fetch: other than a partial holder's
345 /// pull-through refusal, a `Declined` refusal, or a mid-stream voucher
346 /// rejection that no bundle heals and that names no funding reason. None
347 /// of them starts again for this blob in this fetch, and a rediscovery
348 /// does not lift it (ADR 039 §Failure handling).
349 declined: HashSet<Address>,
350 /// The reasons the declining providers gave, each once: what the
351 /// [`NoNodeWillServe`] stop names.
352 decline_reasons: Vec<DeclineReason>,
353 /// The refusal that last marked a source absent, barred it from
354 /// pull-through or declined the fetch: the cause the
355 /// [`NoSourceHasBlob`] stop carries.
356 last_absent: Option<UpstreamRefused>,
357 discovery: Option<Backoff>,
358 /// Bumped each time a source newly joins `absent`, `no_pull_through`,
359 /// `declined_pull_through` or `declined`.
360 mark_epoch: u64,
361 /// The deposit and mark epoch of the last successful discovery. A
362 /// unanimous stop needs a discovery at the current pair.
363 discovered_at: Option<(U256, u64)>,
364 /// The deposit and mark epoch of the last discovery attempt, success or
365 /// failure. Gates the unanimous-stop bypass in [`Self::wants_discovery`]
366 /// so a failing discovery still backs off instead of spinning.
367 looked_at: Option<(U256, u64)>,
368}
369
370impl<P: SourceProvider> std::fmt::Debug for SourceSet<'_, P> {
371 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372 f.debug_struct("SourceSet")
373 .field("holders", &self.holders.len())
374 .field("lanes", &self.lanes.len())
375 .finish_non_exhaustive()
376 }
377}
378
379impl<'p, P: SourceProvider> SourceSet<'p, P> {
380 /// A set over the `holders` the command already resolved for `hash`.
381 #[must_use]
382 pub fn new(
383 provider: &'p P,
384 hash: [u8; 32],
385 health: Arc<PeerHealth>,
386 holders: Vec<Holder>,
387 ) -> Self {
388 let mut set = Self {
389 provider,
390 hash,
391 health,
392 holders: Vec::new(),
393 lanes: HashMap::new(),
394 build_retry: HashMap::new(),
395 start_retry: HashMap::new(),
396 absent: HashSet::new(),
397 not_found: HashMap::new(),
398 pull_through_not_found: HashMap::new(),
399 pull_through_refused_at: HashMap::new(),
400 pull_through_served_at: HashMap::new(),
401 no_pull_through: HashMap::new(),
402 covered_not_found: HashMap::new(),
403 stale_blocks: HashMap::new(),
404 advertised: HashMap::new(),
405 pull_through_bars: HashMap::new(),
406 declined_pull_through: HashSet::new(),
407 declined: HashSet::new(),
408 decline_reasons: Vec::new(),
409 last_absent: None,
410 discovery: None,
411 mark_epoch: 0,
412 discovered_at: None,
413 looked_at: None,
414 };
415 set.merge(holders);
416 set
417 }
418
419 /// The provider seam, borrowed for as long as the set's provider lives, so
420 /// the acquire loop can run its futures while it mutates the set.
421 #[must_use]
422 pub const fn provider(&self) -> &'p P {
423 self.provider
424 }
425
426 /// The blob these sources hold.
427 #[must_use]
428 pub const fn hash(&self) -> [u8; 32] {
429 self.hash
430 }
431
432 /// The command-wide health the set records into.
433 #[must_use]
434 pub const fn health(&self) -> &Arc<PeerHealth> {
435 &self.health
436 }
437
438 /// Every known holder: the starting ones and those discovery added.
439 #[must_use]
440 pub fn holders(&self) -> &[Holder] {
441 &self.holders
442 }
443
444 /// The known holder for `provider`.
445 #[must_use]
446 pub fn holder(&self, provider: Address) -> Option<&Holder> {
447 self.holders.iter().find(|h| h.provider == provider)
448 }
449
450 /// The nearest source that may start now and is not already `running`. A
451 /// source that declined this fetch never starts again.
452 #[must_use]
453 pub fn next_to_start(
454 &self,
455 now: Instant,
456 deposit: U256,
457 running: &HashSet<Address>,
458 ) -> Option<Holder> {
459 self.holders
460 .iter()
461 .filter(|h| !running.contains(&h.provider))
462 .filter(|h| !self.declined.contains(&h.provider))
463 .filter(|h| {
464 self.build_retry
465 .get(&h.provider)
466 .is_none_or(|b| now >= b.next_at)
467 })
468 .filter(|h| {
469 self.start_retry
470 .get(&h.provider)
471 .is_none_or(|at| now >= *at)
472 })
473 .filter(|h| self.health.usable(h.provider, now, deposit))
474 .min_by(|a, b| a.rtt_ms.total_cmp(&b.rtt_ms))
475 .cloned()
476 }
477
478 /// The lane already built for `provider`.
479 #[must_use]
480 pub fn cached_lane(&self, provider: Address) -> Option<Arc<StreamCandidate<P::Source>>> {
481 self.lanes.get(&provider).cloned()
482 }
483
484 /// Drop every built lane `stale` names, so the next start of its provider
485 /// builds a new one. A swapped credential retires the lanes on the old key
486 /// this way.
487 pub(crate) fn drop_lanes(&mut self, stale: impl Fn(&StreamCandidate<P::Source>) -> bool) {
488 self.lanes.retain(|_, lane| !stale(lane));
489 }
490
491 /// Take every built lane out of the set, keyed by provider. A caller that
492 /// ran [`crate::first_open()`] on this set hands the lanes on to the set its
493 /// fetch builds, so the fetch reuses them instead of building them again.
494 pub fn take_lanes(&mut self) -> Vec<(Address, Arc<StreamCandidate<P::Source>>)> {
495 self.lanes.drain().collect()
496 }
497
498 /// Record the result of `connect` for `provider`. A failure schedules a
499 /// retry and comes back as a [`LaneBuildFault`].
500 ///
501 /// # Errors
502 ///
503 /// The build error, wrapped in [`LaneBuildFault`].
504 pub fn lane_built(
505 &mut self,
506 provider: Address,
507 built: anyhow::Result<StreamCandidate<P::Source>>,
508 now: Instant,
509 ) -> anyhow::Result<Arc<StreamCandidate<P::Source>>> {
510 match built {
511 Ok(lane) => {
512 self.build_retry.remove(&provider);
513 let lane = Arc::new(lane);
514 self.lanes.insert(provider, Arc::clone(&lane));
515 Ok(lane)
516 }
517 Err(err) => {
518 let prior = self
519 .build_retry
520 .get(&provider)
521 .copied()
522 .unwrap_or(Backoff::new(now));
523 self.build_retry
524 .insert(provider, prior.fail(now, BUILD_RETRY_BASE, BUILD_RETRY_CAP));
525 Err(anyhow::Error::new(LaneBuildFault(err)))
526 }
527 }
528 }
529
530 /// Hold `provider`'s built lane back until `until`: it found no free
531 /// stream to start again on ([`crate::LaneWiden`]). Unlike a failed
532 /// build, the wait does not grow, so the lane starts again soon after a
533 /// stream frees. Returns `true` for the first refusal since the lane last
534 /// started.
535 pub(crate) fn start_refused(&mut self, provider: Address, until: Instant) -> bool {
536 self.start_retry.insert(provider, until).is_none()
537 }
538
539 /// Clear `provider`'s start hold: its built lane took a stream.
540 pub(crate) fn start_taken(&mut self, provider: Address) {
541 self.start_retry.remove(&provider);
542 }
543
544 /// Record a refusal on an extra stream of `provider`'s lane
545 /// ([`crate::LaneWiden`]) for `range`, given at `at`, while the lane's own
546 /// worker runs or the node is already charged for this outage. The node
547 /// does not cool for it. A `NotFound` for a range outside the coverage of
548 /// a probed partial holder counts toward barring it from pull-through,
549 /// and a `Declined` refusal or an unhealed voucher rejection declines it
550 /// at once, as on its own stream. The scheduler passes only uncovered
551 /// ranges here: an extra stream's covered `NotFound` is most often the
552 /// node's per-signer live cap.
553 pub(crate) fn record_extra_refusal(
554 &mut self,
555 provider: Address,
556 err: &anyhow::Error,
557 range: LaneRange,
558 at: Instant,
559 ) {
560 if let Some(reason) = crate::fault::declining_rejection(err) {
561 self.record_declined(provider, DeclineReason::Voucher(reason), None);
562 return;
563 }
564 let Some(refused) = err.downcast_ref::<UpstreamRefused>() else {
565 return;
566 };
567 if crate::fault::says_absent(err) && !range.past_end {
568 self.record_not_found(provider, refused, Some(&range), at);
569 }
570 if matches!(refused.error(), StreamError::Declined) {
571 self.record_declined(provider, DeclineReason::Refused, Some(refused));
572 }
573 }
574
575 /// Record the fault `provider`'s lane ended with, and return its class.
576 ///
577 /// Each fault is logged where it is recorded, at warn for a fault an
578 /// operator watches for ([`Fault::Source`], [`Fault::Fatal`]) and at info
579 /// for a deposit wait or a chain-side retry (see `Fault::warns`). The line
580 /// carries the provider, the blob, the lane's `range` and the bytes of it
581 /// that landed when the lane held one, the error, and the provider's
582 /// record in the set when the fault is logged: `probed_holder` and its
583 /// `coverage` as block runs (`whole blob` with none measured, `unknown`
584 /// for a provider the set has no record of). A rediscovery can change the record after the lane
585 /// started. A fetch that recovers reports no lane fault, so this line is
586 /// the record of it.
587 #[allow(clippy::cognitive_complexity)] // Two log lines, each expanded at two levels.
588 pub fn record_fault(
589 &mut self,
590 provider: Address,
591 err: &anyhow::Error,
592 range: Option<LaneRange>,
593 now: Instant,
594 deposit: U256,
595 ) -> Fault {
596 let fault = classify(err);
597 let hash = blake3::Hash::from_bytes(self.hash).to_hex();
598 let (probed_holder, coverage) = self.holder(provider).map_or_else(
599 || (false, "unknown".to_owned()),
600 |holder| {
601 (
602 holder.probed_holder,
603 holder
604 .coverage
605 .as_ref()
606 .map_or_else(|| "whole blob".to_owned(), crate::scheduler::block_runs),
607 )
608 },
609 );
610 if let Some(LaneRange {
611 offset,
612 len,
613 landed,
614 past_end,
615 uncovered,
616 }) = range
617 {
618 crate::fault::warn_or_info!(
619 fault.warns(),
620 %provider,
621 %hash,
622 offset,
623 len,
624 landed,
625 past_end,
626 uncovered,
627 probed_holder,
628 %coverage,
629 ?fault,
630 error = %decdn_common::redact::sanitize_err_chain(err),
631 "a lane faulted; its remainder goes to the other lanes"
632 );
633 } else {
634 crate::fault::warn_or_info!(
635 fault.warns(),
636 %provider,
637 %hash,
638 probed_holder,
639 %coverage,
640 ?fault,
641 error = %decdn_common::redact::sanitize_err_chain(err),
642 "a source faulted"
643 );
644 }
645 let left_the_hash = match fault {
646 // This client's voucher, not the node's delivery: the node declines
647 // this fetch, and its peer-store record keeps no failure stamp.
648 Fault::Source => match crate::fault::declining_rejection(err) {
649 Some(reason) => {
650 self.record_declined(provider, DeclineReason::Voucher(reason), None)
651 }
652 None => self.record_source_fault(provider, err, range.as_ref(), now),
653 },
654 Fault::Fatal(_) | Fault::Unaffordable | Fault::Transient => false,
655 };
656 // A decline scopes to this hash (ADR 005 §Open-time refusal classes).
657 // The health is shared across a bundle's entries, so a node that left
658 // this hash does not cool for the others.
659 if !left_the_hash {
660 self.health.record(provider, fault, now, deposit);
661 }
662 fault
663 }
664
665 /// Hold `provider` off briefly instead of pricing it out, when `err` is a
666 /// funding refusal that arrives while a funding recovery step's top-up
667 /// settles ([`crate::RecoveryGate::settling`]): the node's chain watcher
668 /// may not have seen the new deposit yet. Returns whether it held the
669 /// source; any other fault is the caller's to record.
670 pub fn hold_while_settling(
671 &mut self,
672 provider: Address,
673 err: &anyhow::Error,
674 now: Instant,
675 ) -> bool {
676 if classify(err) != Fault::Unaffordable {
677 return false;
678 }
679 tracing::info!(
680 %provider,
681 hash = %blake3::Hash::from_bytes(self.hash).to_hex(),
682 error = %format_args!("{err:#}"),
683 "a source refused the pool's funding while a funding recovery step settles; \
684 asking it again"
685 );
686 self.health
687 .hold(provider, now + crate::recovery::SETTLE_STEP);
688 true
689 }
690
691 /// The pool spend the built lanes committed, summed.
692 #[must_use]
693 pub(crate) fn lanes_spent(&self) -> U256 {
694 self.lanes
695 .values()
696 .map(|lane| lane.ledger.committed().amount)
697 .fold(U256::ZERO, U256::saturating_add)
698 }
699
700 /// The highest deposit any built lane's pool context holds now. A lane
701 /// shares its context with sibling entries through the run's lane
702 /// registry, so a sibling's funding recovery step shows here.
703 #[must_use]
704 pub(crate) fn lanes_deposit(&self) -> U256 {
705 self.lanes
706 .values()
707 .filter_map(|lane| lane.ctx.lock().ok().map(|ctx| ctx.deposit))
708 .fold(U256::ZERO, U256::max)
709 }
710
711 /// The deposit this set is exhausted at: `deposit`, or the highest deposit
712 /// a known source is priced out at when that is higher. Bundle entries
713 /// share their sources' health, so a sibling entry's lane can price a
714 /// source out at a deposit above the one this set's own lanes report, and
715 /// before this set builds a lane at all.
716 #[must_use]
717 pub(crate) fn priced_out_at(&self, deposit: U256) -> U256 {
718 self.holders
719 .iter()
720 .filter_map(|h| match self.health.health(h.provider) {
721 crate::health::Health::Unaffordable { at_deposit } => Some(at_deposit),
722 crate::health::Health::Healthy { .. } | crate::health::Health::Cooling { .. } => {
723 None
724 }
725 })
726 .fold(deposit, U256::max)
727 }
728
729 /// Every built lane.
730 #[must_use]
731 pub(crate) fn built_lanes(&self) -> Vec<Arc<StreamCandidate<P::Source>>> {
732 self.lanes.values().cloned().collect()
733 }
734
735 /// Raise every built lane's pool context to at least `deposit`, after a
736 /// funding recovery step.
737 pub(crate) fn credit_lanes(&self, deposit: U256) {
738 for lane in self.lanes.values() {
739 let mut ctx = lane
740 .ctx
741 .lock()
742 .unwrap_or_else(std::sync::PoisonError::into_inner);
743 ctx.deposit = ctx.deposit.max(deposit);
744 }
745 }
746
747 /// Record a delivery fault of `provider`'s: a `NotFound` toward marking it
748 /// absent, a `Declined` refusal as a bar, and a failure stamp in its
749 /// peer-store record. A `Declined` refusal that removes the provider from
750 /// this hash ([`Self::record_declined`]) is not a delivery fault: it
751 /// stamps nothing, and the call returns `true`.
752 fn record_source_fault(
753 &mut self,
754 provider: Address,
755 err: &anyhow::Error,
756 range: Option<&LaneRange>,
757 now: Instant,
758 ) -> bool {
759 if crate::fault::says_absent(err)
760 && !range.is_some_and(|r| r.past_end)
761 && let Some(refused) = err.downcast_ref::<UpstreamRefused>()
762 {
763 self.record_not_found(provider, refused, range, now);
764 }
765 if let Some(refused) = err.downcast_ref::<UpstreamRefused>()
766 && matches!(refused.error(), StreamError::Declined)
767 && self.record_declined(provider, DeclineReason::Refused, Some(refused))
768 {
769 return true;
770 }
771 if let Some(holder) = self.holder(provider) {
772 self.provider.on_source_fault(holder);
773 }
774 false
775 }
776
777 /// Count a `NotFound` from `provider` for a piece below the end the fetch
778 /// knows ([`LaneRange::past_end`]). A provider that is not a probed
779 /// holder is marked absent once its count reaches
780 /// [`ABSENT_AFTER_NOT_FOUND`]. A probed holder is never marked absent.
781 /// For a partial holder, an answer for a range that holds a chunk outside
782 /// its coverage ([`LaneRange::uncovered`]) counts toward barring it from
783 /// pull-through, and one for a range wholly inside it counts toward
784 /// dropping each block the refused part of the range touches from its
785 /// coverage. To a whole holder the answer is a delivery fault only.
786 fn record_not_found(
787 &mut self,
788 provider: Address,
789 refused: &UpstreamRefused,
790 range: Option<&LaneRange>,
791 now: Instant,
792 ) {
793 let Some(holder) = self.holder(provider) else {
794 return;
795 };
796 if holder.probed_holder {
797 if holder.coverage.is_some() {
798 match range {
799 Some(range) if range.uncovered => {
800 self.record_pull_through_refusal(provider, refused, now);
801 }
802 Some(range) => self.record_covered_refusal(provider, refused, range, now),
803 None => {}
804 }
805 }
806 return;
807 }
808 let count = self.not_found.entry(provider).or_insert(0);
809 *count = count.saturating_add(1);
810 if *count < ABSENT_AFTER_NOT_FOUND {
811 return;
812 }
813 let newly = self.absent.insert(provider);
814 self.marked(newly, refused);
815 }
816
817 /// Record that `provider` declined this fetch for `reason`. A partial
818 /// holder's `Declined` refusal is most often its size ceiling, which a
819 /// node applies only when it pulls the blob through, so a partial holder
820 /// is barred from pull-through only and keeps the blocks it covers. Any
821 /// other source, and any source that rejected this client's voucher,
822 /// never starts again for this blob in this fetch. Returns whether the
823 /// provider left this blob: `false` for a partial holder's pull-through
824 /// bar.
825 fn record_declined(
826 &mut self,
827 provider: Address,
828 reason: DeclineReason,
829 refused: Option<&UpstreamRefused>,
830 ) -> bool {
831 let partial = self
832 .holder(provider)
833 .is_some_and(|holder| holder.coverage.is_some());
834 let left = !(partial && reason == DeclineReason::Refused);
835 let newly = if left {
836 self.declined.insert(provider)
837 } else {
838 self.declined_pull_through.insert(provider)
839 };
840 if !self.decline_reasons.contains(&reason) {
841 self.decline_reasons.push(reason);
842 }
843 if newly {
844 self.mark_epoch = self.mark_epoch.saturating_add(1);
845 }
846 if let Some(refused) = refused {
847 self.last_absent = Some(refused.clone());
848 }
849 left
850 }
851
852 /// Count a `NotFound` from probed partial holder `provider` for `range`,
853 /// which its coverage claims, given at `at`. The node refuses the whole
854 /// range at its first chunk it does not hold, and the refusal does not say
855 /// which chunk that is, so the count goes to every block `range` touches.
856 /// A block that reaches [`ABSENT_AFTER_NOT_FOUND`] answers leaves the
857 /// holder's coverage for [`PULL_THROUGH_BAR`]: its ranges go to the other
858 /// sources, and the holder keeps the blocks it was not refused. A
859 /// `NotFound` can also be a transient refusal, so the drop ends, and the
860 /// block's count starts again ([`Self::expire_pull_through_bars`]).
861 fn record_covered_refusal(
862 &mut self,
863 provider: Address,
864 refused: &UpstreamRefused,
865 range: &LaneRange,
866 at: Instant,
867 ) {
868 // The bytes before `landed` verified: only the part after them is refused.
869 let refused_from = range.offset.saturating_add(range.landed);
870 let refused_end = range.offset.saturating_add(range.len);
871 let until = at.checked_add(PULL_THROUGH_BAR).unwrap_or(at);
872 let mut dropped = Vec::new();
873 for block in blocks_of(refused_from, refused_end) {
874 let (count, last_at) = self
875 .covered_not_found
876 .entry((provider, block))
877 .or_insert((0, None));
878 match *last_at {
879 // One burst (the own stream and its extras refused together, or
880 // one load-shed moment) counts once.
881 Some(last) if at < last + crate::health::COOL_BASE => continue,
882 Some(last) if last + PULL_THROUGH_BAR <= at => *count = 0,
883 _ => {}
884 }
885 *count = count.saturating_add(1);
886 *last_at = Some(at);
887 if *count >= ABSENT_AFTER_NOT_FOUND
888 && self
889 .stale_blocks
890 .entry(provider)
891 .or_default()
892 .insert(block, until)
893 .is_none()
894 {
895 dropped.push(block);
896 }
897 }
898 if dropped.is_empty() {
899 return;
900 }
901 let hash = blake3::Hash::from_bytes(self.hash).to_hex();
902 tracing::info!(
903 %provider,
904 %hash,
905 blocks = ?dropped,
906 "a partial holder keeps refusing blocks its coverage claims; dropping them from \
907 its coverage for a while"
908 );
909 if let Some(coverage) = self.holder(provider).and_then(|h| h.coverage.clone()) {
910 self.advertised.entry(provider).or_insert(coverage);
911 }
912 self.narrow(provider);
913 self.marked(true, refused);
914 }
915
916 /// Record bytes `provider` verified in `ranges` (`(offset, len)`) of its
917 /// own coverage: it holds those blocks after all, so their counts of
918 /// covered refusals clear.
919 pub(crate) fn record_covered_served(&mut self, provider: Address, ranges: &[(u64, u64)]) {
920 for &(offset, len) in ranges {
921 for block in blocks_of(offset, offset.saturating_add(len)) {
922 self.covered_not_found.remove(&(provider, block));
923 }
924 }
925 }
926
927 /// Set `provider`'s coverage to what it last advertised, less the blocks
928 /// dropped from it now. A holder whose coverage is the whole blob keeps it.
929 fn narrow(&mut self, provider: Address) {
930 let Some(advertised) = self.advertised.get(&provider) else {
931 return;
932 };
933 if self.holder(provider).is_some_and(|h| h.coverage.is_none()) {
934 return;
935 }
936 let stale = self.stale_blocks.get(&provider);
937 let narrowed = without_blocks(advertised, |b| stale.is_some_and(|s| s.contains_key(&b)));
938 if let Some(holder) = self.holders.iter_mut().find(|h| h.provider == provider) {
939 holder.coverage = Some(narrowed);
940 }
941 }
942
943 /// Count a `NotFound` from probed partial holder `provider` for a range
944 /// outside its coverage, given at `at`. At [`ABSENT_AFTER_NOT_FOUND`]
945 /// answers the holder is barred from pull-through. An answer older than
946 /// the holder's last verified byte outside its coverage is stale and does
947 /// not count.
948 fn record_pull_through_refusal(
949 &mut self,
950 provider: Address,
951 refused: &UpstreamRefused,
952 at: Instant,
953 ) {
954 if self
955 .pull_through_served_at
956 .get(&provider)
957 .is_some_and(|served_at| *served_at > at)
958 {
959 return;
960 }
961 let refused_at = self.pull_through_refused_at.entry(provider).or_insert(at);
962 *refused_at = (*refused_at).max(at);
963 let count = self.pull_through_not_found.entry(provider).or_insert(0);
964 *count = count.saturating_add(1);
965 if *count < ABSENT_AFTER_NOT_FOUND {
966 return;
967 }
968 let until = at.checked_add(PULL_THROUGH_BAR).unwrap_or(at);
969 let newly = self.no_pull_through.insert(provider, until).is_none();
970 if newly {
971 let bars = self.pull_through_bars.entry(provider).or_insert(0);
972 *bars = bars.saturating_add(1);
973 }
974 self.marked(newly, refused);
975 }
976
977 /// Lift each `NotFound` bar from pull-through whose time ended by `now`,
978 /// and restart its holder's count of refusals. The holder takes one
979 /// uncovered range again as a probe; [`ABSENT_AFTER_NOT_FOUND`] more
980 /// refusals bar it again. A size-ceiling bar does not end. Each block
981 /// dropped from a holder's coverage whose time ended returns to it the
982 /// same way, with its count restarted.
983 pub fn expire_pull_through_bars(&mut self, now: Instant) {
984 let ended: Vec<Address> = self
985 .no_pull_through
986 .iter()
987 .filter(|&(_, until)| *until <= now)
988 .map(|(provider, _)| *provider)
989 .collect();
990 for provider in ended {
991 self.no_pull_through.remove(&provider);
992 self.pull_through_not_found.remove(&provider);
993 }
994 let mut restored = Vec::new();
995 for (&provider, blocks) in &mut self.stale_blocks {
996 let before = blocks.len();
997 blocks.retain(|&block, until| {
998 let keep = *until > now;
999 if !keep {
1000 self.covered_not_found.remove(&(provider, block));
1001 }
1002 keep
1003 });
1004 if blocks.len() != before {
1005 restored.push(provider);
1006 }
1007 }
1008 for provider in restored {
1009 self.narrow(provider);
1010 if self
1011 .stale_blocks
1012 .get(&provider)
1013 .is_some_and(HashMap::is_empty)
1014 {
1015 self.stale_blocks.remove(&provider);
1016 self.advertised.remove(&provider);
1017 }
1018 }
1019 }
1020
1021 /// Record `refused` as the cause of a mark that excludes a source, and
1022 /// bump the mark epoch when the mark is `newly` set.
1023 fn marked(&mut self, newly: bool, refused: &UpstreamRefused) {
1024 self.last_absent = Some(refused.clone());
1025 if newly {
1026 self.mark_epoch = self.mark_epoch.saturating_add(1);
1027 }
1028 }
1029
1030 /// Record a verified byte from `provider`: it holds the blob after all.
1031 /// A bar from pull-through stays: a partial holder serves the blocks it
1032 /// covers whether or not it serves the others.
1033 pub fn record_progress(&mut self, provider: Address) {
1034 self.absent.remove(&provider);
1035 self.not_found.remove(&provider);
1036 self.health.record_progress(provider);
1037 }
1038
1039 /// Record a byte from `provider` verified at `at` on a range outside its
1040 /// coverage: it serves such ranges by pull-through after all, so its
1041 /// count of `NotFound` answers for them clears, and so does the bar they
1042 /// set. A byte verified no later than its last such answer changes
1043 /// nothing. A bar from a `Declined` refusal stays.
1044 pub fn record_pull_through(&mut self, provider: Address, at: Instant) {
1045 let served_at = self.pull_through_served_at.entry(provider).or_insert(at);
1046 *served_at = (*served_at).max(at);
1047 if self
1048 .pull_through_refused_at
1049 .get(&provider)
1050 .is_some_and(|refused_at| *refused_at >= at)
1051 {
1052 return;
1053 }
1054 self.pull_through_not_found.remove(&provider);
1055 self.pull_through_refused_at.remove(&provider);
1056 self.no_pull_through.remove(&provider);
1057 self.pull_through_bars.remove(&provider);
1058 }
1059
1060 /// Whether `provider` is barred from pull-through: a probed partial
1061 /// holder that said `NotFound` to [`ABSENT_AFTER_NOT_FOUND`] ranges
1062 /// outside its coverage, with no such range served since and its bar not
1063 /// yet ended ([`Self::expire_pull_through_bars`]), or that refused such a
1064 /// range `Declined`. It still serves the blocks it covers and takes no
1065 /// range outside them. A discovery that reports wider coverage for it
1066 /// clears a `NotFound` bar; a `Declined` bar stays for the fetch.
1067 #[must_use]
1068 pub fn no_pull_through(&self, provider: Address) -> bool {
1069 self.no_pull_through.contains_key(&provider)
1070 || self.declined_pull_through.contains(&provider)
1071 }
1072
1073 /// Whether a discovery should run now.
1074 ///
1075 /// A unanimous set (every known source excluded or item-marked) is driven
1076 /// by the deposit/mark-epoch pair it last looked at and the discovery
1077 /// backoff alone, regardless of `running`/`uncovered`: a source that becomes startable
1078 /// again only because its delivery cooldown expired must not stop a
1079 /// unanimously-stuck set from looking for a better one. A deposit and mark
1080 /// epoch the set has not looked at yet skips the backoff once, so a
1081 /// unanimous set always gets one fresh look before it stops; once looked
1082 /// at, the normal backoff applies. Otherwise (not unanimous), a discovery
1083 /// runs when no source is running and none can start, or a missing range
1084 /// has no usable holder, and the backoff allows it.
1085 #[must_use]
1086 pub fn wants_discovery(
1087 &self,
1088 now: Instant,
1089 deposit: U256,
1090 running: usize,
1091 uncovered: bool,
1092 ) -> bool {
1093 let unanimous = self.all_excluded(deposit, false) || self.all_item_marked(false);
1094 if unanimous {
1095 if self.looked_at != Some((deposit, self.mark_epoch)) {
1096 return true;
1097 }
1098 return self.discovery.is_none_or(|b| now >= b.next_at);
1099 }
1100 let starved = running == 0 && self.next_to_start(now, deposit, &HashSet::new()).is_none();
1101 if !(starved || uncovered) {
1102 return false;
1103 }
1104 self.discovery.is_none_or(|b| now >= b.next_at)
1105 }
1106
1107 /// Record a discovery's result. New holders join; known ones update their
1108 /// coverage and RTT. An error keeps every known holder and backs off.
1109 /// Bringing in at least one genuinely new provider resets the backoff, so
1110 /// the set looks again soon rather than waiting out the doubled wait; any
1111 /// other outcome (an error, or a discovery that found nothing new) doubles
1112 /// it as usual.
1113 pub fn discovery_done(
1114 &mut self,
1115 found: anyhow::Result<Vec<Holder>>,
1116 now: Instant,
1117 deposit: U256,
1118 ) {
1119 match found {
1120 Ok(holders) => {
1121 let brought_new = holders.iter().any(|h| {
1122 self.holders
1123 .iter()
1124 .all(|known| known.provider != h.provider)
1125 });
1126 self.merge(holders);
1127 self.discovered_at = Some((deposit, self.mark_epoch));
1128 self.discovery = if brought_new {
1129 None
1130 } else {
1131 Some(self.next_discovery_backoff(now))
1132 };
1133 }
1134 Err(err) => {
1135 tracing::debug!(error = %decdn_common::redact::sanitize_err_chain(&err), "discovery failed");
1136 self.discovery = Some(self.next_discovery_backoff(now));
1137 }
1138 }
1139 self.looked_at = Some((deposit, self.mark_epoch));
1140 }
1141
1142 /// Merge holders the provider pushed ([`SourceProvider::arrivals`]). New
1143 /// holders join and known ones update their coverage and RTT, as in
1144 /// [`Self::discovery_done`]. A pushed holder is not a discovery attempt,
1145 /// so the discovery backoff is unchanged.
1146 pub fn holders_arrived(&mut self, holders: Vec<Holder>) {
1147 self.merge(holders);
1148 }
1149
1150 /// The next discovery backoff after an attempt at `now`.
1151 fn next_discovery_backoff(&self, now: Instant) -> Backoff {
1152 let prior = self.discovery.unwrap_or(Backoff {
1153 next_at: now,
1154 attempts: 0,
1155 });
1156 prior.fail(now, DISCOVERY_BASE, DISCOVERY_CAP)
1157 }
1158
1159 /// The next instant a source may start or a retry may run.
1160 #[must_use]
1161 pub fn next_wake(&self, now: Instant) -> Option<Instant> {
1162 let cooling = self
1163 .holders
1164 .iter()
1165 .filter_map(|h| self.health.cooling_until(h.provider, now));
1166 let builds = self
1167 .build_retry
1168 .values()
1169 .map(|b| b.next_at)
1170 .chain(self.start_retry.values().copied())
1171 .filter(|t| *t > now);
1172 let discovery = self.discovery.map(|b| b.next_at).filter(|t| *t > now);
1173 let bars = self.no_pull_through.values().copied().filter(|t| *t > now);
1174 cooling.chain(builds).chain(discovery).chain(bars).min()
1175 }
1176
1177 /// The stop for an exhausted candidate set (ADR 039 §Failure handling), by
1178 /// the first rule that applies:
1179 ///
1180 /// 1. Every known source declined this fetch: [`NoNodeWillServe`] at once,
1181 /// with no fresh discovery.
1182 /// 2. After a discovery at this deposit and mark epoch, every known source
1183 /// is priced out or can serve none of the work left, with at least one
1184 /// priced out: [`NoAffordableSource`]. The caller runs its funding
1185 /// recovery step ([`crate::RecoveryGate::step`]) before it ends the fetch
1186 /// "funding needed".
1187 /// 3. After such a discovery, every known source can serve none of the
1188 /// work left: [`NoSourceHasBlob`], not-found.
1189 ///
1190 /// A source can serve none of the work left when it says the blob is
1191 /// absent, when it declined this fetch, or when it is barred from
1192 /// pull-through ([`Self::no_pull_through`]) and `only_uncovered_left` says
1193 /// no work left lies inside the coverage of a barred holder.
1194 #[must_use]
1195 pub fn exhausted(&self, deposit: U256, only_uncovered_left: bool) -> Option<anyhow::Error> {
1196 if self.all_declined(only_uncovered_left) {
1197 return Some(anyhow::Error::new(NoNodeWillServe {
1198 reasons: self.decline_reasons.clone(),
1199 }));
1200 }
1201 if self.holders.is_empty() || self.discovered_at != Some((deposit, self.mark_epoch)) {
1202 return None;
1203 }
1204 if self.all_excluded(deposit, only_uncovered_left) {
1205 return Some(anyhow::Error::new(NoAffordableSource { deposit }));
1206 }
1207 if self.all_item_marked(only_uncovered_left) {
1208 return Some(match &self.last_absent {
1209 Some(refused) => anyhow::Error::new(refused.clone()).context(NoSourceHasBlob),
1210 None => anyhow::Error::new(NoSourceHasBlob),
1211 });
1212 }
1213 None
1214 }
1215
1216 /// Whether every known source declined this fetch: a `Declined` refusal
1217 /// or an unhealed voucher rejection, or, with `only_uncovered_left`, a
1218 /// partial holder's `Declined` pull-through refusal.
1219 fn all_declined(&self, only_uncovered_left: bool) -> bool {
1220 !self.holders.is_empty()
1221 && self.holders.iter().all(|h| {
1222 self.declined.contains(&h.provider)
1223 || (only_uncovered_left && self.declined_pull_through.contains(&h.provider))
1224 })
1225 }
1226
1227 /// Whether every known source can serve none of the work left
1228 /// ([`Self::cannot_serve`]).
1229 fn all_item_marked(&self, only_uncovered_left: bool) -> bool {
1230 !self.holders.is_empty()
1231 && self
1232 .holders
1233 .iter()
1234 .all(|h| self.cannot_serve(h.provider, only_uncovered_left))
1235 }
1236
1237 /// Whether every known source is priced out at `deposit` or can serve
1238 /// none of the work left, with at least one priced out that could serve
1239 /// the work at a higher deposit: a top-up cannot help a source that
1240 /// declined this blob or holds none of the work left. A set
1241 /// that is unanimous only on the latter is handled by
1242 /// [`Self::all_item_marked`] instead, so this is the affordability verdict
1243 /// even when it is mixed with absence marks.
1244 fn all_excluded(&self, deposit: U256, only_uncovered_left: bool) -> bool {
1245 !self.holders.is_empty()
1246 && self.holders.iter().any(|h| {
1247 self.is_unaffordable(h.provider, deposit)
1248 && !self.cannot_serve(h.provider, only_uncovered_left)
1249 })
1250 && self.holders.iter().all(|h| {
1251 self.cannot_serve(h.provider, only_uncovered_left)
1252 || self.is_unaffordable(h.provider, deposit)
1253 })
1254 }
1255
1256 /// Whether `provider` can serve none of the work left: it says the blob is
1257 /// absent, it declined this fetch, or `only_uncovered_left` says
1258 /// no work left lies inside the coverage of a barred holder and the holder
1259 /// is barred for good: by a `Declined` refusal, or by `NotFound` again
1260 /// after the probe that followed its first bar.
1261 fn cannot_serve(&self, provider: Address, only_uncovered_left: bool) -> bool {
1262 let barred_for_good = self.declined_pull_through.contains(&provider)
1263 || (self.no_pull_through.contains_key(&provider)
1264 && self
1265 .pull_through_bars
1266 .get(&provider)
1267 .is_some_and(|bars| *bars >= 2));
1268 self.absent.contains(&provider)
1269 || self.declined.contains(&provider)
1270 || (only_uncovered_left && barred_for_good)
1271 }
1272
1273 /// Whether `provider`'s health says it is priced out at `deposit`.
1274 fn is_unaffordable(&self, provider: Address, deposit: U256) -> bool {
1275 matches!(
1276 self.health.health(provider),
1277 crate::health::Health::Unaffordable { at_deposit } if deposit <= at_deposit
1278 )
1279 }
1280
1281 /// Add new holders and update known ones. A provider a probe now reports
1282 /// as a holder loses its absent mark and its `NotFound` count. A probe
1283 /// that reports a block the holder did not cover before also clears its
1284 /// pull-through count and bar: the block it refused may be one it now
1285 /// holds.
1286 ///
1287 /// A block a holder's coverage claimed but it refused
1288 /// ([`Self::record_covered_refusal`]) stays out of the coverage a probe
1289 /// reports again until its drop ends: the record that claimed it is the
1290 /// likely stale one.
1291 ///
1292 /// A probe that reports a holder as whole replaces its partial record, so
1293 /// it also ends every block drop and count of covered refusals it had.
1294 fn merge(&mut self, holders: Vec<Holder>) {
1295 for mut holder in holders {
1296 if holder.coverage.is_none() {
1297 let provider = holder.provider;
1298 self.stale_blocks.remove(&provider);
1299 self.advertised.remove(&provider);
1300 self.covered_not_found.retain(|(p, _), _| *p != provider);
1301 }
1302 if let (Some(coverage), Some(stale)) = (
1303 holder.coverage.as_ref(),
1304 self.stale_blocks.get(&holder.provider),
1305 ) {
1306 self.advertised.insert(holder.provider, coverage.clone());
1307 holder.coverage = Some(without_blocks(coverage, |b| stale.contains_key(&b)));
1308 }
1309 if holder.probed_holder {
1310 self.absent.remove(&holder.provider);
1311 self.not_found.remove(&holder.provider);
1312 let wider = self.holder(holder.provider).is_some_and(|known| {
1313 covers_more(holder.coverage.as_ref(), known.coverage.as_ref())
1314 });
1315 if wider {
1316 self.pull_through_not_found.remove(&holder.provider);
1317 self.pull_through_refused_at.remove(&holder.provider);
1318 self.no_pull_through.remove(&holder.provider);
1319 self.pull_through_bars.remove(&holder.provider);
1320 }
1321 }
1322 match self
1323 .holders
1324 .iter_mut()
1325 .find(|h| h.provider == holder.provider)
1326 {
1327 Some(known) => *known = holder,
1328 None => self.holders.push(holder),
1329 }
1330 }
1331 }
1332}
1333
1334/// The discovery blocks the byte range `[start, end)` touches; none for an
1335/// empty range.
1336fn blocks_of(start: u64, end: u64) -> std::ops::Range<u32> {
1337 if start >= end {
1338 return 0..0;
1339 }
1340 let block_bytes = decdn_protocol::discovery_block_bytes();
1341 let block_of = |offset: u64| u32::try_from(offset / block_bytes).unwrap_or(u32::MAX);
1342 block_of(start)..block_of(end - 1).saturating_add(1)
1343}
1344
1345/// `coverage` less the blocks `dropped` names.
1346fn without_blocks(coverage: &Coverage, dropped: impl Fn(u32) -> bool) -> Coverage {
1347 let num_blocks = coverage
1348 .covered_blocks()
1349 .last()
1350 .map_or(0, |b| b.saturating_add(1));
1351 Coverage::from_block_indices(
1352 num_blocks,
1353 coverage.covered_blocks().filter(|&b| !dropped(b)),
1354 )
1355}
1356
1357/// Whether coverage `new` holds a block `old` does not. `None` is the whole
1358/// blob.
1359fn covers_more(new: Option<&Coverage>, old: Option<&Coverage>) -> bool {
1360 match (new, old) {
1361 (_, None) => false,
1362 (None, Some(_)) => true,
1363 (Some(new), Some(old)) => new.covered_blocks().any(|block| !old.covers(block)),
1364 }
1365}
1366
1367/// A [`SourceProvider`] over a fixed set of prebuilt lanes: for SDK callers
1368/// that dial their own providers, and for tests. Discovery returns the same
1369/// set; each lane is handed out once. So a static set serves one blob's
1370/// [`SourceSet`]: a [`crate::Downloader`] over it fetches one target, and a
1371/// second target finds no lane to build.
1372pub struct StaticSources<S> {
1373 holders: Vec<Holder>,
1374 lanes: std::sync::Mutex<HashMap<Address, StreamCandidate<S>>>,
1375}
1376
1377impl<S> std::fmt::Debug for StaticSources<S> {
1378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1379 f.debug_struct("StaticSources")
1380 .field("holders", &self.holders.len())
1381 .finish_non_exhaustive()
1382 }
1383}
1384
1385impl<S> StaticSources<S> {
1386 /// Wrap `candidates`, keyed by each one's on-chain provider. Each one is
1387 /// a probed holder ([`Holder::probed_holder`]).
1388 ///
1389 /// # Errors
1390 ///
1391 /// No candidate at all (its discovery could never find one), or two
1392 /// candidates naming the same provider.
1393 pub fn new(candidates: Vec<StreamCandidate<S>>) -> anyhow::Result<Self> {
1394 anyhow::ensure!(
1395 !candidates.is_empty(),
1396 "static sources need at least one candidate to fetch from"
1397 );
1398 let mut holders = Vec::with_capacity(candidates.len());
1399 let mut lanes = HashMap::with_capacity(candidates.len());
1400 for (rank, candidate) in candidates.into_iter().enumerate() {
1401 let provider = candidate
1402 .ctx
1403 .lock()
1404 .unwrap_or_else(std::sync::PoisonError::into_inner)
1405 .provider;
1406 let rtt_ms = f64::from(u32::try_from(rank).unwrap_or(u32::MAX));
1407 holders.push(Holder {
1408 provider,
1409 coverage: candidate.coverage.clone(),
1410 rtt_ms,
1411 probed_holder: true,
1412 });
1413 anyhow::ensure!(
1414 lanes.insert(provider, candidate).is_none(),
1415 "two candidates name provider {provider}"
1416 );
1417 }
1418 Ok(Self {
1419 holders,
1420 lanes: std::sync::Mutex::new(lanes),
1421 })
1422 }
1423
1424 /// The holders, in the order the candidates were given.
1425 #[must_use]
1426 pub fn holders(&self) -> Vec<Holder> {
1427 self.holders.clone()
1428 }
1429
1430 /// The same sources, with no probe reporting any of them as a holder:
1431 /// pull-through targets.
1432 #[cfg(test)]
1433 #[must_use]
1434 pub(crate) fn not_probed(mut self) -> Self {
1435 for holder in &mut self.holders {
1436 holder.probed_holder = false;
1437 }
1438 self
1439 }
1440}
1441
1442impl<S: BlobSource> SourceProvider for StaticSources<S> {
1443 type Source = S;
1444
1445 fn discover(&self, _hash: [u8; 32]) -> SourceFuture<'_, Vec<Holder>> {
1446 let holders = self.holders.clone();
1447 Box::pin(async move { Ok(holders) })
1448 }
1449
1450 fn connect<'a>(&'a self, holder: &'a Holder) -> SourceFuture<'a, StreamCandidate<S>> {
1451 let taken = self
1452 .lanes
1453 .lock()
1454 .unwrap_or_else(std::sync::PoisonError::into_inner)
1455 .remove(&holder.provider)
1456 .ok_or_else(|| anyhow::anyhow!("no static lane for {}", holder.provider));
1457 Box::pin(async move { taken })
1458 }
1459}
1460
1461#[cfg(test)]
1462mod tests;