mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
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.
This commit is contained in:
@@ -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.*`).
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -31,6 +31,18 @@ pub struct TreeState {
|
||||
hold_down: Duration,
|
||||
/// Timestamp of last parent switch (for hold-down enforcement).
|
||||
last_parent_switch: Option<Instant>,
|
||||
/// Number of parent switches in current flap window.
|
||||
flap_count: u32,
|
||||
/// Start of the current flap counting window.
|
||||
flap_window_start: Option<Instant>,
|
||||
/// If dampened, suppressed until this instant.
|
||||
flap_dampening_until: Option<Instant>,
|
||||
/// 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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user