Skip to main content

dig_dht/
lookup.rs

1//! The iterative Kademlia lookup: converge on the `k` closest peers to a target key by repeatedly
2//! querying the `α` closest un-queried peers we know, folding their returned closer contacts back
3//! into the shortlist, until no closer peer can be found.
4//!
5//! This is the heart of a Kademlia DHT. [`iterative_find`] drives it:
6//!
7//! 1. Seed a **shortlist** with the closest contacts we already know (routing table + bootstrap).
8//! 2. Each round, pick the `α` closest **un-queried** contacts and query them in parallel with the
9//!    supplied `query` closure (a `find_node` for node lookup, or a `find_providers` for provider
10//!    lookup — the closure returns both closer contacts AND any providers it collected).
11//! 3. Merge every returned contact into the shortlist (dedup by `peer_id`), keeping it sorted by XOR
12//!    distance to the target and capped so it stays bounded.
13//! 4. Stop when a full round of the `k` closest have all been queried and none produced a strictly
14//!    closer un-queried contact — the shortlist has converged. Return the `k` closest and any
15//!    providers collected.
16//!
17//! The closure abstraction means node-lookup and provider-lookup share ONE convergence engine; only
18//! what each query returns differs. Transport failures to individual peers are non-fatal — a peer
19//! that errors is simply marked queried and the walk continues (the service's query closure maps a
20//! transport error to `Err(())`).
21//!
22//! Each round's `α`-sized batch is queried **truly concurrently**: every peer's RPC is
23//! [`tokio::spawn`]ed as its own task, so all `α` requests are in flight at once rather than
24//! awaited one at a time. A round of `α` peers that each stall to the transport timeout therefore
25//! costs about one `rpc_timeout`, not `α × rpc_timeout`.
26
27use std::collections::{HashMap, HashSet};
28use std::future::Future;
29
30use crate::key::Key;
31use crate::record::ProviderRecord;
32use crate::routing::Contact;
33
34/// The outcome of querying one peer during a lookup: the closer contacts it knows, and any provider
35/// records it holds for the target (empty for a pure node lookup).
36#[derive(Debug, Default, Clone)]
37pub struct QueryOutcome {
38    /// Contacts the queried peer returned as closer to the target (its `find_node` answer).
39    pub closer: Vec<Contact>,
40    /// Provider records the queried peer holds for the target key (its `find_providers` answer).
41    pub providers: Vec<ProviderRecord>,
42}
43
44/// The result of a completed iterative lookup.
45#[derive(Debug, Default, Clone)]
46pub struct LookupResult {
47    /// The `k` closest contacts to the target the lookup converged on (closest-first).
48    pub closest: Vec<Contact>,
49    /// All distinct provider records collected across the walk (dedup by provider `peer_id`).
50    pub providers: Vec<ProviderRecord>,
51}
52
53/// A single node in the lookup shortlist: a contact, its precomputed distance to the target, and
54/// whether we have queried it yet.
55struct ShortlistEntry {
56    contact: Contact,
57    distance: crate::key::Distance,
58    queried: bool,
59    /// A peer that returned an error / was unreachable — counts as queried, never re-tried.
60    failed: bool,
61}
62
63/// Drive an iterative lookup toward `target`, seeded with `seeds`, querying at most `alpha` peers per
64/// round and converging on the `k` closest.
65///
66/// `query` is invoked per peer to talk to it; it returns the peer's [`QueryOutcome`] (closer
67/// contacts + any providers). An `Err` from `query` marks that peer failed and the walk continues —
68/// a single unreachable peer never aborts the lookup. `stop_on_providers`, when `true`, ends the
69/// lookup as soon as at least one provider has been collected (used by `find_providers`, which wants
70/// the first holders fast, not the exhaustive `k`-closest walk).
71///
72/// The `query` closure is cloned per peer, so it must be `Clone` (the service passes an `Arc`-backed
73/// closure over the transport). It must also be `Send + 'static` (and its future `Send + 'static`)
74/// because each peer's query is [`tokio::spawn`]ed to run the round's `α`-sized batch concurrently.
75pub async fn iterative_find<F, Fut>(
76    target: Key,
77    seeds: Vec<Contact>,
78    k: usize,
79    alpha: usize,
80    stop_on_providers: bool,
81    query: F,
82) -> LookupResult
83where
84    F: Fn(Contact) -> Fut + Clone + Send + 'static,
85    Fut: Future<Output = Result<QueryOutcome, ()>> + Send + 'static,
86{
87    let mut shortlist: Vec<ShortlistEntry> = Vec::new();
88    let mut seen: HashSet<String> = HashSet::new();
89    // Collected providers, deduped by provider peer_id.
90    let mut providers: HashMap<String, ProviderRecord> = HashMap::new();
91
92    // Seed.
93    for c in seeds {
94        merge_contact(&mut shortlist, &mut seen, &target, c);
95    }
96    sort_and_cap(&mut shortlist, k, alpha);
97
98    loop {
99        // Pick the α closest un-queried, non-failed entries.
100        let batch: Vec<Contact> = shortlist
101            .iter()
102            .filter(|e| !e.queried && !e.failed)
103            .take(alpha)
104            .map(|e| e.contact.clone())
105            .collect();
106
107        if batch.is_empty() {
108            break; // nothing left to ask → converged
109        }
110
111        // Query the batch CONCURRENTLY: spawn each peer's RPC as its own task so all `alpha`
112        // requests are in flight at once, instead of awaiting them one at a time (which would cost
113        // up to alpha * rpc_timeout when peers stall — SECURITY_AUDIT_P2P.md #179).
114        let mut set = tokio::task::JoinSet::new();
115        for c in &batch {
116            let q = query.clone();
117            let c2 = c.clone();
118            set.spawn(async move { (c2.peer_id.clone(), q(c2).await) });
119        }
120        let mut results = Vec::with_capacity(batch.len());
121        while let Some(joined) = set.join_next().await {
122            match joined {
123                Ok(pair) => results.push(pair),
124                Err(_join_err) => {
125                    // A spawned query task panicked or was cancelled. We don't know which peer_id
126                    // it was (the JoinError doesn't carry our closure's captured id), so it cannot
127                    // be marked queried/failed individually; the lookup still terminates because
128                    // the batch-empty / converged checks below do not depend on every task
129                    // succeeding, and a peer that never reports back simply stays eligible to be
130                    // re-picked in the extremely unlikely event this happens (task panics are not
131                    // expected from the query closure's Result-returning contract).
132                }
133            }
134        }
135
136        // Fold results back in.
137        for (peer_id, res) in results {
138            mark_queried(&mut shortlist, &peer_id, res.is_err());
139            if let Ok(outcome) = res {
140                for p in outcome.providers {
141                    providers.entry(p.provider_peer_id.clone()).or_insert(p);
142                }
143                for c in outcome.closer {
144                    merge_contact(&mut shortlist, &mut seen, &target, c);
145                }
146            }
147        }
148        sort_and_cap(&mut shortlist, k, alpha);
149
150        if stop_on_providers && !providers.is_empty() {
151            break;
152        }
153
154        // Converged? If the α closest are all queried/failed and no un-queried peer is closer than
155        // the k-th closest already-queried peer, we are done. The batch-empty check at the top of the
156        // next loop handles the general case; this early-out avoids an extra idle round.
157        let any_unqueried_in_top_k = shortlist.iter().take(k).any(|e| !e.queried && !e.failed);
158        if !any_unqueried_in_top_k {
159            break;
160        }
161    }
162
163    let closest = shortlist
164        .into_iter()
165        .filter(|e| !e.failed)
166        .take(k)
167        .map(|e| e.contact)
168        .collect();
169    LookupResult {
170        closest,
171        providers: providers.into_values().collect(),
172    }
173}
174
175/// Insert `contact` into the shortlist if new (dedup by `peer_id`), computing its distance to the
176/// target. A contact with a malformed `peer_id` (no key) is skipped.
177fn merge_contact(
178    shortlist: &mut Vec<ShortlistEntry>,
179    seen: &mut HashSet<String>,
180    target: &Key,
181    contact: Contact,
182) {
183    if seen.contains(&contact.peer_id) {
184        return;
185    }
186    let Some(key) = contact.key() else {
187        return;
188    };
189    seen.insert(contact.peer_id.clone());
190    let distance = target.distance(&key);
191    shortlist.push(ShortlistEntry {
192        contact,
193        distance,
194        queried: false,
195        failed: false,
196    });
197}
198
199/// Mark the entry for `peer_id` as queried (and failed if the query errored).
200fn mark_queried(shortlist: &mut [ShortlistEntry], peer_id: &str, failed: bool) {
201    if let Some(e) = shortlist.iter_mut().find(|e| e.contact.peer_id == peer_id) {
202        e.queried = true;
203        e.failed = failed;
204    }
205}
206
207/// Keep the shortlist sorted closest-first and bounded. We keep more than `k` so late-arriving
208/// closer contacts still have room, but cap it to avoid unbounded growth on a large network.
209fn sort_and_cap(shortlist: &mut Vec<ShortlistEntry>, k: usize, alpha: usize) {
210    shortlist.sort_by_key(|e| e.distance);
211    // A generous cap: the k closest we will return, plus headroom for α parallel probes and their
212    // returned neighbours. Bounds memory without discarding a contact that could still be in the top-k.
213    let cap = (k * 3).max(k + alpha * 2);
214    if shortlist.len() > cap {
215        shortlist.truncate(cap);
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::record::CandidateAddr;
223    use dig_nat::PeerId;
224    use std::sync::atomic::{AtomicUsize, Ordering};
225    use std::sync::Arc;
226
227    fn contact_from_key(key_bytes: [u8; 32]) -> Contact {
228        Contact::new(
229            &PeerId::from_bytes(key_bytes),
230            vec![CandidateAddr::direct("h", 1)],
231        )
232    }
233
234    /// A simulated network: each node knows the `k` closest of all node ids to any target (a perfect
235    /// oracle neighbour set). This lets us assert convergence to the true k-closest.
236    fn oracle_query(
237        all_ids: Vec<[u8; 32]>,
238        target: Key,
239        k: usize,
240        counter: Arc<AtomicUsize>,
241    ) -> impl Fn(Contact) -> std::pin::Pin<Box<dyn Future<Output = Result<QueryOutcome, ()>> + Send>>
242           + Clone {
243        move |_c: Contact| {
244            counter.fetch_add(1, Ordering::SeqCst);
245            let all_ids = all_ids.clone();
246            Box::pin(async move {
247                let mut sorted: Vec<[u8; 32]> = all_ids;
248                sorted.sort_by_key(|id| *target.distance(&Key::from_bytes(*id)).as_bytes());
249                let closer = sorted.into_iter().take(k).map(contact_from_key).collect();
250                Ok(QueryOutcome {
251                    closer,
252                    providers: vec![],
253                })
254            })
255        }
256    }
257
258    #[tokio::test]
259    async fn converges_to_k_closest_in_simulated_network() {
260        // 50 node ids with varied top bytes.
261        let all_ids: Vec<[u8; 32]> = (0u8..50)
262            .map(|i| {
263                let mut b = [0u8; 32];
264                b[0] = i.wrapping_mul(5);
265                b[1] = i;
266                b
267            })
268            .collect();
269        let target = Key::from_bytes([0u8; 32]);
270        let k = 20;
271        let counter = Arc::new(AtomicUsize::new(0));
272
273        // Seed with just a couple of arbitrary (possibly far) nodes.
274        let seeds = vec![contact_from_key(all_ids[40]), contact_from_key(all_ids[45])];
275        let query = oracle_query(all_ids.clone(), target, k, counter.clone());
276
277        let result = iterative_find(target, seeds, k, 3, false, query).await;
278
279        // Compute the true k-closest.
280        let mut expected = all_ids.clone();
281        expected.sort_by_key(|id| *target.distance(&Key::from_bytes(*id)).as_bytes());
282        let expected_top: Vec<Contact> =
283            expected.into_iter().take(k).map(contact_from_key).collect();
284
285        assert_eq!(result.closest.len(), k);
286        // The closest returned must equal the true k-closest as a set.
287        let got: HashSet<String> = result.closest.iter().map(|c| c.peer_id.clone()).collect();
288        let exp: HashSet<String> = expected_top.iter().map(|c| c.peer_id.clone()).collect();
289        assert_eq!(got, exp, "lookup must converge on the true k-closest");
290        // The closest entry must be the globally closest id.
291        assert_eq!(result.closest[0].peer_id, expected_top[0].peer_id);
292    }
293
294    #[tokio::test]
295    async fn empty_seeds_returns_empty() {
296        let target = Key::from_bytes([0u8; 32]);
297        let result = iterative_find(target, vec![], 20, 3, false, |_c: Contact| async {
298            Ok(QueryOutcome::default())
299        })
300        .await;
301        assert!(result.closest.is_empty());
302        assert!(result.providers.is_empty());
303    }
304
305    #[tokio::test]
306    async fn stop_on_providers_ends_early() {
307        let target = Key::from_bytes([0u8; 32]);
308        let seed = contact_from_key([0x10; 32]);
309        let provider = ProviderRecord::new(
310            &target,
311            &PeerId::from_bytes([0xAB; 32]),
312            vec![CandidateAddr::direct("h", 9444)],
313            u64::MAX,
314        );
315        let p2 = provider.clone();
316        let result = iterative_find(target, vec![seed], 20, 3, true, move |_c: Contact| {
317            let p = p2.clone();
318            async move {
319                Ok(QueryOutcome {
320                    closer: vec![],
321                    providers: vec![p],
322                })
323            }
324        })
325        .await;
326        assert_eq!(result.providers.len(), 1);
327        assert_eq!(
328            result.providers[0].provider_peer_id,
329            provider.provider_peer_id
330        );
331    }
332
333    #[tokio::test]
334    async fn failed_peers_do_not_abort_lookup() {
335        // Every query fails → lookup still terminates, returns no closer/providers, no panic/hang.
336        let target = Key::from_bytes([0u8; 32]);
337        let seeds = vec![contact_from_key([0x01; 32]), contact_from_key([0x02; 32])];
338        let result = iterative_find(target, seeds, 20, 3, false, |_c: Contact| async {
339            Err::<QueryOutcome, ()>(())
340        })
341        .await;
342        // Failed seeds are excluded from `closest`.
343        assert!(result.closest.is_empty());
344    }
345
346    #[tokio::test]
347    async fn dedups_providers_by_peer_id() {
348        let target = Key::from_bytes([0u8; 32]);
349        let seeds = vec![contact_from_key([0x01; 32]), contact_from_key([0x02; 32])];
350        let provider =
351            ProviderRecord::new(&target, &PeerId::from_bytes([0xAB; 32]), vec![], u64::MAX);
352        let p = provider.clone();
353        // Both seeds return the SAME provider → must be deduped to 1.
354        let result = iterative_find(target, seeds, 20, 3, false, move |_c: Contact| {
355            let p = p.clone();
356            async move {
357                Ok(QueryOutcome {
358                    closer: vec![],
359                    providers: vec![p],
360                })
361            }
362        })
363        .await;
364        assert_eq!(result.providers.len(), 1);
365    }
366
367    // ---- Concurrency (MEDIUM/optimization: join_all awaits sequentially, SECURITY_AUDIT_P2P.md #179) ----
368
369    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
370    async fn alpha_batch_is_queried_concurrently_not_sequentially() {
371        // Seed exactly `alpha` peers who each "stall" for DELAY, and never return closer contacts
372        // (so the walk needs exactly one round). If the batch is awaited sequentially, wall clock
373        // is ~alpha * DELAY; if concurrent, it is ~1 * DELAY. Assert well under alpha * DELAY so a
374        // regression back to sequential awaiting fails this test.
375        const ALPHA: usize = 4;
376        const DELAY: std::time::Duration = std::time::Duration::from_millis(150);
377
378        let target = Key::from_bytes([0u8; 32]);
379        let seeds: Vec<Contact> = (1u8..=ALPHA as u8)
380            .map(|i| contact_from_key([i; 32]))
381            .collect();
382
383        let start = std::time::Instant::now();
384        let result = iterative_find(target, seeds, 20, ALPHA, false, |_c: Contact| async move {
385            tokio::time::sleep(DELAY).await;
386            Ok(QueryOutcome::default())
387        })
388        .await;
389        let elapsed = start.elapsed();
390
391        assert_eq!(result.closest.len(), ALPHA, "all alpha peers answered");
392        assert!(
393            elapsed < DELAY * (ALPHA as u32) / 2,
394            "batch must run concurrently: expected well under {:?} (alpha * delay), got {:?}",
395            DELAY * (ALPHA as u32),
396            elapsed
397        );
398    }
399}