openmoat_proxy/
coalesce.rs1use std::sync::{Mutex, PoisonError};
14use std::time::{Duration, Instant};
15
16pub const INTERVAL: Duration = Duration::from_secs(10);
18
19#[derive(Debug, Default, Clone, Copy)]
21struct Kind {
22 last: Option<Instant>,
23 unrecorded: u64,
24}
25
26#[derive(Debug)]
28pub struct Coalesce<const N: usize> {
29 kinds: Mutex<[Kind; N]>,
30}
31
32impl<const N: usize> Default for Coalesce<N> {
33 fn default() -> Self {
34 Self {
35 kinds: Mutex::new([Kind::default(); N]),
36 }
37 }
38}
39
40impl<const N: usize> Coalesce<N> {
41 pub fn refused(&self, kind: usize, now: Instant) -> Option<u64> {
46 let mut kinds = self.kinds.lock().unwrap_or_else(PoisonError::into_inner);
47 let slot = kinds.get_mut(kind.min(N.saturating_sub(1)))?;
48 slot.unrecorded = slot.unrecorded.saturating_add(1);
49 take_due(slot, now)
50 }
51
52 pub fn due(&self, now: Instant) -> Vec<(usize, u64)> {
55 let mut kinds = self.kinds.lock().unwrap_or_else(PoisonError::into_inner);
56 kinds
57 .iter_mut()
58 .enumerate()
59 .filter(|(_, k)| k.unrecorded > 0)
60 .filter_map(|(i, k)| take_due(k, now).map(|n| (i, n)))
61 .collect()
62 }
63
64 pub fn rest(&self) -> Vec<(usize, u64)> {
66 let mut kinds = self.kinds.lock().unwrap_or_else(PoisonError::into_inner);
67 kinds
68 .iter_mut()
69 .enumerate()
70 .filter(|(_, k)| k.unrecorded > 0)
71 .map(|(i, k)| (i, std::mem::take(&mut k.unrecorded)))
72 .collect()
73 }
74}
75
76fn take_due(kind: &mut Kind, now: Instant) -> Option<u64> {
78 let due = kind
79 .last
80 .is_none_or(|last| now.saturating_duration_since(last) >= INTERVAL);
81 if !due {
82 return None;
83 }
84 kind.last = Some(now);
85 Some(std::mem::take(&mut kind.unrecorded))
86}
87
88pub fn counted(count: u64, one: &str, many: &str) -> String {
90 let noun = if count == 1 { one } else { many };
91 format!("{count} {noun}")
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn the_first_of_a_kind_is_recorded_and_a_flood_is_one_row_per_interval() {
100 let counter = Coalesce::<2>::default();
101 let start = Instant::now();
102 assert_eq!(counter.refused(0, start), Some(1));
103 for i in 1..1000u32 {
104 let at = start + Duration::from_millis(u64::from(i));
105 assert_eq!(counter.refused(0, at), None, "refusal {i}");
106 }
107 assert_eq!(counter.refused(1, start), Some(1));
109 let later = start + INTERVAL;
110 assert_eq!(
111 counter.refused(0, later),
112 Some(1000),
113 "999 counted plus this one"
114 );
115 assert_eq!(counter.due(later), []);
116 }
117
118 #[test]
119 fn a_burst_that_ends_is_recorded_by_the_next_connection_or_the_end() {
120 let counter = Coalesce::<1>::default();
121 let start = Instant::now();
122 counter.refused(0, start);
123 counter.refused(0, start);
124 counter.refused(0, start);
125 assert!(counter.due(start).is_empty(), "within the interval");
126 assert_eq!(counter.due(start + INTERVAL), [(0, 2)]);
127 assert!(counter.due(start + INTERVAL * 3).is_empty(), "nothing left");
128 counter.refused(0, start + INTERVAL * 3);
129 counter.refused(0, start + INTERVAL * 3);
130 assert_eq!(counter.rest(), [(0, 1)]);
131 assert_eq!(counter.rest(), []);
132 }
133
134 #[test]
135 fn rows_are_bounded_by_time_not_by_refusals() {
136 let counter = Coalesce::<1>::default();
137 let start = Instant::now();
138 let mut rows = 0;
139 let mut total = 0;
140 for i in 0..100_000u64 {
142 let at = start + Duration::from_micros(i * 600);
143 if let Some(n) = counter.refused(0, at) {
144 rows += 1;
145 total += n;
146 }
147 }
148 total += counter.rest().iter().map(|(_, n)| n).sum::<u64>();
149 assert!(rows <= 7, "{rows} rows");
150 assert_eq!(total, 100_000, "every refusal is counted");
151 }
152
153 #[test]
154 fn words() {
155 assert_eq!(counted(1, "request", "requests"), "1 request");
156 assert_eq!(counted(3, "request", "requests"), "3 requests");
157 }
158}