1use serde::{Deserialize, Serialize};
38use std::collections::{HashMap, VecDeque};
39use std::sync::atomic::{AtomicU64, Ordering};
40use std::sync::{Mutex, RwLock};
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum GossipMessage {
50 PeerAnnounce {
52 peer_id: String,
53 capabilities: Vec<String>,
54 sequence: u64,
55 },
56 IndexStats {
58 peer_id: String,
59 vector_count: u64,
60 dimensions: u32,
61 sequence: u64,
62 },
63 RoundStatus {
65 round_id: u64,
66 status: String,
67 peer_id: String,
68 sequence: u64,
69 },
70 Heartbeat {
72 peer_id: String,
73 uptime_secs: u64,
74 sequence: u64,
75 },
76}
77
78impl GossipMessage {
79 pub fn message_type(&self) -> &str {
81 match self {
82 GossipMessage::PeerAnnounce { .. } => "peer_announce",
83 GossipMessage::IndexStats { .. } => "index_stats",
84 GossipMessage::RoundStatus { .. } => "round_status",
85 GossipMessage::Heartbeat { .. } => "heartbeat",
86 }
87 }
88
89 pub fn sequence(&self) -> u64 {
91 match self {
92 GossipMessage::PeerAnnounce { sequence, .. } => *sequence,
93 GossipMessage::IndexStats { sequence, .. } => *sequence,
94 GossipMessage::RoundStatus { sequence, .. } => *sequence,
95 GossipMessage::Heartbeat { sequence, .. } => *sequence,
96 }
97 }
98
99 pub fn peer_id(&self) -> &str {
101 match self {
102 GossipMessage::PeerAnnounce { peer_id, .. } => peer_id,
103 GossipMessage::IndexStats { peer_id, .. } => peer_id,
104 GossipMessage::RoundStatus { peer_id, .. } => peer_id,
105 GossipMessage::Heartbeat { peer_id, .. } => peer_id,
106 }
107 }
108}
109
110#[derive(Debug, Default)]
116pub struct GossipState {
117 pub seen: HashMap<String, u64>,
119}
120
121impl GossipState {
122 pub fn is_new(&self, peer_id: &str, sequence: u64) -> bool {
125 match self.seen.get(peer_id) {
126 Some(&last) => sequence > last,
127 None => true,
128 }
129 }
130
131 pub fn record(&mut self, peer_id: &str, sequence: u64) {
134 let entry = self.seen.entry(peer_id.to_string()).or_insert(0);
135 if sequence > *entry {
136 *entry = sequence;
137 }
138 }
139
140 pub fn prune_stale(&mut self, threshold_seq: u64) {
143 self.seen.retain(|_, &mut seq| seq > threshold_seq);
144 }
145}
146
147#[derive(Debug)]
153pub struct GossipFanout {
154 pub peers: Vec<String>,
156 pub fanout: usize,
158}
159
160impl Default for GossipFanout {
161 fn default() -> Self {
162 Self {
163 peers: Vec::new(),
164 fanout: 3,
165 }
166 }
167}
168
169impl GossipFanout {
170 pub fn new(fanout: usize) -> Self {
172 Self {
173 peers: Vec::new(),
174 fanout,
175 }
176 }
177
178 pub fn select_targets(&self, exclude: &str) -> Vec<String> {
185 let candidates: Vec<&String> = self
186 .peers
187 .iter()
188 .filter(|p| p.as_str() != exclude)
189 .collect();
190 if candidates.is_empty() {
191 return Vec::new();
192 }
193
194 let hash = fnv1a_hash(exclude.as_bytes());
196 let start = (hash as usize) % candidates.len();
197
198 let take = self.fanout.min(candidates.len());
199 let mut result = Vec::with_capacity(take);
200 for i in 0..take {
201 let idx = (start + i) % candidates.len();
202 result.push(candidates[idx].clone());
203 }
204 result
205 }
206
207 pub fn add_peer(&mut self, peer_id: String) {
209 if !self.peers.contains(&peer_id) {
210 self.peers.push(peer_id);
211 }
212 }
213
214 pub fn remove_peer(&mut self, peer_id: &str) {
216 self.peers.retain(|p| p.as_str() != peer_id);
217 }
218
219 pub fn len(&self) -> usize {
221 self.peers.len()
222 }
223
224 pub fn is_empty(&self) -> bool {
226 self.peers.is_empty()
227 }
228}
229
230fn fnv1a_hash(bytes: &[u8]) -> u64 {
232 const FNV_OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
233 const FNV_PRIME: u64 = 1_099_511_628_211;
234 let mut hash = FNV_OFFSET_BASIS;
235 for &b in bytes {
236 hash ^= u64::from(b);
237 hash = hash.wrapping_mul(FNV_PRIME);
238 }
239 hash
240}
241
242#[derive(Debug, Default)]
248pub struct GossipStats {
249 pub total_received: AtomicU64,
251 pub total_new: AtomicU64,
253 pub total_duplicates: AtomicU64,
255 pub total_fanned_out: AtomicU64,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
261pub struct GossipStatsSnapshot {
262 pub total_received: u64,
263 pub total_new: u64,
264 pub total_duplicates: u64,
265 pub total_fanned_out: u64,
266}
267
268impl GossipStats {
269 pub fn snapshot(&self) -> GossipStatsSnapshot {
271 GossipStatsSnapshot {
272 total_received: self.total_received.load(Ordering::Relaxed),
273 total_new: self.total_new.load(Ordering::Relaxed),
274 total_duplicates: self.total_duplicates.load(Ordering::Relaxed),
275 total_fanned_out: self.total_fanned_out.load(Ordering::Relaxed),
276 }
277 }
278}
279
280#[derive(Debug, Default)]
296pub struct GossipOverlayManager {
297 state: Mutex<GossipState>,
298 fanout: RwLock<GossipFanout>,
299 inbound_queue: Mutex<VecDeque<GossipMessage>>,
300 outbound_queue: Mutex<VecDeque<(String, GossipMessage)>>,
301 pub stats: GossipStats,
302}
303
304impl GossipOverlayManager {
305 pub fn with_fanout(fanout: usize) -> Self {
307 Self {
308 fanout: RwLock::new(GossipFanout::new(fanout)),
309 ..Default::default()
310 }
311 }
312
313 pub fn add_peer(&self, peer_id: String) {
319 self.fanout
320 .write()
321 .expect("fanout RwLock poisoned")
322 .add_peer(peer_id);
323 }
324
325 pub fn remove_peer(&self, peer_id: &str) {
327 self.fanout
328 .write()
329 .expect("fanout RwLock poisoned")
330 .remove_peer(peer_id);
331 }
332
333 pub fn peer_count(&self) -> usize {
335 self.fanout.read().expect("fanout RwLock poisoned").len()
336 }
337
338 pub fn receive(&self, msg: GossipMessage) -> bool {
351 self.stats.total_received.fetch_add(1, Ordering::Relaxed);
352
353 let pid = msg.peer_id().to_string();
354 let seq = msg.sequence();
355
356 let is_new = {
357 let mut state = self.state.lock().expect("state Mutex poisoned");
358 if state.is_new(&pid, seq) {
359 state.record(&pid, seq);
360 true
361 } else {
362 false
363 }
364 };
365
366 if !is_new {
367 self.stats.total_duplicates.fetch_add(1, Ordering::Relaxed);
368 return false;
369 }
370
371 self.stats.total_new.fetch_add(1, Ordering::Relaxed);
372
373 self.inbound_queue
375 .lock()
376 .expect("inbound_queue Mutex poisoned")
377 .push_back(msg.clone());
378
379 let targets = self
381 .fanout
382 .read()
383 .expect("fanout RwLock poisoned")
384 .select_targets(&pid);
385
386 let fanout_count = targets.len() as u64;
387 {
388 let mut out = self
389 .outbound_queue
390 .lock()
391 .expect("outbound_queue Mutex poisoned");
392 for target in targets {
393 out.push_back((target, msg.clone()));
394 }
395 }
396 self.stats
397 .total_fanned_out
398 .fetch_add(fanout_count, Ordering::Relaxed);
399
400 true
401 }
402
403 pub fn drain_inbound(&self) -> Vec<GossipMessage> {
405 let mut q = self
406 .inbound_queue
407 .lock()
408 .expect("inbound_queue Mutex poisoned");
409 q.drain(..).collect()
410 }
411
412 pub fn drain_outbound(&self) -> Vec<(String, GossipMessage)> {
414 let mut q = self
415 .outbound_queue
416 .lock()
417 .expect("outbound_queue Mutex poisoned");
418 q.drain(..).collect()
419 }
420
421 pub fn broadcast(&self, msg: GossipMessage) {
424 let peers: Vec<String> = self
425 .fanout
426 .read()
427 .expect("fanout RwLock poisoned")
428 .peers
429 .clone();
430
431 let count = peers.len() as u64;
432 {
433 let mut out = self
434 .outbound_queue
435 .lock()
436 .expect("outbound_queue Mutex poisoned");
437 for peer in peers {
438 out.push_back((peer, msg.clone()));
439 }
440 }
441 self.stats
442 .total_fanned_out
443 .fetch_add(count, Ordering::Relaxed);
444 }
445
446 pub fn stats_snapshot(&self) -> GossipStatsSnapshot {
448 self.stats.snapshot()
449 }
450}
451
452#[cfg(test)]
457mod tests {
458 use super::*;
459
460 fn make_announce(peer_id: &str, seq: u64) -> GossipMessage {
463 GossipMessage::PeerAnnounce {
464 peer_id: peer_id.to_string(),
465 capabilities: vec!["vector-search".to_string()],
466 sequence: seq,
467 }
468 }
469
470 fn make_index_stats(peer_id: &str, seq: u64) -> GossipMessage {
471 GossipMessage::IndexStats {
472 peer_id: peer_id.to_string(),
473 vector_count: 1000,
474 dimensions: 768,
475 sequence: seq,
476 }
477 }
478
479 fn make_round_status(peer_id: &str, seq: u64) -> GossipMessage {
480 GossipMessage::RoundStatus {
481 round_id: 7,
482 status: "running".to_string(),
483 peer_id: peer_id.to_string(),
484 sequence: seq,
485 }
486 }
487
488 fn make_heartbeat(peer_id: &str, seq: u64) -> GossipMessage {
489 GossipMessage::Heartbeat {
490 peer_id: peer_id.to_string(),
491 uptime_secs: 300,
492 sequence: seq,
493 }
494 }
495
496 #[test]
498 fn test_message_type_peer_announce() {
499 let msg = make_announce("p1", 1);
500 assert_eq!(msg.message_type(), "peer_announce");
501 }
502
503 #[test]
505 fn test_message_type_index_stats() {
506 let msg = make_index_stats("p1", 1);
507 assert_eq!(msg.message_type(), "index_stats");
508 }
509
510 #[test]
512 fn test_message_type_round_status() {
513 let msg = make_round_status("p1", 1);
514 assert_eq!(msg.message_type(), "round_status");
515 }
516
517 #[test]
519 fn test_message_type_heartbeat() {
520 let msg = make_heartbeat("p1", 1);
521 assert_eq!(msg.message_type(), "heartbeat");
522 }
523
524 #[test]
526 fn test_message_accessors() {
527 let msg = make_announce("alice", 42);
528 assert_eq!(msg.sequence(), 42);
529 assert_eq!(msg.peer_id(), "alice");
530 }
531
532 #[test]
534 fn test_gossip_state_is_new_first() {
535 let state = GossipState::default();
536 assert!(state.is_new("peer-A", 1));
537 }
538
539 #[test]
541 fn test_gossip_state_dedup() {
542 let mut state = GossipState::default();
543 state.record("peer-A", 5);
544 assert!(!state.is_new("peer-A", 5));
545 assert!(!state.is_new("peer-A", 4));
546 assert!(state.is_new("peer-A", 6));
547 }
548
549 #[test]
551 fn test_gossip_state_prune_stale() {
552 let mut state = GossipState::default();
553 state.record("peer-A", 3);
554 state.record("peer-B", 7);
555 state.record("peer-C", 10);
556 state.prune_stale(7); assert!(!state.seen.contains_key("peer-A"));
558 assert!(!state.seen.contains_key("peer-B"));
559 assert!(state.seen.contains_key("peer-C"));
560 }
561
562 #[test]
564 fn test_fanout_select_excludes_sender() {
565 let mut fanout = GossipFanout::new(3);
566 fanout.add_peer("A".to_string());
567 fanout.add_peer("B".to_string());
568 fanout.add_peer("C".to_string());
569 fanout.add_peer("D".to_string());
570
571 let targets = fanout.select_targets("A");
572 assert!(
573 !targets.contains(&"A".to_string()),
574 "sender must not appear in targets"
575 );
576 assert!(targets.len() <= 3);
577 }
578
579 #[test]
581 fn test_fanout_select_width() {
582 let mut fanout = GossipFanout::new(2);
583 for i in 0..10u32 {
584 fanout.add_peer(format!("peer-{i}"));
585 }
586 let targets = fanout.select_targets("peer-0");
587 assert_eq!(targets.len(), 2);
588 }
589
590 #[test]
592 fn test_receive_new_vs_duplicate() {
593 let mgr = GossipOverlayManager::default();
594 let msg = make_heartbeat("peer-X", 1);
595 assert!(mgr.receive(msg.clone()), "first receive must be new");
596 assert!(!mgr.receive(msg), "second receive must be duplicate");
597 }
598
599 #[test]
601 fn test_receive_enqueues_fanout() {
602 let mgr = GossipOverlayManager::default();
603 mgr.add_peer("peer-A".to_string());
604 mgr.add_peer("peer-B".to_string());
605 mgr.add_peer("peer-C".to_string());
606
607 let msg = make_heartbeat("sender", 1);
608 mgr.receive(msg);
609
610 let out = mgr.drain_outbound();
611 assert_eq!(out.len(), 3, "all 3 registered peers should receive fanout");
613 for (target, _) in &out {
614 assert_ne!(target, "sender");
615 }
616 }
617
618 #[test]
620 fn test_drain_inbound_clears_queue() {
621 let mgr = GossipOverlayManager::default();
622 mgr.receive(make_announce("p1", 1));
623 mgr.receive(make_announce("p2", 1));
624 let first_drain = mgr.drain_inbound();
625 assert_eq!(first_drain.len(), 2);
626 let second_drain = mgr.drain_inbound();
627 assert!(second_drain.is_empty(), "queue should be empty after drain");
628 }
629
630 #[test]
632 fn test_drain_outbound_clears_queue() {
633 let mgr = GossipOverlayManager::default();
634 mgr.add_peer("peer-A".to_string());
635 mgr.receive(make_heartbeat("sender", 1));
636 let first = mgr.drain_outbound();
637 assert!(!first.is_empty());
638 let second = mgr.drain_outbound();
639 assert!(
640 second.is_empty(),
641 "outbound queue should be empty after drain"
642 );
643 }
644
645 #[test]
647 fn test_broadcast_reaches_all_peers() {
648 let mgr = GossipOverlayManager::default();
649 mgr.add_peer("peer-A".to_string());
650 mgr.add_peer("peer-B".to_string());
651 mgr.add_peer("peer-C".to_string());
652
653 let msg = make_index_stats("local-node", 1);
654 mgr.broadcast(msg.clone());
655
656 let out = mgr.drain_outbound();
657 assert_eq!(
658 out.len(),
659 3,
660 "broadcast must enqueue one copy per registered peer"
661 );
662 let targets: Vec<&str> = out.iter().map(|(t, _)| t.as_str()).collect();
663 assert!(targets.contains(&"peer-A"));
664 assert!(targets.contains(&"peer-B"));
665 assert!(targets.contains(&"peer-C"));
666 }
667
668 #[test]
670 fn test_stats_accumulation() {
671 let mgr = GossipOverlayManager::default();
672 mgr.add_peer("peer-A".to_string());
673 mgr.add_peer("peer-B".to_string());
674
675 let msg = make_round_status("origin", 10);
676 mgr.receive(msg.clone()); mgr.receive(msg.clone()); let snap = mgr.stats_snapshot();
680 assert_eq!(snap.total_received, 2);
681 assert_eq!(snap.total_new, 1);
682 assert_eq!(snap.total_duplicates, 1);
683 assert_eq!(snap.total_fanned_out, 2);
685 }
686
687 #[test]
689 fn test_peer_count() {
690 let mgr = GossipOverlayManager::default();
691 assert_eq!(mgr.peer_count(), 0);
692 mgr.add_peer("p1".to_string());
693 mgr.add_peer("p2".to_string());
694 assert_eq!(mgr.peer_count(), 2);
695 mgr.remove_peer("p1");
696 assert_eq!(mgr.peer_count(), 1);
697 }
698
699 #[test]
701 fn test_duplicate_not_enqueued_to_inbound() {
702 let mgr = GossipOverlayManager::default();
703 let msg = make_announce("p1", 5);
704 mgr.receive(msg.clone());
705 mgr.receive(msg.clone()); let inbound = mgr.drain_inbound();
708 assert_eq!(
709 inbound.len(),
710 1,
711 "only one copy should reach the inbound queue"
712 );
713 }
714
715 #[test]
717 fn test_higher_sequence_after_duplicate_is_new() {
718 let mgr = GossipOverlayManager::default();
719 let msg_v1 = make_heartbeat("peer-Z", 1);
720 let msg_v2 = make_heartbeat("peer-Z", 2);
721
722 assert!(mgr.receive(msg_v1));
723 assert!(!mgr.receive(make_heartbeat("peer-Z", 1))); assert!(mgr.receive(msg_v2)); let snap = mgr.stats_snapshot();
727 assert_eq!(snap.total_new, 2);
728 assert_eq!(snap.total_duplicates, 1);
729 }
730
731 #[test]
733 fn test_serde_round_trip() {
734 let messages = vec![
735 make_announce("p1", 1),
736 make_index_stats("p2", 2),
737 make_round_status("p3", 3),
738 make_heartbeat("p4", 4),
739 ];
740 for msg in messages {
741 let json = serde_json::to_string(&msg).expect("serialization failed");
742 let decoded: GossipMessage =
743 serde_json::from_str(&json).expect("deserialization failed");
744 assert_eq!(msg, decoded);
745 }
746 }
747}