Skip to main content

ipfrs_network/
routing_table_sharding.rs

1//! Sharded DHT routing table that distributes peers across multiple shards
2//! based on XOR-distance partitioning, enabling parallel lookups and reduced
3//! contention under high peer churn.
4//!
5//! ## Design overview
6//!
7//! * `NodeId([u8; 32])` — 256-bit node identifier.  Shard assignment uses
8//!   `node_id.0[0] % num_shards` so that the bit-prefix distribution matches
9//!   the Kademlia bucket model while keeping O(1) assignment.
10//! * `RoutingTableSharding` stores one `HashMap<NodeId, RoutingEntry>` per
11//!   shard and never needs a global lock for single-shard operations.
12//! * Eviction is pluggable via `EvictionPolicy` — choose the least-recently-
13//!   seen node, the highest-RTT node, or a deterministic pseudo-random node
14//!   (xorshift64 seeded at construction time).
15//!
16//! ## Example
17//!
18//! ```rust
19//! use ipfrs_network::routing_table_sharding::{
20//!     EvictionPolicy, NodeId as RtsNodeId, RoutingEntry as RtsRoutingEntry,
21//!     RoutingTableSharding, ShardConfig,
22//! };
23//!
24//! let cfg = ShardConfig::default();
25//! let mut table = RoutingTableSharding::new(cfg);
26//!
27//! let id = RtsNodeId([1u8; 32]);
28//! let entry = RtsRoutingEntry {
29//!     node_id: id.clone(),
30//!     addr: "127.0.0.1:4001".to_string(),
31//!     last_seen: 1_000,
32//!     rtt_ms: 10,
33//!     shard: table.shard_for(&id),
34//! };
35//! let evicted = table.insert(entry, 1_000);
36//! assert!(!evicted);
37//! assert_eq!(table.total_entries(), 1);
38//! ```
39
40use std::cell::Cell;
41use std::collections::HashMap;
42
43// ─────────────────────────────────────────────────────────────────────────────
44// NodeId
45// ─────────────────────────────────────────────────────────────────────────────
46
47/// 256-bit node identifier used for XOR-distance routing.
48#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
49pub struct NodeId(pub [u8; 32]);
50
51impl std::fmt::Debug for NodeId {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(f, "NodeId(")?;
54        for b in &self.0 {
55            write!(f, "{b:02x}")?;
56        }
57        write!(f, ")")
58    }
59}
60
61impl std::fmt::Display for NodeId {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        for b in &self.0 {
64            write!(f, "{b:02x}")?;
65        }
66        Ok(())
67    }
68}
69
70impl NodeId {
71    /// Compute byte-wise XOR distance between `self` and `other`.
72    #[inline]
73    pub fn xor_distance(&self, other: &NodeId) -> [u8; 32] {
74        let mut out = [0u8; 32];
75        for (o, (&a, &b)) in out.iter_mut().zip(self.0.iter().zip(other.0.iter())) {
76            *o = a ^ b;
77        }
78        out
79    }
80
81    /// Count the number of leading zero *bits* in the raw bytes.
82    ///
83    /// This is used as the Kademlia bucket index.
84    #[inline]
85    pub fn leading_zeros(&self) -> u32 {
86        let mut count = 0u32;
87        for &byte in &self.0 {
88            if byte == 0 {
89                count += 8;
90            } else {
91                count += byte.leading_zeros();
92                break;
93            }
94        }
95        count
96    }
97
98    /// Parse a 64-character lower-case hex string into a `NodeId`.
99    ///
100    /// Returns `None` if the string is not exactly 64 hex characters.
101    pub fn from_str_hex(s: &str) -> Option<NodeId> {
102        let s = s.trim();
103        if s.len() != 64 {
104            return None;
105        }
106        let mut bytes = [0u8; 32];
107        for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
108            let hi = hex_nibble(chunk[0])?;
109            let lo = hex_nibble(chunk[1])?;
110            bytes[i] = (hi << 4) | lo;
111        }
112        Some(NodeId(bytes))
113    }
114}
115
116/// Convert a single ASCII hex character to its nibble value.
117#[inline]
118fn hex_nibble(b: u8) -> Option<u8> {
119    match b {
120        b'0'..=b'9' => Some(b - b'0'),
121        b'a'..=b'f' => Some(b - b'a' + 10),
122        b'A'..=b'F' => Some(b - b'A' + 10),
123        _ => None,
124    }
125}
126
127// ─────────────────────────────────────────────────────────────────────────────
128// ShardId
129// ─────────────────────────────────────────────────────────────────────────────
130
131/// Index of a shard in `[0, num_shards)`.
132#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
133pub struct ShardId(pub u8);
134
135// ─────────────────────────────────────────────────────────────────────────────
136// RoutingEntry
137// ─────────────────────────────────────────────────────────────────────────────
138
139/// A single peer record stored inside a shard.
140#[derive(Clone, Debug, PartialEq, Eq)]
141pub struct RoutingEntry {
142    /// The peer's 256-bit identifier.
143    pub node_id: NodeId,
144    /// Network address string (e.g. `"127.0.0.1:4001"`).
145    pub addr: String,
146    /// Unix-epoch millisecond timestamp of the last successful contact.
147    pub last_seen: u64,
148    /// Observed round-trip time in milliseconds.
149    pub rtt_ms: u32,
150    /// Pre-computed shard assignment for this entry.
151    pub shard: ShardId,
152}
153
154// ─────────────────────────────────────────────────────────────────────────────
155// EvictionPolicy
156// ─────────────────────────────────────────────────────────────────────────────
157
158/// Strategy used to select the victim when a shard is full.
159#[derive(Clone, Debug, Default, PartialEq, Eq)]
160pub enum EvictionPolicy {
161    /// Evict the peer that has not been seen for the longest time.
162    #[default]
163    LeastRecentlySeen,
164    /// Evict the peer with the highest observed RTT.
165    HighestRtt,
166    /// Deterministic pseudo-random eviction using an xorshift64 PRNG.
167    ///
168    /// The seed is advanced on every eviction decision.
169    Random {
170        /// Initial xorshift64 seed (must be non-zero; zero is replaced with 1).
171        seed: u64,
172    },
173}
174
175// ─────────────────────────────────────────────────────────────────────────────
176// ShardConfig
177// ─────────────────────────────────────────────────────────────────────────────
178
179/// Configuration for `RoutingTableSharding`.
180#[derive(Clone, Debug)]
181pub struct ShardConfig {
182    /// Number of shards.  Must be in `[1, 255]`.
183    pub num_shards: u8,
184    /// Maximum entries stored in each shard before eviction is triggered.
185    pub max_entries_per_shard: usize,
186    /// Policy used to select the eviction victim.
187    pub eviction_policy: EvictionPolicy,
188}
189
190impl Default for ShardConfig {
191    fn default() -> Self {
192        Self {
193            num_shards: 16,
194            max_entries_per_shard: 256,
195            eviction_policy: EvictionPolicy::LeastRecentlySeen,
196        }
197    }
198}
199
200// ─────────────────────────────────────────────────────────────────────────────
201// ShardStats
202// ─────────────────────────────────────────────────────────────────────────────
203
204/// Per-shard statistics snapshot.
205#[derive(Clone, Debug, PartialEq)]
206pub struct ShardStats {
207    /// Shard identifier.
208    pub shard_id: u8,
209    /// Number of entries currently in the shard.
210    pub entry_count: usize,
211    /// Mean RTT across all entries in the shard; `0.0` when the shard is empty.
212    pub avg_rtt_ms: f64,
213    /// `last_seen` value of the oldest (least-recently-seen) entry; `0` when empty.
214    pub oldest_entry_ms: u64,
215}
216
217// ─────────────────────────────────────────────────────────────────────────────
218// RoutingTableSharding
219// ─────────────────────────────────────────────────────────────────────────────
220
221/// Sharded DHT routing table.
222///
223/// Peers are placed into shards by `node_id.0[0] % num_shards`, giving an
224/// approximately uniform distribution for random node IDs while preserving the
225/// XOR-metric locality needed for closest-peer queries.
226pub struct RoutingTableSharding {
227    /// Shard configuration (immutable after construction).
228    pub config: ShardConfig,
229    /// One `HashMap` per shard.
230    pub shards: Vec<HashMap<NodeId, RoutingEntry>>,
231    /// Monotonically increasing count of successful `insert` calls.
232    pub total_insertions: u64,
233    /// Monotonically increasing count of evictions caused by capacity enforcement.
234    pub total_evictions: u64,
235    /// Monotonically increasing count of `get` / `closest_nodes` calls.
236    ///
237    /// Uses `Cell` for interior-mutability so that read-only methods
238    /// (`get`, `closest_nodes`) can increment the counter without requiring
239    /// `&mut self`.
240    pub total_lookups: Cell<u64>,
241    /// Current xorshift64 state for `EvictionPolicy::Random`.
242    rng_state: u64,
243}
244
245impl RoutingTableSharding {
246    /// Construct a new routing table with `config.num_shards` empty shards.
247    pub fn new(config: ShardConfig) -> Self {
248        let n = config.num_shards as usize;
249        // Initialise the RNG state from the seed embedded in the eviction policy
250        // (if present); fall back to a fixed non-zero value.
251        let rng_state = match &config.eviction_policy {
252            EvictionPolicy::Random { seed } => {
253                if *seed == 0 {
254                    1
255                } else {
256                    *seed
257                }
258            }
259            _ => 6_364_136_223_846_793_005_u64, // arbitrary non-zero constant
260        };
261        Self {
262            shards: (0..n).map(|_| HashMap::new()).collect(),
263            config,
264            total_insertions: 0,
265            total_evictions: 0,
266            total_lookups: Cell::new(0),
267            rng_state,
268        }
269    }
270
271    // ── Shard routing ────────────────────────────────────────────────────────
272
273    /// Compute which shard a `NodeId` belongs to.
274    #[inline]
275    pub fn shard_for(&self, node_id: &NodeId) -> ShardId {
276        ShardId(node_id.0[0] % self.config.num_shards)
277    }
278
279    // ── Mutation ─────────────────────────────────────────────────────────────
280
281    /// Insert `entry` into the appropriate shard.
282    ///
283    /// If the shard is already at capacity after the insertion, one entry is
284    /// evicted according to `config.eviction_policy`.
285    ///
286    /// Returns `true` if an eviction occurred, `false` otherwise.
287    pub fn insert(&mut self, entry: RoutingEntry, _now: u64) -> bool {
288        let shard_idx = self.shard_for(&entry.node_id).0 as usize;
289        let shard = match self.shards.get_mut(shard_idx) {
290            Some(s) => s,
291            None => return false,
292        };
293        shard.insert(entry.node_id, entry);
294        self.total_insertions += 1;
295
296        if shard.len() > self.config.max_entries_per_shard {
297            self.evict_one(shard_idx);
298            true
299        } else {
300            false
301        }
302    }
303
304    /// Remove the entry for `node_id`.  Returns `true` if an entry existed.
305    pub fn remove(&mut self, node_id: &NodeId) -> bool {
306        let shard_idx = self.shard_for(node_id).0 as usize;
307        match self.shards.get_mut(shard_idx) {
308            Some(shard) => shard.remove(node_id).is_some(),
309            None => false,
310        }
311    }
312
313    /// Look up a single entry by `NodeId`.
314    pub fn get(&self, node_id: &NodeId) -> Option<&RoutingEntry> {
315        self.total_lookups_inc();
316        let shard_idx = self.shard_for(node_id).0 as usize;
317        self.shards.get(shard_idx)?.get(node_id)
318    }
319
320    /// Update the RTT and `last_seen` timestamp for an existing entry.
321    ///
322    /// Returns `true` if the entry was found and updated.
323    pub fn update_rtt(&mut self, node_id: &NodeId, rtt_ms: u32, now: u64) -> bool {
324        let shard_idx = self.shard_for(node_id).0 as usize;
325        match self.shards.get_mut(shard_idx) {
326            Some(shard) => match shard.get_mut(node_id) {
327                Some(e) => {
328                    e.rtt_ms = rtt_ms;
329                    e.last_seen = now;
330                    true
331                }
332                None => false,
333            },
334            None => false,
335        }
336    }
337
338    // ── Queries ──────────────────────────────────────────────────────────────
339
340    /// Return the `k` entries whose XOR distance to `target` is smallest,
341    /// sorted ascending by distance (closest first).
342    ///
343    /// Scans all shards; O(N log k) where N is the total number of entries.
344    pub fn closest_nodes(&self, target: &NodeId, k: usize) -> Vec<&RoutingEntry> {
345        self.total_lookups_inc();
346        let mut all: Vec<(&NodeId, &RoutingEntry)> =
347            self.shards.iter().flat_map(|shard| shard.iter()).collect();
348
349        all.sort_by(|(a_id, _), (b_id, _)| {
350            let da = a_id.xor_distance(target);
351            let db = b_id.xor_distance(target);
352            da.cmp(&db)
353        });
354
355        all.into_iter().take(k).map(|(_, e)| e).collect()
356    }
357
358    /// Return all entries in the specified shard.
359    pub fn nodes_in_shard(&self, shard: ShardId) -> Vec<&RoutingEntry> {
360        match self.shards.get(shard.0 as usize) {
361            Some(s) => s.values().collect(),
362            None => vec![],
363        }
364    }
365
366    /// Remove all entries where `now.saturating_sub(last_seen) > max_age_ms`.
367    ///
368    /// Returns the number of entries removed.
369    pub fn evict_stale(&mut self, now: u64, max_age_ms: u64) -> usize {
370        let mut removed = 0usize;
371        for shard in &mut self.shards {
372            let before = shard.len();
373            shard.retain(|_, e| now.saturating_sub(e.last_seen) <= max_age_ms);
374            removed += before - shard.len();
375        }
376        removed
377    }
378
379    // ── Statistics ───────────────────────────────────────────────────────────
380
381    /// Statistics for a single shard at time `now`.
382    pub fn shard_stats(&self, shard: ShardId, now: u64) -> ShardStats {
383        let shard_idx = shard.0 as usize;
384        let shard_map = match self.shards.get(shard_idx) {
385            Some(s) => s,
386            None => {
387                return ShardStats {
388                    shard_id: shard.0,
389                    entry_count: 0,
390                    avg_rtt_ms: 0.0,
391                    oldest_entry_ms: 0,
392                };
393            }
394        };
395
396        let _ = now; // available for future use (e.g. age-based metrics)
397        let entry_count = shard_map.len();
398        if entry_count == 0 {
399            return ShardStats {
400                shard_id: shard.0,
401                entry_count: 0,
402                avg_rtt_ms: 0.0,
403                oldest_entry_ms: 0,
404            };
405        }
406
407        let sum_rtt: u64 = shard_map.values().map(|e| e.rtt_ms as u64).sum();
408        let avg_rtt_ms = sum_rtt as f64 / entry_count as f64;
409        let oldest_entry_ms = shard_map.values().map(|e| e.last_seen).min().unwrap_or(0);
410
411        ShardStats {
412            shard_id: shard.0,
413            entry_count,
414            avg_rtt_ms,
415            oldest_entry_ms,
416        }
417    }
418
419    /// Statistics for all shards, sorted ascending by `shard_id`.
420    pub fn all_stats(&self, now: u64) -> Vec<ShardStats> {
421        (0..self.config.num_shards)
422            .map(|i| self.shard_stats(ShardId(i), now))
423            .collect()
424    }
425
426    /// Total number of entries across all shards.
427    pub fn total_entries(&self) -> usize {
428        self.shards.iter().map(|s| s.len()).sum()
429    }
430
431    /// Global counters: `(total_insertions, total_evictions, total_lookups)`.
432    pub fn global_stats(&self) -> (u64, u64, u64) {
433        (
434            self.total_insertions,
435            self.total_evictions,
436            self.total_lookups.get(),
437        )
438    }
439
440    // ── Internal helpers ─────────────────────────────────────────────────────
441
442    /// Increment the `total_lookups` counter via `Cell<u64>` interior mutability.
443    #[inline]
444    fn total_lookups_inc(&self) {
445        self.total_lookups
446            .set(self.total_lookups.get().wrapping_add(1));
447    }
448
449    /// Pick and remove one victim from shard `shard_idx` according to the
450    /// configured eviction policy.
451    fn evict_one(&mut self, shard_idx: usize) {
452        use EvictionPolicy::{HighestRtt, LeastRecentlySeen, Random};
453
454        // Snapshot the node IDs so we can find the victim without holding a
455        // mutable reference to the shard at the same time.
456        let victim_id: Option<NodeId> = {
457            let shard = match self.shards.get(shard_idx) {
458                Some(s) => s,
459                None => return,
460            };
461
462            match &self.config.eviction_policy {
463                LeastRecentlySeen => shard
464                    .values()
465                    .min_by_key(|e| e.last_seen)
466                    .map(|e| e.node_id),
467
468                HighestRtt => shard.values().max_by_key(|e| e.rtt_ms).map(|e| e.node_id),
469
470                Random { .. } => {
471                    // We need a stable index-based traversal; collect keys.
472                    let keys: Vec<NodeId> = shard.keys().copied().collect();
473                    if keys.is_empty() {
474                        None
475                    } else {
476                        // xorshift64 step
477                        let mut state = self.rng_state;
478                        state ^= state << 13;
479                        state ^= state >> 7;
480                        state ^= state << 17;
481                        // Store updated state before immutable borrow ends.
482                        Some(keys[state as usize % keys.len()])
483                        // state is updated below after victim_id is determined
484                    }
485                }
486            }
487        };
488
489        // Advance PRNG state after the immutable borrow of `self.config` ends.
490        if matches!(&self.config.eviction_policy, EvictionPolicy::Random { .. }) {
491            let mut state = self.rng_state;
492            state ^= state << 13;
493            state ^= state >> 7;
494            state ^= state << 17;
495            self.rng_state = state;
496        }
497
498        if let Some(id) = victim_id {
499            if let Some(shard) = self.shards.get_mut(shard_idx) {
500                if shard.remove(&id).is_some() {
501                    self.total_evictions += 1;
502                }
503            }
504        }
505    }
506}
507
508// ─────────────────────────────────────────────────────────────────────────────
509// Tests
510// ─────────────────────────────────────────────────────────────────────────────
511
512#[cfg(test)]
513mod tests {
514    use super::{EvictionPolicy, NodeId, RoutingEntry, RoutingTableSharding, ShardConfig, ShardId};
515
516    // ── helpers ──────────────────────────────────────────────────────────────
517
518    fn make_id(first_byte: u8) -> NodeId {
519        let mut b = [0u8; 32];
520        b[0] = first_byte;
521        NodeId(b)
522    }
523
524    fn make_id_full(bytes: [u8; 32]) -> NodeId {
525        NodeId(bytes)
526    }
527
528    fn make_entry(
529        id: NodeId,
530        addr: &str,
531        last_seen: u64,
532        rtt_ms: u32,
533        shard: ShardId,
534    ) -> RoutingEntry {
535        RoutingEntry {
536            node_id: id,
537            addr: addr.to_string(),
538            last_seen,
539            rtt_ms,
540            shard,
541        }
542    }
543
544    fn default_table() -> RoutingTableSharding {
545        RoutingTableSharding::new(ShardConfig::default())
546    }
547
548    // ─────────────────────────────────────────────────────────────────────────
549    // NodeId tests
550    // ─────────────────────────────────────────────────────────────────────────
551
552    #[test]
553    fn node_id_xor_distance_identity() {
554        let id = make_id(0xAB);
555        let dist = id.xor_distance(&id);
556        assert_eq!(dist, [0u8; 32]);
557    }
558
559    #[test]
560    fn node_id_xor_distance_known_value() {
561        let a = make_id(0b0000_0001);
562        let b = make_id(0b0000_0011);
563        let dist = a.xor_distance(&b);
564        assert_eq!(dist[0], 0b0000_0010);
565        assert_eq!(&dist[1..], &[0u8; 31]);
566    }
567
568    #[test]
569    fn node_id_leading_zeros_all_zero() {
570        let id = NodeId([0u8; 32]);
571        assert_eq!(id.leading_zeros(), 256);
572    }
573
574    #[test]
575    fn node_id_leading_zeros_first_bit_set() {
576        let id = make_id(0x80); // 1000_0000
577        assert_eq!(id.leading_zeros(), 0);
578    }
579
580    #[test]
581    fn node_id_leading_zeros_second_bit_set() {
582        let id = make_id(0x40); // 0100_0000
583        assert_eq!(id.leading_zeros(), 1);
584    }
585
586    #[test]
587    fn node_id_leading_zeros_cross_byte_boundary() {
588        let mut b = [0u8; 32];
589        b[1] = 0x80; // second byte first bit set → 8 leading zeros
590        let id = NodeId(b);
591        assert_eq!(id.leading_zeros(), 8);
592    }
593
594    #[test]
595    fn node_id_from_str_hex_valid() {
596        let hex = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
597        let id = NodeId::from_str_hex(hex).expect("valid hex");
598        assert_eq!(id.0[0], 0x01);
599        assert_eq!(id.0[31], 0x20);
600    }
601
602    #[test]
603    fn node_id_from_str_hex_invalid_length() {
604        assert!(NodeId::from_str_hex("0102").is_none());
605    }
606
607    #[test]
608    fn node_id_from_str_hex_invalid_char() {
609        let bad = "zz02030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
610        assert!(NodeId::from_str_hex(bad).is_none());
611    }
612
613    #[test]
614    fn node_id_from_str_hex_roundtrip() {
615        let id = make_id(0xDE);
616        let hex = format!("{id}");
617        let id2 = NodeId::from_str_hex(&hex).expect("roundtrip");
618        assert_eq!(id, id2);
619    }
620
621    #[test]
622    fn node_id_ordering() {
623        let a = make_id(1);
624        let b = make_id(2);
625        assert!(a < b);
626    }
627
628    // ─────────────────────────────────────────────────────────────────────────
629    // ShardConfig / default
630    // ─────────────────────────────────────────────────────────────────────────
631
632    #[test]
633    fn shard_config_default_values() {
634        let cfg = ShardConfig::default();
635        assert_eq!(cfg.num_shards, 16);
636        assert_eq!(cfg.max_entries_per_shard, 256);
637        assert_eq!(cfg.eviction_policy, EvictionPolicy::LeastRecentlySeen);
638    }
639
640    // ─────────────────────────────────────────────────────────────────────────
641    // shard_for
642    // ─────────────────────────────────────────────────────────────────────────
643
644    #[test]
645    fn shard_for_is_first_byte_mod_num_shards() {
646        let table = default_table();
647        for first in 0u8..=255 {
648            let id = make_id(first);
649            assert_eq!(table.shard_for(&id).0, first % 16);
650        }
651    }
652
653    #[test]
654    fn shard_for_single_shard_always_zero() {
655        let cfg = ShardConfig {
656            num_shards: 1,
657            ..Default::default()
658        };
659        let table = RoutingTableSharding::new(cfg);
660        for first in 0u8..=255 {
661            assert_eq!(table.shard_for(&make_id(first)).0, 0);
662        }
663    }
664
665    // ─────────────────────────────────────────────────────────────────────────
666    // insert / get / remove
667    // ─────────────────────────────────────────────────────────────────────────
668
669    #[test]
670    fn insert_and_get_basic() {
671        let mut table = default_table();
672        let id = make_id(0x00);
673        let shard = table.shard_for(&id);
674        let entry = make_entry(id, "127.0.0.1:4001", 1000, 5, shard);
675        let evicted = table.insert(entry, 1000);
676        assert!(!evicted);
677        let got = table.get(&id).expect("entry present");
678        assert_eq!(got.addr, "127.0.0.1:4001");
679        assert_eq!(got.rtt_ms, 5);
680    }
681
682    #[test]
683    fn insert_increments_total_insertions() {
684        let mut table = default_table();
685        let id = make_id(0x10);
686        let shard = table.shard_for(&id);
687        table.insert(make_entry(id, "a", 0, 0, shard), 0);
688        assert_eq!(table.total_insertions, 1);
689    }
690
691    #[test]
692    fn remove_existing_entry() {
693        let mut table = default_table();
694        let id = make_id(0x20);
695        let shard = table.shard_for(&id);
696        table.insert(make_entry(id, "a", 0, 0, shard), 0);
697        assert!(table.remove(&id));
698        assert!(table.get(&id).is_none());
699    }
700
701    #[test]
702    fn remove_absent_entry_returns_false() {
703        let mut table = default_table();
704        assert!(!table.remove(&make_id(0x30)));
705    }
706
707    #[test]
708    fn total_entries_reflects_inserts_and_removes() {
709        let mut table = default_table();
710        let ids: Vec<NodeId> = (0..5).map(|i| make_id(i * 16)).collect();
711        for &id in &ids {
712            let shard = table.shard_for(&id);
713            table.insert(make_entry(id, "x", 0, 0, shard), 0);
714        }
715        assert_eq!(table.total_entries(), 5);
716        table.remove(&ids[0]);
717        assert_eq!(table.total_entries(), 4);
718    }
719
720    #[test]
721    fn duplicate_insert_overwrites_existing_entry() {
722        let mut table = default_table();
723        let id = make_id(0x01);
724        let shard = table.shard_for(&id);
725        table.insert(make_entry(id, "old", 100, 10, shard), 100);
726        table.insert(make_entry(id, "new", 200, 20, shard), 200);
727        let got = table.get(&id).expect("entry present");
728        assert_eq!(got.addr, "new");
729        assert_eq!(got.rtt_ms, 20);
730    }
731
732    // ─────────────────────────────────────────────────────────────────────────
733    // update_rtt
734    // ─────────────────────────────────────────────────────────────────────────
735
736    #[test]
737    fn update_rtt_existing_entry() {
738        let mut table = default_table();
739        let id = make_id(0x02);
740        let shard = table.shard_for(&id);
741        table.insert(make_entry(id, "a", 1000, 50, shard), 1000);
742        let ok = table.update_rtt(&id, 25, 2000);
743        assert!(ok);
744        let e = table.get(&id).expect("still present");
745        assert_eq!(e.rtt_ms, 25);
746        assert_eq!(e.last_seen, 2000);
747    }
748
749    #[test]
750    fn update_rtt_missing_entry_returns_false() {
751        let mut table = default_table();
752        assert!(!table.update_rtt(&make_id(0xFF), 10, 0));
753    }
754
755    // ─────────────────────────────────────────────────────────────────────────
756    // closest_nodes
757    // ─────────────────────────────────────────────────────────────────────────
758
759    #[test]
760    fn closest_nodes_returns_sorted_by_xor() {
761        let mut table = RoutingTableSharding::new(ShardConfig {
762            num_shards: 4,
763            max_entries_per_shard: 256,
764            eviction_policy: EvictionPolicy::LeastRecentlySeen,
765        });
766        // Create IDs with known distances from target 0x00...
767        let target = NodeId([0u8; 32]);
768        let ids: Vec<NodeId> = (1u8..=4).map(make_id).collect();
769        for &id in &ids {
770            let shard = table.shard_for(&id);
771            table.insert(make_entry(id, "a", 0, 0, shard), 0);
772        }
773        let closest = table.closest_nodes(&target, 2);
774        assert_eq!(closest.len(), 2);
775        // node with first byte=1 is closer than first byte=2
776        assert_eq!(closest[0].node_id, make_id(1));
777        assert_eq!(closest[1].node_id, make_id(2));
778    }
779
780    #[test]
781    fn closest_nodes_k_larger_than_total_returns_all() {
782        let mut table = default_table();
783        for i in 0u8..5 {
784            let id = make_id(i);
785            let shard = table.shard_for(&id);
786            table.insert(make_entry(id, "a", 0, 0, shard), 0);
787        }
788        let result = table.closest_nodes(&NodeId([0u8; 32]), 100);
789        assert_eq!(result.len(), 5);
790    }
791
792    #[test]
793    fn closest_nodes_increments_lookup_counter() {
794        let table = default_table();
795        table.closest_nodes(&NodeId([0u8; 32]), 5);
796        assert_eq!(table.total_lookups.get(), 1);
797    }
798
799    // ─────────────────────────────────────────────────────────────────────────
800    // nodes_in_shard
801    // ─────────────────────────────────────────────────────────────────────────
802
803    #[test]
804    fn nodes_in_shard_returns_correct_entries() {
805        let mut table = RoutingTableSharding::new(ShardConfig {
806            num_shards: 4,
807            max_entries_per_shard: 256,
808            eviction_policy: EvictionPolicy::LeastRecentlySeen,
809        });
810        // first_byte=0 → shard 0; first_byte=1 → shard 1
811        let id0 = make_id(0);
812        let id1 = make_id(1);
813        table.insert(make_entry(id0, "a", 0, 0, ShardId(0)), 0);
814        table.insert(make_entry(id1, "b", 0, 0, ShardId(1)), 0);
815        assert_eq!(table.nodes_in_shard(ShardId(0)).len(), 1);
816        assert_eq!(table.nodes_in_shard(ShardId(1)).len(), 1);
817        assert_eq!(table.nodes_in_shard(ShardId(2)).len(), 0);
818    }
819
820    #[test]
821    fn nodes_in_shard_invalid_shard_returns_empty() {
822        let table = default_table(); // 16 shards
823        assert_eq!(table.nodes_in_shard(ShardId(200)).len(), 0);
824    }
825
826    // ─────────────────────────────────────────────────────────────────────────
827    // evict_stale
828    // ─────────────────────────────────────────────────────────────────────────
829
830    #[test]
831    fn evict_stale_removes_old_entries() {
832        let mut table = default_table();
833        let id_old = make_id(0x00);
834        let id_new = make_id(0x10);
835        let s_old = table.shard_for(&id_old);
836        let s_new = table.shard_for(&id_new);
837        table.insert(make_entry(id_old, "old", 100, 0, s_old), 100);
838        table.insert(make_entry(id_new, "new", 2000, 0, s_new), 2000);
839
840        // now=3000, max_age=500 → 3000-100=2900 > 500 → id_old stale
841        //                        → 3000-2000=1000 > 500 → id_new also stale!
842        // Use max_age=1500 to keep id_new
843        let removed = table.evict_stale(3000, 1500);
844        assert_eq!(removed, 1);
845        assert!(table.get(&id_old).is_none());
846        assert!(table.get(&id_new).is_some());
847    }
848
849    #[test]
850    fn evict_stale_no_stale_entries_returns_zero() {
851        let mut table = default_table();
852        let id = make_id(0x05);
853        let shard = table.shard_for(&id);
854        table.insert(make_entry(id, "a", 1000, 0, shard), 1000);
855        let removed = table.evict_stale(1500, 1000); // age=500 ≤ 1000
856        assert_eq!(removed, 0);
857    }
858
859    #[test]
860    fn evict_stale_removes_all_stale() {
861        let mut table = default_table();
862        for i in 0u8..8 {
863            let id = make_id(i);
864            let shard = table.shard_for(&id);
865            table.insert(make_entry(id, "a", 0, 0, shard), 0);
866        }
867        let removed = table.evict_stale(10_000, 100); // all entries are older than 100 ms
868        assert_eq!(removed, 8);
869        assert_eq!(table.total_entries(), 0);
870    }
871
872    // ─────────────────────────────────────────────────────────────────────────
873    // shard_stats / all_stats
874    // ─────────────────────────────────────────────────────────────────────────
875
876    #[test]
877    fn shard_stats_empty_shard() {
878        let table = default_table();
879        let stats = table.shard_stats(ShardId(0), 1000);
880        assert_eq!(stats.entry_count, 0);
881        assert_eq!(stats.avg_rtt_ms, 0.0);
882        assert_eq!(stats.oldest_entry_ms, 0);
883    }
884
885    #[test]
886    fn shard_stats_single_entry() {
887        let mut table = RoutingTableSharding::new(ShardConfig {
888            num_shards: 4,
889            max_entries_per_shard: 256,
890            eviction_policy: EvictionPolicy::LeastRecentlySeen,
891        });
892        let id = make_id(0); // shard 0
893        table.insert(make_entry(id, "a", 500, 20, ShardId(0)), 500);
894        let stats = table.shard_stats(ShardId(0), 1000);
895        assert_eq!(stats.entry_count, 1);
896        assert!((stats.avg_rtt_ms - 20.0).abs() < f64::EPSILON);
897        assert_eq!(stats.oldest_entry_ms, 500);
898    }
899
900    #[test]
901    fn shard_stats_avg_rtt_multiple_entries() {
902        let mut table = RoutingTableSharding::new(ShardConfig {
903            num_shards: 1,
904            max_entries_per_shard: 256,
905            eviction_policy: EvictionPolicy::LeastRecentlySeen,
906        });
907        // All in shard 0 (num_shards=1)
908        let ids: Vec<NodeId> = (0u8..4).map(make_id).collect();
909        let rtts = [10u32, 20, 30, 40];
910        for (id, &rtt) in ids.iter().zip(rtts.iter()) {
911            table.insert(make_entry(*id, "a", 1000, rtt, ShardId(0)), 1000);
912        }
913        let stats = table.shard_stats(ShardId(0), 1000);
914        assert_eq!(stats.entry_count, 4);
915        assert!((stats.avg_rtt_ms - 25.0).abs() < 1e-9);
916    }
917
918    #[test]
919    fn all_stats_returns_one_per_shard_sorted() {
920        let table = default_table(); // 16 shards
921        let stats = table.all_stats(1000);
922        assert_eq!(stats.len(), 16);
923        for (i, s) in stats.iter().enumerate() {
924            assert_eq!(s.shard_id, i as u8);
925        }
926    }
927
928    // ─────────────────────────────────────────────────────────────────────────
929    // Eviction policies
930    // ─────────────────────────────────────────────────────────────────────────
931
932    fn fill_shard_to_capacity(
933        table: &mut RoutingTableSharding,
934        shard_first_byte: u8,
935        count: usize,
936    ) {
937        // Insert `count` entries all mapping to the same shard.
938        // num_shards is assumed to divide evenly.  We vary bytes 1..31 to get
939        // distinct NodeIds that still land in the same shard.
940        let num_shards = table.config.num_shards;
941        for i in 0u8..(count as u8) {
942            let mut b = [0u8; 32];
943            b[0] = shard_first_byte;
944            b[1] = i;
945            let id = NodeId(b);
946            // Ensure the entry really maps to the intended shard
947            debug_assert_eq!(id.0[0] % num_shards, shard_first_byte % num_shards);
948            let shard = table.shard_for(&id);
949            let entry = RoutingEntry {
950                node_id: id,
951                addr: format!("10.0.0.{i}:4001"),
952                last_seen: i as u64 * 100, // ascending last_seen
953                rtt_ms: 200 - i as u32,    // descending RTT (i=0 → rtt=200 highest)
954                shard,
955            };
956            table.insert(entry, i as u64 * 100);
957        }
958    }
959
960    #[test]
961    fn eviction_lrs_removes_least_recently_seen() {
962        let max = 4usize;
963        let cfg = ShardConfig {
964            num_shards: 16,
965            max_entries_per_shard: max,
966            eviction_policy: EvictionPolicy::LeastRecentlySeen,
967        };
968        let mut table = RoutingTableSharding::new(cfg);
969        // Fill shard 0 to exactly `max` entries.
970        // Entry with b[1]=0 has last_seen=0 (oldest).
971        fill_shard_to_capacity(&mut table, 0, max);
972        assert_eq!(table.total_entries(), max);
973
974        // Insert one more → triggers eviction.
975        let mut b = [0u8; 32];
976        b[0] = 0;
977        b[1] = 100; // unique
978        let trigger_id = NodeId(b);
979        let shard = table.shard_for(&trigger_id);
980        table.insert(
981            RoutingEntry {
982                node_id: trigger_id,
983                addr: "trigger".to_string(),
984                last_seen: 9999,
985                rtt_ms: 1,
986                shard,
987            },
988            9999,
989        );
990        // Shard is back to `max` entries and total_evictions incremented.
991        assert_eq!(table.nodes_in_shard(shard).len(), max);
992        assert_eq!(table.total_evictions, 1);
993
994        // Entry with last_seen=0 (b[1]=0) should be gone.
995        let mut victim_b = [0u8; 32];
996        victim_b[0] = 0;
997        victim_b[1] = 0;
998        assert!(
999            table.get(&NodeId(victim_b)).is_none(),
1000            "LRS victim should be evicted"
1001        );
1002    }
1003
1004    #[test]
1005    fn eviction_highest_rtt_removes_slowest_peer() {
1006        let max = 4usize;
1007        let cfg = ShardConfig {
1008            num_shards: 16,
1009            max_entries_per_shard: max,
1010            eviction_policy: EvictionPolicy::HighestRtt,
1011        };
1012        let mut table = RoutingTableSharding::new(cfg);
1013        fill_shard_to_capacity(&mut table, 0, max);
1014
1015        let mut b = [0u8; 32];
1016        b[0] = 0;
1017        b[1] = 100;
1018        let trigger_id = NodeId(b);
1019        let shard = table.shard_for(&trigger_id);
1020        table.insert(
1021            RoutingEntry {
1022                node_id: trigger_id,
1023                addr: "trigger".to_string(),
1024                last_seen: 9999,
1025                rtt_ms: 1,
1026                shard,
1027            },
1028            9999,
1029        );
1030        assert_eq!(table.total_evictions, 1);
1031
1032        // Entry with rtt_ms=200 (b[1]=0) should be gone.
1033        let mut victim_b = [0u8; 32];
1034        victim_b[0] = 0;
1035        victim_b[1] = 0;
1036        assert!(
1037            table.get(&NodeId(victim_b)).is_none(),
1038            "HighestRtt victim should be evicted"
1039        );
1040    }
1041
1042    #[test]
1043    fn eviction_random_removes_one_entry() {
1044        let max = 4usize;
1045        let cfg = ShardConfig {
1046            num_shards: 16,
1047            max_entries_per_shard: max,
1048            eviction_policy: EvictionPolicy::Random {
1049                seed: 0xDEAD_BEEF_1234_5678,
1050            },
1051        };
1052        let mut table = RoutingTableSharding::new(cfg);
1053        fill_shard_to_capacity(&mut table, 0, max);
1054
1055        let mut b = [0u8; 32];
1056        b[0] = 0;
1057        b[1] = 100;
1058        let trigger_id = NodeId(b);
1059        let shard = table.shard_for(&trigger_id);
1060        let evicted = table.insert(
1061            RoutingEntry {
1062                node_id: trigger_id,
1063                addr: "trigger".to_string(),
1064                last_seen: 9999,
1065                rtt_ms: 1,
1066                shard,
1067            },
1068            9999,
1069        );
1070        assert!(evicted, "Should have evicted one entry");
1071        assert_eq!(table.nodes_in_shard(ShardId(0)).len(), max);
1072        assert_eq!(table.total_evictions, 1);
1073    }
1074
1075    #[test]
1076    fn eviction_random_seed_zero_replaced_with_one() {
1077        // seed=0 is replaced with 1; construction must not panic/infinite-loop.
1078        let cfg = ShardConfig {
1079            num_shards: 1,
1080            max_entries_per_shard: 2,
1081            eviction_policy: EvictionPolicy::Random { seed: 0 },
1082        };
1083        let mut table = RoutingTableSharding::new(cfg);
1084        for i in 0u8..3 {
1085            let id = make_id(i);
1086            let shard = table.shard_for(&id);
1087            table.insert(make_entry(id, "a", i as u64 * 10, 10, shard), i as u64 * 10);
1088        }
1089        assert_eq!(table.total_entries(), 2);
1090        assert_eq!(table.total_evictions, 1);
1091    }
1092
1093    // ─────────────────────────────────────────────────────────────────────────
1094    // global_stats
1095    // ─────────────────────────────────────────────────────────────────────────
1096
1097    #[test]
1098    fn global_stats_tracks_all_counters() {
1099        let mut table = default_table();
1100        let id = make_id(0x04);
1101        let shard = table.shard_for(&id);
1102        table.insert(make_entry(id, "a", 0, 0, shard), 0);
1103        let _ = table.get(&id);
1104        table.closest_nodes(&NodeId([0u8; 32]), 10);
1105        let (ins, evic, look) = table.global_stats();
1106        assert_eq!(ins, 1);
1107        assert_eq!(evic, 0);
1108        assert_eq!(look, 2); // one get + one closest_nodes
1109    }
1110
1111    // ─────────────────────────────────────────────────────────────────────────
1112    // Misc / edge cases
1113    // ─────────────────────────────────────────────────────────────────────────
1114
1115    #[test]
1116    fn empty_table_closest_nodes_returns_empty() {
1117        let table = default_table();
1118        assert!(table.closest_nodes(&NodeId([0u8; 32]), 10).is_empty());
1119    }
1120
1121    #[test]
1122    fn empty_table_evict_stale_returns_zero() {
1123        let mut table = default_table();
1124        assert_eq!(table.evict_stale(9999, 0), 0);
1125    }
1126
1127    #[test]
1128    fn large_insertion_stress() {
1129        let cfg = ShardConfig {
1130            num_shards: 8,
1131            max_entries_per_shard: 50,
1132            eviction_policy: EvictionPolicy::LeastRecentlySeen,
1133        };
1134        let mut table = RoutingTableSharding::new(cfg);
1135        for i in 0u32..1000 {
1136            let mut b = [0u8; 32];
1137            b[0] = (i % 256) as u8;
1138            b[1] = (i / 256) as u8;
1139            let id = NodeId(b);
1140            let shard = table.shard_for(&id);
1141            table.insert(
1142                RoutingEntry {
1143                    node_id: id,
1144                    addr: "x".to_string(),
1145                    last_seen: i as u64,
1146                    rtt_ms: 1,
1147                    shard,
1148                },
1149                i as u64,
1150            );
1151        }
1152        // Each shard holds at most max_entries_per_shard
1153        for shard_map in &table.shards {
1154            assert!(
1155                shard_map.len() <= 50,
1156                "Shard overflow: {} > 50",
1157                shard_map.len()
1158            );
1159        }
1160        assert!(
1161            table.total_evictions > 0,
1162            "Expected some evictions under stress"
1163        );
1164    }
1165
1166    #[test]
1167    fn node_id_xor_distance_commutativity() {
1168        let a = make_id_full({
1169            let mut b = [0u8; 32];
1170            b[0] = 0xAB;
1171            b[15] = 0xCD;
1172            b
1173        });
1174        let b = make_id_full({
1175            let mut b = [0u8; 32];
1176            b[0] = 0x12;
1177            b[15] = 0x34;
1178            b
1179        });
1180        assert_eq!(a.xor_distance(&b), b.xor_distance(&a));
1181    }
1182
1183    #[test]
1184    fn multiple_evictions_counted_correctly() {
1185        let cfg = ShardConfig {
1186            num_shards: 1,
1187            max_entries_per_shard: 3,
1188            eviction_policy: EvictionPolicy::HighestRtt,
1189        };
1190        let mut table = RoutingTableSharding::new(cfg);
1191        for i in 0u8..10 {
1192            let id = make_id(i);
1193            let shard = table.shard_for(&id);
1194            table.insert(make_entry(id, "a", i as u64, i as u32, shard), i as u64);
1195        }
1196        assert_eq!(table.total_evictions, 7); // 10 inserts - 3 remaining = 7 evictions
1197        assert_eq!(table.total_entries(), 3);
1198    }
1199
1200    #[test]
1201    fn update_rtt_does_not_change_shard_assignment() {
1202        let mut table = default_table();
1203        let id = make_id(0x07);
1204        let shard_before = table.shard_for(&id);
1205        table.insert(make_entry(id, "a", 0, 10, shard_before), 0);
1206        table.update_rtt(&id, 99, 500);
1207        let entry = table.get(&id).expect("still present");
1208        assert_eq!(entry.shard, shard_before);
1209    }
1210
1211    #[test]
1212    fn closest_nodes_across_multiple_shards() {
1213        let mut table = RoutingTableSharding::new(ShardConfig {
1214            num_shards: 16,
1215            max_entries_per_shard: 256,
1216            eviction_policy: EvictionPolicy::LeastRecentlySeen,
1217        });
1218        // Spread entries across many shards.
1219        for i in 0u8..32 {
1220            let id = make_id(i);
1221            let shard = table.shard_for(&id);
1222            table.insert(make_entry(id, "a", 0, 0, shard), 0);
1223        }
1224        let target = NodeId([0u8; 32]);
1225        let result = table.closest_nodes(&target, 5);
1226        assert_eq!(result.len(), 5);
1227        // Verify sorted order: XOR distance should be non-decreasing
1228        for w in result.windows(2) {
1229            let d0 = w[0].node_id.xor_distance(&target);
1230            let d1 = w[1].node_id.xor_distance(&target);
1231            assert!(d0 <= d1);
1232        }
1233    }
1234}