Skip to main content

decdn_node/dht/
bucket_refresh.rs

1//! Routing-table bucket refresh (ADR 022 §Routing Table — "Bucket
2//! refresh interval | 1 hour").
3//!
4//! Every routing-table bucket holds peers at a specific XOR-distance
5//! prefix from `self.node_id`. As nodes leave the network those
6//! peers' entries silently die — without refresh, a long-lived node's
7//! buckets fill with stale entries and lookup convergence degrades.
8//!
9//! The fix per ADR 022 §Routing Table is to periodically issue a
10//! `FindNode(random_id_in_bucket)` to one peer in each bucket, every
11//! hour. Live peers in that distance shell respond with their own
12//! K-closest, populating the bucket with fresh entries.
13//!
14//! Implementation: a single tokio task wakes on a 1-hour ticker and
15//! refreshes *every* non-empty bucket per tick (see
16//! [`BUCKET_REFRESH_TICK`] for the rate-budget argument). For each
17//! non-empty bucket it synthesises a random `NodeId` whose XOR
18//! distance to `self` falls in that bucket's range and runs
19//! `FindNode` against the bucket's most-recently-seen peer; the
20//! per-bucket RPCs fan out in parallel.
21//!
22//! This is a "best-effort hygiene" task: a refresh that fails (peer
23//! unreachable, timeout) is silently ignored — the next tick will try
24//! the next bucket. Failure has no operator-actionable signal.
25
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::{Arc, Mutex};
28use std::time::Duration;
29
30use iroh::{Endpoint, EndpointAddr, PublicKey};
31use rand::RngExt;
32use tokio::sync::oneshot;
33
34use crate::dht::chain_projection::with_lock;
35use crate::dht::client;
36use crate::dht::routing::{KEYSPACE_BITS, NodeId, RoutingTable, bucket_index};
37
38/// Bucket-refresh tick interval. ADR 022 §Routing Table specifies "1
39/// hour" as the refresh interval **per bucket** — we run *every*
40/// non-empty bucket per tick so each populated bucket gets the
41/// ADR-mandated hourly refresh. At realistic scale (≤30 populated
42/// buckets even in a 500-node network) this is ≤30 `FindNode` RPCs
43/// per hour, comfortably within the routing-table peers' own per-IP
44/// rate limit (100 req/s by default).
45pub const BUCKET_REFRESH_TICK: Duration = Duration::from_hours(1);
46
47/// Long-running task: every [`BUCKET_REFRESH_TICK`] runs a
48/// `FindNode(random_id_in_bucket)` against each non-empty bucket's
49/// most-recently-seen peer in parallel. Exits on `stop_rx`.
50///
51/// `refresh_clock` is stamped with the wall-clock microseconds at which
52/// each refresh pass completes, so the `admin_v1_status` health view
53/// (issue #741) can report the network-wide "last refreshed" time. Since
54/// a pass touches *all* non-empty buckets at once, a single timestamp is
55/// the honest representation — there is no per-bucket refresh time. It
56/// starts at 0 ("never refreshed") and is updated every tick regardless
57/// of whether any bucket was populated, so it tracks "the refresh task is
58/// alive and ran at T" rather than "a bucket received fresh entries".
59///
60/// Each pass also counts its failed bucket refreshes into
61/// `decdn_dht_bucket_refresh_failures_total` and publishes the table's size
62/// as `decdn_dht_routing_table_size`.
63pub async fn run_bucket_refresh(
64    endpoint: Endpoint,
65    self_id: PublicKey,
66    routing: Arc<Mutex<RoutingTable>>,
67    mut stop_rx: oneshot::Receiver<()>,
68    interval: Duration,
69    refresh_clock: Arc<AtomicU64>,
70    metrics: Arc<crate::metrics::Metrics>,
71) {
72    let self_node_id = NodeId::from_bytes(*self_id.as_bytes());
73    let mut ticker = tokio::time::interval(interval);
74    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
75    ticker.tick().await; // burn first tick — bucket refresh isn't needed at t=0
76
77    loop {
78        tokio::select! {
79            biased;
80            _ = &mut stop_rx => {
81                tracing::debug!("dht bucket-refresh: shutdown signal received");
82                return;
83            }
84            _ = ticker.tick() => {
85                let failures =
86                    refresh_all_non_empty_buckets(&endpoint, self_node_id, &routing).await;
87                for _ in 0..failures {
88                    metrics.dht_bucket_refresh_failure();
89                }
90                metrics.dht_routing_table_size(
91                    with_lock(&routing, "dht routing table", |table| table.len()),
92                );
93                // Stamp after the pass completes so `admin_v1_status`
94                // reports the time refresh last *finished*. `Relaxed` is
95                // sufficient: the admin reader only needs the latest value
96                // eventually, with no ordering dependency on other state.
97                refresh_clock.store(now_us(), Ordering::Relaxed);
98            }
99        }
100    }
101}
102
103/// Wall-clock now in microseconds since `UNIX_EPOCH`. Falls back to 0 if
104/// the host clock is set before the epoch (panic-free per the workspace
105/// anti-panic policy), logging at `error` level on that path — same
106/// treatment as [`crate::handlers::dht`]'s `now_us`, since a 0 stamp here
107/// makes `admin_v1_status` report `last_refresh=never` even though a pass
108/// completed, so the misconfiguration must be diagnosable from the logs.
109/// (The byte-identical-but-silent copy in [`crate::dht::publish`] only
110/// feeds scheduling, where the fallback has no operator-visible effect.)
111fn now_us() -> u64 {
112    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
113        Ok(d) => u64::try_from(d.as_micros()).unwrap_or(u64::MAX),
114        Err(err) => {
115            tracing::error!(
116                error = %err,
117                "wall-clock before UNIX_EPOCH; bucket-refresh timestamp falls back to 0, so \
118                 admin_v1_status will report last_refresh=never despite refreshes running. \
119                 Set the host clock."
120            );
121            0
122        }
123    }
124}
125
126/// Refresh every non-empty bucket once. Snapshots the (`bucket_index`,
127/// peer, target) tuples under a single short lock acquire, then fans
128/// the network requests out in parallel.
129///
130/// Returns how many bucket refreshes failed: a `FindNode` RPC error, a
131/// routing-table peer that is not a valid public key, or a panicked refresh
132/// task.
133async fn refresh_all_non_empty_buckets(
134    endpoint: &Endpoint,
135    self_node_id: NodeId,
136    routing: &Arc<Mutex<RoutingTable>>,
137) -> usize {
138    let picks: Vec<BucketPick> = with_lock(routing, "dht routing table", |table| {
139        all_non_empty_picks(table)
140    });
141    if picks.is_empty() {
142        return 0;
143    }
144    let mut handles = Vec::with_capacity(picks.len());
145    for pick in picks {
146        let endpoint_cloned = endpoint.clone();
147        let routing_cloned = Arc::clone(routing);
148        handles.push(tokio::spawn(async move {
149            refresh_one_bucket(&endpoint_cloned, self_node_id, &routing_cloned, pick).await
150        }));
151    }
152    let mut failures = 0;
153    for h in handles {
154        // A panicked refresh task counts as a failed refresh.
155        if !h.await.unwrap_or(false) {
156            failures += 1;
157        }
158    }
159    failures
160}
161
162/// Snapshot all non-empty buckets as `BucketPick`s. Returns one pick
163/// per non-empty bucket; the caller fans the corresponding `FindNode`
164/// RPCs out in parallel.
165fn all_non_empty_picks(table: &RoutingTable) -> Vec<BucketPick> {
166    (0..KEYSPACE_BITS)
167        .filter_map(|idx| {
168            let peer = bucket_iter(table, idx).last().copied()?;
169            let target = random_target_in_bucket(table.self_id(), idx);
170            Some(BucketPick {
171                bucket_index: idx,
172                peer,
173                target,
174            })
175        })
176        .collect()
177}
178
179/// Run a single bucket's refresh: `FindNode(target)` against the
180/// bucket's most-recently-seen peer, then insert any closer-nodes the
181/// responder returns. The pick is computed upstream in
182/// [`all_non_empty_picks`] so the routing-table lock is held only
183/// briefly under the snapshot.
184async fn refresh_one_bucket(
185    endpoint: &Endpoint,
186    self_node_id: NodeId,
187    routing: &Arc<Mutex<RoutingTable>>,
188    pick: BucketPick,
189) -> bool {
190    let BucketPick {
191        bucket_index,
192        peer,
193        target,
194    } = pick;
195    let target_pk = match PublicKey::from_bytes(peer.as_bytes()) {
196        Ok(k) => k,
197        Err(e) => {
198            tracing::warn!(
199                peer = %peer,
200                error = %e,
201                "dht bucket-refresh: routing-table peer not a valid public key"
202            );
203            return false;
204        }
205    };
206    let addr = EndpointAddr::new(target_pk);
207    match client::find_node(endpoint, addr, target, self_node_id).await {
208        Ok(resp) => {
209            // Insert any new peers from the response into the routing
210            // table. The bucket's freshness is updated by virtue of
211            // these inserts moving entries to MRU. These are
212            // response-learned ids (not the authenticated peer), inserted
213            // as Kademlia probe candidates by design — distinct from the
214            // `AuthenticatedNodeId`-gated recency refresh in the handler.
215            with_lock(routing, "dht routing table", |table| {
216                for nid in resp.closer_nodes.into_inner() {
217                    if nid != self_node_id {
218                        table.insert(nid);
219                    }
220                }
221            });
222            true
223        }
224        Err(e) => {
225            tracing::debug!(
226                bucket = bucket_index,
227                error = %e,
228                "dht bucket-refresh: FindNode failed; will retry on next tick"
229            );
230            false
231        }
232    }
233}
234
235struct BucketPick {
236    bucket_index: usize,
237    /// The peer we'll send the `FindNode` to — the bucket's
238    /// most-recently-seen entry (highest chance of responding).
239    peer: NodeId,
240    /// A random `NodeId` whose XOR distance to `self_id` falls in
241    /// `bucket_index`'s prefix range — used as the `target` of the
242    /// `FindNode` so the responder returns peers from that distance
243    /// shell.
244    target: NodeId,
245}
246
247/// Build a single non-empty bucket's pick. Used by tests; the
248/// production loop uses [`all_non_empty_picks`] which collects every
249/// non-empty bucket per tick.
250#[cfg(test)]
251fn pick_bucket(table: &RoutingTable, bucket: usize) -> Option<BucketPick> {
252    let peer = bucket_iter(table, bucket).last().copied()?;
253    let target = random_target_in_bucket(table.self_id(), bucket);
254    Some(BucketPick {
255        bucket_index: bucket,
256        peer,
257        target,
258    })
259}
260
261/// Iterate over the peers in `bucket_index` by filtering `iter_peers`
262/// on the distance computation. Marginal cost compared to exposing a
263/// dedicated `bucket(idx)` accessor on `RoutingTable`, but keeps the
264/// `RoutingTable` API minimal.
265fn bucket_iter(table: &RoutingTable, target_bucket: usize) -> impl Iterator<Item = &NodeId> {
266    let self_id = *table.self_id();
267    table.iter_peers().filter(move |peer| {
268        let d = crate::dht::routing::xor_distance(&self_id, peer);
269        bucket_index(&d) == Some(target_bucket)
270    })
271}
272
273/// Produce a random `NodeId` whose XOR-distance to `self_id` falls
274/// inside `bucket_index`'s prefix range. Used as the `target` of the
275/// refresh `FindNode` so the responder returns peers from that
276/// distance shell.
277fn random_target_in_bucket(self_id: &NodeId, bucket_index: usize) -> NodeId {
278    // bucket_index = KEYSPACE_BITS - 1 - bit_from_msb  →  bit_from_msb = 255 - bucket_index
279    let bit_from_msb = KEYSPACE_BITS.saturating_sub(1).saturating_sub(bucket_index);
280    let byte_idx = bit_from_msb / 8;
281    let bit_within = bit_from_msb % 8;
282    let mut target = *self_id.as_bytes();
283    // Flip the bucket's prefix bit (guarantees the XOR distance has
284    // its highest set bit at this position).
285    let flip_mask = 1u8 << (7 - bit_within);
286    if let Some(b) = target.get_mut(byte_idx) {
287        *b ^= flip_mask;
288    }
289    // Randomise the lower-order bits so the target isn't predictable.
290    let mut rng = rand::rng();
291    for slot in target.iter_mut().skip(byte_idx + 1) {
292        *slot = rng.random();
293    }
294    NodeId::from_bytes(target)
295}
296
297#[cfg(test)]
298#[allow(
299    clippy::unwrap_used,
300    clippy::expect_used,
301    clippy::panic,
302    clippy::indexing_slicing
303)]
304mod tests;