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;