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;