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