Skip to main content

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;