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}