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}