mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 16:43:12 +00:00
Implement FilterAnnounce send/receive, remove TTL/K-hop scoping
Add bloom filter reachability announcement protocol: - FilterAnnounce encode/decode (wire format 0x20, 1035 bytes) - node/bloom.rs: send/receive with debounce, split-horizon loop prevention - Handler wiring: dispatch, tick, peer promotion/removal, cross-connection - Five integration tests: 10-node, star, chain, ring, 100-node convergence Remove TTL/K-hop mechanism from code and design docs after discovering that per-entry TTL scoping is fundamentally incompatible with flat bloom filter merge + regeneration architecture. Each node re-originates filters with fresh TTL, making propagation unbounded regardless of TTL value. Split-horizon remains the primary loop prevention mechanism. Document spanning tree known limitations (v1) in spanning-tree-dynamics.md. 316 tests pass, clean build, zero warnings.
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
//! Bloom filter announce send/receive logic.
|
||||
//!
|
||||
//! Handles building, sending, and receiving FilterAnnounce messages,
|
||||
//! including debounced propagation to peers.
|
||||
|
||||
use crate::bloom::BloomFilter;
|
||||
use crate::protocol::FilterAnnounce;
|
||||
use crate::NodeAddr;
|
||||
|
||||
use super::{Node, NodeError};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{debug, info};
|
||||
|
||||
impl Node {
|
||||
/// Collect inbound filters from all peers for outgoing filter computation.
|
||||
///
|
||||
/// Returns a map of (peer_node_addr -> filter) for peers that
|
||||
/// have sent us a FilterAnnounce.
|
||||
fn peer_inbound_filters(&self) -> HashMap<NodeAddr, BloomFilter> {
|
||||
let mut filters = HashMap::new();
|
||||
for (addr, peer) in &self.peers {
|
||||
if let Some(filter) = peer.inbound_filter() {
|
||||
filters.insert(*addr, filter.clone());
|
||||
}
|
||||
}
|
||||
filters
|
||||
}
|
||||
|
||||
/// Build a FilterAnnounce for a specific peer.
|
||||
///
|
||||
/// The outgoing filter excludes the destination peer's own filter
|
||||
/// to prevent routing loops (don't tell a peer about destinations
|
||||
/// reachable only through them).
|
||||
fn build_filter_announce(&mut self, exclude_peer: &NodeAddr) -> FilterAnnounce {
|
||||
let peer_filters = self.peer_inbound_filters();
|
||||
let filter = self
|
||||
.bloom_state
|
||||
.compute_outgoing_filter(exclude_peer, &peer_filters);
|
||||
let sequence = self.bloom_state.next_sequence();
|
||||
FilterAnnounce::new(filter, sequence)
|
||||
}
|
||||
|
||||
/// Send a FilterAnnounce to a specific peer, respecting debounce.
|
||||
///
|
||||
/// If the peer is rate-limited, the update stays pending for
|
||||
/// delivery on the next tick cycle.
|
||||
pub(super) async fn send_filter_announce_to_peer(
|
||||
&mut self,
|
||||
peer_addr: &NodeAddr,
|
||||
) -> Result<(), NodeError> {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Check debounce
|
||||
if !self.bloom_state.should_send_update(peer_addr, now_ms) {
|
||||
// Either not pending or rate-limited; will retry on tick
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Build and encode
|
||||
let announce = self.build_filter_announce(peer_addr);
|
||||
let encoded = announce.encode().map_err(|e| NodeError::SendFailed {
|
||||
node_addr: *peer_addr,
|
||||
reason: format!("FilterAnnounce encode failed: {}", e),
|
||||
})?;
|
||||
|
||||
// Send
|
||||
self.send_encrypted_link_message(peer_addr, &encoded).await?;
|
||||
|
||||
// Record send
|
||||
self.bloom_state.record_update_sent(*peer_addr, now_ms);
|
||||
if let Some(peer) = self.peers.get_mut(peer_addr) {
|
||||
peer.clear_filter_update_needed();
|
||||
}
|
||||
|
||||
debug!(peer = %peer_addr, seq = announce.sequence, "Sent FilterAnnounce");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send pending rate-limited filter announces whose debounce has expired.
|
||||
pub(super) async fn send_pending_filter_announces(&mut self) {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let ready: Vec<NodeAddr> = self
|
||||
.peers
|
||||
.keys()
|
||||
.filter(|addr| self.bloom_state.should_send_update(addr, now_ms))
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
for peer_addr in ready {
|
||||
if let Err(e) = self.send_filter_announce_to_peer(&peer_addr).await {
|
||||
debug!(
|
||||
peer = %peer_addr,
|
||||
error = %e,
|
||||
"Failed to send pending FilterAnnounce"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an inbound FilterAnnounce from an authenticated peer.
|
||||
///
|
||||
/// 1. Decode and validate the message
|
||||
/// 2. Check sequence freshness (reject stale/replay)
|
||||
/// 3. Store the filter on the peer
|
||||
/// 4. Mark other peers for outgoing filter update
|
||||
pub(super) async fn handle_filter_announce(&mut self, from: &NodeAddr, payload: &[u8]) {
|
||||
let announce = match FilterAnnounce::decode(payload) {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
debug!(from = %from, error = %e, "Malformed FilterAnnounce");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Validate
|
||||
if !announce.is_valid() {
|
||||
debug!(from = %from, "FilterAnnounce filter/size_class mismatch");
|
||||
return;
|
||||
}
|
||||
if !announce.is_v1_compliant() {
|
||||
debug!(from = %from, size_class = announce.size_class, "Non-v1 FilterAnnounce rejected");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check peer exists
|
||||
let current_seq = match self.peers.get(from) {
|
||||
Some(peer) => peer.filter_sequence(),
|
||||
None => {
|
||||
debug!(from = %from, "FilterAnnounce from unknown peer");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Reject stale/replay
|
||||
if announce.sequence <= current_seq {
|
||||
debug!(
|
||||
from = %from,
|
||||
received_seq = announce.sequence,
|
||||
current_seq = current_seq,
|
||||
"Stale FilterAnnounce rejected"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Store on peer
|
||||
if let Some(peer) = self.peers.get_mut(from) {
|
||||
peer.update_filter(announce.filter, announce.sequence, now_ms);
|
||||
}
|
||||
|
||||
info!(
|
||||
from = %from,
|
||||
seq = announce.sequence,
|
||||
"Received FilterAnnounce"
|
||||
);
|
||||
|
||||
// Our outgoing filter changed — mark all other peers for update
|
||||
let other_peers: Vec<NodeAddr> = self
|
||||
.peers
|
||||
.keys()
|
||||
.filter(|addr| *addr != from)
|
||||
.copied()
|
||||
.collect();
|
||||
self.bloom_state.mark_all_updates_needed(other_peers);
|
||||
}
|
||||
|
||||
/// Check bloom filter state on tick (called from event loop).
|
||||
///
|
||||
/// Sends any pending debounced filter announces.
|
||||
pub(super) async fn check_bloom_state(&mut self) {
|
||||
self.send_pending_filter_announces().await;
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -44,6 +44,7 @@ impl Node {
|
||||
.unwrap_or(0);
|
||||
self.process_pending_retries(now_ms).await;
|
||||
self.check_tree_state().await;
|
||||
self.check_bloom_state().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,6 +321,8 @@ impl Node {
|
||||
if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await {
|
||||
debug!(peer = %node_addr, error = %e, "Failed to send initial TreeAnnounce");
|
||||
}
|
||||
// Schedule filter announce (sent on next tick via debounce)
|
||||
self.bloom_state.mark_update_needed(node_addr);
|
||||
}
|
||||
PromotionResult::CrossConnectionWon { loser_link_id, node_addr } => {
|
||||
// Clean up the losing connection's link
|
||||
@@ -333,6 +336,8 @@ impl Node {
|
||||
if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await {
|
||||
debug!(peer = %node_addr, error = %e, "Failed to send initial TreeAnnounce");
|
||||
}
|
||||
// Schedule filter announce (sent on next tick via debounce)
|
||||
self.bloom_state.mark_update_needed(node_addr);
|
||||
}
|
||||
PromotionResult::CrossConnectionLost { winner_link_id } => {
|
||||
// This connection lost — clean up its link
|
||||
@@ -534,6 +539,8 @@ impl Node {
|
||||
if let Err(e) = self.send_tree_announce_to_peer(&peer_node_addr).await {
|
||||
debug!(peer = %peer_node_addr, error = %e, "Failed to send TreeAnnounce after cross-connection resolution");
|
||||
}
|
||||
// Schedule filter announce (sent on next tick via debounce)
|
||||
self.bloom_state.mark_update_needed(peer_node_addr);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -553,6 +560,8 @@ impl Node {
|
||||
if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await {
|
||||
debug!(peer = %node_addr, error = %e, "Failed to send initial TreeAnnounce");
|
||||
}
|
||||
// Schedule filter announce (sent on next tick via debounce)
|
||||
self.bloom_state.mark_update_needed(node_addr);
|
||||
}
|
||||
PromotionResult::CrossConnectionWon { loser_link_id, node_addr } => {
|
||||
// Clean up the losing connection's link
|
||||
@@ -571,6 +580,8 @@ impl Node {
|
||||
if let Err(e) = self.send_tree_announce_to_peer(&node_addr).await {
|
||||
debug!(peer = %node_addr, error = %e, "Failed to send initial TreeAnnounce");
|
||||
}
|
||||
// Schedule filter announce (sent on next tick via debounce)
|
||||
self.bloom_state.mark_update_needed(node_addr);
|
||||
}
|
||||
PromotionResult::CrossConnectionLost { winner_link_id } => {
|
||||
// This connection lost — clean up its link
|
||||
@@ -801,7 +812,7 @@ impl Node {
|
||||
}
|
||||
0x20 => {
|
||||
// FilterAnnounce
|
||||
debug!("Received FilterAnnounce (not yet implemented)");
|
||||
self.handle_filter_announce(from, payload).await;
|
||||
}
|
||||
0x30 => {
|
||||
// LookupRequest
|
||||
@@ -885,6 +896,10 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
// Bloom filter cleanup: our outgoing filter changed (lost a peer's filter)
|
||||
let remaining_peers: Vec<NodeAddr> = self.peers.keys().copied().collect();
|
||||
self.bloom_state.mark_all_updates_needed(remaining_peers);
|
||||
|
||||
info!(
|
||||
node_addr = %node_addr,
|
||||
link_id = %link_id,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! holds all state required for mesh routing: identity, tree state,
|
||||
//! Bloom filters, coordinate caches, transports, links, and peers.
|
||||
|
||||
mod bloom;
|
||||
mod handlers;
|
||||
mod lifecycle;
|
||||
mod retry;
|
||||
|
||||
+236
-1
@@ -1720,9 +1720,10 @@ async fn drain_all_packets(nodes: &mut [TestNode], verbose: bool) -> usize {
|
||||
// Wait for rate limit window (500ms) to fully expire
|
||||
tokio::time::sleep(Duration::from_millis(550)).await;
|
||||
|
||||
// Flush pending rate-limited tree announces on all nodes
|
||||
// Flush pending rate-limited tree and filter announces on all nodes
|
||||
for tn in nodes.iter_mut() {
|
||||
tn.node.send_pending_tree_announces().await;
|
||||
tn.node.send_pending_filter_announces().await;
|
||||
}
|
||||
|
||||
// Allow flushed packets to arrive
|
||||
@@ -2113,3 +2114,237 @@ async fn test_spanning_tree_disconnected() {
|
||||
verify_tree_convergence_components(&nodes, &[vec![0, 1, 2], vec![3, 4, 5]]);
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
// ===== Bloom Filter Integration Tests =====
|
||||
|
||||
/// Verify that all peer pairs have exchanged bloom filters and each
|
||||
/// peer's inbound filter contains the peer's own node_addr.
|
||||
///
|
||||
/// Also verifies propagation: for each node, check that destinations
|
||||
/// reachable through a peer's filter include the peer's direct neighbors.
|
||||
fn verify_bloom_filter_exchange(nodes: &[TestNode], edges: &[(usize, usize)]) {
|
||||
// Build adjacency for hop distance computation
|
||||
let n = nodes.len();
|
||||
let mut adj = vec![vec![]; n];
|
||||
for &(i, j) in edges {
|
||||
adj[i].push(j);
|
||||
adj[j].push(i);
|
||||
}
|
||||
|
||||
// Every peer pair must have exchanged filters
|
||||
for &(i, j) in edges {
|
||||
let j_addr = *nodes[j].node.node_addr();
|
||||
let i_addr = *nodes[i].node.node_addr();
|
||||
|
||||
// Node i should have a filter from node j
|
||||
let peer_j = nodes[i]
|
||||
.node
|
||||
.get_peer(&j_addr)
|
||||
.unwrap_or_else(|| panic!("Node {} should have peer {}", i, j));
|
||||
let filter_from_j = peer_j.inbound_filter().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Node {} should have inbound filter from node {} (addr={})",
|
||||
i, j, j_addr
|
||||
)
|
||||
});
|
||||
|
||||
// The filter from j must contain j's own node_addr
|
||||
assert!(
|
||||
filter_from_j.contains(&j_addr),
|
||||
"Node {}'s filter from node {} should contain node {}'s addr",
|
||||
i,
|
||||
j,
|
||||
j
|
||||
);
|
||||
|
||||
// Node j should have a filter from node i
|
||||
let peer_i = nodes[j]
|
||||
.node
|
||||
.get_peer(&i_addr)
|
||||
.unwrap_or_else(|| panic!("Node {} should have peer {}", j, i));
|
||||
let filter_from_i = peer_i.inbound_filter().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"Node {} should have inbound filter from node {} (addr={})",
|
||||
j, i, i_addr
|
||||
)
|
||||
});
|
||||
|
||||
// The filter from i must contain i's own node_addr
|
||||
assert!(
|
||||
filter_from_i.contains(&i_addr),
|
||||
"Node {}'s filter from node {} should contain node {}'s addr",
|
||||
j,
|
||||
i,
|
||||
i
|
||||
);
|
||||
}
|
||||
|
||||
// Verify propagation: each node's filter from a peer should
|
||||
// contain addresses of the peer's direct neighbors (which were
|
||||
// merged into the peer's outgoing filter).
|
||||
for &(i, j) in edges {
|
||||
let j_addr = *nodes[j].node.node_addr();
|
||||
let peer_j = nodes[i].node.get_peer(&j_addr).unwrap();
|
||||
let filter = peer_j.inbound_filter().unwrap();
|
||||
|
||||
// All of j's direct neighbors (except i) should be in j's filter to i
|
||||
for &neighbor_idx in &adj[j] {
|
||||
if neighbor_idx == i {
|
||||
continue; // j excludes i's direction from i's filter
|
||||
}
|
||||
let neighbor_addr = *nodes[neighbor_idx].node.node_addr();
|
||||
assert!(
|
||||
filter.contains(&neighbor_addr),
|
||||
"Node {}'s filter from node {} should contain node {}'s neighbor {} (addr={})",
|
||||
i,
|
||||
j,
|
||||
j,
|
||||
neighbor_idx,
|
||||
neighbor_addr
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 10-node random graph: tree + bloom filter convergence.
|
||||
#[tokio::test]
|
||||
async fn test_bloom_filter_10_nodes() {
|
||||
let edges = generate_random_edges(10, 20, 123);
|
||||
let mut nodes = run_tree_test(10, &edges, false).await;
|
||||
verify_tree_convergence(&nodes);
|
||||
verify_bloom_filter_exchange(&nodes, &edges);
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// 5-node star: hub node's filter should contain all spokes.
|
||||
#[tokio::test]
|
||||
async fn test_bloom_filter_star() {
|
||||
let edges: Vec<(usize, usize)> = vec![(0, 1), (0, 2), (0, 3), (0, 4)];
|
||||
let mut nodes = run_tree_test(5, &edges, false).await;
|
||||
verify_tree_convergence(&nodes);
|
||||
verify_bloom_filter_exchange(&nodes, &edges);
|
||||
|
||||
// Hub (node 0) sends each spoke a filter containing the other spokes
|
||||
let hub_addr = *nodes[0].node.node_addr();
|
||||
for spoke in 1..5 {
|
||||
let peer = nodes[spoke].node.get_peer(&hub_addr).unwrap();
|
||||
let filter = peer.inbound_filter().unwrap();
|
||||
|
||||
// Filter from hub should contain all OTHER spokes
|
||||
for other in 1..5 {
|
||||
if other == spoke {
|
||||
continue;
|
||||
}
|
||||
let other_addr = *nodes[other].node.node_addr();
|
||||
assert!(
|
||||
filter.contains(&other_addr),
|
||||
"Spoke {}'s filter from hub should contain spoke {} (addr={})",
|
||||
spoke,
|
||||
other,
|
||||
other_addr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// 8-node chain: verify full propagation.
|
||||
///
|
||||
/// Chain: 0-1-2-3-4-5-6-7. Each node's outgoing filter is the merge
|
||||
/// of its own address plus all peer inbound filters (excluding the
|
||||
/// destination peer). This means entries propagate through the entire
|
||||
/// chain: node 1 merges node 2's filter, which contains node 3's
|
||||
/// entries, and so on. Both endpoints should see all other nodes.
|
||||
#[tokio::test]
|
||||
async fn test_bloom_filter_chain_propagation() {
|
||||
let edges: Vec<(usize, usize)> =
|
||||
vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 7)];
|
||||
let mut nodes = run_tree_test(8, &edges, false).await;
|
||||
verify_tree_convergence(&nodes);
|
||||
verify_bloom_filter_exchange(&nodes, &edges);
|
||||
|
||||
let addrs: Vec<NodeAddr> = nodes.iter().map(|tn| *tn.node.node_addr()).collect();
|
||||
|
||||
// Node 0's filter from node 1 should contain node 1 and its
|
||||
// immediate neighbor node 2 (node 1 directly merges node 2's filter).
|
||||
let peer_1 = nodes[0].node.get_peer(&addrs[1]).unwrap();
|
||||
let filter = peer_1.inbound_filter().unwrap();
|
||||
assert!(filter.contains(&addrs[1]), "Should contain node 1 (self)");
|
||||
assert!(
|
||||
filter.contains(&addrs[2]),
|
||||
"Should contain node 2 (1-hop neighbor of node 1)"
|
||||
);
|
||||
|
||||
// Entries propagate through the full chain because each
|
||||
// intermediate node merges its peer's filter into its outgoing
|
||||
// filter. Verify all nodes are reachable from the endpoints.
|
||||
for i in 2..8 {
|
||||
assert!(
|
||||
filter.contains(&addrs[i]),
|
||||
"Node 0's filter from node 1 should contain node {} \
|
||||
(chain merge propagation)",
|
||||
i
|
||||
);
|
||||
}
|
||||
|
||||
// Verify symmetric: node 7's filter from node 6 should contain all
|
||||
for i in 0..6 {
|
||||
let peer_6 = nodes[7].node.get_peer(&addrs[6]).unwrap();
|
||||
let filter_6 = peer_6.inbound_filter().unwrap();
|
||||
assert!(
|
||||
filter_6.contains(&addrs[i]),
|
||||
"Node 7's filter from node 6 should contain node {} \
|
||||
(chain merge propagation)",
|
||||
i
|
||||
);
|
||||
}
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// 5-node ring: every node should see all others (all within 2-hop reach).
|
||||
#[tokio::test]
|
||||
async fn test_bloom_filter_ring() {
|
||||
let edges: Vec<(usize, usize)> = vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)];
|
||||
let mut nodes = run_tree_test(5, &edges, false).await;
|
||||
verify_tree_convergence(&nodes);
|
||||
verify_bloom_filter_exchange(&nodes, &edges);
|
||||
|
||||
// In a 5-node ring, each node has 2 peers. Through each peer,
|
||||
// the other 3 nodes are at most 2 hops away. So every node should
|
||||
// be reachable via at least one peer's filter.
|
||||
for i in 0..5 {
|
||||
for j in 0..5 {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
let target_addr = *nodes[j].node.node_addr();
|
||||
let reachable = nodes[i]
|
||||
.node
|
||||
.peers()
|
||||
.any(|peer| peer.may_reach(&target_addr));
|
||||
assert!(
|
||||
reachable,
|
||||
"Node {} should see node {} as reachable via at least one peer's filter",
|
||||
i, j
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// 100-node random graph: bloom filter exchange at scale.
|
||||
#[tokio::test]
|
||||
async fn test_bloom_filter_convergence_100_nodes() {
|
||||
const NUM_NODES: usize = 100;
|
||||
const TARGET_EDGES: usize = 250;
|
||||
const SEED: u64 = 42;
|
||||
|
||||
let edges = generate_random_edges(NUM_NODES, TARGET_EDGES, SEED);
|
||||
let mut nodes = run_tree_test(NUM_NODES, &edges, false).await;
|
||||
verify_tree_convergence(&nodes);
|
||||
verify_bloom_filter_exchange(&nodes, &edges);
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user