Skip to main content

dig_download/
select.rs

1//! [`SourceSelector`] — the **selection seam**: dig-download delegates "which of these candidate
2//! peers should serve this content, and in what order?" to an injected brain, and reports the real
3//! measured outcome of every range fetch back to it.
4//!
5//! # Why a seam, not a built-in brain (the anti-second-brain rule)
6//!
7//! dig-download is the **executor**: it fans byte ranges across peers, verifies each independently,
8//! retries the bad ones, and reassembles — as fast as the peers allow. It deliberately owns **no
9//! throughput model / speed ranking / cross-transfer learning**. That intelligence lives in ONE place
10//! (`dig-peer-selector`, the self-tuning decision layer), wired in by dig-node so a single learning
11//! loop informs every transfer. If dig-download also ranked peers it would be a *second, competing*
12//! brain — divergent, un-tunable, and impossible to keep coherent with the real selector.
13//!
14//! So dig-download exposes this trait and calls it for ordering; the selector decides **who + in what
15//! order**, dig-download **executes + reports outcomes back**. With no selector injected, the
16//! [`NullSelector`] (a fair round-robin) keeps the crate fully usable standalone.
17//!
18//! # Layering (why the DTOs live here, not in dig-peer-selector)
19//!
20//! dig-download and dig-peer-selector are both level-30 crates, so dig-download may **not** depend on
21//! dig-peer-selector (reference-DOWN only — no same-level edge). The seam is therefore defined here in
22//! dig-download with its OWN minimal DTOs; dig-peer-selector (or dig-node's adapter) *implements*
23//! [`SourceSelector`] against them. dig-node's richer notions — a peer's discovery `Provenance`, its
24//! address book — never enter these types; a candidate carries only an opaque [`CandidateRef::tag`]
25//! the selector may round-trip.
26
27use std::sync::atomic::{AtomicUsize, Ordering};
28use std::time::Duration;
29
30/// One candidate peer the selector may choose to fetch from: its `peer_id` (64-hex), its dialable
31/// address strings, and an **opaque** selector-defined tag.
32///
33/// The `tag` is Provenance-agnostic on purpose: dig-download never interprets it, it only round-trips
34/// whatever a caller attaches (dig-node stamps a small discovery-source hint here). Keeping it opaque
35/// keeps dig-node's `Provenance` out of dig-download entirely (layering).
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CandidateRef {
38    /// The candidate provider's `peer_id` as lowercase 64-hex.
39    pub peer_id: String,
40    /// The candidate's dialable address strings (`host:port`), best-first, or empty for a
41    /// relay-only-reachable peer.
42    pub addrs: Vec<String>,
43    /// An opaque selector-defined tag dig-download round-trips but never inspects (dig-node stamps a
44    /// discovery-source hint). `None` when unset.
45    pub tag: Option<u64>,
46}
47
48impl CandidateRef {
49    /// A candidate with no tag (the value dig-download itself constructs — it has no Provenance to
50    /// stamp).
51    pub fn new(peer_id: impl Into<String>, addrs: Vec<String>) -> Self {
52        CandidateRef {
53            peer_id: peer_id.into(),
54            addrs,
55            tag: None,
56        }
57    }
58}
59
60/// The question posed to the selector on each scheduling pass: given these live candidates and the
61/// download's current state, which peers should serve the still-needed ranges, and in what order?
62#[derive(Debug, Clone)]
63pub struct SelectRequest<'a> {
64    /// The content's stable key (64-hex of its DHT content key) — an opaque identity the selector may
65    /// use to scope per-content learning; dig-download does not require the selector to use it.
66    pub content_key: &'a str,
67    /// The candidate peers currently eligible to be scheduled (already filtered to live, non-backed-off
68    /// holders — liveness/backoff is dig-download's mechanical debounce, NOT the selector's job).
69    pub candidates: &'a [CandidateRef],
70    /// How many ranges still need fetching (pending, not yet done/in-flight).
71    pub ranges_needed: usize,
72    /// How many range fetches are currently in flight across all peers (the scheduler's live load).
73    pub inflight: usize,
74}
75
76/// The selector's answer: the candidates to use, in preference order (best first), plus an OPTIONAL
77/// explicit per-range assignment.
78///
79/// The scheduler assigns each pending range to the first peer in [`ordered`](Self::ordered) that is
80/// under its per-source in-flight cap. An entry in [`assignments`](Self::assignments) pins a specific
81/// range to a specific peer when present; ranges without an assignment fall back to `ordered`.
82#[derive(Debug, Clone, Default)]
83pub struct SelectPlan {
84    /// Candidate `peer_id`s in preference order (best first). A subset of the request's candidates —
85    /// the selector may drop candidates it wants to avoid this pass.
86    pub ordered: Vec<String>,
87    /// Optional explicit `(range_index, peer_id)` pins. Empty means "assign purely by `ordered`".
88    pub assignments: Vec<(usize, String)>,
89}
90
91impl SelectPlan {
92    /// A plan that simply uses the given peers in the given order (no explicit per-range pins).
93    pub fn ordered(peers: Vec<String>) -> Self {
94        SelectPlan {
95            ordered: peers,
96            assignments: Vec::new(),
97        }
98    }
99}
100
101/// How a single range fetch turned out — fed back to the selector so its learning loop sees the real,
102/// measured result of every transfer (this is dig-download's ONLY reporting channel; it computes no
103/// ranking of its own from these numbers).
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct RangeOutcome {
106    /// The `peer_id` (64-hex) the range was fetched from.
107    pub peer_id: String,
108    /// Bytes actually transferred (0 for a failure/timeout before any verified bytes landed).
109    pub bytes: u64,
110    /// Wall-clock elapsed for the fetch attempt.
111    pub elapsed: Duration,
112    /// The result of the attempt.
113    pub result: RangeResult,
114}
115
116/// The three terminal states of one range fetch attempt.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum RangeResult {
119    /// The range was fetched and verified successfully.
120    Ok,
121    /// The fetch failed (transport error) or the bytes failed verification.
122    Failed,
123    /// The fetch exceeded the configured per-range timeout.
124    TimedOut,
125}
126
127/// The selection brain dig-download delegates peer choice to. Implemented by dig-peer-selector (wired
128/// in by dig-node); [`NullSelector`] is the standalone default.
129///
130/// Both methods take `&self` (interior mutability if the impl learns) so one selector instance can
131/// inform many concurrent downloads.
132pub trait SourceSelector: Send + Sync {
133    /// Choose which candidates to fetch from, and in what order, for this scheduling pass.
134    fn select(&self, req: &SelectRequest) -> SelectPlan;
135
136    /// Report the measured outcome of one range fetch, feeding the selector's learning loop.
137    fn record(&self, outcome: &RangeOutcome);
138}
139
140/// The default [`SourceSelector`] when none is injected: a fair **round-robin** over the candidates,
141/// rotating the starting offset each pass so no single peer is always tried first. It keeps NO speed
142/// model — it is deliberately un-intelligent, so dig-download standalone has no hidden ranking brain.
143#[derive(Debug, Default)]
144pub struct NullSelector {
145    cursor: AtomicUsize,
146}
147
148impl NullSelector {
149    /// A fresh round-robin selector.
150    pub fn new() -> Self {
151        NullSelector::default()
152    }
153}
154
155impl SourceSelector for NullSelector {
156    fn select(&self, req: &SelectRequest) -> SelectPlan {
157        let n = req.candidates.len();
158        if n == 0 {
159            return SelectPlan::default();
160        }
161        let start = self.cursor.fetch_add(1, Ordering::Relaxed) % n;
162        let ordered = req
163            .candidates
164            .iter()
165            .cycle()
166            .skip(start)
167            .take(n)
168            .map(|c| c.peer_id.clone())
169            .collect();
170        SelectPlan::ordered(ordered)
171    }
172
173    /// The null selector learns nothing — outcomes are intentionally ignored.
174    fn record(&self, _outcome: &RangeOutcome) {}
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    fn candidates(n: usize) -> Vec<CandidateRef> {
182        (0..n)
183            .map(|i| CandidateRef::new(format!("peer{i}"), vec![format!("10.0.0.{i}:9444")]))
184            .collect()
185    }
186
187    fn request(cands: &[CandidateRef]) -> SelectRequest<'_> {
188        SelectRequest {
189            content_key: "test-content-key",
190            candidates: cands,
191            ranges_needed: 4,
192            inflight: 0,
193        }
194    }
195
196    #[test]
197    fn null_selector_returns_all_candidates_in_a_rotating_order() {
198        let sel = NullSelector::new();
199        let cands = candidates(3);
200        let p0 = sel.select(&request(&cands));
201        let p1 = sel.select(&request(&cands));
202        // Every pass returns ALL candidates (a permutation), and the starting peer rotates.
203        assert_eq!(p0.ordered.len(), 3);
204        assert_eq!(p1.ordered.len(), 3);
205        assert_ne!(p0.ordered[0], p1.ordered[0], "start rotates each pass");
206        let mut sorted = p0.ordered.clone();
207        sorted.sort();
208        assert_eq!(sorted, vec!["peer0", "peer1", "peer2"]);
209    }
210
211    #[test]
212    fn null_selector_empty_candidates_is_empty_plan() {
213        let sel = NullSelector::new();
214        let cands = candidates(0);
215        let plan = sel.select(&request(&cands));
216        assert!(plan.ordered.is_empty());
217        assert!(plan.assignments.is_empty());
218    }
219
220    #[test]
221    fn null_selector_record_is_a_noop() {
222        let sel = NullSelector::new();
223        // Recording must not panic and must not alter subsequent selection.
224        sel.record(&RangeOutcome {
225            peer_id: "peer0".into(),
226            bytes: 1024,
227            elapsed: Duration::from_millis(5),
228            result: RangeResult::Ok,
229        });
230        let cands = candidates(2);
231        assert_eq!(sel.select(&request(&cands)).ordered.len(), 2);
232    }
233
234    #[test]
235    fn select_plan_ordered_helper_has_no_assignments() {
236        let plan = SelectPlan::ordered(vec!["a".into(), "b".into()]);
237        assert_eq!(plan.ordered, vec!["a", "b"]);
238        assert!(plan.assignments.is_empty());
239    }
240}