kevy_replicate/slot.rs
1//! Per-replica slot bookkeeping for the primary's streaming loop.
2//!
3//! Each connected (or recently-disconnected) replica owns one
4//! [`ReplicaSlot`]. The [`SlotTable`] is the source of truth for "who
5//! still has a chance to resume from the backlog vs whom we have
6//! given up on". Slots are pure data; socket lifetime and the
7//! streaming loop's wakeups live in the wiring layer.
8//!
9//! Clock policy: all timestamps are `u64` monotonic nanoseconds
10//! supplied by the caller. The module never reads the clock itself —
11//! tests pump synthetic timestamps, and the production hook uses a
12//! single `Instant`-derived `u64`. Same pattern as the cached-clock
13//! work in `kevy-store`.
14//!
15//! ```
16//! use kevy_replicate::slot::SlotTable;
17//!
18//! const SEC: u64 = 1_000_000_000;
19//! let mut slots = SlotTable::new();
20//! slots.insert_or_touch("r1", 10, 0); // handshake / ack at t = 0 s
21//! slots.insert_or_touch("r2", 4, 3 * SEC);
22//! assert_eq!(slots.min_acked_offset(), Some(4)); // the backlog must keep offset 4 on
23//!
24//! // a 5 s reconnect window: r1 gives up at t = 5 s
25//! assert_eq!(slots.expire(5 * SEC, 5 * SEC), ["r1"]);
26//! ```
27
28/// One connected-or-recently-disconnected replica.
29///
30/// ```
31/// use kevy_replicate::slot::SlotTable;
32///
33/// let mut slots = SlotTable::new();
34/// slots.insert_or_touch("r1", 42, 1_000);
35/// let slot = slots.get("r1").expect("tracked");
36/// assert_eq!((slot.id.as_str(), slot.acked_offset, slot.last_seen_ns), ("r1", 42, 1_000));
37/// ```
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39#[non_exhaustive]
40pub struct ReplicaSlot {
41 /// Operator-set replica identifier (opaque to the primary other
42 /// than for slot bookkeeping).
43 ///
44 /// ```
45 /// let mut slots = kevy_replicate::slot::SlotTable::new();
46 /// slots.insert_or_touch("replica-a", 0, 0);
47 /// assert_eq!(slots.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(), ["replica-a"]);
48 /// ```
49 pub id: String,
50 /// Monotonic ns timestamp of the most recent contact (handshake
51 /// or ack). Drives expiry under `reconnect_window_ms`.
52 ///
53 /// ```
54 /// let mut slots = kevy_replicate::slot::SlotTable::new();
55 /// slots.insert_or_touch("r1", 0, 100);
56 /// slots.touch_or_insert_unacked("r1", 250); // a later contact
57 /// assert_eq!(slots.get("r1").map(|s| s.last_seen_ns), Some(250));
58 /// ```
59 pub last_seen_ns: u64,
60 /// Highest offset the replica has acked. The streaming loop
61 /// resumes sending from here on reconnect.
62 ///
63 /// ```
64 /// let mut slots = kevy_replicate::slot::SlotTable::new();
65 /// slots.insert_or_touch("r1", 9, 0);
66 /// slots.insert_or_touch("r1", 7, 1); // a stale ack never moves it back
67 /// assert_eq!(slots.get("r1").map(|s| s.acked_offset), Some(9));
68 /// ```
69 pub acked_offset: u64,
70}
71
72/// Mutable collection of [`ReplicaSlot`]s. Slots are addressed by id;
73/// duplicate insertion upserts (newer state wins). Replica counts in
74/// realistic deployments are small (< 16); a linear `Vec` is faster
75/// than a `HashMap` at this size and avoids the cost of the hasher
76/// the rest of the workspace uses.
77///
78/// ```
79/// use kevy_replicate::slot::SlotTable;
80///
81/// let mut slots = SlotTable::new();
82/// slots.insert_or_touch("r1", 3, 0);
83/// slots.insert_or_touch("r1", 8, 1); // upsert, not a second row
84/// assert_eq!(slots.len(), 1);
85/// assert_eq!(slots.min_acked_offset(), Some(8));
86/// ```
87#[derive(Debug, Default)]
88pub struct SlotTable {
89 slots: Vec<ReplicaSlot>,
90}
91
92impl SlotTable {
93 /// A fresh empty table.
94 ///
95 /// ```
96 /// let slots = kevy_replicate::slot::SlotTable::new();
97 /// assert!(slots.is_empty());
98 /// ```
99 pub fn new() -> Self {
100 Self::default()
101 }
102
103 /// Number of slots currently tracked.
104 ///
105 /// ```
106 /// let mut slots = kevy_replicate::slot::SlotTable::new();
107 /// slots.insert_or_touch("r1", 0, 0);
108 /// slots.insert_or_touch("r2", 0, 0);
109 /// assert_eq!(slots.len(), 2);
110 /// ```
111 pub fn len(&self) -> usize {
112 self.slots.len()
113 }
114
115 /// Whether the table has no slots.
116 ///
117 /// ```
118 /// let mut slots = kevy_replicate::slot::SlotTable::new();
119 /// assert!(slots.is_empty());
120 /// slots.insert_or_touch("r1", 0, 0);
121 /// assert!(!slots.is_empty());
122 /// ```
123 pub fn is_empty(&self) -> bool {
124 self.slots.is_empty()
125 }
126
127 /// Look up a slot by id.
128 ///
129 /// ```
130 /// let mut slots = kevy_replicate::slot::SlotTable::new();
131 /// slots.insert_or_touch("r1", 5, 0);
132 /// assert_eq!(slots.get("r1").map(|s| s.acked_offset), Some(5));
133 /// assert!(slots.get("r2").is_none());
134 /// ```
135 pub fn get(&self, id: &str) -> Option<&ReplicaSlot> {
136 self.slots.iter().find(|s| s.id == id)
137 }
138
139 /// Refresh a slot's `last_seen_ns` WITHOUT advancing its acked
140 /// offset — the disconnect-time variant of
141 /// [`Self::insert_or_touch`]. A closing conn knows what it SENT,
142 /// not what the peer acked; recording sent-as-acked would let
143 /// `WAIT` / min-replicas count bytes the replica never confirmed
144 /// and let the backlog trim frames the peer may still need. A
145 /// previously unseen id is inserted at `acked_offset = 0`
146 /// (never acked — the truthful floor).
147 ///
148 /// ```
149 /// let mut slots = kevy_replicate::slot::SlotTable::new();
150 /// slots.insert_or_touch("r1", 12, 100);
151 /// slots.touch_or_insert_unacked("r1", 200); // the link closed at t = 200
152 /// slots.touch_or_insert_unacked("r2", 200); // never acked anything
153 /// assert_eq!(slots.get("r1").map(|s| (s.acked_offset, s.last_seen_ns)), Some((12, 200)));
154 /// assert_eq!(slots.get("r2").map(|s| s.acked_offset), Some(0));
155 /// ```
156 pub fn touch_or_insert_unacked(&mut self, id: &str, now_ns: u64) {
157 if let Some(s) = self.slots.iter_mut().find(|s| s.id == id) {
158 s.last_seen_ns = now_ns;
159 return;
160 }
161 self.slots.push(ReplicaSlot { id: id.to_string(), last_seen_ns: now_ns, acked_offset: 0 });
162 }
163
164 /// Iterate over all slots.
165 ///
166 /// ```
167 /// let mut slots = kevy_replicate::slot::SlotTable::new();
168 /// slots.insert_or_touch("r1", 4, 0);
169 /// slots.insert_or_touch("r2", 6, 0);
170 /// let acked: u64 = slots.iter().map(|s| s.acked_offset).sum();
171 /// assert_eq!(acked, 10);
172 /// ```
173 pub fn iter(&self) -> impl Iterator<Item = &ReplicaSlot> {
174 self.slots.iter()
175 }
176
177 /// Insert a new slot or update an existing one. Touching always
178 /// refreshes `last_seen_ns` and advances `acked_offset` if the
179 /// new value is higher (a slot's acked offset is monotonic — a
180 /// peer reporting a lower offset than we already recorded is
181 /// almost always a bug; the silent max() here defends the
182 /// invariant).
183 ///
184 /// ```
185 /// let mut slots = kevy_replicate::slot::SlotTable::new();
186 /// slots.insert_or_touch("r1", 5, 100); // first contact inserts
187 /// slots.insert_or_touch("r1", 9, 200); // later acks advance
188 /// assert_eq!(slots.get("r1").map(|s| (s.acked_offset, s.last_seen_ns)), Some((9, 200)));
189 /// ```
190 pub fn insert_or_touch(&mut self, id: &str, acked_offset: u64, now_ns: u64) {
191 if let Some(s) = self.slots.iter_mut().find(|s| s.id == id) {
192 s.last_seen_ns = now_ns;
193 if acked_offset > s.acked_offset {
194 s.acked_offset = acked_offset;
195 }
196 return;
197 }
198 self.slots.push(ReplicaSlot { id: id.to_string(), last_seen_ns: now_ns, acked_offset });
199 }
200
201 /// Remove the slot with the given id. Returns `true` if a slot
202 /// was actually removed.
203 ///
204 /// ```
205 /// let mut slots = kevy_replicate::slot::SlotTable::new();
206 /// slots.insert_or_touch("r1", 0, 0);
207 /// assert!(slots.remove("r1"));
208 /// assert!(!slots.remove("r1")); // already gone
209 /// ```
210 pub fn remove(&mut self, id: &str) -> bool {
211 if let Some(pos) = self.slots.iter().position(|s| s.id == id) {
212 self.slots.swap_remove(pos);
213 true
214 } else {
215 false
216 }
217 }
218
219 /// Drop slots whose `last_seen_ns + window_ns ≤ now_ns`. Returns
220 /// the ids of the dropped slots so callers can fire metrics or
221 /// log lines. Order is unspecified (swap-remove internally).
222 ///
223 /// ```
224 /// let mut slots = kevy_replicate::slot::SlotTable::new();
225 /// slots.insert_or_touch("stale", 0, 100);
226 /// slots.insert_or_touch("live", 0, 900);
227 /// assert_eq!(slots.expire(1_000, 500), ["stale"]); // 100 + 500 <= 1000
228 /// assert_eq!(slots.len(), 1);
229 /// ```
230 pub fn expire(&mut self, now_ns: u64, window_ns: u64) -> Vec<String> {
231 let mut dropped = Vec::new();
232 // Walk backward so swap_remove doesn't shift indices we still
233 // need to visit.
234 let mut i = self.slots.len();
235 while i > 0 {
236 i -= 1;
237 let cutoff = self.slots[i].last_seen_ns.saturating_add(window_ns);
238 if cutoff <= now_ns {
239 let s = self.slots.swap_remove(i);
240 dropped.push(s.id);
241 }
242 }
243 dropped
244 }
245
246 /// Lowest acked offset across all tracked slots. Useful for the
247 /// streaming loop to know how far back the backlog must still
248 /// retain frames; `None` when the table is empty.
249 ///
250 /// ```
251 /// use kevy_replicate::slot::SlotTable;
252 ///
253 /// let mut slots = SlotTable::new();
254 /// assert_eq!(slots.min_acked_offset(), None);
255 /// slots.insert_or_touch("fast", 90, 0);
256 /// slots.insert_or_touch("slow", 40, 0);
257 /// assert_eq!(slots.min_acked_offset(), Some(40)); // trim the backlog below 40 only
258 /// ```
259 pub fn min_acked_offset(&self) -> Option<u64> {
260 self.slots.iter().map(|s| s.acked_offset).min()
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn fresh_table_is_empty() {
270 let t = SlotTable::new();
271 assert!(t.is_empty());
272 assert_eq!(t.len(), 0);
273 assert_eq!(t.min_acked_offset(), None);
274 }
275
276 #[test]
277 fn insert_then_get_returns_the_slot() {
278 let mut t = SlotTable::new();
279 t.insert_or_touch("a", 5, 100);
280 let s = t.get("a").unwrap();
281 assert_eq!(s.id, "a");
282 assert_eq!(s.acked_offset, 5);
283 assert_eq!(s.last_seen_ns, 100);
284 assert_eq!(t.len(), 1);
285 }
286
287 #[test]
288 fn touch_advances_last_seen_and_acked_offset() {
289 let mut t = SlotTable::new();
290 t.insert_or_touch("a", 5, 100);
291 t.insert_or_touch("a", 9, 200);
292 let s = t.get("a").unwrap();
293 assert_eq!(s.acked_offset, 9);
294 assert_eq!(s.last_seen_ns, 200);
295 // No duplicate row.
296 assert_eq!(t.len(), 1);
297 }
298
299 #[test]
300 fn touch_with_lower_acked_offset_keeps_the_higher_one() {
301 let mut t = SlotTable::new();
302 t.insert_or_touch("a", 10, 100);
303 // Peer reports a lower offset (bug / stale ack); slot keeps 10.
304 t.insert_or_touch("a", 7, 200);
305 let s = t.get("a").unwrap();
306 assert_eq!(s.acked_offset, 10);
307 assert_eq!(s.last_seen_ns, 200, "last_seen still advances");
308 }
309
310 #[test]
311 fn remove_existing_returns_true_and_drops_slot() {
312 let mut t = SlotTable::new();
313 t.insert_or_touch("a", 1, 100);
314 assert!(t.remove("a"));
315 assert!(t.is_empty());
316 assert_eq!(t.get("a"), None);
317 }
318
319 #[test]
320 fn remove_missing_returns_false() {
321 let mut t = SlotTable::new();
322 assert!(!t.remove("missing"));
323 }
324
325 #[test]
326 fn expire_drops_slots_past_window() {
327 let mut t = SlotTable::new();
328 t.insert_or_touch("old", 1, 100);
329 t.insert_or_touch("fresh", 1, 500);
330 // Window 200 ns; "now" = 350. "old" expires (100+200 ≤ 350),
331 // "fresh" survives (500+200 > 350).
332 let dropped = t.expire(350, 200);
333 assert_eq!(dropped, vec!["old".to_string()]);
334 assert_eq!(t.len(), 1);
335 assert!(t.get("fresh").is_some());
336 }
337
338 #[test]
339 fn expire_when_nothing_expires_returns_empty() {
340 let mut t = SlotTable::new();
341 t.insert_or_touch("a", 1, 1000);
342 let dropped = t.expire(1100, 500); // 1000 + 500 = 1500 > 1100
343 assert!(dropped.is_empty());
344 assert_eq!(t.len(), 1);
345 }
346
347 #[test]
348 fn expire_with_overflow_window_saturates_does_not_panic() {
349 // `last_seen + window` must saturate at u64::MAX rather than
350 // wrap. With now=u64::MAX-1 the saturated cutoff stays above
351 // now, so the slot survives. (At now=u64::MAX a saturated
352 // cutoff would equal now and the slot does expire — but the
353 // safety invariant being asserted is "no panic".)
354 let mut t = SlotTable::new();
355 t.insert_or_touch("a", 1, u64::MAX - 10);
356 let dropped = t.expire(u64::MAX - 1, u64::MAX);
357 assert!(dropped.is_empty());
358 assert_eq!(t.len(), 1);
359 }
360
361 #[test]
362 fn min_acked_offset_returns_the_floor() {
363 let mut t = SlotTable::new();
364 t.insert_or_touch("a", 7, 100);
365 t.insert_or_touch("b", 3, 100);
366 t.insert_or_touch("c", 12, 100);
367 assert_eq!(t.min_acked_offset(), Some(3));
368 }
369
370 #[test]
371 fn touch_or_insert_unacked_never_advances_acked() {
372 let mut t = SlotTable::new();
373 t.insert_or_touch("a", 7, 100);
374 // Disconnect-time touch: last_seen refreshes, acked stays.
375 t.touch_or_insert_unacked("a", 200);
376 let s = t.get("a").unwrap();
377 assert_eq!(s.last_seen_ns, 200);
378 assert_eq!(s.acked_offset, 7, "close must not write sent-as-acked");
379 // Unknown id inserts at the truthful floor: never acked.
380 t.touch_or_insert_unacked("b", 300);
381 assert_eq!(t.get("b").unwrap().acked_offset, 0);
382 }
383
384 #[test]
385 fn iter_visits_every_slot() {
386 let mut t = SlotTable::new();
387 t.insert_or_touch("a", 1, 100);
388 t.insert_or_touch("b", 2, 100);
389 let mut ids: Vec<_> = t.iter().map(|s| s.id.clone()).collect();
390 ids.sort();
391 assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
392 }
393
394 #[test]
395 fn expire_all_when_window_zero() {
396 let mut t = SlotTable::new();
397 t.insert_or_touch("a", 1, 100);
398 t.insert_or_touch("b", 1, 100);
399 let dropped = t.expire(100, 0);
400 assert_eq!(dropped.len(), 2);
401 assert!(t.is_empty());
402 }
403}