Skip to main content

decdn_node/
selection.rs

1//! Provider selection algorithm (ADR 001 § Node Selection Algorithm + ADR 008
2//! § Tie-Breaking).
3//!
4//! `rank_candidates` returns the input list ordered best-first (lowest score
5//! first) with the three-tier tie-breaker applied. The caller iterates the
6//! result in order and stops after `MAX_PROVIDER_ATTEMPTS` failed providers.
7
8use rand::RngExt;
9use std::collections::HashSet;
10use std::time::Duration;
11
12use decdn_protocol::Coverage;
13
14use crate::dht::lookup::{DEFAULT_ROUND_TIMEOUT, MAX_LOOKUP_ROUNDS};
15
16/// Maximum providers to attempt before reporting a fetch failure to the
17/// caller (issue #322 — "max 3 provider attempts before returning error").
18pub const MAX_PROVIDER_ATTEMPTS: usize = 3;
19
20/// Per-candidate probe timeout. Because candidates are probed concurrently, this also
21/// bounds the whole probe-collection phase. The budget covers connection setup plus one
22/// unpaid probe request/response. `probe_once` dials a fresh connection every call, and
23/// the protocol has no 0-RTT path, so even a resumable session pays a handshake round trip
24/// before the request goes out — every probe costs two RTTs, not one. At the 250-300 ms
25/// inter-continental RTTs this budget is sized against, that puts the furthest candidates
26/// at or past this ceiling. Dropping them is the intended trade (a slow candidate must not
27/// burn the caller's miss-latency budget), but this is the first term to revisit if
28/// distant-region selection looks too sparse. Both the figure and the trade are ADR 001
29/// § Probe response collection.
30///
31/// What is left of the budget after the exchange goes to `probe_once`'s wait for hole
32/// punching to select a direct path. A candidate whose answer arrives in time is kept even
33/// when that wait runs out: it ranks on its exchange RTT, which can be the relay detour, and
34/// that RTT still feeds the ADR 030 region-latency penalty.
35///
36/// Lives here, beside the deadline arithmetic that has to budget for it, rather than in
37/// `node_origin` where it is used (#1145 review). Every term of [`outer_pull_deadline`] is
38/// then visible in one file, which is what stops the next one from being guessed.
39pub const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
40
41/// How many blob-holding candidates a SINGLE-SOURCE probe round collects before
42/// it stops waiting on the rest. [`PROBE_TIMEOUT`] stays the ceiling — a sparse
43/// round that never reaches this count still waits it out — but when this many
44/// good providers have already answered, the round selects among them instead of
45/// waiting the full window for a straggler or a dead peer that will only time
46/// out. The count leaves at least one alternate in hand for per-blob failover
47/// while keeping cold-miss discovery latency at the speed of the fastest good
48/// answers rather than the ceiling. ADR 001 § Probe response collection.
49///
50/// Equal to [`MAX_PROVIDER_ATTEMPTS`] on purpose: the single-source pull loop
51/// tries at most that many providers, so collecting that many viable candidates
52/// already fills the failover budget — waiting for more only serves stragglers
53/// the loop would never reach.
54///
55/// This fixed count is CORRECT only when one working provider is enough — the
56/// buffered single-source pull. The ranged-drive assembly path (#1506, ADR 039)
57/// needs a candidate set whose coverage UNION spans the requested range, not a
58/// fixed count: three responders that all hold discovery block 0 do not cover a
59/// three-block blob, yet stopping at this count would drop the block-1/block-2
60/// holders in the same fanout. That path gathers by coverage union instead (see
61/// `node_origin::probe_and_rank`'s `ProbeGather::CoverageUnion`), bounded only by
62/// the probe fanout.
63pub const PROBE_EARLY_EXIT_CANDIDATES: usize = MAX_PROVIDER_ATTEMPTS;
64
65/// One-time headroom added on top of the `MAX_PROVIDER_ATTEMPTS` sequential per-candidate
66/// costs when computing the outer pull-through deadline (#859). It covers the *one-time*
67/// `discover → probe → rank` overhead: work that runs under the outer deadline but does not
68/// scale with the attempt count.
69///
70/// **Derived, not chosen** (#1145 review). A flat constant would be smaller than the thing
71/// it is named for:
72///
73/// - probing is concurrent, so it costs one [`PROBE_TIMEOUT`] — 500 ms; but
74/// - discovery is `find_providers`, whose rounds are each bounded by
75///   [`DEFAULT_ROUND_TIMEOUT`] (8 s) and whose round count is capped by
76///   [`MAX_LOOKUP_ROUNDS`] (4).
77///
78/// At those defaults, four discovery rounds plus the probe phase can cost 32.5 s. A flat
79/// budget short of that leaves the outer deadline short of what the fetch can actually spend,
80/// so the `tokio::time::timeout` around `discover → probe → rank → pull` can fire while
81/// candidate #3 is still in its stall window — the #859 fallback starvation this formula
82/// exists to prevent, reachable at the defaults.
83///
84/// The fix is in two halves, and both are necessary: [`MAX_LOOKUP_ROUNDS`] makes discovery's
85/// worst case finite, and this makes it a TERM. An unbounded cost cannot be budgeted for by
86/// any constant, however generous.
87pub const PULL_THROUGH_OUTER_SLACK: Duration =
88    PROBE_TIMEOUT.saturating_add(DEFAULT_ROUND_TIMEOUT.saturating_mul(MAX_LOOKUP_ROUNDS));
89
90/// How long a pull is willing to WAIT on a buyer-channel open before giving up on
91/// that candidate — not how long the open itself is allowed to take (#1143).
92///
93/// The `openChannel` runs in a detached task that owns the tx, so a
94/// caller that stops waiting costs nothing: the open continues, the channel lands,
95/// and the next pull to that provider reuses it. What the caller buys by waiting is
96/// only the chance to use the channel on *this* pull. That makes a short budget the
97/// right trade — a cache miss must fall through to another candidate in seconds,
98/// while an `openChannel` may legitimately need minutes to mine on a slow L2.
99///
100/// Deliberately much smaller than `cache.node_pull_timeout_sec`: the channel open, the
101/// stream open, and the streaming stage are SEQUENTIAL stages of one candidate attempt,
102/// and [`outer_pull_deadline`] has to cover all three for every candidate.
103pub const CHANNEL_OPEN_CALLER_BUDGET: Duration = Duration::from_secs(5);
104
105/// Outer deadline for a node-to-node pull-through, derived from the two configured
106/// per-candidate budgets: the stream-open timeout (`cache.node_pull_timeout_sec`) and
107/// the streaming throughput-floor window (`cache.node_pull_stall_window_sec`).
108///
109/// The delivery handler wraps the whole `discover → probe → rank → pull` fetch
110/// in a single `tokio::time::timeout`. For the sequential `MAX_PROVIDER_ATTEMPTS`
111/// fallback loop to actually reach candidates #2..N when candidate #1 *stalls*,
112/// this outer deadline must strictly exceed the sum of all per-candidate costs —
113/// otherwise both clocks expire
114/// together and the outer timeout cancels the whole fetch at the instant candidate
115/// #1's own timeout fires, killing the fallback.
116///
117/// # What one candidate actually costs
118///
119/// THREE sequential bounded stages:
120///
121/// 1. the **channel open** — [`CHANNEL_OPEN_CALLER_BUDGET`] (#1143); then
122/// 2. the **stream open** — connect → handshake → verified `StreamResponse`,
123///    bounded by `per_candidate` (#1134); then
124/// 3. **streaming**, which for a peer that opens honestly and then goes SILENT costs
125///    one full throughput-floor window (`stall`) before the pull gives up on it (#1797).
126///
127/// So the worst case for a candidate is `CHANNEL_OPEN_CALLER_BUDGET + per_candidate + stall`,
128/// and that is what this must budget `MAX_PROVIDER_ATTEMPTS` of, plus
129/// [`PULL_THROUGH_OUTER_SLACK`] of one-time discovery overhead.
130///
131/// Every stage has to be a term here. Budgeting only `per_candidate` would let a cold
132/// cache against a slow L2 burn the whole deadline on candidates #1 and #2 and never
133/// dial #3. Covering the channel open but not the stall window would leave the same hole
134/// for a peer that goes silent mid-stream instead of failing to open — and a worse one,
135/// because `stall` is operator-tunable: at `node_pull_stall_window_sec = 120` a single
136/// silent candidate outlasts a two-term deadline on its own. Taking `stall` as an
137/// argument keeps this deadline in step with `node_pull_stall_window_sec`.
138///
139/// # What this deadline does not bound
140///
141/// A candidate's streaming stage is bounded by *inactivity* rather than a wall clock,
142/// because a wall clock over the bytes caps the blob size a node can pull through
143/// (#1134). The `stall` term above is therefore the cost of a candidate that **stops**
144/// — not a ceiling on one that keeps going. A candidate that is **slow but progressing**
145/// resets that clock on every byte and can consume the whole outer deadline on its own.
146///
147/// That is correct: it is succeeding, and falling through mid-stream would restart the
148/// download from zero against another peer, throwing away the bytes already paid for.
149/// On expiry the foreground request gives up with a clean miss and nothing continues in
150/// the background — there is no detached warm, so a pull only ever runs while a
151/// client is waiting, and the blob is re-acquired on the next real client request.
152///
153/// So this bounds how long a **client** waits; no acquisition outlives that wait.
154#[must_use]
155pub fn outer_pull_deadline(per_candidate: Duration, stall: Duration) -> Duration {
156    let attempts = u32::try_from(MAX_PROVIDER_ATTEMPTS).unwrap_or(u32::MAX);
157    CHANNEL_OPEN_CALLER_BUDGET
158        .saturating_add(per_candidate)
159        .saturating_add(stall)
160        .saturating_mul(attempts)
161        .saturating_add(PULL_THROUGH_OUTER_SLACK)
162}
163
164/// Reputation floor in the score denominator (ADR 001).
165const REPUTATION_FLOOR: f32 = 0.1;
166
167/// Reputation ceiling in the score denominator — the top of
168/// [`Candidate::reputation`]'s documented domain (#1458). Named rather than
169/// written inline so the code, the doc prose, and the tests that pin it move
170/// together, the way [`REPUTATION_FLOOR`] already does.
171const REPUTATION_CEILING: f32 = 1.0;
172
173/// Score-equivalence threshold for tie-break activation (ADR 001 — "scores
174/// within 1% of each other").
175const TIE_THRESHOLD: f64 = 0.01;
176
177/// A candidate provider produced by content discovery, ready to be ranked.
178#[derive(Debug, Clone)]
179pub struct Candidate {
180    /// Iroh `NodeId` (Ed25519 public key) of the candidate.
181    pub node_id: [u8; 32],
182    /// Quoted price in token base units per MB, from the most recent
183    /// `ProbeResponse` (`decdn_protocol::message::ProbeResponse::rate_per_mb`).
184    pub rate_per_mb: u64,
185    /// Round-trip latency observed during probing.
186    pub rtt_ms: u32,
187    /// Local reputation in `[0.0, 1.0]` from the reputation engine.
188    pub reputation: f32,
189    /// ISO 3166-1 alpha-2 region self-attested by the peer on-chain (ADR 030),
190    /// resolved from the `CapacityBond` registry projection. Used by the
191    /// geo-diversity tie-break tier.
192    pub region: String,
193    /// On-chain stake in TOKEN base units; higher stake wins the stake
194    /// tie-break tier. `0` means *known to hold no bond* — there is no
195    /// "not looked up" state, by design (#1470).
196    ///
197    /// This type has no *encoding* for a failed read — not the same as
198    /// preventing one. A chain read succeeds for some peers and fails for
199    /// others, and a mixed population is the steady state for an RPC read, not
200    /// an edge case; folding a failure to `0` here would silently sink a
201    /// well-staked peer below a provably-unbonded one. So the lookup layer must
202    /// resolve the failure where it is still visible: retry it, or drop the
203    /// candidate. **Nothing in the type enforces that today** — a future caller
204    /// can still write `stake: 0` on a failed read and it will compile. The
205    /// constructor-level enforcement (a `Stake` obtainable only from a
206    /// successful read, with `Candidate`'s fields made private) lands with the
207    /// lookup itself; see #1470. Nothing populates this yet — on-chain
208    /// integration is deferred; see ADR 019 for the capacity-bond interface
209    /// that will.
210    pub stake: u64,
211    /// Which discovery blocks (`decdn_protocol::coverage`) this candidate is
212    /// confirmed to hold, per its fresh `ProbeResponseExt.coverage` (#1506).
213    /// This is the PROBE-confirmed value, never the stale DHT-lookup hint —
214    /// discovery's ranking and selection consume only what a live probe just
215    /// verified. Ranking itself ignores this field; it rides alongside the
216    /// rank/RTT/rate fields for a range-aware caller to read after selection.
217    pub coverage: Coverage,
218    /// The blob size the candidate reported on that probe
219    /// (`ProbeResponseExt.total_bytes`), when it knows it. UNSIGNED: a sizing
220    /// hint, never a commitment. The pull leg cuts its first upstream open from
221    /// it. When that open runs past the blob's end, the pull leg opens the whole
222    /// blob instead; when the signed size differs from the hint, it drops the
223    /// pull.
224    pub total_bytes_hint: Option<u64>,
225}
226
227/// A candidate paired with its computed selection score. Lower score is better.
228#[derive(Debug, Clone)]
229pub struct RankedCandidate {
230    /// The peer that was scored.
231    pub candidate: Candidate,
232    /// Its selection score. Lower is better.
233    pub score: f64,
234}
235
236/// Compute the unified selection score (ADR 001). Lower is better.
237///
238/// `score = rate_per_mb × max(rtt_ms, 1) × (1 / clamp(reputation, 0.1, 1.0)²)`
239///
240/// The RTT has a floor of 1 ms. A probe faster than 1 ms truncates to
241/// `rtt_ms = 0`, and a zero factor would make the score 0 for any rate: two
242/// such peers would tie whatever they quote, and a dear one would outrank every
243/// cheaper peer at 1 ms or more. With the floor, sub-millisecond peers rank on
244/// rate and reputation, and only `rate_per_mb = 0` gives a score of 0.
245///
246/// ADR 001 § Node Selection Algorithm states the denominator as
247/// `max(reputation, 0.1)`; the upper bound here is a defensive extension
248/// (#1458), a no-op for any input inside the ADR's `[0.0, 1.0]` domain.
249///
250/// Reputation is clamped between [`REPUTATION_FLOOR`] and
251/// [`REPUTATION_CEILING`] before squaring — both ends, and both ends matter.
252/// The floor prevents division by zero and caps the worst-case penalty
253/// multiplier at 100×; the ceiling stops an out-of-domain value from buying an
254/// unearned *bonus*. Without it, `reputation = 10.0` divides the score by 100
255/// and outranks a perfect-reputation peer by 100×, and `f32::INFINITY` yields
256/// score `0.0` — which [`tie_group_end`] treats as a tie group of one, so it
257/// takes the top slot outright, beyond the reach of every tie-break tier.
258#[allow(clippy::cast_precision_loss)]
259// f64 has 53-bit mantissa; ULP-level imprecision on huge u64 rates does not
260// affect ordering decisions here.
261fn compute_score(rate_per_mb: u64, rtt_ms: u32, reputation: f32) -> f64 {
262    let rate = rate_per_mb as f64;
263    let rtt = f64::from(rtt_ms.max(1));
264    // Clamp in f32 (input's domain), then promote once for the f64 score
265    // arithmetic. Promoting first would let `f32(0.1)` slip just above the
266    // floor (it rounds to ~0.10000000149f64), an unintuitive boundary.
267    //
268    // `.max().min()` rather than `.clamp(..)`, and floor FIRST: `f32::max` and
269    // `f32::min` are documented to ignore NaN and return the other operand, so
270    // a NaN reputation lands on the floor. `f32::clamp` propagates NaN instead,
271    // and `.min()` first would send NaN to the CEILING — either way a garbage
272    // reputation becomes a perfect peer. Hence the waiver below: taking clippy's
273    // `manual_clamp` suggestion would turn a NaN-total function into a
274    // NaN-propagating one and fail `score_nan_reputation_clamps_to_floor`.
275    #[allow(clippy::manual_clamp)]
276    let rep = f64::from(reputation.max(REPUTATION_FLOOR).min(REPUTATION_CEILING));
277    rate * rtt / (rep * rep)
278}
279
280/// Rank candidates by selection score, lowest (best) first.
281///
282/// Within-1%-score tie groups are reordered by the three-tier ADR 008
283/// tie-breaker (geo → stake → random). The random tier uses a fresh
284/// thread-local RNG; tests inside this module use the private
285/// `rank_candidates_with_rng` variant for determinism.
286pub fn rank_candidates(candidates: Vec<Candidate>) -> Vec<RankedCandidate> {
287    let mut rng = rand::rng();
288    rank_candidates_with_rng(candidates, &mut rng)
289}
290
291/// Variant of [`rank_candidates`] taking an explicit RNG. Internal — used by
292/// tests for deterministic random tie-breaking.
293fn rank_candidates_with_rng(
294    candidates: Vec<Candidate>,
295    rng: &mut impl rand::Rng,
296) -> Vec<RankedCandidate> {
297    let mut ranked: Vec<RankedCandidate> = candidates
298        .into_iter()
299        .map(|c| RankedCandidate {
300            score: compute_score(c.rate_per_mb, c.rtt_ms, c.reputation),
301            candidate: c,
302        })
303        .collect();
304    ranked.sort_by(|a, b| a.score.total_cmp(&b.score));
305    apply_tiebreaker(&mut ranked, rng);
306    ranked
307}
308
309/// Walk the score-sorted slice and emit each within-1% tie group using the
310/// ADR 008 § Tie-Breaking tiers. Geo diversity is scoped to the current tie
311/// group: candidates within a single within-1% group are spread across
312/// regions, but the tracker is reset between groups so unrelated tie groups
313/// don't bias each other's geo tier.
314fn apply_tiebreaker(ranked: &mut Vec<RankedCandidate>, rng: &mut impl rand::Rng) {
315    let mut output: Vec<RankedCandidate> = Vec::with_capacity(ranked.len());
316
317    while !ranked.is_empty() {
318        // Geo diversity is scoped to the current tie group: candidates within
319        // a single within-1% group are spread across regions, but the tracker
320        // is reset between groups so unrelated tie groups don't bias each
321        // other's geo tier. ADR 008 § Tie-Breaking lists the three tiers;
322        // per-group scoping is this implementation's interpretation of
323        // "within a tie".
324        let mut group_regions: HashSet<String> = HashSet::new();
325        let end = tie_group_end(ranked, 0);
326        // tie_group_end's loop guard bounds `end` at `ranked.len()`, so the
327        // min() is belt-and-suspenders to keep `Vec::drain` from panicking
328        // even on a future invariant break.
329        let split_at = end.min(ranked.len());
330        debug_assert_eq!(
331            end,
332            split_at,
333            "tie_group_end exceeded len: {end} > {}",
334            ranked.len()
335        );
336        // Drain (move) the front tie group out of `ranked` into `group` —
337        // avoids the per-element clone of `to_vec()` on the slice.
338        let mut group: Vec<RankedCandidate> = ranked.drain(..split_at).collect();
339        while !group.is_empty() {
340            let pick_idx = pick_best_in_group(&group, &group_regions, rng);
341            // pick_best_in_group always returns a valid index when the slice
342            // is non-empty (loop guard above ensures non-emptiness). The safe
343            // fallback exists only to satisfy clippy::indexing_slicing; the
344            // debug_assert surfaces any future invariant break in tests. Order
345            // in `group` does not matter — pick_best_in_group rescans from
346            // scratch each iteration — so swap_remove is safe and O(1).
347            debug_assert!(
348                pick_idx < group.len(),
349                "pick_best_in_group returned {pick_idx} for group of len {}",
350                group.len()
351            );
352            let pick = if pick_idx < group.len() {
353                group.swap_remove(pick_idx)
354            } else {
355                group.swap_remove(0)
356            };
357            group_regions.insert(pick.candidate.region.clone());
358            output.push(pick);
359        }
360    }
361    *ranked = output;
362}
363
364/// Return the index in `group` of the candidate that wins the tie under the
365/// geo (relative to `emitted_regions`) → stake → random tiers.
366///
367/// Filters a single index pool in place across the three tiers, so the
368/// function allocates exactly one Vec per call regardless of group size or
369/// tier depth.
370///
371/// **INVARIANT:** the returned index is independent of element order in
372/// `group`. The caller (`apply_tiebreaker`) relies on this to use `swap_remove`
373/// for O(1) removal, which reorders surviving elements. Any future
374/// optimization that reuses state across calls must preserve this property.
375fn pick_best_in_group(
376    group: &[RankedCandidate],
377    emitted_regions: &HashSet<String>,
378    rng: &mut impl rand::Rng,
379) -> usize {
380    debug_assert!(
381        !group.is_empty(),
382        "pick_best_in_group called with empty group"
383    );
384    if group.is_empty() {
385        return 0;
386    }
387    // `pool` is constructed as `0..group.len()` and only ever shrunk via
388    // `retain`, so every `usize` it holds is a valid `group` index. This
389    // makes the `group.get(*i)` calls below provably `Some` and the
390    // final `unwrap_or(0)` provably unreachable in release builds.
391    let mut pool: Vec<usize> = (0..group.len()).collect();
392
393    // Tier 1: prefer regions not in `emitted_regions`. If at least one
394    // candidate in the pool is in an unseen region, restrict to those.
395    let any_unseen = pool.iter().any(|i| {
396        group
397            .get(*i)
398            .is_some_and(|r| !emitted_regions.contains(&r.candidate.region))
399    });
400    if any_unseen {
401        pool.retain(|i| {
402            group
403                .get(*i)
404                .is_some_and(|r| !emitted_regions.contains(&r.candidate.region))
405        });
406    }
407
408    // Tier 2: higher stake wins. Every value here is meant to be a completed
409    // observation — a failed on-chain read must be resolved at the lookup layer
410    // (retried, or the candidate dropped) rather than reaching
411    // `Candidate.stake` — so this tier can rank on the numbers alone. See the
412    // field doc for why that boundary matters, and for the fact that nothing
413    // enforces it yet (#1470).
414    //
415    // Uniformly a no-op today: every production construction site passes `0`
416    // because on-chain integration is deferred (ADR 003 § Node Registry for the
417    // contract surface, ADR 019 for the capacity-bond interface that will
418    // populate it).
419    let max_stake = pool
420        .iter()
421        .filter_map(|i| group.get(*i).map(|r| r.candidate.stake))
422        .max();
423    if let Some(top) = max_stake {
424        pool.retain(|i| group.get(*i).map(|r| r.candidate.stake) == Some(top));
425    }
426
427    // Tier 3: random tie-break. Uniformly pick from the remaining pool.
428    if pool.is_empty() {
429        0
430    } else {
431        let idx = rng.random_range(0..pool.len());
432        // pool.get(idx) is always Some — random_range stays within 0..pool.len().
433        // The unwrap_or(0) keeps us off the indexing_slicing-denied path without
434        // using expect/unwrap; debug_assert surfaces any invariant break in tests.
435        let picked = pool.get(idx).copied();
436        debug_assert!(
437            picked.is_some(),
438            "random_range({}) returned out-of-bounds idx {idx}",
439            pool.len()
440        );
441        picked.unwrap_or(0)
442    }
443}
444
445/// Return the exclusive end index of the tie group beginning at `start`.
446/// Two adjacent candidates are in the same group iff their relative score
447/// difference is ≤ [`TIE_THRESHOLD`]. Two candidates with score `0.0` are
448/// always grouped together; a zero-score candidate is strictly better than
449/// any positive-score candidate and ends the group.
450///
451/// **Termination contract:** returns `> start` whenever `start < ranked.len()`,
452/// so the caller's drain-the-front loop in [`apply_tiebreaker`] always makes
453/// progress on a non-empty `ranked`.
454fn tie_group_end(ranked: &[RankedCandidate], start: usize) -> usize {
455    let pivot = match ranked.get(start) {
456        Some(r) => r.score,
457        None => return start,
458    };
459    let mut end = start + 1;
460    while end < ranked.len() {
461        let next = match ranked.get(end) {
462            Some(r) => r.score,
463            None => break,
464        };
465        // Sort is ascending, so next >= pivot. Equal scores stay in the
466        // group; otherwise the relative diff against the (positive) pivot
467        // decides. A pivot of 0.0 with a strictly larger next ends the
468        // group — zero is strictly better than any positive score.
469        if next > pivot && (pivot == 0.0 || (next - pivot) / pivot > TIE_THRESHOLD) {
470            break;
471        }
472        end += 1;
473    }
474    end
475}
476
477#[cfg(test)]
478#[allow(clippy::unwrap_used, clippy::expect_used, clippy::float_cmp)]
479mod tests;