Skip to main content

bezant_server/events/
ring.rs

1//! Per-topic ring buffer for captured events.
2//!
3//! Each [`TopicRing`] is bounded in capacity. When a push would exceed
4//! capacity, the oldest event is evicted (FIFO). Cursors are server-
5//! assigned monotonically within `(topic, reset_epoch)` — they never
6//! collide as long as `reset_epoch` is unique per process run, and they
7//! never reset within a single run (so client cursor comparisons stay
8//! cheap).
9//!
10//! The ring is read by cursor: `read_since(cursor, limit)` returns events
11//! whose cursor is strictly greater than `cursor`, up to `limit`. If the
12//! caller's cursor is older than the buffer's current head (the smallest
13//! cursor still buffered), the read returns [`ReadResult::CursorExpired`]
14//! so the client can resync to head and emit a synthetic gap event.
15
16use std::collections::VecDeque;
17
18use serde_json::Value;
19
20use super::ObservedEvent;
21
22/// FIFO ring of [`ObservedEvent`] keyed by cursor. Single owner; not
23/// `Send + Sync` on its own — wrap in `Arc<RwLock<…>>` for cross-task
24/// access.
25#[derive(Debug)]
26pub struct TopicRing {
27    inner: VecDeque<ObservedEvent>,
28    capacity: usize,
29    next_cursor: u64,
30    head_cursor: u64,
31    reset_epoch: u64,
32    topic: String,
33}
34
35/// Outcome of [`TopicRing::read_since`].
36#[derive(Debug, Clone)]
37pub enum ReadResult {
38    /// Zero or more events newer than the requested cursor. `next_cursor`
39    /// is the cursor the caller should send on their next poll.
40    Ok {
41        /// Events strictly newer than the requested cursor, in insertion
42        /// order, capped at the requested limit.
43        events: Vec<ObservedEvent>,
44        /// Cursor to send on the next poll.
45        next_cursor: u64,
46    },
47    /// The caller's cursor is older than the oldest event still buffered.
48    /// They must reset to `head_cursor - 1` (or `0`) and emit a gap.
49    CursorExpired {
50        /// Smallest cursor currently buffered. Use `head_cursor - 1` (or
51        /// `0` if `head_cursor == 0`) as the new `since=` parameter.
52        head_cursor: u64,
53        /// Current `reset_epoch` so the caller can detect resets.
54        reset_epoch: u64,
55    },
56}
57
58impl TopicRing {
59    /// Construct a ring with the given capacity for a specific topic and
60    /// reset epoch.
61    #[must_use]
62    pub fn new(topic: impl Into<String>, capacity: usize, reset_epoch: u64) -> Self {
63        Self {
64            inner: VecDeque::with_capacity(capacity.max(1)),
65            capacity: capacity.max(1),
66            next_cursor: 1,
67            head_cursor: 1,
68            reset_epoch,
69            topic: topic.into(),
70        }
71    }
72
73    /// Topic name this ring captures (e.g. `"orders"`).
74    #[must_use]
75    pub fn topic(&self) -> &str {
76        &self.topic
77    }
78
79    /// Current `reset_epoch`.
80    #[must_use]
81    pub fn reset_epoch(&self) -> u64 {
82        self.reset_epoch
83    }
84
85    /// Number of events currently buffered.
86    #[must_use]
87    pub fn len(&self) -> usize {
88        self.inner.len()
89    }
90
91    /// Whether the ring contains no events.
92    #[must_use]
93    pub fn is_empty(&self) -> bool {
94        self.inner.is_empty()
95    }
96
97    /// Smallest cursor currently buffered. Equal to `next_cursor` when empty.
98    #[must_use]
99    pub fn head_cursor(&self) -> u64 {
100        self.head_cursor
101    }
102
103    /// Cursor that the next push will use. Useful in tests.
104    #[must_use]
105    pub fn next_cursor(&self) -> u64 {
106        self.next_cursor
107    }
108
109    /// Push a new event with the given payload + RFC 3339 receive
110    /// timestamp. Returns the assigned cursor. If the ring is at
111    /// capacity, the oldest event is evicted (and `head_cursor` advances).
112    pub fn push(&mut self, payload: Value, received_at: String) -> u64 {
113        let cursor = self.next_cursor;
114        self.next_cursor = self.next_cursor.saturating_add(1);
115
116        let event = ObservedEvent {
117            cursor,
118            topic: self.topic.clone(),
119            received_at,
120            reset_epoch: self.reset_epoch,
121            payload,
122        };
123
124        if self.inner.len() == self.capacity {
125            // Evict oldest. head_cursor moves to whatever's now the
126            // oldest still-buffered event's cursor.
127            let evicted = self.inner.pop_front();
128            if evicted.is_some() {
129                // After eviction the new oldest is whatever's at front
130                // (or, if empty, the cursor we're about to push).
131                self.head_cursor = self.inner.front().map(|e| e.cursor).unwrap_or(cursor);
132            }
133        }
134
135        self.inner.push_back(event);
136        // If this was the very first event in a fresh ring, anchor head.
137        if self.inner.len() == 1 {
138            self.head_cursor = cursor;
139        }
140
141        cursor
142    }
143
144    /// Read events whose cursor is strictly greater than `since`, capped
145    /// at `limit`. `since == 0` means "everything currently buffered".
146    ///
147    /// Returns [`ReadResult::CursorExpired`] when `since < head_cursor - 1`
148    /// AND the ring has evicted at least one event the caller hasn't seen
149    /// — i.e. there's a real gap, not just "you've read everything".
150    #[must_use]
151    pub fn read_since(&self, since: u64, limit: usize) -> ReadResult {
152        // Caller is fully caught up — no gap, no expiry, just empty.
153        if since >= self.next_cursor.saturating_sub(1) {
154            return ReadResult::Ok {
155                events: Vec::new(),
156                next_cursor: self.next_cursor.saturating_sub(1),
157            };
158        }
159
160        // Caller's cursor is older than our oldest buffered event AND we
161        // have evicted at least one event past their cursor.
162        if since + 1 < self.head_cursor {
163            return ReadResult::CursorExpired {
164                head_cursor: self.head_cursor,
165                reset_epoch: self.reset_epoch,
166            };
167        }
168
169        let events: Vec<ObservedEvent> = self
170            .inner
171            .iter()
172            .filter(|e| e.cursor > since)
173            .take(limit.max(1))
174            .cloned()
175            .collect();
176
177        let next_cursor = events
178            .last()
179            .map(|e| e.cursor)
180            .unwrap_or_else(|| self.next_cursor.saturating_sub(1));
181
182        ReadResult::Ok {
183            events,
184            next_cursor,
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use serde_json::json;
193
194    fn ts() -> String {
195        "2026-05-06T00:00:00Z".to_string()
196    }
197
198    #[test]
199    fn push_assigns_monotonic_cursors() {
200        let mut ring = TopicRing::new("orders", 10, 0);
201        let c1 = ring.push(json!({"n": 1}), ts());
202        let c2 = ring.push(json!({"n": 2}), ts());
203        let c3 = ring.push(json!({"n": 3}), ts());
204        assert_eq!(c1, 1);
205        assert_eq!(c2, 2);
206        assert_eq!(c3, 3);
207    }
208
209    #[test]
210    fn read_since_zero_returns_all_buffered() {
211        let mut ring = TopicRing::new("orders", 10, 0);
212        ring.push(json!({"n": 1}), ts());
213        ring.push(json!({"n": 2}), ts());
214        match ring.read_since(0, 100) {
215            ReadResult::Ok {
216                events,
217                next_cursor,
218            } => {
219                assert_eq!(events.len(), 2);
220                assert_eq!(next_cursor, 2);
221                assert_eq!(events[0].payload, json!({"n": 1}));
222                assert_eq!(events[1].payload, json!({"n": 2}));
223            }
224            other => panic!("expected Ok, got {other:?}"),
225        }
226    }
227
228    #[test]
229    fn read_since_returns_only_new_events() {
230        let mut ring = TopicRing::new("orders", 10, 0);
231        ring.push(json!({"n": 1}), ts());
232        ring.push(json!({"n": 2}), ts());
233        ring.push(json!({"n": 3}), ts());
234        match ring.read_since(1, 100) {
235            ReadResult::Ok {
236                events,
237                next_cursor,
238            } => {
239                assert_eq!(events.len(), 2);
240                assert_eq!(events[0].cursor, 2);
241                assert_eq!(events[1].cursor, 3);
242                assert_eq!(next_cursor, 3);
243            }
244            other => panic!("expected Ok, got {other:?}"),
245        }
246    }
247
248    #[test]
249    fn read_since_caught_up_returns_empty() {
250        let mut ring = TopicRing::new("orders", 10, 0);
251        ring.push(json!({"n": 1}), ts());
252        ring.push(json!({"n": 2}), ts());
253        match ring.read_since(2, 100) {
254            ReadResult::Ok {
255                events,
256                next_cursor,
257            } => {
258                assert!(events.is_empty());
259                assert_eq!(next_cursor, 2);
260            }
261            other => panic!("expected empty Ok, got {other:?}"),
262        }
263    }
264
265    #[test]
266    fn read_since_respects_limit() {
267        let mut ring = TopicRing::new("orders", 100, 0);
268        for i in 0..50 {
269            ring.push(json!({ "n": i }), ts());
270        }
271        match ring.read_since(0, 10) {
272            ReadResult::Ok {
273                events,
274                next_cursor,
275            } => {
276                assert_eq!(events.len(), 10);
277                assert_eq!(next_cursor, 10);
278            }
279            other => panic!("expected Ok, got {other:?}"),
280        }
281    }
282
283    #[test]
284    fn capacity_evicts_oldest_first() {
285        let mut ring = TopicRing::new("orders", 3, 0);
286        for i in 1..=5 {
287            ring.push(json!({"n": i}), ts());
288        }
289        // After 5 pushes into cap-3 ring, cursors 1+2 evicted; 3,4,5 remain.
290        assert_eq!(ring.len(), 3);
291        assert_eq!(ring.head_cursor(), 3);
292        match ring.read_since(0, 100) {
293            ReadResult::CursorExpired { head_cursor, .. } => {
294                assert_eq!(head_cursor, 3);
295            }
296            other => panic!("expected CursorExpired, got {other:?}"),
297        }
298    }
299
300    #[test]
301    fn read_after_overflow_at_head_succeeds() {
302        let mut ring = TopicRing::new("orders", 3, 0);
303        for i in 1..=5 {
304            ring.push(json!({"n": i}), ts());
305        }
306        // Asking from head-1 (cursor 2) is OK — we still have cursor 3.
307        match ring.read_since(2, 100) {
308            ReadResult::Ok {
309                events,
310                next_cursor,
311            } => {
312                assert_eq!(events.len(), 3);
313                assert_eq!(events[0].cursor, 3);
314                assert_eq!(next_cursor, 5);
315            }
316            other => panic!("expected Ok, got {other:?}"),
317        }
318    }
319
320    #[test]
321    fn read_at_or_past_overflow_returns_expired() {
322        let mut ring = TopicRing::new("orders", 3, 0);
323        for i in 1..=5 {
324            ring.push(json!({"n": i}), ts());
325        }
326        // Cursor 1 was evicted; asking from 0 sees a gap.
327        assert!(matches!(
328            ring.read_since(0, 100),
329            ReadResult::CursorExpired { .. }
330        ));
331        assert!(matches!(
332            ring.read_since(1, 100),
333            ReadResult::CursorExpired { .. }
334        ));
335    }
336
337    #[test]
338    fn reset_epoch_propagates_to_read_result() {
339        let mut ring = TopicRing::new("orders", 2, 42);
340        for i in 1..=4 {
341            ring.push(json!({"n": i}), ts());
342        }
343        match ring.read_since(0, 100) {
344            ReadResult::CursorExpired { reset_epoch, .. } => {
345                assert_eq!(reset_epoch, 42);
346            }
347            other => panic!("expected CursorExpired, got {other:?}"),
348        }
349    }
350
351    #[test]
352    fn reset_epoch_propagates_to_pushed_events() {
353        let mut ring = TopicRing::new("orders", 4, 7);
354        ring.push(json!({"n": 1}), ts());
355        match ring.read_since(0, 10) {
356            ReadResult::Ok { events, .. } => {
357                assert_eq!(events[0].reset_epoch, 7);
358            }
359            other => panic!("expected Ok, got {other:?}"),
360        }
361    }
362
363    #[test]
364    fn empty_ring_read_since_zero_is_ok_empty() {
365        let ring = TopicRing::new("orders", 10, 0);
366        match ring.read_since(0, 100) {
367            ReadResult::Ok {
368                events,
369                next_cursor,
370            } => {
371                assert!(events.is_empty());
372                assert_eq!(next_cursor, 0);
373            }
374            other => panic!("expected Ok, got {other:?}"),
375        }
376    }
377
378    #[test]
379    fn topic_name_appears_on_pushed_events() {
380        let mut ring = TopicRing::new("marketdata:265598", 4, 0);
381        ring.push(json!({"31": "150.25"}), ts());
382        match ring.read_since(0, 10) {
383            ReadResult::Ok { events, .. } => {
384                assert_eq!(events[0].topic, "marketdata:265598");
385            }
386            other => panic!("expected Ok, got {other:?}"),
387        }
388    }
389}