Skip to main content

syncweb_core/net/
neighbor_map.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use dashmap::DashMap;
5use iroh::PublicKey;
6use tokio::time::{Instant, sleep};
7
8/// Information about a known peer in the neighbor map.
9#[derive(Debug, Clone)]
10#[non_exhaustive]
11pub struct PeerInfo {
12    pub node_id: PublicKey,
13    pub last_seen: Instant,
14    pub name: Option<String>,
15    pub connected: bool,
16}
17
18impl PeerInfo {
19    #[must_use]
20    pub fn new(node_id: PublicKey) -> Self {
21        Self {
22            node_id,
23            last_seen: Instant::now(),
24            name: None,
25            connected: true,
26        }
27    }
28
29    pub fn touch(&mut self) {
30        self.last_seen = Instant::now();
31    }
32}
33
34/// Thread-safe map of discovered gossip peers with automatic staleness expiry.
35///
36/// Tracks which peers are actively participating in a gossip topic. A background
37/// cleanup task removes peers whose `last_seen` exceeds the expiration timeout.
38/// This handles the case where a peer crashes silently without sending
39/// `NeighborDown`.
40#[derive(Debug, Clone)]
41pub struct NeighborMap {
42    peers: Arc<DashMap<PublicKey, PeerInfo>>,
43    expiration_timeout: Duration,
44}
45
46impl NeighborMap {
47    /// Create a new neighbor map and spawn a background cleanup task.
48    ///
49    /// The cleanup task runs every `expiration_timeout / 3` and removes
50    /// peers not seen within the timeout.
51    #[must_use]
52    pub fn new(expiration_timeout: Duration) -> Self {
53        let map = Self::new_without_cleanup(expiration_timeout);
54        map.spawn_cleanup_task();
55        map
56    }
57
58    /// Create a neighbor map without automatic cleanup (for testing).
59    #[must_use]
60    pub fn new_without_cleanup(expiration_timeout: Duration) -> Self {
61        Self {
62            peers: Arc::new(DashMap::new()),
63            expiration_timeout,
64        }
65    }
66
67    /// Record a peer as seen, resetting its staleness timer.
68    pub fn touch(&self, node_id: PublicKey, name: Option<&str>) {
69        let mut entry = self.peers.entry(node_id).or_insert_with(|| PeerInfo::new(node_id));
70        entry.touch();
71        if let Some(n) = name {
72            entry.name = Some(n.to_string());
73        }
74        entry.connected = true;
75    }
76
77    /// Mark a peer as disconnected (without removing it).
78    pub fn mark_disconnected(&self, node_id: &PublicKey) {
79        if let Some(mut entry) = self.peers.get_mut(node_id) {
80            entry.connected = false;
81        }
82    }
83
84    /// Remove a peer (e.g. on explicit `NeighborDown`).
85    #[must_use]
86    pub fn remove(&self, node_id: &PublicKey) -> Option<PeerInfo> {
87        self.peers.remove(node_id).map(|(_, info)| info)
88    }
89
90    /// List all non-expired peers.
91    #[must_use]
92    pub fn list(&self) -> Vec<PeerInfo> {
93        let now = Instant::now();
94        self.peers
95            .iter()
96            .filter(|entry| now.duration_since(entry.last_seen) <= self.expiration_timeout)
97            .map(|entry| entry.value().clone())
98            .collect()
99    }
100
101    /// List all non-expired, currently connected peers.
102    #[must_use]
103    pub fn list_connected(&self) -> Vec<PeerInfo> {
104        let now = Instant::now();
105        self.peers
106            .iter()
107            .filter(|entry| entry.connected && now.duration_since(entry.last_seen) <= self.expiration_timeout)
108            .map(|entry| entry.value().clone())
109            .collect()
110    }
111
112    /// Return a reference to the underlying peer map.
113    #[must_use]
114    pub const fn inner(&self) -> &Arc<DashMap<PublicKey, PeerInfo>> {
115        &self.peers
116    }
117
118    #[must_use]
119    pub fn len(&self) -> usize {
120        self.peers.len()
121    }
122
123    #[must_use]
124    pub fn is_empty(&self) -> bool {
125        self.peers.is_empty()
126    }
127
128    /// Manually trigger cleanup of all expired peers.
129    ///
130    /// Returns the number of removed entries.
131    #[must_use]
132    pub fn cleanup(&self) -> usize {
133        let now = Instant::now();
134        let expired: Vec<PublicKey> = self
135            .peers
136            .iter()
137            .filter_map(|entry| (now.duration_since(entry.last_seen) > self.expiration_timeout).then(|| *entry.key()))
138            .collect();
139
140        let count = expired.len();
141        for key in expired {
142            self.peers.remove(&key);
143        }
144        count
145    }
146
147    fn spawn_cleanup_task(&self) {
148        let peers = Arc::clone(&self.peers);
149        let expiration_timeout = self.expiration_timeout;
150        let cleanup_interval = Duration::from_secs(expiration_timeout.as_secs().saturating_div(3));
151
152        tokio::spawn(async move {
153            loop {
154                sleep(cleanup_interval).await;
155                let now = Instant::now();
156                let expired: Vec<PublicKey> = peers
157                    .iter()
158                    .filter_map(|entry| {
159                        (now.duration_since(entry.last_seen) > expiration_timeout).then(|| *entry.key())
160                    })
161                    .collect();
162
163                for key in expired {
164                    peers.remove(&key);
165                }
166            }
167        });
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use std::time::Duration;
175
176    fn test_key(id: u8) -> PublicKey {
177        let mut seed = [0_u8; 32];
178        seed[0] = id;
179        let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
180        let verifying_key = signing_key.verifying_key();
181        PublicKey::from_bytes(&verifying_key.to_bytes()).unwrap()
182    }
183
184    #[test]
185    fn test_insert_and_list() {
186        let map = NeighborMap::new_without_cleanup(Duration::from_mins(1));
187        let id = test_key(1);
188
189        map.touch(id, Some("alice"));
190        assert_eq!(map.len(), 1);
191        assert!(!map.is_empty());
192
193        let peers = map.list();
194        assert_eq!(peers.len(), 1);
195        assert_eq!(peers.first().unwrap().name.as_deref(), Some("alice"));
196    }
197
198    #[test]
199    fn test_touch_updates_last_seen() {
200        let map = NeighborMap::new_without_cleanup(Duration::from_mins(1));
201        let id = test_key(1);
202
203        map.touch(id, None);
204        let first_seen = map.list().first().unwrap().last_seen;
205
206        // Wait a tiny bit and touch again
207        std::thread::sleep(Duration::from_millis(10));
208        map.touch(id, None);
209        let second_seen = map.list().first().unwrap().last_seen;
210
211        assert!(second_seen > first_seen);
212    }
213
214    #[test]
215    fn test_remove_explicit() {
216        let map = NeighborMap::new_without_cleanup(Duration::from_mins(1));
217        let id = test_key(1);
218        map.touch(id, None);
219
220        let info = map.remove(&id);
221        assert!(info.is_some());
222        assert!(map.is_empty());
223    }
224
225    #[test]
226    fn test_cleanup_removes_expired() {
227        let map = NeighborMap::new_without_cleanup(Duration::from_millis(50));
228        let id = test_key(1);
229
230        map.touch(id, None);
231
232        std::thread::sleep(Duration::from_millis(100));
233
234        let removed = map.cleanup();
235        assert_eq!(removed, 1);
236        assert!(map.is_empty());
237    }
238
239    #[test]
240    fn test_cleanup_preserves_fresh() {
241        let map = NeighborMap::new_without_cleanup(Duration::from_mins(1));
242        let alice = test_key(1);
243        let bob = test_key(2);
244
245        map.touch(alice, None);
246        map.touch(bob, None);
247
248        // Bob is expired, alice is not
249        // Actually neither is expired with 60s timeout
250        let removed = map.cleanup();
251        assert_eq!(removed, 0);
252        assert_eq!(map.len(), 2);
253    }
254
255    #[test]
256    fn test_mark_disconnected() {
257        let map = NeighborMap::new_without_cleanup(Duration::from_mins(1));
258        let id = test_key(1);
259
260        map.touch(id, None);
261        assert!(map.list_connected().len() == 1);
262
263        map.mark_disconnected(&id);
264        assert!(map.list_connected().is_empty());
265        assert_eq!(map.len(), 1); // still tracked, just not connected
266    }
267
268    #[test]
269    fn test_empty_map() {
270        let map = NeighborMap::new_without_cleanup(Duration::from_mins(1));
271        assert!(map.list().is_empty());
272        assert!(map.is_empty());
273        assert_eq!(map.cleanup(), 0);
274    }
275
276    #[test]
277    fn test_double_touch_resets_timer() {
278        let map = NeighborMap::new_without_cleanup(Duration::from_millis(50));
279        let id = test_key(1);
280
281        map.touch(id, None);
282
283        // Wait almost up to expiry
284        std::thread::sleep(Duration::from_millis(40));
285
286        // Touch again, resetting the timer
287        map.touch(id, None);
288
289        // Now wait past the original expiry but before the new one
290        std::thread::sleep(Duration::from_millis(20));
291
292        // Should NOT be expired because we touched at 40ms
293        assert_eq!(map.cleanup(), 0);
294        assert_eq!(map.len(), 1);
295    }
296
297    #[test]
298    fn test_multiple_peers() {
299        let map = NeighborMap::new_without_cleanup(Duration::from_mins(1));
300        let ids: Vec<PublicKey> = (0..5).map(test_key).collect();
301
302        for id in &ids {
303            map.touch(*id, None);
304        }
305
306        assert_eq!(map.len(), 5);
307        assert_eq!(map.list().len(), 5);
308
309        let _ = map.remove(ids.first().unwrap());
310        assert_eq!(map.len(), 4);
311
312        let _ = map.cleanup();
313        assert_eq!(map.len(), 4);
314    }
315}