Skip to main content

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;