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 {
74            id: id.to_string(),
75            last_seen_ns: now_ns,
76            acked_offset: 0,
77        });
78    }
79
80    /// Iterate over all slots.
81    pub fn iter(&self) -> impl Iterator<Item = &ReplicaSlot> {
82        self.slots.iter()
83    }
84
85    /// Insert a new slot or update an existing one. Touching always
86    /// refreshes `last_seen_ns` and advances `acked_offset` if the
87    /// new value is higher (a slot's acked offset is monotonic — a
88    /// peer reporting a lower offset than we already recorded is
89    /// almost always a bug; the silent max() here defends the
90    /// invariant).
91    pub fn insert_or_touch(&mut self, id: &str, acked_offset: u64, now_ns: u64) {
92        if let Some(s) = self.slots.iter_mut().find(|s| s.id == id) {
93            s.last_seen_ns = now_ns;
94            if acked_offset > s.acked_offset {
95                s.acked_offset = acked_offset;
96            }
97            return;
98        }
99        self.slots.push(ReplicaSlot {
100            id: id.to_string(),
101            last_seen_ns: now_ns,
102            acked_offset,
103        });
104    }
105
106    /// Remove the slot with the given id. Returns `true` if a slot
107    /// was actually removed.
108    pub fn remove(&mut self, id: &str) -> bool {
109        if let Some(pos) = self.slots.iter().position(|s| s.id == id) {
110            self.slots.swap_remove(pos);
111            true
112        } else {
113            false
114        }
115    }
116
117    /// Drop slots whose `last_seen_ns + window_ns ≤ now_ns`. Returns
118    /// the ids of the dropped slots so callers can fire metrics or
119    /// log lines. Order is unspecified (swap-remove internally).
120    pub fn expire(&mut self, now_ns: u64, window_ns: u64) -> Vec<String> {
121        let mut dropped = Vec::new();
122        // Walk backward so swap_remove doesn't shift indices we still
123        // need to visit.
124        let mut i = self.slots.len();
125        while i > 0 {
126            i -= 1;
127            let cutoff = self.slots[i].last_seen_ns.saturating_add(window_ns);
128            if cutoff <= now_ns {
129                let s = self.slots.swap_remove(i);
130                dropped.push(s.id);
131            }
132        }
133        dropped
134    }
135
136    /// Lowest acked offset across all tracked slots. Useful for the
137    /// streaming loop to know how far back the backlog must still
138    /// retain frames; `None` when the table is empty.
139    pub fn min_acked_offset(&self) -> Option<u64> {
140        self.slots.iter().map(|s| s.acked_offset).min()
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn fresh_table_is_empty() {
150        let t = SlotTable::new();
151        assert!(t.is_empty());
152        assert_eq!(t.len(), 0);
153        assert_eq!(t.min_acked_offset(), None);
154    }
155
156    #[test]
157    fn insert_then_get_returns_the_slot() {
158        let mut t = SlotTable::new();
159        t.insert_or_touch("a", 5, 100);
160        let s = t.get("a").unwrap();
161        assert_eq!(s.id, "a");
162        assert_eq!(s.acked_offset, 5);
163        assert_eq!(s.last_seen_ns, 100);
164        assert_eq!(t.len(), 1);
165    }
166
167    #[test]
168    fn touch_advances_last_seen_and_acked_offset() {
169        let mut t = SlotTable::new();
170        t.insert_or_touch("a", 5, 100);
171        t.insert_or_touch("a", 9, 200);
172        let s = t.get("a").unwrap();
173        assert_eq!(s.acked_offset, 9);
174        assert_eq!(s.last_seen_ns, 200);
175        // No duplicate row.
176        assert_eq!(t.len(), 1);
177    }
178
179    #[test]
180    fn touch_with_lower_acked_offset_keeps_the_higher_one() {
181        let mut t = SlotTable::new();
182        t.insert_or_touch("a", 10, 100);
183        // Peer reports a lower offset (bug / stale ack); slot keeps 10.
184        t.insert_or_touch("a", 7, 200);
185        let s = t.get("a").unwrap();
186        assert_eq!(s.acked_offset, 10);
187        assert_eq!(s.last_seen_ns, 200, "last_seen still advances");
188    }
189
190    #[test]
191    fn remove_existing_returns_true_and_drops_slot() {
192        let mut t = SlotTable::new();
193        t.insert_or_touch("a", 1, 100);
194        assert!(t.remove("a"));
195        assert!(t.is_empty());
196        assert_eq!(t.get("a"), None);
197    }
198
199    #[test]
200    fn remove_missing_returns_false() {
201        let mut t = SlotTable::new();
202        assert!(!t.remove("missing"));
203    }
204
205    #[test]
206    fn expire_drops_slots_past_window() {
207        let mut t = SlotTable::new();
208        t.insert_or_touch("old", 1, 100);
209        t.insert_or_touch("fresh", 1, 500);
210        // Window 200 ns; "now" = 350. "old" expires (100+200 ≤ 350),
211        // "fresh" survives (500+200 > 350).
212        let dropped = t.expire(350, 200);
213        assert_eq!(dropped, vec!["old".to_string()]);
214        assert_eq!(t.len(), 1);
215        assert!(t.get("fresh").is_some());
216    }
217
218    #[test]
219    fn expire_when_nothing_expires_returns_empty() {
220        let mut t = SlotTable::new();
221        t.insert_or_touch("a", 1, 1000);
222        let dropped = t.expire(1100, 500); // 1000 + 500 = 1500 > 1100
223        assert!(dropped.is_empty());
224        assert_eq!(t.len(), 1);
225    }
226
227    #[test]
228    fn expire_with_overflow_window_saturates_does_not_panic() {
229        // `last_seen + window` must saturate at u64::MAX rather than
230        // wrap. With now=u64::MAX-1 the saturated cutoff stays above
231        // now, so the slot survives. (At now=u64::MAX a saturated
232        // cutoff would equal now and the slot does expire — but the
233        // safety invariant being asserted is "no panic".)
234        let mut t = SlotTable::new();
235        t.insert_or_touch("a", 1, u64::MAX - 10);
236        let dropped = t.expire(u64::MAX - 1, u64::MAX);
237        assert!(dropped.is_empty());
238        assert_eq!(t.len(), 1);
239    }
240
241    #[test]
242    fn min_acked_offset_returns_the_floor() {
243        let mut t = SlotTable::new();
244        t.insert_or_touch("a", 7, 100);
245        t.insert_or_touch("b", 3, 100);
246        t.insert_or_touch("c", 12, 100);
247        assert_eq!(t.min_acked_offset(), Some(3));
248    }
249
250    #[test]
251    fn touch_or_insert_unacked_never_advances_acked() {
252        let mut t = SlotTable::new();
253        t.insert_or_touch("a", 7, 100);
254        // Disconnect-time touch: last_seen refreshes, acked stays.
255        t.touch_or_insert_unacked("a", 200);
256        let s = t.get("a").unwrap();
257        assert_eq!(s.last_seen_ns, 200);
258        assert_eq!(s.acked_offset, 7, "close must not write sent-as-acked");
259        // Unknown id inserts at the truthful floor: never acked.
260        t.touch_or_insert_unacked("b", 300);
261        assert_eq!(t.get("b").unwrap().acked_offset, 0);
262    }
263
264    #[test]
265    fn iter_visits_every_slot() {
266        let mut t = SlotTable::new();
267        t.insert_or_touch("a", 1, 100);
268        t.insert_or_touch("b", 2, 100);
269        let mut ids: Vec<_> = t.iter().map(|s| s.id.clone()).collect();
270        ids.sort();
271        assert_eq!(ids, vec!["a".to_string(), "b".to_string()]);
272    }
273
274    #[test]
275    fn expire_all_when_window_zero() {
276        let mut t = SlotTable::new();
277        t.insert_or_touch("a", 1, 100);
278        t.insert_or_touch("b", 1, 100);
279        let dropped = t.expire(100, 0);
280        assert_eq!(dropped.len(), 2);
281        assert!(t.is_empty());
282    }
283}