1#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ReplicaSlot {
18 pub id: String,
21 pub last_seen_ns: u64,
24 pub acked_offset: u64,
27}
28
29#[derive(Debug, Default)]
35pub struct SlotTable {
36 slots: Vec<ReplicaSlot>,
37}
38
39impl SlotTable {
40 pub fn new() -> Self {
42 Self::default()
43 }
44
45 pub fn len(&self) -> usize {
47 self.slots.len()
48 }
49
50 pub fn is_empty(&self) -> bool {
52 self.slots.is_empty()
53 }
54
55 pub fn get(&self, id: &str) -> Option<&ReplicaSlot> {
57 self.slots.iter().find(|s| s.id == id)
58 }
59
60 pub fn touch_or_insert_unacked(&mut self, id: &str, now_ns: u64) {
69 if let Some(s) = self.slots.iter_mut().find(|s| s.id == id) {
70 s.last_seen_ns = now_ns;
71 return;
72 }
73 self.slots.push(ReplicaSlot { id: id.to_string(), last_seen_ns: now_ns, acked_offset: 0 });
74 }
75
76 pub fn iter(&self) -> impl Iterator<Item = &ReplicaSlot> {
78 self.slots.iter()
79 }
80
81 pub fn insert_or_touch(&mut self, id: &str, acked_offset: u64, now_ns: u64) {
88 if let Some(s) = self.slots.iter_mut().find(|s| s.id == id) {
89 s.last_seen_ns = now_ns;
90 if acked_offset > s.acked_offset {
91 s.acked_offset = acked_offset;
92 }
93 return;
94 }
95 self.slots.push(ReplicaSlot { id: id.to_string(), last_seen_ns: now_ns, acked_offset });
96 }
97
98 pub fn remove(&mut self, id: &str) -> bool {
101 if let Some(pos) = self.slots.iter().position(|s| s.id == id) {
102 self.slots.swap_remove(pos);
103 true
104 } else {
105 false
106 }
107 }
108
109 pub fn expire(&mut self, now_ns: u64, window_ns: u64) -> Vec<String> {
113 let mut dropped = Vec::new();
114 let mut i = self.slots.len();
117 while i > 0 {
118 i -= 1;
119 let cutoff = self.slots[i].last_seen_ns.saturating_add(window_ns);
120 if cutoff <= now_ns {
121 let s = self.slots.swap_remove(i);
122 dropped.push(s.id);
123 }
124 }
125 dropped
126 }
127
128 pub fn min_acked_offset(&self) -> Option<u64> {
132 self.slots.iter().map(|s| s.acked_offset).min()
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn fresh_table_is_empty() {
142 let t = SlotTable::new();
143 assert!(t.is_empty());
144 assert_eq!(t.len(), 0);
145 assert_eq!(t.min_acked_offset(), None);
146 }
147
148 #[test]
149 fn insert_then_get_returns_the_slot() {
150 let mut t = SlotTable::new();
151 t.insert_or_touch("a", 5, 100);
152 let s = t.get("a").unwrap();
153 assert_eq!(s.id, "a");
154 assert_eq!(s.acked_offset, 5);
155 assert_eq!(s.last_seen_ns, 100);
156 assert_eq!(t.len(), 1);
157 }
158
159 #[test]
160 fn touch_advances_last_seen_and_acked_offset() {
161 let mut t = SlotTable::new();
162 t.insert_or_touch("a", 5, 100);
163 t.insert_or_touch("a", 9, 200);
164 let s = t.get("a").unwrap();
165 assert_eq!(s.acked_offset, 9);
166 assert_eq!(s.last_seen_ns, 200);
167 assert_eq!(t.len(), 1);
169 }
170
171 #[test]
172 fn touch_with_lower_acked_offset_keeps_the_higher_one() {
173 let mut t = SlotTable::new();
174 t.insert_or_touch("a", 10, 100);
175 t.insert_or_touch("a", 7, 200);
177 let s = t.get("a").unwrap();
178 assert_eq!(s.acked_offset, 10);
179 assert_eq!(s.last_seen_ns, 200, "last_seen still advances");
180 }
181
182 #[test]
183 fn remove_existing_returns_true_and_drops_slot() {
184 let mut t = SlotTable::new();
185 t.insert_or_touch("a", 1, 100);
186 assert!(t.remove("a"));
187 assert!(t.is_empty());
188 assert_eq!(t.get("a"), None);
189 }
190
191 #[test]
192 fn remove_missing_returns_false() {
193 let mut t = SlotTable::new();
194 assert!(!t.remove("missing"));
195 }
196
197 #[test]
198 fn expire_drops_slots_past_window() {
199 let mut t = SlotTable::new();
200 t.insert_or_touch("old", 1, 100);
201 t.insert_or_touch("fresh", 1, 500);
202 let dropped = t.expire(350, 200);
205 assert_eq!(dropped, vec!["old".to_string()]);
206 assert_eq!(t.len(), 1);
207 assert!(t.get("fresh").is_some());
208 }
209
210 #[test]
211 fn expire_when_nothing_expires_returns_empty() {
212 let mut t = SlotTable::new();
213 t.insert_or_touch("a", 1, 1000);
214 let dropped = t.expire(1100, 500); assert!(dropped.is_empty());
216 assert_eq!(t.len(), 1);
217 }
218
219 #[test]
220 fn expire_with_overflow_window_saturates_does_not_panic() {
221 let mut t = SlotTable::new();
227 t.insert_or_touch("a", 1, u64::MAX - 10);
228 let dropped = t.expire(u64::MAX - 1, u64::MAX);
229 assert!(dropped.is_empty());
230 assert_eq!(t.len(), 1);
231 }
232
233 #[test]
234 fn min_acked_offset_returns_the_floor() {
235 let mut t = SlotTable::new();
236 t.insert_or_touch("a", 7, 100);
237 t.insert_or_touch("b", 3, 100);
238 t.insert_or_touch("c", 12, 100);
239 assert_eq!(t.min_acked_offset(), Some(3));
240 }
241
242 #[test]
243 fn touch_or_insert_unacked_never_advances_acked() {
244 let mut t = SlotTable::new();
245 t.insert_or_touch("a", 7, 100);
246 t.touch_or_insert_unacked("a", 200);
248 let s = t.get("a").unwrap();
249 assert_eq!(s.last_seen_ns, 200);
250 assert_eq!(s.acked_offset, 7, "close must not write sent-as-acked");
251 t.touch_or_insert_unacked("b", 300);
253 assert_eq!(t.get("b").unwrap().acked_offset, 0);
254 }
255
256 #[test]
257 fn iter_visits_every_slot() {
258 let mut t = SlotTable::new();
259 t.insert_or_touch("a", 1, 100);
260 t.insert_or_touch("b", 2, 100);
261 let mut ids: Vec<_> = t.iter().map(|s| s.id.clone()).collect();
262 ids.sort();
263 assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
264 }
265
266 #[test]
267 fn expire_all_when_window_zero() {
268 let mut t = SlotTable::new();
269 t.insert_or_touch("a", 1, 100);
270 t.insert_or_touch("b", 1, 100);
271 let dropped = t.expire(100, 0);
272 assert_eq!(dropped.len(), 2);
273 assert!(t.is_empty());
274 }
275}