mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-11 09:07:44 +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:
+74
-2
@@ -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