Skip to main content

veilid_tools/
flap_detector.rs

1/// Flapping detector: decaying-penalty counter (BGP-damping style) over an observed value.
2/// `record` counts a transition whenever the value differs from the last observed one; the
3/// penalty decays with `half_life_us` and crossing `threshold` means flapping.
4/// Timestamps are raw microseconds (veilid-tools has no Timestamp type).
5#[derive(Debug, Clone)]
6pub struct FlapDetector<T: PartialEq> {
7    penalty: f64,
8    threshold: f64,
9    half_life_us: f64,
10    last_update_us: u64,
11    flapping: bool,
12    last_value: Option<T>,
13}
14
15impl<T: PartialEq> FlapDetector<T> {
16    /// Creates a detector that trips at `threshold` accumulated penalty, decaying by half every `half_life_us`.
17    #[must_use]
18    pub fn new(threshold: f64, half_life_us: u64) -> Self {
19        Self {
20            penalty: 0.0,
21            threshold,
22            half_life_us: half_life_us as f64,
23            last_update_us: 0,
24            flapping: false,
25            last_value: None,
26        }
27    }
28
29    /// Convenience: half-life in whole seconds.
30    #[must_use]
31    pub fn new_secs(threshold: f64, half_life_secs: u64) -> Self {
32        Self::new(threshold, half_life_secs * 1_000_000)
33    }
34
35    fn decay(&mut self, now_us: u64) {
36        if self.last_update_us != 0 && self.half_life_us > 0.0 {
37            let elapsed = now_us.saturating_sub(self.last_update_us) as f64;
38            self.penalty *= 0.5_f64.powf(elapsed / self.half_life_us);
39        }
40        self.last_update_us = now_us;
41    }
42
43    /// Observe a value; counts a transition when it differs from the last. Returns `Some(penalty)`
44    /// only on the transition that tips into flapping (re-arms after decay).
45    pub fn record(&mut self, now_us: u64, value: T) -> Option<f64> {
46        let changed = matches!(&self.last_value, Some(lv) if *lv != value);
47        self.last_value = Some(value);
48        if !changed {
49            return None;
50        }
51        self.record_event(now_us)
52    }
53
54    /// Count a discrete flap event (e.g. a connection drop). Returns `Some(penalty)` only on the
55    /// event that tips into flapping (re-arms after decay).
56    pub fn record_event(&mut self, now_us: u64) -> Option<f64> {
57        self.decay(now_us);
58        if self.flapping && self.penalty < self.threshold {
59            self.flapping = false;
60        }
61        self.penalty += 1.0;
62        if self.penalty >= self.threshold && !self.flapping {
63            self.flapping = true;
64            return Some(self.penalty);
65        }
66        None
67    }
68
69    /// Clear accumulated penalty and history. Use after a deliberate offline period
70    /// (e.g. detach) so it isn't counted as flapping; threshold/half-life are kept.
71    pub fn reset(&mut self) {
72        self.penalty = 0.0;
73        self.last_update_us = 0;
74        self.flapping = false;
75        self.last_value = None;
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    const SEC: u64 = 1_000_000;
83
84    #[test]
85    fn stable_value_never_flaps() {
86        let mut fd = FlapDetector::new(3.0, 30 * SEC);
87        for i in 0..10 {
88            assert_eq!(fd.record(i * SEC, true), None);
89        }
90    }
91
92    #[test]
93    fn first_observation_is_not_a_transition() {
94        let mut fd = FlapDetector::new(3.0, 30 * SEC);
95        assert_eq!(fd.record(0, true), None);
96    }
97
98    #[test]
99    fn sustained_flipping_trips_threshold() {
100        let mut fd = FlapDetector::new(3.0, 30 * SEC);
101        let t = 5 * SEC; // same instant within the burst => decay-free, deterministic
102        assert_eq!(fd.record(t, true), None); // first observation
103        assert_eq!(fd.record(t, false), None); // transition 1, penalty 1
104        assert_eq!(fd.record(t, true), None); // transition 2, penalty 2
105        assert!(fd.record(t, false).is_some()); // transition 3, penalty 3 >= 3 => trips
106    }
107
108    #[test]
109    fn rearms_after_decay() {
110        let mut fd = FlapDetector::new(3.0, SEC);
111        let t = 10 * SEC;
112        fd.record(t, true);
113        fd.record(t, false);
114        fd.record(t, true);
115        assert!(fd.record(t, false).is_some()); // trips
116                                                // a long quiet period decays the penalty well below threshold; a fresh burst re-arms
117        let t2 = t + 1000 * SEC;
118        assert_eq!(fd.record(t2, true), None);
119        assert_eq!(fd.record(t2, false), None);
120        assert!(fd.record(t2, true).is_some());
121    }
122
123    #[test]
124    fn reset_clears_penalty_and_history() {
125        let mut fd = FlapDetector::new(3.0, 30 * SEC);
126        let t = 5 * SEC;
127        fd.record(t, true);
128        fd.record(t, false);
129        fd.record(t, true); // penalty 2, just under threshold
130        fd.reset();
131        // After reset the next observation is a fresh baseline (no transition),
132        // so it takes a full new burst to trip rather than one more flip.
133        assert_eq!(fd.record(t, false), None); // first observation post-reset
134        assert_eq!(fd.record(t, true), None); // transition 1, penalty 1
135        assert_eq!(fd.record(t, false), None); // transition 2, penalty 2
136        assert!(fd.record(t, true).is_some()); // transition 3, penalty 3 => trips
137    }
138}