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