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/// One connected-or-recently-disconnected replica.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct ReplicaSlot {
18    /// Operator-set replica identifier (opaque to the primary other
19    /// than for slot bookkeeping).
20    pub id: String,
21    /// Monotonic ns timestamp of the most recent contact (handshake
22    /// or ack). Drives expiry under `reconnect_window_ms`.
23    pub last_seen_ns: u64,
24    /// Highest offset the replica has acked. The streaming loop
25    /// resumes sending from here on reconnect.
26    pub acked_offset: u64,
27}
28
29/// Mutable collection of [`ReplicaSlot`]s. Slots are addressed by id;
30/// duplicate insertion upserts (newer state wins). Replica counts in
31/// realistic deployments are small (< 16); a linear `Vec` is faster
32/// than a `HashMap` at this size and avoids the cost of the hasher
33/// the rest of the workspace uses.
34#[derive(Debug, Default)]
35pub struct SlotTable {
36    slots: Vec<ReplicaSlot>,
37}
38
39impl SlotTable {
40    /// A fresh empty table.
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Number of slots currently tracked.
46    pub fn len(&self) -> usize {
47        self.slots.len()
48    }
49
50    /// Whether the table has no slots.
51    pub fn is_empty(&self) -> bool {
52        self.slots.is_empty()
53    }
54
55    /// Look up a slot by id.
56    pub fn get(&self, id: &str) -> Option<&ReplicaSlot> {
57        self.slots.iter().find(|s| s.id == id)
58    }
59
60    /// Refresh a slot's `last_seen_ns` WITHOUT advancing its acked
61    /// offset — the disconnect-time variant of
62    /// [`Self::insert_or_touch`]. A closing conn knows what it SENT,
63    /// not what the peer acked; recording sent-as-acked would let
64    /// `WAIT` / min-replicas count bytes the replica never confirmed
65    /// and let the backlog trim frames the peer may still need. A
66    /// previously unseen id is inserted at `acked_offset = 0`
67    /// (never acked — the truthful floor).
68    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    /// Iterate over all slots.
77    pub fn iter(&self) -> impl Iterator<Item = &ReplicaSlot> {
78        self.slots.iter()
79    }
80
81    /// Insert a new slot or update an existing one. Touching always
82    /// refreshes `last_seen_ns` and advances `acked_offset` if the
83    /// new value is higher (a slot's acked offset is monotonic — a
84    /// peer reporting a lower offset than we already recorded is
85    /// almost always a bug; the silent max() here defends the
86    /// invariant).
87    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    /// Remove the slot with the given id. Returns `true` if a slot
99    /// was actually removed.
100    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    /// Drop slots whose `last_seen_ns + window_ns ≤ now_ns`. Returns
110    /// the ids of the dropped slots so callers can fire metrics or
111    /// log lines. Order is unspecified (swap-remove internally).
112    pub fn expire(&mut self, now_ns: u64, window_ns: u64) -> Vec<String> {
113        let mut dropped = Vec::new();
114        // Walk backward so swap_remove doesn't shift indices we still
115        // need to visit.
116        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    /// Lowest acked offset across all tracked slots. Useful for the
129    /// streaming loop to know how far back the backlog must still
130    /// retain frames; `None` when the table is empty.
131    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        // No duplicate row.
168        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        // Peer reports a lower offset (bug / stale ack); slot keeps 10.
176        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        // Window 200 ns; "now" = 350. "old" expires (100+200 ≤ 350),
203        // "fresh" survives (500+200 > 350).
204        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); // 1000 + 500 = 1500 > 1100
215        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        // `last_seen + window` must saturate at u64::MAX rather than
222        // wrap. With now=u64::MAX-1 the saturated cutoff stays above
223        // now, so the slot survives. (At now=u64::MAX a saturated
224        // cutoff would equal now and the slot does expire — but the
225        // safety invariant being asserted is "no panic".)
226        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        // Disconnect-time touch: last_seen refreshes, acked stays.
247        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        // Unknown id inserts at the truthful floor: never acked.
252        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}