decdn_node/dht/lookup.rs
1//! Requester-side iterative `FindValue` lookup (ADR 022
2//! §`FIND_VALUE` Flow + §Lookup integrity).
3//!
4//! Wraps the per-RPC primitive [`super::client::find_value`] in a
5//! standard Kademlia iteration with α=3 parallelism, three response
6//! filters applied in order, and provider-set randomisation before
7//! return. The lookup returns the *raw* surviving provider set;
8//! ranking / probing belongs to the downstream caller.
9//!
10//! ## Termination
11//!
12//! Stops as soon as **either**:
13//! - `providers.len() >= k` (saturation — caller has enough to probe), OR
14//! - a full round completes without surfacing any candidate strictly
15//! closer to `target` than the current best queried node
16//! (convergence — no further progress possible).
17//!
18//! Mirrors the textbook Kademlia termination. The empty-providers
19//! convergence case lets the caller fall back to the on-chain
20//! origin-directory ([`super::OriginDirectory`]) per ADR 022 § `FIND_VALUE` Flow.
21//!
22//! ## Three filters (ADR 022 § Lookup integrity)
23//!
24//! Applied in order to **every** `FindValueResponse`:
25//! 1. **XOR-distance** — drop `closer_nodes` entries whose distance
26//! to the target is not strictly less than the responder's own
27//! distance. Honest Kademlia responders never produce such entries.
28//! 2. **Active-staker** — drop non-staked `NodeId`s from both
29//! `closer_nodes` and `providers`. The DHT routing pool is
30//! restricted to staked nodes by spec.
31//! 3. **Negative probe cache** — drop `(provider, target)` pairs the
32//! requester has already observed returning `has_blob: false`
33//! within the cache TTL. Only applies to `providers`, never to
34//! `closer_nodes`.
35//!
36//! After filtering, the surviving provider set is **randomised**
37//! before return. ADR 022 § Lookup integrity: "Randomization on the requester is
38//! the load-bearing defense against ordering manipulation."
39
40use std::collections::{BTreeMap, HashSet};
41use std::num::NonZeroUsize;
42use std::sync::{Arc, Mutex};
43use std::time::Duration;
44
45use decdn_protocol::Coverage;
46use indexmap::IndexMap;
47use iroh::{Endpoint, EndpointAddr, PublicKey};
48use rand::seq::SliceRandom;
49use tokio::task::JoinSet;
50use tracing::{Instrument as _, debug, warn};
51
52use crate::dht::client;
53use crate::dht::negative_cache::{Hash, NegativeProbeCache};
54use crate::dht::routing::{K_BUCKET_SIZE, NODE_ID_LEN, NodeId, RoutingTable, xor_distance_bytes};
55use crate::dht::staker_set::StakerSet;
56
57/// Default α — parallel in-flight RPCs per round. ADR 022 § Routing Table.
58pub const DEFAULT_ALPHA: NonZeroUsize = match NonZeroUsize::new(3) {
59 Some(n) => n,
60 None => NonZeroUsize::MIN,
61};
62/// Default K — providers-accumulated saturation cap. ADR 022 § Routing Table.
63pub const DEFAULT_K: NonZeroUsize = match NonZeroUsize::new(K_BUCKET_SIZE) {
64 Some(n) => n,
65 None => NonZeroUsize::MIN,
66};
67/// Default round timeout. Belt over the per-RPC 8s timeout in
68/// [`super::client`]; bounds the wall-clock budget for a single
69/// iteration even if a few peers hang.
70pub const DEFAULT_ROUND_TIMEOUT: Duration = Duration::from_secs(8);
71
72/// Hard ceiling on the number of rounds one lookup may run (#1145 review).
73///
74/// Each round is individually bounded by `round_timeout`, but the LOOP was not: it exits on
75/// convergence (no closer node observed, no candidates left, or K providers found), never on
76/// a count. So the lookup's worst case was `round_timeout × (unbounded)`, and it runs INSIDE
77/// the caller's outer pull deadline — which is derived from a slack term that has to know
78/// what discovery can cost. An unbounded term cannot be budgeted for, and
79/// [`crate::selection::PULL_THROUGH_OUTER_SLACK`] budgeted 10 s for something that could
80/// exceed that in a single slow round.
81///
82/// Kademlia converges in `O(log n)` rounds. For the initial deployment target — tens of
83/// nodes on a testnet — that is ~2-3 rounds, so this ceiling clears a healthy lookup while
84/// bounding the pathological one, which is all a deadline needs. It is NOT generous at scale:
85/// at thousands of nodes `O(log n)` with α-way fan-out is already ~10 rounds, ABOVE this cap
86/// (`dht_lookup_round_ceiling` is the signal that the network outgrew the constant and it —
87/// and `PULL_THROUGH_OUTER_SLACK`, derived from it — must be raised).
88pub const MAX_LOOKUP_ROUNDS: u32 = 4;
89
90/// Lookup tuning knobs. `alpha` and `k` are `NonZeroUsize` so the
91/// type system rules out the silent-no-op states a zero would
92/// produce — empty batches from `pick_round_batch`, instant
93/// "saturation" from `have_enough_providers`. Caller-side clamping
94/// belongs at the config-parse boundary, not at every lookup entry.
95#[derive(Debug, Clone, Copy)]
96pub struct LookupConfig {
97 /// Parallel in-flight RPCs per round.
98 pub alpha: NonZeroUsize,
99 /// Stop after this many providers accumulated.
100 pub k: NonZeroUsize,
101 /// Per-round timeout — applied around the `JoinSet` drain so a
102 /// slow / hanging peer doesn't wedge the iteration.
103 pub round_timeout: Duration,
104}
105
106impl Default for LookupConfig {
107 fn default() -> Self {
108 Self {
109 alpha: DEFAULT_ALPHA,
110 k: DEFAULT_K,
111 round_timeout: DEFAULT_ROUND_TIMEOUT,
112 }
113 }
114}
115
116/// Run the iterative `FindValue` lookup for `target`.
117///
118/// Returns the randomised, deduplicated, filter-survived provider
119/// list. An empty return means convergence with no usable providers
120/// — the caller should fall back to the on-chain origin directory
121/// (ADR 022 § `FIND_VALUE` Flow) if applicable.
122///
123/// The function never panics or returns `Err`. Per-peer RPC
124/// failures are classified: transport-level failures are absorbed
125/// as empty responses and logged at `debug!`; an invalid Ed25519
126/// `NodeId` (which implies state corruption upstream) is logged at
127/// `warn!`; round-timeout aborts log the aborted-peer count at
128/// `warn!`. The lookup is best-effort by spec; total failure
129/// manifests as an empty return.
130/// `metrics` is optional so the DHT test suite can drive a lookup without standing one up;
131/// the production caller always passes `Some`, and it is the only way the round ceiling
132/// below becomes observable.
133///
134/// Runs inside a `dht_lookup` span that records the `rounds` run and the
135/// `providers` found; each round's per-peer RPCs run inside it too.
136// One argument over the threshold, and every one of them is a distinct collaborator the
137// lookup genuinely needs. Bundling them into a struct would move the same list one level out
138// without making any call site clearer.
139#[allow(clippy::too_many_arguments)]
140#[tracing::instrument(
141 name = "dht_lookup",
142 skip_all,
143 fields(
144 hash = %target,
145 rounds = tracing::field::Empty,
146 providers = tracing::field::Empty,
147 )
148)]
149pub async fn find_providers(
150 endpoint: &Endpoint,
151 routing_table: &Arc<Mutex<RoutingTable>>,
152 staker_set: &Arc<dyn StakerSet>,
153 negative_cache: &NegativeProbeCache,
154 requester_id: NodeId,
155 target: Hash,
156 cfg: LookupConfig,
157 metrics: Option<&crate::metrics::Metrics>,
158) -> Vec<(NodeId, Coverage)> {
159 if let Some(metrics) = metrics {
160 metrics.dht_findvalue_query();
161 }
162 let ctx = LookupCtx {
163 endpoint,
164 staker_set: staker_set.as_ref(),
165 negative_cache,
166 requester_id,
167 target,
168 cfg,
169 metrics,
170 };
171 let mut state = LookupState::new(routing_table, &target, requester_id, cfg);
172
173 let mut rounds_run: u32 = 0;
174 for round in 0..MAX_LOOKUP_ROUNDS {
175 if state.have_enough_providers() {
176 break;
177 }
178 let batch = state.pick_round_batch();
179 if batch.is_empty() {
180 break;
181 }
182 rounds_run = rounds_run.saturating_add(1);
183 let observed_closer = run_round(&ctx, &batch, &mut state).await;
184 if !observed_closer {
185 break;
186 }
187 // Cut off a lookup that keeps finding closer nodes without saturating. The loop used
188 // to run until it converged, with no count bound, so its worst case was unbounded —
189 // and it runs inside the caller's outer pull deadline, whose slack term has to know
190 // what discovery can cost (#1145 review). Not an error: whatever providers we have
191 // are still usable, and the pull proceeds with them.
192 //
193 // But it is not nothing either, and a `debug!` alone made it invisible at the
194 // project's own default `RUST_LOG=info` (#1145 review). Truncating here shrinks the
195 // candidate set that feeds `MAX_PROVIDER_ATTEMPTS`, so a node whose network is large
196 // enough to hit the ceiling on EVERY lookup is systematically pulling from a worse
197 // provider set than it should — and had no way to know. The counter makes "my ceiling
198 // is too low for my network size" an observable fact rather than a guess.
199 if round + 1 == MAX_LOOKUP_ROUNDS {
200 if let Some(metrics) = metrics {
201 metrics.dht_lookup_round_ceiling();
202 }
203 tracing::debug!(
204 rounds = MAX_LOOKUP_ROUNDS,
205 providers = state.provider_count(),
206 "dht lookup: hit the round ceiling; proceeding with the providers found so far"
207 );
208 }
209 }
210
211 let providers = state.into_randomised_providers();
212 let span = tracing::Span::current();
213 span.record("rounds", rounds_run);
214 span.record("providers", providers.len());
215 providers
216}
217
218/// Per-peer error category surfaced by a single RPC attempt within a
219/// round. Distinguishes:
220///
221/// - **`InvalidPubKey`** — the candidate `NodeId` failed Ed25519
222/// decode. The bytes already survived the routing-table seed
223/// (see [`LookupState::new`]) and/or three response filters, so
224/// reaching this arm implies one of two upstream paths leaked an
225/// invalid id: a corrupt routing table, or a staker-set seeded
226/// with malformed `NodeId` bytes. Either is actionable and gets a
227/// `warn!`.
228/// - **`Transport(_)`** — a generic transport-level RPC failure
229/// (connect timeout, stream drop, malformed response decode).
230/// Expected best-effort outcome per the function docstring;
231/// logged at `debug!`.
232enum RoundRpcError {
233 InvalidPubKey,
234 Transport(anyhow::Error),
235}
236
237/// Shared inputs to a lookup round.
238struct LookupCtx<'a> {
239 endpoint: &'a Endpoint,
240 staker_set: &'a dyn StakerSet,
241 negative_cache: &'a NegativeProbeCache,
242 requester_id: NodeId,
243 target: Hash,
244 cfg: LookupConfig,
245 metrics: Option<&'a crate::metrics::Metrics>,
246}
247
248/// Run one round of α parallel `find_value` RPCs against `batch`,
249/// applying the three filters to each response and folding results
250/// into `state`. Returns whether the round surfaced any candidate
251/// strictly closer to the target than the previous best queried
252/// distance (the convergence signal).
253async fn run_round(ctx: &LookupCtx<'_>, batch: &[NodeId], state: &mut LookupState) -> bool {
254 type RoundResult = (
255 NodeId,
256 Result<decdn_protocol::dht::FindValueResponse, RoundRpcError>,
257 );
258 let mut tasks: JoinSet<RoundResult> = JoinSet::new();
259 for &peer in batch {
260 let endpoint = ctx.endpoint.clone();
261 let requester_id = ctx.requester_id;
262 let target = ctx.target;
263 tasks.spawn(
264 async move {
265 let Ok(pk) = PublicKey::from_bytes(peer.as_bytes()) else {
266 return (peer, Err(RoundRpcError::InvalidPubKey));
267 };
268 let addr = EndpointAddr::new(pk);
269 let result = client::find_value(&endpoint, addr, target, requester_id)
270 .await
271 .map_err(RoundRpcError::Transport);
272 (peer, result)
273 }
274 .in_current_span(),
275 );
276 }
277
278 let mut observed_closer = false;
279 let drain = async {
280 while let Some(joined) = tasks.join_next().await {
281 match joined {
282 Ok((responder, Ok(resp))) => {
283 if process_response(ctx, resp, responder, state) {
284 observed_closer = true;
285 }
286 }
287 Ok((responder, Err(RoundRpcError::InvalidPubKey))) => {
288 // Bytes already survived the routing-table seed
289 // and/or three response filters; failing Ed25519
290 // decode here points at state corruption upstream.
291 warn!(
292 peer = %responder,
293 "dht lookup: peer NodeId is not a valid Ed25519 public key"
294 );
295 }
296 Ok((responder, Err(RoundRpcError::Transport(err)))) => {
297 debug!(peer = %responder, error = %err, "dht lookup: find_value RPC failed");
298 }
299 Err(err) => {
300 warn!(error = %err, "dht lookup: round task panicked");
301 }
302 }
303 }
304 };
305
306 if tokio::time::timeout(ctx.cfg.round_timeout, drain)
307 .await
308 .is_err()
309 {
310 // After the drain future drops, `tasks` still holds the
311 // handles for peers that hadn't been joined yet — these are
312 // the ones being aborted. Logging the count lets a future
313 // dashboard distinguish "round drained cleanly" from "round
314 // timed out with N peers in flight."
315 let aborted = tasks.len();
316 if let Some(metrics) = ctx.metrics {
317 metrics.dht_lookup_round_timeout();
318 }
319 warn!(
320 aborted_peers = aborted,
321 timeout_ms = u64::try_from(ctx.cfg.round_timeout.as_millis()).unwrap_or(u64::MAX),
322 "dht lookup: round timeout fired; aborting in-flight RPCs"
323 );
324 tasks.abort_all();
325 }
326
327 observed_closer
328}
329
330/// Apply the three lookup-integrity filters to one response and
331/// fold survivors into `state`. Returns whether any surviving
332/// `closer_node` is strictly closer to `target` than the previous
333/// best queried distance.
334fn process_response(
335 ctx: &LookupCtx<'_>,
336 resp: decdn_protocol::dht::FindValueResponse,
337 responder: NodeId,
338 state: &mut LookupState,
339) -> bool {
340 fold_response(
341 &ctx.target,
342 ctx.staker_set,
343 ctx.negative_cache,
344 resp,
345 responder,
346 state,
347 )
348}
349
350/// Filter + fold step shared with the unit tests, which can't easily
351/// stand up an `iroh::Endpoint` to populate a full `LookupCtx`. Pure
352/// over its inputs.
353fn fold_response(
354 target: &Hash,
355 staker_set: &dyn StakerSet,
356 negative_cache: &NegativeProbeCache,
357 resp: decdn_protocol::dht::FindValueResponse,
358 responder: NodeId,
359 state: &mut LookupState,
360) -> bool {
361 let kept_closer = filter_xor_closer(resp.closer_nodes.into_inner(), target, &responder);
362 let kept_closer = filter_active_stakers(kept_closer, staker_set);
363
364 // Filters 2 and 3 apply to `providers` exactly as they do to
365 // `closer_nodes` above, but `providers` carries `Coverage` alongside
366 // each `NodeId` and the shared filters operate on bare `NodeId`s — run
367 // them over the extracted ids, then keep only the `Provider`s whose id
368 // survived, so `coverage` rides along to `record_provider` rather than
369 // being dropped by a `Vec<NodeId>` round-trip.
370 let provider_ids: Vec<NodeId> = resp.providers.iter().map(|p| p.node).collect();
371 let kept_ids = filter_active_stakers(provider_ids, staker_set);
372 let kept_ids = filter_negative_cache(kept_ids, target, negative_cache);
373 let kept_ids: HashSet<NodeId> = kept_ids.into_iter().collect();
374
375 for p in resp
376 .providers
377 .into_iter()
378 .filter(|p| kept_ids.contains(&p.node))
379 {
380 state.record_provider(p.node, p.coverage);
381 }
382 let mut observed_closer = false;
383 for c in kept_closer {
384 if state.add_candidate(c) {
385 observed_closer = true;
386 }
387 }
388 observed_closer
389}
390
391// ============================================================
392// Filter helpers (ADR 022 § Lookup integrity) — pure free functions.
393// ============================================================
394
395/// Filter 1: drop `closer_nodes` entries whose XOR distance to
396/// `target` is not strictly less than `responder`'s own distance.
397/// Honest Kademlia responders never produce such entries; an
398/// offending entry is either responder misbehaviour or a relay-induced
399/// stale value.
400#[must_use]
401fn filter_xor_closer(closer_nodes: Vec<NodeId>, target: &Hash, responder: &NodeId) -> Vec<NodeId> {
402 // Cross-domain XOR: `target` is a content hash, `responder`/`n` are node
403 // ids — they share the 256-bit keyspace (ADR 022 §Routing Table).
404 let responder_distance = xor_distance_bytes(responder.as_bytes(), target.as_bytes());
405 closer_nodes
406 .into_iter()
407 .filter(|n| xor_distance_bytes(n.as_bytes(), target.as_bytes()) < responder_distance)
408 .collect()
409}
410
411/// Filter 2: drop non-staked `NodeId`s. Applies symmetrically to
412/// `closer_nodes` and `providers` (the caller invokes once per
413/// field). The DHT routing pool is restricted to staked nodes per
414/// ADR 022 § Lookup integrity.
415#[must_use]
416fn filter_active_stakers(nodes: Vec<NodeId>, staker_set: &dyn StakerSet) -> Vec<NodeId> {
417 nodes
418 .into_iter()
419 .filter(|n| staker_set.is_active(n))
420 .collect()
421}
422
423/// Filter 3: drop `(provider, target)` pairs already present in the
424/// negative probe cache. Only applies to `providers` — a peer that
425/// previously denied holding `target` may still legitimately route
426/// `closer_nodes` for it.
427#[must_use]
428fn filter_negative_cache(
429 providers: Vec<NodeId>,
430 target: &Hash,
431 cache: &NegativeProbeCache,
432) -> Vec<NodeId> {
433 providers
434 .into_iter()
435 .filter(|p| !cache.contains_active(p, target))
436 .collect()
437}
438
439// ============================================================
440// Lookup state machine
441// ============================================================
442
443/// Candidate set + queried tracking + accumulated providers for a
444/// single iterative lookup. Distance to `target` is computed once
445/// on insertion and used as the key for closest-first iteration.
446struct LookupState {
447 target: Hash,
448 /// Identity of the local node. Used to reject self from the
449 /// candidate / provider sets — self-filtering lives here, on the
450 /// state object, so a future caller can't accidentally pollute
451 /// the state via a code path that forgets the guard.
452 requester_id: NodeId,
453 /// Distance → `NodeId`, sorted ascending by XOR distance so
454 /// `iter().next()` yields the closest unqueried candidate.
455 candidates: BTreeMap<[u8; NODE_ID_LEN], NodeId>,
456 queried: HashSet<NodeId>,
457 /// Survivor set, deduplicated by `NodeId`, keyed to its most recently
458 /// folded `Coverage`. Insertion order preserved; randomisation happens
459 /// at return time.
460 providers: IndexMap<NodeId, Coverage>,
461 /// Smallest XOR distance among queried nodes so far. New
462 /// candidates only count as "closer" if they beat this.
463 best_queried_distance: [u8; NODE_ID_LEN],
464 k: usize,
465 alpha: usize,
466}
467
468impl LookupState {
469 fn new(
470 routing_table: &Arc<Mutex<RoutingTable>>,
471 target: &Hash,
472 requester_id: NodeId,
473 cfg: LookupConfig,
474 ) -> Self {
475 let mut state = Self {
476 target: *target,
477 requester_id,
478 candidates: BTreeMap::new(),
479 queried: HashSet::new(),
480 providers: IndexMap::new(),
481 best_queried_distance: [0xFFu8; NODE_ID_LEN],
482 k: cfg.k.get(),
483 alpha: cfg.alpha.get(),
484 };
485 // Seed from the local routing table. Take more than α so the
486 // first round has alternates if the closest few don't answer.
487 let seed_count = cfg.alpha.get().saturating_mul(4);
488 let seed = match routing_table.lock() {
489 Ok(t) => t.closest(target.as_bytes(), seed_count),
490 Err(poisoned) => {
491 warn!("dht lookup: routing table mutex poisoned; recovering inner state");
492 poisoned.into_inner().closest(target.as_bytes(), seed_count)
493 }
494 };
495 for peer in seed {
496 state.add_candidate(peer);
497 }
498 state
499 }
500
501 /// Insert `peer` into the candidate set if not already present
502 /// and not already queried. Returns whether the insertion brought
503 /// in a candidate strictly closer than the current best queried
504 /// distance (the convergence-tracking signal). Rejects
505 /// `requester_id`.
506 fn add_candidate(&mut self, peer: NodeId) -> bool {
507 if peer == self.requester_id {
508 return false;
509 }
510 let dist = xor_distance_bytes(peer.as_bytes(), self.target.as_bytes());
511 // XOR distance is bijective for a fixed target, so
512 // `contains_key(&dist)` is equivalent to scanning values for
513 // `peer` — but O(log N) instead of O(N).
514 if self.queried.contains(&peer) || self.candidates.contains_key(&dist) {
515 return false;
516 }
517 let strictly_closer = dist < self.best_queried_distance;
518 self.candidates.insert(dist, peer);
519 strictly_closer
520 }
521
522 /// Pick up to α unqueried candidates ordered by closeness, mark
523 /// them queried, return the batch. The closest is at the front
524 /// of the returned vec.
525 fn pick_round_batch(&mut self) -> Vec<NodeId> {
526 let mut picked = Vec::with_capacity(self.alpha);
527 while picked.len() < self.alpha {
528 let Some((&dist, &peer)) = self.candidates.iter().next() else {
529 break;
530 };
531 self.candidates.remove(&dist);
532 self.queried.insert(peer);
533 if dist < self.best_queried_distance {
534 self.best_queried_distance = dist;
535 }
536 picked.push(peer);
537 }
538 picked
539 }
540
541 /// Insert `peer` into the provider survivor set (or refresh its
542 /// coverage if already present). Rejects `requester_id`.
543 /// `IndexMap::insert` handles dedup and insertion-order preservation —
544 /// re-inserting an existing key updates its value in place without
545 /// moving it — in one step.
546 fn record_provider(&mut self, peer: NodeId, coverage: Coverage) {
547 if peer == self.requester_id {
548 return;
549 }
550 self.providers.insert(peer, coverage);
551 }
552
553 fn have_enough_providers(&self) -> bool {
554 self.providers.len() >= self.k
555 }
556
557 /// Providers accumulated so far — for the round-ceiling log line, which is only useful
558 /// if it says what the truncated lookup came away with.
559 fn provider_count(&self) -> usize {
560 self.providers.len()
561 }
562
563 /// Consume state, return the surviving provider set with order
564 /// randomised, then truncated to K. ADR 022 § Lookup integrity requires
565 /// randomisation of the *surviving* set — shuffle precedes
566 /// truncation so the truncated K is a random sample, not a
567 /// deterministic prefix.
568 fn into_randomised_providers(self) -> Vec<(NodeId, Coverage)> {
569 let mut providers: Vec<(NodeId, Coverage)> = self.providers.into_iter().collect();
570 providers.shuffle(&mut rand::rng());
571 providers.truncate(self.k);
572 providers
573 }
574}
575
576#[cfg(test)]
577#[allow(
578 clippy::unwrap_used,
579 clippy::expect_used,
580 clippy::panic,
581 clippy::indexing_slicing
582)]
583mod tests;