Skip to main content

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}