Skip to content

fix: implement standard Kademlia DHT maintenance#60

Closed
grumbach wants to merge 1 commit into
mainfrom
fix/kademlia-standard-dht
Closed

fix: implement standard Kademlia DHT maintenance#60
grumbach wants to merge 1 commit into
mainfrom
fix/kademlia-standard-dht

Conversation

@grumbach

@grumbach grumbach commented Mar 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Implements 5 standard Kademlia mechanisms that were missing from the DHT, causing routing table underpopulation (nodes discovering only 13-17% of the network via DHT despite 90% transport connectivity)
  • These changes are required for merkle payment close group stability in ant-node

Problem

ant-node e2e testing (PR WithAutonomi/ant-node#45) measured:

Network Size Transport Connections DHT Discovery Gap
25 nodes 90.8% 48.8% 42%
100 nodes ~95% 13.3% ~82%

This causes different nodes to return different "closest K nodes" for the same address, breaking merkle payment verification where client (quoting) and node (verification) must agree on the close group.

Changes

  1. K=8 to K=20 -- bucket size was below Kademlia/libp2p standard
  2. Stale-node eviction -- full buckets now ping LRS node instead of rejecting new nodes
  3. Replacement cache -- 5-entry per-bucket cache for nodes that couldn't be inserted
  4. Periodic bucket refresh -- every 5min, refresh buckets not looked up in 1 hour (Kademlia Section 2.4)
  5. Add unknown senders on message receipt -- every DHT message now populates the routing table (Kademlia Section 2.1)

Test plan

  • All 215 unit tests pass
  • All integration tests pass
  • cargo build clean
  • Re-run ant-node close group stability tests after bumping saorsa-core dependency

Greptile Summary

This PR implements five standard Kademlia DHT maintenance mechanisms that were missing from the routing table, directly addressing severe network under-discovery (13–17% DHT connectivity vs. 90%+ transport connectivity). The changes are well-motivated, clearly documented with references to the Kademlia paper, and the core data structures (BucketInsertResult, replacement cache, last_lookup) are implemented correctly.\n\nKey changes:\n- K bumped from 8 → 20 (libp2p/Kademlia standard), matching CLAUDE.md config docs\n- Stale-node eviction: full buckets now ping the LRS node instead of silently rejecting new nodes\n- Per-bucket replacement cache (5 slots, FIFO eviction) correctly promoted on remove_node\n- Periodic bucket refresh task (5 min interval, 1 hr stale threshold) with 60 s initial delay\n- Unknown message senders are now added to the routing table (Kademlia Section 2.1)\n\nIssues found:\n- find_nodes was changed from routing_table.read() to routing_table.write() because find_closest_nodes now mutates last_lookup. This serialises all DHT lookups behind a single write lock — a meaningful performance regression on a very hot path\n- The bucket refresh task stamps last_lookup = Instant::now() before the network lookup runs; a failed lookup silently suppresses retry for ~1 hour\n- find_closest_nodes_network is called with a hardcoded 20 instead of the K constant\n- A stale doc-comment fragment was left at the top of update_peer_info's doc block

Confidence Score: 3/5

The functional Kademlia logic is sound, but converting find_nodes to a write lock introduces a serialisation bottleneck on the hottest path in the DHT that is likely to regress throughput under load.

Two P1 issues exist: the write-lock regression on find_nodes (impacts every concurrent DHT lookup) and the premature last_lookup update that suppresses bucket refresh retries for up to an hour after a failed lookup. Neither causes data loss or a security issue, but the lock contention issue is a production reliability concern at scale. The correctness of the core Kademlia data structures (eviction, replacement cache, sender-add) is clean. Resolving the two P1s would move this to 4–5.

src/dht/core_engine.rs (write-lock regression in find_nodes, random_key_in_bucket pivot-byte coverage) and src/dht_network_manager.rs (premature last_lookup stamp in bucket refresh)

Important Files Changed

Filename Overview
src/dht/core_engine.rs Implements stale-node eviction, replacement cache, bucket refresh helpers, and K=20 upgrade; main concern is find_nodes now requiring a write lock on every DHT lookup due to last_lookup mutation in find_closest_nodes.
src/dht_network_manager.rs Wires up periodic bucket refresh and adds unknown-sender insertion; bucket timestamp is marked before the network lookup succeeds (could skip retry for up to 1 hour on failure), and K count is hardcoded instead of using the constant.

Sequence Diagram

sequenceDiagram
    participant NM as DhtNetworkManager
    participant DHT as DhtCoreEngine
    participant RT as KademliaRoutingTable
    participant Net as Network (other peers)

    Note over NM: On message receipt (Kademlia §2.1)
    NM->>DHT: touch_node(sender_id) [read lock on dht]
    DHT->>RT: touch_node → move to tail [write lock on routing_table]
    alt node already in table
        RT-->>NM: true → return early
    else node unknown
        NM->>DHT: add_node(sender_info) [write lock on dht]
        DHT->>RT: add_node [write lock on routing_table]
        alt bucket has space
            RT-->>NM: BucketInsertResult::Inserted
        else bucket full
            RT-->>NM: BucketFull { lrs_node_id, pending_node }
            NM->>Net: is_peer_connected(lrs_id) [spawned task]
            alt LRS alive
                NM->>DHT: add_to_replacement_cache(pending_node)
            else LRS gone
                NM->>DHT: evict_and_insert(lrs_id, pending_node)
                DHT->>RT: promote_replacement from cache
            end
        end
    end

    Note over NM: Periodic bucket refresh (every 5 min)
    loop every 5 minutes
        NM->>DHT: stale_bucket_indices(1h) [read lock]
        DHT->>RT: check last_lookup per bucket [read lock]
        RT-->>NM: stale bucket indices
        loop for each stale bucket
            NM->>DHT: random_key_for_bucket_refresh(idx) [write lock]
            DHT->>RT: mark_bucket_lookup + random_key_in_bucket
            RT-->>NM: DhtKey
            NM->>Net: find_closest_nodes_network(key, 20)
            Net-->>NM: closest nodes (routing table populated as side-effect)
        end
    end
Loading

Comments Outside Diff (1)

  1. src/dht_network_manager.rs, line 1638-1651 (link)

    P2 Duplicate/merged doc comment on update_peer_info

    The function's doc block contains a fragment of the old comment ("routing table entry to move it to the tail of its k-bucket…") immediately followed by the new Kademlia Section 2.1 comment. The old fragment was not removed, leaving a run-on sentence at the top of the doc block. The stale lines should be deleted so only the new description remains.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/dht_network_manager.rs
    Line: 1638-1651
    
    Comment:
    **Duplicate/merged doc comment on `update_peer_info`**
    
    The function's doc block contains a fragment of the old comment ("routing table entry to move it to the tail of its k-bucket…") immediately followed by the new Kademlia Section 2.1 comment. The old fragment was not removed, leaving a run-on sentence at the top of the doc block. The stale lines should be deleted so only the new description remains.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/dht/core_engine.rs
Line: 547-549

Comment:
**`find_nodes` downgraded to write-lock on a hot read path**

`find_closest_nodes` now mutates `last_lookup` on every call, forcing `find_nodes` to acquire a `routing_table.write()` lock. Previously this was a `read()` lock. Because `tokio::sync::RwLock` gives writers priority over readers, this change serialises all concurrent DHT lookups — any code that calls `find_nodes` in parallel (e.g. iterative network walks, bucket-refresh loops, and the `find_closest_nodes_network` fan-out) will queue behind a single writer instead of running concurrently.

`last_lookup` is only needed for the once-per-hour refresh heuristic. Consider storing it as an `AtomicU64` (epoch milliseconds) inside `KBucket`, which allows `find_closest_nodes` to stay a `&self` function and `find_nodes` to keep its `read()` lock:

```rust
use std::sync::atomic::{AtomicU64, Ordering};

struct KBucket {
    nodes: Vec<NodeInfo>,
    max_size: usize,
    replacement_cache: Vec<NodeInfo>,
    last_lookup_ms: AtomicU64,   // replaces `last_lookup: Instant`
}
```

Then the staleness check becomes a simple `Ordering::Relaxed` load, and `find_closest_nodes` can be `&self` again, restoring the read lock in `find_nodes`.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/dht_network_manager.rs
Line: 440-461

Comment:
**Bucket timestamp updated before the refresh lookup succeeds**

`random_key_for_bucket_refresh` calls `routing.mark_bucket_lookup(bucket_index)` _before_ the `find_closest_nodes_network` call. If the network lookup fails (timeout, no peers, etc.), `last_lookup` has already been set to `Instant::now()`, so the bucket is treated as freshly refreshed and won't be retried for another hour — defeating the purpose of the refresh.

Move `mark_bucket_lookup` to after a successful lookup, or split the helper so the key is generated without updating the timestamp:

```rust
// generate key (does NOT update last_lookup)
let key = {
    let routing = self_arc.dht.read().await.routing_table.read().await;
    routing.random_key_in_bucket(bucket_idx)
};

// only mark refreshed if lookup succeeds
if self_arc.find_closest_nodes_network(key.as_bytes(), 20).await.is_ok() {
    let dht = self_arc.dht.read().await;
    dht.mark_bucket_lookup(bucket_idx).await;
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/dht_network_manager.rs
Line: 452-455

Comment:
**Hardcoded `20` should reference the `K` constant**

The count passed to `find_closest_nodes_network` is hardcoded as `20`. If `K` is ever changed again, this won't track it automatically. Reference the constant instead (requires making `K` `pub(crate)` or re-exporting it).

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/dht/core_engine.rs
Line: 383-393

Comment:
**`random_key_in_bucket` skips lower bits of the pivot byte**

After XOR-flipping the bit at `bit_idx`, the code randomizes all bytes after `byte_idx` but leaves the bits below `bit_idx` in `byte_idx` unchanged (they retain the local node ID's value). For bucket 1 (second MSB), bits 0–6 of byte 0 are always fixed, artificially constraining key diversity for that bucket.

The bits below the pivot bit in the same byte should also be randomized:

```rust
let lower_mask: u8 = (1u8 << bit_idx).wrapping_sub(1);
let random_low: u8 = rand::random();
key_bytes[byte_idx] = (key_bytes[byte_idx] & !lower_mask) | (random_low & lower_mask);
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/dht_network_manager.rs
Line: 1638-1651

Comment:
**Duplicate/merged doc comment on `update_peer_info`**

The function's doc block contains a fragment of the old comment ("routing table entry to move it to the tail of its k-bucket…") immediately followed by the new Kademlia Section 2.1 comment. The old fragment was not removed, leaving a run-on sentence at the top of the doc block. The stale lines should be deleted so only the new description remains.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix: implement standard Kademlia DHT mai..." | Re-trigger Greptile

Greptile also left 4 inline comments on this PR.

The DHT routing table was severely underpopulated because several
standard Kademlia mechanisms were missing. ant-node testing showed
nodes discovering only 13-17% of the network via DHT despite being
transport-connected to ~90%, causing close group instability that
breaks merkle payment verification.

Changes:

1. K=8 to K=20: Bucket size was far below the standard Kademlia and
   libp2p value of 20. More peers per distance range means iterative
   lookups have better starting candidates and converge faster.

2. Stale-node eviction: When a k-bucket is full, the least-recently-
   seen node is checked for liveness instead of silently rejecting
   the new node. Dead peers get evicted and replaced. Per Kademlia
   Section 2.2: "contacts that have been around longer are more
   likely to remain."

3. Replacement cache: Nodes that cannot be inserted into full buckets
   (because the LRS node is alive) are held in a per-bucket cache
   (max 5). When a node is removed for any reason, the most recently
   seen replacement is promoted.

4. Periodic bucket refresh: Background task checks every 5 minutes
   and refreshes any bucket not looked up in the last hour by
   performing a find_closest_nodes with a random key in the bucket's
   XOR distance range. Per Kademlia Section 2.4.

5. Add unknown senders on message receipt: Per Kademlia Section 2.1,
   "when a node receives any message from another node, it updates
   the appropriate k-bucket." Previously only touched existing
   entries; now unknown senders are added (subject to diversity
   checks and bucket capacity).
Copilot AI review requested due to automatic review settings March 26, 2026 11:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements missing Kademlia maintenance mechanisms in the DHT to improve routing-table completeness and stabilize “closest K nodes” selection across the network (needed for merkle payment close-group stability in ant-node).

Changes:

  • Adds periodic k-bucket refresh scheduling and executes random-key FIND_NODE lookups for stale buckets.
  • Implements full-bucket insert handling with least-recently-seen (LRS) eviction flow plus a per-bucket replacement cache.
  • Updates routing-table maintenance to also add previously-unknown message senders on receipt, and increases core K-bucket size from 8 to 20.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 10 comments.

File Description
src/dht_network_manager.rs Starts a background bucket-refresh task and updates routing-table population/eviction behavior on message receipt and peer connect.
src/dht/core_engine.rs Updates routing table internals for K=20, introduces replacement cache + bucket refresh metadata, and exposes APIs used by the manager refresh task.

Comment on lines +1709 to +1710
Err(_) => {
// Diversity check failed — expected, no action needed

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swallowing all add_node errors here makes it hard to diagnose unexpected failures (e.g., non-diversity errors). At least log the error at debug/trace level, and consider only silencing the specific expected diversity-rejection cases.

Suggested change
Err(_) => {
// Diversity check failed — expected, no action needed
Err(err) => {
// Typically indicates a diversity check failure; log for diagnostics.
debug!(
"Failed to add message sender {} to routing table: {:?}",
app_peer_id.to_hex(),
err
);

Copilot uses AI. Check for mistakes.
Comment thread src/dht/core_engine.rs
Comment on lines +369 to +375
/// Return indices of buckets that haven't been looked up within `max_age`
/// and have at least one node (empty buckets don't need refresh).
fn stale_bucket_indices(&self, max_age: std::time::Duration) -> Vec<usize> {
self.buckets
.iter()
.enumerate()
.filter(|(_, b)| !b.nodes.is_empty() && b.last_lookup.elapsed() > max_age)

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stale_bucket_indices excludes empty buckets from refresh. In standard Kademlia bucket refresh, empty buckets can still need refresh (a random-ID lookup is exactly how you discover peers in that range). Skipping empty buckets may leave the table permanently underpopulated in those ranges.

Suggested change
/// Return indices of buckets that haven't been looked up within `max_age`
/// and have at least one node (empty buckets don't need refresh).
fn stale_bucket_indices(&self, max_age: std::time::Duration) -> Vec<usize> {
self.buckets
.iter()
.enumerate()
.filter(|(_, b)| !b.nodes.is_empty() && b.last_lookup.elapsed() > max_age)
/// Return indices of buckets that haven't been looked up within `max_age`.
/// All buckets (including empty ones) may need refresh to discover peers.
fn stale_bucket_indices(&self, max_age: std::time::Duration) -> Vec<usize> {
self.buckets
.iter()
.enumerate()
.filter(|(_, b)| b.last_lookup.elapsed() > max_age)

Copilot uses AI. Check for mistakes.
Comment thread src/dht/core_engine.rs
/// Find nodes closest to a key
pub async fn find_nodes(&self, key: &DhtKey, count: usize) -> Result<Vec<NodeInfo>> {
let routing = self.routing_table.read().await;
let mut routing = self.routing_table.write().await;

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

find_nodes now takes a write lock on the routing table just to update last_lookup, which reduces concurrency for read-only lookups and may become a contention point under load. Consider storing per-bucket last_lookup in a separate, cheaper-to-update structure (e.g., atomics or a small mutex per bucket) so lookups can keep using a read lock for routing-table access.

Suggested change
let mut routing = self.routing_table.write().await;
let routing = self.routing_table.read().await;

Copilot uses AI. Check for mistakes.
Comment thread src/dht/core_engine.rs
Comment on lines 583 to +589
/// Add a node to the DHT with security checks.
///
/// Diversity limits (IP subnet and geographic region) are derived from the
/// live routing table contents on every call, so counts are always accurate
/// regardless of evictions, reconnections, or address changes.
pub async fn add_node(&mut self, node: NodeInfo) -> Result<()> {
/// Add a node to the DHT routing table with diversity checks.
///

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment for add_node looks duplicated (it has both “Add a node to the DHT with security checks.” and “Add a node to the DHT routing table with diversity checks.”). Consider consolidating to a single description to avoid confusion.

Copilot uses AI. Check for mistakes.
Comment on lines +417 to +418
// Wait for initial bootstrap to complete before starting refresh
tokio::time::sleep(std::time::Duration::from_secs(60)).await;

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refresh task won’t observe shutdown cancellation during the initial 60s sleep, so stop() can return while this task is still pending and may run a refresh cycle later. Consider wrapping the initial delay in a tokio::select! on shutdown.cancelled() (or using an interval that is immediately cancellable).

Suggested change
// Wait for initial bootstrap to complete before starting refresh
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
// Wait for initial bootstrap to complete before starting refresh,
// but allow early exit if shutdown is requested.
tokio::select! {
() = shutdown.cancelled() => {
return;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
// Initial delay elapsed; proceed to periodic refresh.
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +450 to +455
// Request 20 closest nodes (K value)
if let Err(e) = self_arc
.find_closest_nodes_network(
key.as_bytes(),
20,
)

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hard-codes 20 as the K value, but other parts of the DHT manager still use a separate closest-nodes constant. To avoid inconsistencies, use a single source of truth (e.g., the DHT core’s K constant/config) instead of embedding 20 here.

Copilot uses AI. Check for mistakes.
Comment on lines +441 to +446
for bucket_idx in stale {
let key = {
let dht = self_arc.dht.read().await;
dht.random_key_for_bucket_refresh(bucket_idx).await
};

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refreshing every stale bucket in one loop can trigger a large burst of network lookups (potentially up to 256) in a single tick, which can add load and interfere with normal operations. Consider capping the number of buckets refreshed per interval and/or spreading work over time with bounded concurrency.

Copilot uses AI. Check for mistakes.
Comment on lines +1699 to +1703
tokio::spawn(async move {
let lrs_alive = transport.is_peer_connected(&lrs_id).await;
let dht_guard = dht.write().await;
if lrs_alive {
dht_guard.add_to_replacement_cache(pending_node).await;

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bucket-full handling claims to “ping” the least-recently-seen node, but it only checks transport.is_peer_connected. Connection status is not a liveness probe in Kademlia (the node could be alive but currently disconnected), and this can cause incorrect evictions/caching decisions. Use an actual ping RPC (and optionally dial first) with a timeout to decide whether to evict or keep the LRS node.

Copilot uses AI. Check for mistakes.
Comment on lines +1823 to +1829
let lrs_alive = transport
.is_peer_connected(&lrs_id)
.await;
let dht_guard = dht.write().await;
if lrs_alive {
// LRS node is still connected — keep it, cache the new node
dht_guard.add_to_replacement_cache(pending_node).await;

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above: this code path says it is “pinging LRS”, but it only checks whether the peer is currently connected. That’s not equivalent to a Kademlia ping/liveness check and can lead to evicting reachable-but-disconnected nodes. Consider performing a real DHT ping (and dialing if needed) before deciding whether to evict or cache.

Copilot uses AI. Check for mistakes.
Comment thread src/dht/core_engine.rs
Comment on lines +388 to +389
key_bytes[byte_idx] ^= 1 << bit_idx;
// Randomize remaining bits (after the prefix) for diversity

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

random_key_in_bucket only randomizes bytes after byte_idx + 1, leaving the remaining bits in the same byte as the flipped bucket bit unchanged. That reduces randomness within the bucket range and can bias refresh lookups. Consider randomizing all bits after bucket_index (including the lower bits of key_bytes[byte_idx]) while preserving the prefix bits before bucket_index.

Suggested change
key_bytes[byte_idx] ^= 1 << bit_idx;
// Randomize remaining bits (after the prefix) for diversity
// First flip the bucket bit
let flipped = key_bytes[byte_idx] ^ (1 << bit_idx);
// Preserve prefix bits (more significant than `bucket_index`), fix the bucket bit,
// and randomize all less-significant bits (including those in the same byte).
let high_mask: u8 = (0xFFu8 << (bit_idx + 1)) & 0xFF;
let low_mask: u8 = !high_mask;
let bucket_mask: u8 = 1 << bit_idx;
let rand_low: u8 = rand::random();
let preserved_high = flipped & high_mask;
let bucket_bit = flipped & bucket_mask;
let randomized_low = rand_low & (low_mask & !bucket_mask);
key_bytes[byte_idx] = preserved_high | bucket_bit | randomized_low;
// Randomize all subsequent bytes for diversity within the bucket range

Copilot uses AI. Check for mistakes.
Comment thread src/dht/core_engine.rs
Comment on lines 547 to 549
pub async fn find_nodes(&self, key: &DhtKey, count: usize) -> Result<Vec<NodeInfo>> {
let routing = self.routing_table.read().await;
let mut routing = self.routing_table.write().await;
Ok(routing.find_closest_nodes(key, count))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 find_nodes downgraded to write-lock on a hot read path

find_closest_nodes now mutates last_lookup on every call, forcing find_nodes to acquire a routing_table.write() lock. Previously this was a read() lock. Because tokio::sync::RwLock gives writers priority over readers, this change serialises all concurrent DHT lookups — any code that calls find_nodes in parallel (e.g. iterative network walks, bucket-refresh loops, and the find_closest_nodes_network fan-out) will queue behind a single writer instead of running concurrently.

last_lookup is only needed for the once-per-hour refresh heuristic. Consider storing it as an AtomicU64 (epoch milliseconds) inside KBucket, which allows find_closest_nodes to stay a &self function and find_nodes to keep its read() lock:

use std::sync::atomic::{AtomicU64, Ordering};

struct KBucket {
    nodes: Vec<NodeInfo>,
    max_size: usize,
    replacement_cache: Vec<NodeInfo>,
    last_lookup_ms: AtomicU64,   // replaces `last_lookup: Instant`
}

Then the staleness check becomes a simple Ordering::Relaxed load, and find_closest_nodes can be &self again, restoring the read lock in find_nodes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/dht/core_engine.rs
Line: 547-549

Comment:
**`find_nodes` downgraded to write-lock on a hot read path**

`find_closest_nodes` now mutates `last_lookup` on every call, forcing `find_nodes` to acquire a `routing_table.write()` lock. Previously this was a `read()` lock. Because `tokio::sync::RwLock` gives writers priority over readers, this change serialises all concurrent DHT lookups — any code that calls `find_nodes` in parallel (e.g. iterative network walks, bucket-refresh loops, and the `find_closest_nodes_network` fan-out) will queue behind a single writer instead of running concurrently.

`last_lookup` is only needed for the once-per-hour refresh heuristic. Consider storing it as an `AtomicU64` (epoch milliseconds) inside `KBucket`, which allows `find_closest_nodes` to stay a `&self` function and `find_nodes` to keep its `read()` lock:

```rust
use std::sync::atomic::{AtomicU64, Ordering};

struct KBucket {
    nodes: Vec<NodeInfo>,
    max_size: usize,
    replacement_cache: Vec<NodeInfo>,
    last_lookup_ms: AtomicU64,   // replaces `last_lookup: Instant`
}
```

Then the staleness check becomes a simple `Ordering::Relaxed` load, and `find_closest_nodes` can be `&self` again, restoring the read lock in `find_nodes`.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +440 to +461

for bucket_idx in stale {
let key = {
let dht = self_arc.dht.read().await;
dht.random_key_for_bucket_refresh(bucket_idx).await
};

// Perform a find_node lookup for the random key.
// The lookup itself populates the routing table
// as a side effect of the iterative walk.
// Request 20 closest nodes (K value)
if let Err(e) = self_arc
.find_closest_nodes_network(
key.as_bytes(),
20,
)
.await
{
tracing::debug!(
"Bucket refresh lookup for bucket {bucket_idx} failed: {e}"
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Bucket timestamp updated before the refresh lookup succeeds

random_key_for_bucket_refresh calls routing.mark_bucket_lookup(bucket_index) before the find_closest_nodes_network call. If the network lookup fails (timeout, no peers, etc.), last_lookup has already been set to Instant::now(), so the bucket is treated as freshly refreshed and won't be retried for another hour — defeating the purpose of the refresh.

Move mark_bucket_lookup to after a successful lookup, or split the helper so the key is generated without updating the timestamp:

// generate key (does NOT update last_lookup)
let key = {
    let routing = self_arc.dht.read().await.routing_table.read().await;
    routing.random_key_in_bucket(bucket_idx)
};

// only mark refreshed if lookup succeeds
if self_arc.find_closest_nodes_network(key.as_bytes(), 20).await.is_ok() {
    let dht = self_arc.dht.read().await;
    dht.mark_bucket_lookup(bucket_idx).await;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/dht_network_manager.rs
Line: 440-461

Comment:
**Bucket timestamp updated before the refresh lookup succeeds**

`random_key_for_bucket_refresh` calls `routing.mark_bucket_lookup(bucket_index)` _before_ the `find_closest_nodes_network` call. If the network lookup fails (timeout, no peers, etc.), `last_lookup` has already been set to `Instant::now()`, so the bucket is treated as freshly refreshed and won't be retried for another hour — defeating the purpose of the refresh.

Move `mark_bucket_lookup` to after a successful lookup, or split the helper so the key is generated without updating the timestamp:

```rust
// generate key (does NOT update last_lookup)
let key = {
    let routing = self_arc.dht.read().await.routing_table.read().await;
    routing.random_key_in_bucket(bucket_idx)
};

// only mark refreshed if lookup succeeds
if self_arc.find_closest_nodes_network(key.as_bytes(), 20).await.is_ok() {
    let dht = self_arc.dht.read().await;
    dht.mark_bucket_lookup(bucket_idx).await;
}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +452 to +455
.find_closest_nodes_network(
key.as_bytes(),
20,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded 20 should reference the K constant

The count passed to find_closest_nodes_network is hardcoded as 20. If K is ever changed again, this won't track it automatically. Reference the constant instead (requires making K pub(crate) or re-exporting it).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/dht_network_manager.rs
Line: 452-455

Comment:
**Hardcoded `20` should reference the `K` constant**

The count passed to `find_closest_nodes_network` is hardcoded as `20`. If `K` is ever changed again, this won't track it automatically. Reference the constant instead (requires making `K` `pub(crate)` or re-exporting it).

How can I resolve this? If you propose a fix, please make it concise.

Comment thread src/dht/core_engine.rs
Comment on lines +383 to +393
fn random_key_in_bucket(&self, bucket_index: usize) -> DhtKey {
let mut key_bytes = *self.node_id.to_bytes();
// Flip the bit at `bucket_index` to ensure the key is in the bucket's range
let byte_idx = bucket_index / 8;
let bit_idx = 7 - (bucket_index % 8);
key_bytes[byte_idx] ^= 1 << bit_idx;
// Randomize remaining bits (after the prefix) for diversity
for byte in key_bytes.iter_mut().skip(byte_idx + 1) {
*byte = rand::random();
}
DhtKey::from_bytes(key_bytes)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 random_key_in_bucket skips lower bits of the pivot byte

After XOR-flipping the bit at bit_idx, the code randomizes all bytes after byte_idx but leaves the bits below bit_idx in byte_idx unchanged (they retain the local node ID's value). For bucket 1 (second MSB), bits 0–6 of byte 0 are always fixed, artificially constraining key diversity for that bucket.

The bits below the pivot bit in the same byte should also be randomized:

let lower_mask: u8 = (1u8 << bit_idx).wrapping_sub(1);
let random_low: u8 = rand::random();
key_bytes[byte_idx] = (key_bytes[byte_idx] & !lower_mask) | (random_low & lower_mask);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/dht/core_engine.rs
Line: 383-393

Comment:
**`random_key_in_bucket` skips lower bits of the pivot byte**

After XOR-flipping the bit at `bit_idx`, the code randomizes all bytes after `byte_idx` but leaves the bits below `bit_idx` in `byte_idx` unchanged (they retain the local node ID's value). For bucket 1 (second MSB), bits 0–6 of byte 0 are always fixed, artificially constraining key diversity for that bucket.

The bits below the pivot bit in the same byte should also be randomized:

```rust
let lower_mask: u8 = (1u8 << bit_idx).wrapping_sub(1);
let random_low: u8 = rand::random();
key_bytes[byte_idx] = (key_bytes[byte_idx] & !lower_mask) | (random_low & lower_mask);
```

How can I resolve this? If you propose a fix, please make it concise.

@grumbach

Copy link
Copy Markdown
Collaborator Author

Closing for now, @mickvandijke feel free to re-open

@grumbach grumbach closed this Mar 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants