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;