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 and strictly increasing for the life of the ring — a WS
6//! reconnect bumps the ring's `reset_epoch` but never rewinds its cursors.
7//!
8//! A ring's first cursor is its `base`. The connector derives the base from
9//! the boot time (see `connector::cursor_base`), so a restarted process
10//! hands out cursors ABOVE anything the previous process did and a client
11//! holding an old cursor is never told "caught up" by a ring whose cursors
12//! restarted at 1 below it. When that cannot be guaranteed (a clock that
13//! went backwards), a cursor from the future is answered with
14//! [`ReadResult::CursorExpired`] rather than silence.
15//!
16//! The ring is read by cursor: `read_since(cursor, limit)` returns events
17//! whose cursor is strictly greater than `cursor`, up to `limit`. If the
18//! caller's cursor is older than the buffer's current head (the smallest
19//! cursor still buffered), the read returns [`ReadResult::CursorExpired`]
20//! so the client can resync to head and emit a synthetic gap event.
21
22use std::collections::VecDeque;
23
24use serde_json::Value;
25
26use super::ObservedEvent;
27
28/// FIFO ring of [`ObservedEvent`] keyed by cursor. Single owner; not
29/// `Send + Sync` on its own — wrap in `Arc<RwLock<…>>` for cross-task
30/// access.
31#[derive(Debug)]
32pub struct TopicRing {
33    inner: VecDeque<ObservedEvent>,
34    capacity: usize,
35    base: u64,
36    next_cursor: u64,
37    head_cursor: u64,
38    reset_epoch: u64,
39    topic: String,
40}
41
42/// Outcome of [`TopicRing::read_since`].
43#[derive(Debug, Clone)]
44pub enum ReadResult {
45    /// Zero or more events newer than the requested cursor. `next_cursor`
46    /// is the cursor the caller should send on their next poll.
47    Ok {
48        /// Events strictly newer than the requested cursor, in insertion
49        /// order, capped at the requested limit.
50        events: Vec<ObservedEvent>,
51        /// Cursor to send on the next poll.
52        next_cursor: u64,
53        /// The ring's live `reset_epoch` — not the epoch of the first
54        /// event returned, which may predate a reconnect.
55        reset_epoch: u64,
56    },
57    /// The caller's cursor is older than the oldest event still buffered,
58    /// or newer than the newest cursor this ring has ever handed out (a
59    /// cursor from a previous process). Either way they must reset to
60    /// `head_cursor - 1` and emit a gap.
61    CursorExpired {
62        /// Smallest cursor currently buffered. Use `head_cursor - 1` (or
63        /// `0` if `head_cursor == 0`) as the new `since=` parameter.
64        head_cursor: u64,
65        /// Current `reset_epoch` so the caller can detect resets.
66        reset_epoch: u64,
67    },
68}
69
70impl TopicRing {
71    /// Construct a ring with the given capacity for a specific topic and
72    /// reset epoch. Cursors start at 1.
73    #[must_use]
74    pub fn new(topic: impl Into<String>, capacity: usize, reset_epoch: u64) -> Self {
75        Self::with_base(topic, capacity, reset_epoch, 1)
76    }
77
78    /// Construct a ring whose first cursor is `base` (clamped to ≥ 1).
79    #[must_use]
80    pub fn with_base(
81        topic: impl Into<String>,
82        capacity: usize,
83        reset_epoch: u64,
84        base: u64,
85    ) -> Self {
86        let base = base.max(1);
87        Self {
88            inner: VecDeque::with_capacity(capacity.max(1)),
89            capacity: capacity.max(1),
90            base,
91            next_cursor: base,
92            head_cursor: base,
93            reset_epoch,
94            topic: topic.into(),
95        }
96    }
97
98    /// The first cursor this ring hands out.
99    #[must_use]
100    pub fn base(&self) -> u64 {
101        self.base
102    }
103
104    /// Move the ring to a new `reset_epoch`. Events already buffered keep
105    /// the epoch they were captured under; new pushes and every read carry
106    /// the new one.
107    pub fn set_reset_epoch(&mut self, reset_epoch: u64) {
108        self.reset_epoch = reset_epoch;
109    }
110
111    /// Topic name this ring captures (e.g. `"orders"`).
112    #[must_use]
113    pub fn topic(&self) -> &str {
114        &self.topic
115    }
116
117    /// Current `reset_epoch`.
118    #[must_use]
119    pub fn reset_epoch(&self) -> u64 {
120        self.reset_epoch
121    }
122
123    /// Number of events currently buffered.
124    #[must_use]
125    pub fn len(&self) -> usize {
126        self.inner.len()
127    }
128
129    /// Whether the ring contains no events.
130    #[must_use]
131    pub fn is_empty(&self) -> bool {
132        self.inner.is_empty()
133    }
134
135    /// Smallest cursor currently buffered. Equal to `next_cursor` when empty.
136    #[must_use]
137    pub fn head_cursor(&self) -> u64 {
138        self.head_cursor
139    }
140
141    /// Cursor that the next push will use. Useful in tests.
142    #[must_use]
143    pub fn next_cursor(&self) -> u64 {
144        self.next_cursor
145    }
146
147    /// Push a new event with the given payload + RFC 3339 receive
148    /// timestamp. Returns the assigned cursor. If the ring is at
149    /// capacity, the oldest event is evicted (and `head_cursor` advances).
150    pub fn push(&mut self, payload: Value, received_at: String) -> u64 {
151        let cursor = self.next_cursor;
152        self.next_cursor = self.next_cursor.saturating_add(1);
153
154        let event = ObservedEvent {
155            cursor,
156            topic: self.topic.clone(),
157            received_at,
158            reset_epoch: self.reset_epoch,
159            payload,
160        };
161
162        if self.inner.len() == self.capacity {
163            // Evict oldest. head_cursor moves to whatever's now the
164            // oldest still-buffered event's cursor.
165            let evicted = self.inner.pop_front();
166            if evicted.is_some() {
167                // After eviction the new oldest is whatever's at front
168                // (or, if empty, the cursor we're about to push).
169                self.head_cursor = self.inner.front().map(|e| e.cursor).unwrap_or(cursor);
170            }
171        }
172
173        self.inner.push_back(event);
174        // If this was the very first event in a fresh ring, anchor head.
175        if self.inner.len() == 1 {
176            self.head_cursor = cursor;
177        }
178
179        cursor
180    }
181
182    /// Read events whose cursor is strictly greater than `since`, capped
183    /// at `limit`. `since == 0` means "everything currently buffered", and
184    /// so does any cursor below this ring's base (it belongs to a previous
185    /// process; the epoch change tells the client so).
186    ///
187    /// Returns [`ReadResult::CursorExpired`] when events the caller has not
188    /// seen were evicted, or when `since` is past the newest cursor this
189    /// ring has handed out — a cursor this ring never issued, so "caught
190    /// up" would be a lie that parks the client forever.
191    #[must_use]
192    pub fn read_since(&self, since: u64, limit: usize) -> ReadResult {
193        let last = self.next_cursor.saturating_sub(1);
194        if since > last {
195            return self.expired();
196        }
197        // Caller is fully caught up — no gap, no expiry, just empty.
198        if since == last {
199            return ReadResult::Ok {
200                events: Vec::new(),
201                next_cursor: last,
202                reset_epoch: self.reset_epoch,
203            };
204        }
205        let since = since.max(self.base - 1);
206
207        // Caller's cursor is older than our oldest buffered event: the
208        // ring has evicted at least one event past their cursor.
209        if since + 1 < self.head_cursor {
210            return self.expired();
211        }
212
213        let events: Vec<ObservedEvent> = self
214            .inner
215            .iter()
216            .filter(|e| e.cursor > since)
217            .take(limit.max(1))
218            .cloned()
219            .collect();
220
221        let next_cursor = events.last().map_or(last, |e| e.cursor);
222
223        ReadResult::Ok {
224            events,
225            next_cursor,
226            reset_epoch: self.reset_epoch,
227        }
228    }
229
230    fn expired(&self) -> ReadResult {
231        ReadResult::CursorExpired {
232            head_cursor: self.head_cursor,
233            reset_epoch: self.reset_epoch,
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use serde_json::json;
242
243    fn ts() -> String {
244        "2026-05-06T00:00:00Z".to_string()
245    }
246
247    #[test]
248    fn push_assigns_monotonic_cursors() {
249        let mut ring = TopicRing::new("orders", 10, 0);
250        let c1 = ring.push(json!({"n": 1}), ts());
251        let c2 = ring.push(json!({"n": 2}), ts());
252        let c3 = ring.push(json!({"n": 3}), ts());
253        assert_eq!(c1, 1);
254        assert_eq!(c2, 2);
255        assert_eq!(c3, 3);
256    }
257
258    #[test]
259    fn read_since_zero_returns_all_buffered() {
260        let mut ring = TopicRing::new("orders", 10, 0);
261        ring.push(json!({"n": 1}), ts());
262        ring.push(json!({"n": 2}), ts());
263        match ring.read_since(0, 100) {
264            ReadResult::Ok {
265                events,
266                next_cursor,
267                ..
268            } => {
269                assert_eq!(events.len(), 2);
270                assert_eq!(next_cursor, 2);
271                assert_eq!(events[0].payload, json!({"n": 1}));
272                assert_eq!(events[1].payload, json!({"n": 2}));
273            }
274            other => panic!("expected Ok, got {other:?}"),
275        }
276    }
277
278    #[test]
279    fn read_since_returns_only_new_events() {
280        let mut ring = TopicRing::new("orders", 10, 0);
281        ring.push(json!({"n": 1}), ts());
282        ring.push(json!({"n": 2}), ts());
283        ring.push(json!({"n": 3}), ts());
284        match ring.read_since(1, 100) {
285            ReadResult::Ok {
286                events,
287                next_cursor,
288                ..
289            } => {
290                assert_eq!(events.len(), 2);
291                assert_eq!(events[0].cursor, 2);
292                assert_eq!(events[1].cursor, 3);
293                assert_eq!(next_cursor, 3);
294            }
295            other => panic!("expected Ok, got {other:?}"),
296        }
297    }
298
299    #[test]
300    fn read_since_caught_up_returns_empty() {
301        let mut ring = TopicRing::new("orders", 10, 0);
302        ring.push(json!({"n": 1}), ts());
303        ring.push(json!({"n": 2}), ts());
304        match ring.read_since(2, 100) {
305            ReadResult::Ok {
306                events,
307                next_cursor,
308                ..
309            } => {
310                assert!(events.is_empty());
311                assert_eq!(next_cursor, 2);
312            }
313            other => panic!("expected empty Ok, got {other:?}"),
314        }
315    }
316
317    #[test]
318    fn read_since_respects_limit() {
319        let mut ring = TopicRing::new("orders", 100, 0);
320        for i in 0..50 {
321            ring.push(json!({ "n": i }), ts());
322        }
323        match ring.read_since(0, 10) {
324            ReadResult::Ok {
325                events,
326                next_cursor,
327                ..
328            } => {
329                assert_eq!(events.len(), 10);
330                assert_eq!(next_cursor, 10);
331            }
332            other => panic!("expected Ok, got {other:?}"),
333        }
334    }
335
336    #[test]
337    fn capacity_evicts_oldest_first() {
338        let mut ring = TopicRing::new("orders", 3, 0);
339        for i in 1..=5 {
340            ring.push(json!({"n": i}), ts());
341        }
342        // After 5 pushes into cap-3 ring, cursors 1+2 evicted; 3,4,5 remain.
343        assert_eq!(ring.len(), 3);
344        assert_eq!(ring.head_cursor(), 3);
345        match ring.read_since(0, 100) {
346            ReadResult::CursorExpired { head_cursor, .. } => {
347                assert_eq!(head_cursor, 3);
348            }
349            other => panic!("expected CursorExpired, got {other:?}"),
350        }
351    }
352
353    #[test]
354    fn read_after_overflow_at_head_succeeds() {
355        let mut ring = TopicRing::new("orders", 3, 0);
356        for i in 1..=5 {
357            ring.push(json!({"n": i}), ts());
358        }
359        // Asking from head-1 (cursor 2) is OK — we still have cursor 3.
360        match ring.read_since(2, 100) {
361            ReadResult::Ok {
362                events,
363                next_cursor,
364                ..
365            } => {
366                assert_eq!(events.len(), 3);
367                assert_eq!(events[0].cursor, 3);
368                assert_eq!(next_cursor, 5);
369            }
370            other => panic!("expected Ok, got {other:?}"),
371        }
372    }
373
374    #[test]
375    fn read_at_or_past_overflow_returns_expired() {
376        let mut ring = TopicRing::new("orders", 3, 0);
377        for i in 1..=5 {
378            ring.push(json!({"n": i}), ts());
379        }
380        // Cursor 1 was evicted; asking from 0 sees a gap.
381        assert!(matches!(
382            ring.read_since(0, 100),
383            ReadResult::CursorExpired { .. }
384        ));
385        assert!(matches!(
386            ring.read_since(1, 100),
387            ReadResult::CursorExpired { .. }
388        ));
389    }
390
391    #[test]
392    fn reset_epoch_propagates_to_read_result() {
393        let mut ring = TopicRing::new("orders", 2, 42);
394        for i in 1..=4 {
395            ring.push(json!({"n": i}), ts());
396        }
397        match ring.read_since(0, 100) {
398            ReadResult::CursorExpired { reset_epoch, .. } => {
399                assert_eq!(reset_epoch, 42);
400            }
401            other => panic!("expected CursorExpired, got {other:?}"),
402        }
403    }
404
405    #[test]
406    fn reset_epoch_propagates_to_pushed_events() {
407        let mut ring = TopicRing::new("orders", 4, 7);
408        ring.push(json!({"n": 1}), ts());
409        match ring.read_since(0, 10) {
410            ReadResult::Ok { events, .. } => {
411                assert_eq!(events[0].reset_epoch, 7);
412            }
413            other => panic!("expected Ok, got {other:?}"),
414        }
415    }
416
417    #[test]
418    fn empty_ring_read_since_zero_is_ok_empty() {
419        let ring = TopicRing::new("orders", 10, 0);
420        match ring.read_since(0, 100) {
421            ReadResult::Ok {
422                events,
423                next_cursor,
424                ..
425            } => {
426                assert!(events.is_empty());
427                assert_eq!(next_cursor, 0);
428            }
429            other => panic!("expected Ok, got {other:?}"),
430        }
431    }
432
433    #[test]
434    fn a_cursor_from_the_future_is_expired_not_caught_up() {
435        // A client holding cursor 500 from a previous process, against a
436        // ring that has only handed out 1..=3: "caught up" would park it
437        // until this process saw its 500th event.
438        let mut ring = TopicRing::new("orders", 10, 2);
439        for i in 1..=3 {
440            ring.push(json!({ "n": i }), ts());
441        }
442        match ring.read_since(500, 10) {
443            ReadResult::CursorExpired {
444                head_cursor,
445                reset_epoch,
446            } => {
447                assert_eq!(head_cursor, 1);
448                assert_eq!(reset_epoch, 2);
449            }
450            other => panic!("expected CursorExpired, got {other:?}"),
451        }
452    }
453
454    #[test]
455    fn cursors_start_at_the_base() {
456        let mut ring = TopicRing::with_base("orders", 10, 1, 1_000);
457        assert_eq!(ring.push(json!({}), ts()), 1_000);
458        assert_eq!(ring.push(json!({}), ts()), 1_001);
459        assert_eq!(ring.head_cursor(), 1_000);
460    }
461
462    #[test]
463    fn a_cursor_below_the_base_reads_from_the_start() {
464        // A cursor from the previous process sits below this one's base:
465        // every event this ring holds is news to that client.
466        let mut ring = TopicRing::with_base("orders", 10, 1, 1_000);
467        ring.push(json!({"n": 1}), ts());
468        ring.push(json!({"n": 2}), ts());
469        for since in [0, 1, 500, 999] {
470            match ring.read_since(since, 10) {
471                ReadResult::Ok {
472                    events,
473                    next_cursor,
474                    ..
475                } => {
476                    assert_eq!(events.len(), 2, "since={since}");
477                    assert_eq!(next_cursor, 1_001);
478                }
479                other => panic!("since={since}: expected Ok, got {other:?}"),
480            }
481        }
482    }
483
484    #[test]
485    fn an_empty_ring_answers_with_its_own_cursor_not_the_callers() {
486        let ring = TopicRing::with_base("orders", 10, 4, 1_000);
487        match ring.read_since(17, 10) {
488            ReadResult::Ok {
489                events,
490                next_cursor,
491                reset_epoch,
492            } => {
493                assert!(events.is_empty());
494                assert_eq!(next_cursor, 999);
495                assert_eq!(reset_epoch, 4);
496            }
497            other => panic!("expected Ok, got {other:?}"),
498        }
499        assert!(matches!(
500            ring.read_since(5_000, 10),
501            ReadResult::CursorExpired { .. }
502        ));
503    }
504
505    #[test]
506    fn reads_carry_the_live_epoch_not_the_first_events() {
507        let mut ring = TopicRing::new("orders", 10, 1);
508        ring.push(json!({"n": 1}), ts());
509        ring.set_reset_epoch(2);
510        ring.push(json!({"n": 2}), ts());
511        match ring.read_since(0, 10) {
512            ReadResult::Ok {
513                events,
514                reset_epoch,
515                ..
516            } => {
517                assert_eq!(reset_epoch, 2, "the batch straddles a reconnect");
518                assert_eq!(events[0].reset_epoch, 1, "captured before it");
519                assert_eq!(events[1].reset_epoch, 2);
520            }
521            other => panic!("expected Ok, got {other:?}"),
522        }
523        // And a caught-up read reports it too.
524        match ring.read_since(2, 10) {
525            ReadResult::Ok { reset_epoch, .. } => assert_eq!(reset_epoch, 2),
526            other => panic!("expected Ok, got {other:?}"),
527        }
528    }
529
530    #[test]
531    fn topic_name_appears_on_pushed_events() {
532        let mut ring = TopicRing::new("marketdata:265598", 4, 0);
533        ring.push(json!({"31": "150.25"}), ts());
534        match ring.read_since(0, 10) {
535            ReadResult::Ok { events, .. } => {
536                assert_eq!(events[0].topic, "marketdata:265598");
537            }
538            other => panic!("expected Ok, got {other:?}"),
539        }
540    }
541}