From 94c0e19951aac8afd61aa62c7a40fb5eba5ffd0f Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Mon, 23 Feb 2026 19:58:22 +0000 Subject: [PATCH] Add flap dampening to parent selection Track parent switch frequency in a sliding window. When switches exceed a configurable threshold (default 4 in 60s), impose an extended hold-down period (default 120s) that prevents non-mandatory parent changes. Mandatory switches (parent loss, root change, shouldn't-be-root) bypass dampening. The flap counter resets when the window expires naturally. Implements TASK-2026-0030 / IDEA-0013. --- src/config/node.rs | 15 +++ src/node/mod.rs | 10 ++ src/node/tree.rs | 10 +- src/tree/state.rs | 76 ++++++++++++++- src/tree/tests.rs | 233 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 340 insertions(+), 4 deletions(-) diff --git a/src/config/node.rs b/src/config/node.rs index bf26de5..2482978 100644 --- a/src/config/node.rs +++ b/src/config/node.rs @@ -210,6 +210,15 @@ pub struct TreeConfig { /// tree has stabilized. Set to 0 to disable. #[serde(default = "TreeConfig::default_reeval_interval_secs")] pub reeval_interval_secs: u64, + /// Flap dampening: max parent switches before extended hold-down (`node.tree.flap_threshold`). + #[serde(default = "TreeConfig::default_flap_threshold")] + pub flap_threshold: u32, + /// Flap dampening: window in seconds for counting switches (`node.tree.flap_window_secs`). + #[serde(default = "TreeConfig::default_flap_window_secs")] + pub flap_window_secs: u64, + /// Flap dampening: extended hold-down duration in seconds (`node.tree.flap_dampening_secs`). + #[serde(default = "TreeConfig::default_flap_dampening_secs")] + pub flap_dampening_secs: u64, } impl Default for TreeConfig { @@ -219,6 +228,9 @@ impl Default for TreeConfig { parent_hysteresis: 0.2, hold_down_secs: 30, reeval_interval_secs: 60, + flap_threshold: 4, + flap_window_secs: 60, + flap_dampening_secs: 120, } } } @@ -228,6 +240,9 @@ impl TreeConfig { fn default_parent_hysteresis() -> f64 { 0.2 } fn default_hold_down_secs() -> u64 { 30 } fn default_reeval_interval_secs() -> u64 { 60 } + fn default_flap_threshold() -> u32 { 4 } + fn default_flap_window_secs() -> u64 { 60 } + fn default_flap_dampening_secs() -> u64 { 120 } } /// Bloom filter (`node.bloom.*`). diff --git a/src/node/mod.rs b/src/node/mod.rs index 9d705b9..8012822 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -374,6 +374,11 @@ impl Node { let mut tree_state = TreeState::new(node_addr); tree_state.set_parent_hysteresis(config.node.tree.parent_hysteresis); tree_state.set_hold_down(config.node.tree.hold_down_secs); + tree_state.set_flap_dampening( + config.node.tree.flap_threshold, + config.node.tree.flap_window_secs, + config.node.tree.flap_dampening_secs, + ); tree_state .sign_declaration(&identity) .expect("signing own declaration should never fail"); @@ -459,6 +464,11 @@ impl Node { let mut tree_state = TreeState::new(node_addr); tree_state.set_parent_hysteresis(config.node.tree.parent_hysteresis); tree_state.set_hold_down(config.node.tree.hold_down_secs); + tree_state.set_flap_dampening( + config.node.tree.flap_threshold, + config.node.tree.flap_window_secs, + config.node.tree.flap_dampening_secs, + ); tree_state .sign_declaration(&identity) .expect("signing own declaration should never fail"); diff --git a/src/node/tree.rs b/src/node/tree.rs index 04f23f1..8a5ac63 100644 --- a/src/node/tree.rs +++ b/src/node/tree.rs @@ -207,7 +207,7 @@ impl Node { .map(|d| d.as_secs()) .unwrap_or(0); - self.tree_state.set_parent(new_parent, new_seq, timestamp); + let flap_dampened = self.tree_state.set_parent(new_parent, new_seq, timestamp); if let Err(e) = self.tree_state.sign_declaration(&self.identity) { warn!(error = %e, "Failed to sign declaration after parent switch"); return; @@ -222,6 +222,9 @@ impl Node { depth = self.tree_state.my_coords().depth(), "Parent switched, flushed coord cache, announcing to all peers" ); + if flap_dampened { + warn!("Flap dampening engaged: excessive parent switches detected"); + } self.send_tree_announce_to_all().await; @@ -318,7 +321,7 @@ impl Node { .map(|d| d.as_secs()) .unwrap_or(0); - self.tree_state.set_parent(new_parent, new_seq, timestamp); + let flap_dampened = self.tree_state.set_parent(new_parent, new_seq, timestamp); if let Err(e) = self.tree_state.sign_declaration(&self.identity) { warn!(error = %e, "Failed to sign declaration after periodic parent re-eval"); return; @@ -334,6 +337,9 @@ impl Node { trigger = "periodic", "Parent switched via periodic cost re-evaluation" ); + if flap_dampened { + warn!("Flap dampening engaged: excessive parent switches detected"); + } self.send_tree_announce_to_all().await; diff --git a/src/tree/state.rs b/src/tree/state.rs index a629145..f8dad36 100644 --- a/src/tree/state.rs +++ b/src/tree/state.rs @@ -31,6 +31,18 @@ pub struct TreeState { hold_down: Duration, /// Timestamp of last parent switch (for hold-down enforcement). last_parent_switch: Option, + /// Number of parent switches in current flap window. + flap_count: u32, + /// Start of the current flap counting window. + flap_window_start: Option, + /// If dampened, suppressed until this instant. + flap_dampening_until: Option, + /// Flap threshold: max switches before dampening engages. + flap_threshold: u32, + /// Flap window duration. + flap_window: Duration, + /// Dampening duration when threshold exceeded. + flap_dampening_duration: Duration, } impl TreeState { @@ -56,6 +68,12 @@ impl TreeState { parent_hysteresis: 0.0, hold_down: Duration::ZERO, last_parent_switch: None, + flap_count: 0, + flap_window_start: None, + flap_dampening_until: None, + flap_threshold: 4, + flap_window: Duration::from_secs(60), + flap_dampening_duration: Duration::from_secs(120), } } @@ -135,10 +153,19 @@ impl TreeState { /// Update this node's parent selection. /// /// Call this when switching parents. Updates the declaration and coordinates. - pub fn set_parent(&mut self, parent_id: NodeAddr, sequence: u64, timestamp: u64) { + /// Returns true if flap dampening was just engaged due to this switch. + /// Only records a flap when the parent actually changes. + pub fn set_parent(&mut self, parent_id: NodeAddr, sequence: u64, timestamp: u64) -> bool { + let parent_changed = self.is_root() || *self.my_declaration.parent_id() != parent_id; self.my_declaration = ParentDeclaration::new(self.my_node_addr, parent_id, sequence, timestamp); self.last_parent_switch = Some(Instant::now()); - // Coordinates will be recomputed when ancestry is available + // Record switch for flap detection only when parent actually changes; + // coordinates will be recomputed when ancestry is available + if parent_changed { + self.record_parent_switch() + } else { + false + } } /// Update this node's coordinates based on current parent's ancestry. @@ -227,6 +254,45 @@ impl TreeState { self.hold_down = Duration::from_secs(secs); } + /// Configure flap dampening parameters. + pub fn set_flap_dampening(&mut self, threshold: u32, window_secs: u64, dampening_secs: u64) { + self.flap_threshold = threshold; + self.flap_window = Duration::from_secs(window_secs); + self.flap_dampening_duration = Duration::from_secs(dampening_secs); + } + + /// Record a parent switch for flap detection. + /// Returns true if dampening was just engaged. + pub fn record_parent_switch(&mut self) -> bool { + let now = Instant::now(); + + // Reset window if expired or not started + match self.flap_window_start { + Some(start) if now.duration_since(start) < self.flap_window => { + self.flap_count += 1; + } + _ => { + self.flap_window_start = Some(now); + self.flap_count = 1; + } + } + + // Check threshold + if self.flap_count >= self.flap_threshold && self.flap_dampening_until.is_none() { + self.flap_dampening_until = Some(now + self.flap_dampening_duration); + return true; + } + false + } + + /// Check if flap dampening is currently active. + pub fn is_flap_dampened(&self) -> bool { + match self.flap_dampening_until { + Some(until) => Instant::now() < until, + None => false, + } + } + /// Evaluate whether to switch parents based on current peer tree state. /// /// Uses effective_depth (depth + link_cost) for parent comparison. @@ -318,6 +384,12 @@ impl TreeState { return None; } + // --- Flap dampening: suppress after excessive parent switches --- + + if self.is_flap_dampened() { + return None; + } + // --- Same root, cost-aware comparison with hysteresis --- // Current parent's effective_depth diff --git a/src/tree/tests.rs b/src/tree/tests.rs index 39bcf64..56f5343 100644 --- a/src/tree/tests.rs +++ b/src/tree/tests.rs @@ -1107,3 +1107,236 @@ fn test_single_peer_no_reeval_benefit() { let result = state.evaluate_parent(&bad_costs); assert_eq!(result, None); } + +// ===================================================================== +// Flap dampening tests +// ===================================================================== + +#[test] +fn test_flap_dampening_engages_after_threshold() { + // Create TreeState with flap_threshold=3, window=60s, dampening=3600s (long) + let my_node = make_node_addr(5); + let mut state = TreeState::new(my_node); + state.set_flap_dampening(3, 60, 3600); + state.set_hold_down(0); // disable hold-down for this test + + let peer_a = make_node_addr(1); + let peer_b = make_node_addr(2); + let root = make_node_addr(0); + + state.update_peer( + ParentDeclaration::new(peer_a, root, 1, 1000), + make_coords(&[1, 0]), + ); + state.update_peer( + ParentDeclaration::new(peer_b, root, 1, 1000), + make_coords(&[2, 0]), + ); + + // Switch 1: initial parent selection (root -> peer_a) + assert!(!state.is_flap_dampened()); + state.set_parent(peer_a, 1, 1000); + state.recompute_coords(); + assert!(!state.is_flap_dampened()); + + // Switch 2: peer_a -> peer_b + state.set_parent(peer_b, 2, 2000); + state.recompute_coords(); + assert!(!state.is_flap_dampened()); + + // Switch 3: peer_b -> peer_a — threshold reached, dampening engages + let dampened = state.set_parent(peer_a, 3, 3000); + state.recompute_coords(); + assert!(dampened); + assert!(state.is_flap_dampened()); + + // evaluate_parent should return None for non-mandatory switches + // Make peer_b much better than peer_a + let costs = make_costs(&[(1, 10.0), (2, 1.0)]); + let result = state.evaluate_parent(&costs); + assert_eq!(result, None); // suppressed by flap dampening +} + +#[test] +fn test_flap_dampening_allows_mandatory_switches() { + // Engage dampening, then verify mandatory switches still work + let my_node = make_node_addr(5); + let mut state = TreeState::new(my_node); + state.set_flap_dampening(3, 60, 3600); + state.set_hold_down(0); + + let peer_a = make_node_addr(1); + let peer_b = make_node_addr(2); + let root = make_node_addr(0); + + state.update_peer( + ParentDeclaration::new(peer_a, root, 1, 1000), + make_coords(&[1, 0]), + ); + state.update_peer( + ParentDeclaration::new(peer_b, root, 1, 1000), + make_coords(&[2, 0]), + ); + + // Trigger dampening with 3 switches + state.set_parent(peer_a, 1, 1000); + state.recompute_coords(); + state.set_parent(peer_b, 2, 2000); + state.recompute_coords(); + state.set_parent(peer_a, 3, 3000); + state.recompute_coords(); + assert!(state.is_flap_dampened()); + + // Remove current parent (peer_a) — this is a mandatory switch + state.remove_peer(&peer_a); + let result = state.evaluate_parent(&HashMap::new()); + assert_eq!(result, Some(peer_b)); // mandatory switch bypasses dampening +} + +#[test] +fn test_flap_dampening_expires() { + // Test with 0-second dampening duration to verify expiry logic + let my_node = make_node_addr(5); + let mut state = TreeState::new(my_node); + state.set_flap_dampening(3, 60, 0); // 0-second dampening + state.set_hold_down(0); + + let peer_a = make_node_addr(1); + let peer_b = make_node_addr(2); + let root = make_node_addr(0); + + state.update_peer( + ParentDeclaration::new(peer_a, root, 1, 1000), + make_coords(&[1, 0]), + ); + state.update_peer( + ParentDeclaration::new(peer_b, root, 1, 1000), + make_coords(&[2, 0]), + ); + + // Trigger dampening + state.set_parent(peer_a, 1, 1000); + state.recompute_coords(); + state.set_parent(peer_b, 2, 2000); + state.recompute_coords(); + let dampened = state.set_parent(peer_a, 3, 3000); + state.recompute_coords(); + assert!(dampened); // dampening was engaged + + // With 0-second duration, dampening should have already expired + assert!(!state.is_flap_dampened()); + + // evaluate_parent should work normally now + let costs = make_costs(&[(1, 10.0), (2, 1.0)]); + let result = state.evaluate_parent(&costs); + assert_eq!(result, Some(peer_b)); // not suppressed +} + +#[test] +fn test_flap_dampening_below_threshold() { + // Fewer switches than threshold should NOT engage dampening + let my_node = make_node_addr(5); + let mut state = TreeState::new(my_node); + state.set_flap_dampening(4, 60, 3600); // threshold=4 + state.set_hold_down(0); + + let peer_a = make_node_addr(1); + let peer_b = make_node_addr(2); + let root = make_node_addr(0); + + state.update_peer( + ParentDeclaration::new(peer_a, root, 1, 1000), + make_coords(&[1, 0]), + ); + state.update_peer( + ParentDeclaration::new(peer_b, root, 1, 1000), + make_coords(&[2, 0]), + ); + + // Only 3 switches (below threshold of 4) + state.set_parent(peer_a, 1, 1000); + state.recompute_coords(); + state.set_parent(peer_b, 2, 2000); + state.recompute_coords(); + state.set_parent(peer_a, 3, 3000); + state.recompute_coords(); + + assert!(!state.is_flap_dampened()); + + // evaluate_parent should still work normally + let costs = make_costs(&[(1, 10.0), (2, 1.0)]); + let result = state.evaluate_parent(&costs); + assert_eq!(result, Some(peer_b)); // not suppressed +} + +#[test] +fn test_flap_dampening_window_reset() { + // Test that the flap window resets after expiry. + // Use a 0-second window so it immediately expires between switch groups. + let my_node = make_node_addr(5); + let mut state = TreeState::new(my_node); + // threshold=3, window=0s (expires immediately), dampening=3600s + state.set_flap_dampening(3, 0, 3600); + state.set_hold_down(0); + + let peer_a = make_node_addr(1); + let peer_b = make_node_addr(2); + let root = make_node_addr(0); + + state.update_peer( + ParentDeclaration::new(peer_a, root, 1, 1000), + make_coords(&[1, 0]), + ); + state.update_peer( + ParentDeclaration::new(peer_b, root, 1, 1000), + make_coords(&[2, 0]), + ); + + // Each switch resets the window (0s window means every switch starts fresh). + // So we never accumulate enough to reach threshold=3. + state.set_parent(peer_a, 1, 1000); + state.recompute_coords(); + // Window expired, counter resets on next switch + state.set_parent(peer_b, 2, 2000); + state.recompute_coords(); + // Window expired, counter resets on next switch + state.set_parent(peer_a, 3, 3000); + state.recompute_coords(); + + // Dampening should NOT have engaged because each switch reset the window + assert!(!state.is_flap_dampened()); +} + +#[test] +fn test_flap_dampening_same_parent_no_count() { + // Re-declaring the same parent should not count as a flap + let my_node = make_node_addr(5); + let mut state = TreeState::new(my_node); + state.set_flap_dampening(3, 60, 3600); + state.set_hold_down(0); + + let peer_a = make_node_addr(1); + let root = make_node_addr(0); + + state.update_peer( + ParentDeclaration::new(peer_a, root, 1, 1000), + make_coords(&[1, 0]), + ); + + // Initial parent selection + state.set_parent(peer_a, 1, 1000); + state.recompute_coords(); + + // Re-declare same parent multiple times (e.g., parent ancestry changed) + state.set_parent(peer_a, 2, 2000); + state.recompute_coords(); + state.set_parent(peer_a, 3, 3000); + state.recompute_coords(); + state.set_parent(peer_a, 4, 4000); + state.recompute_coords(); + state.set_parent(peer_a, 5, 5000); + state.recompute_coords(); + + // Should NOT be dampened since only the first was a real switch + assert!(!state.is_flap_dampened()); +}