Skip to main content

bezant_server/events/
persistence.rs

1//! Optional sqlite persistence for captured events.
2//!
3//! The ring buffer in [`super::ring::TopicRing`] is the primary read
4//! path — fast, in-memory, bounded. Sqlite is the **historical** store:
5//! every event we push to a ring is also appended to a single
6//! `events` table so the `GET /events/{topic}/history?since_ts=…`
7//! endpoint can reach back beyond ring capacity (and beyond container
8//! lifetime).
9//!
10//! Schema:
11//!
12//! ```sql
13//! CREATE TABLE events (
14//!   id           INTEGER PRIMARY KEY AUTOINCREMENT,
15//!   cursor       INTEGER NOT NULL,
16//!   topic        TEXT    NOT NULL,
17//!   received_at  TEXT    NOT NULL,    -- ISO 8601
18//!   reset_epoch  INTEGER NOT NULL,
19//!   payload      TEXT    NOT NULL     -- JSON
20//! );
21//! CREATE INDEX events_topic_received_at ON events(topic, received_at);
22//! ```
23//!
24//! Retention is configurable per topic:
25//! - `orders`     — 90 days (default)
26//! - `pnl`        — 90 days
27//! - `marketdata:*` — 14 days (high volume; bigger window doesn't pay)
28//! - `gap`        — 365 days (low volume, useful forever)
29//!
30//! [`EventLog::prune`] is intended to be called from a
31//! once-an-hour task; nothing in the read path waits on it.
32
33use std::path::{Path, PathBuf};
34use std::sync::{Mutex, PoisonError};
35
36use rusqlite::{params, Connection, OptionalExtension};
37
38use super::ObservedEvent;
39
40/// Sqlite-backed historical event store. Wraps a [`Connection`] in a
41/// [`Mutex`] so it can be shared across the connector + axum tasks
42/// without unsafe juggling. Reads are fast; writes serialise.
43pub struct EventLog {
44    conn: Mutex<Connection>,
45    /// Path the connection was opened against (for diagnostics).
46    path: PathBuf,
47}
48
49impl std::fmt::Debug for EventLog {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.debug_struct("EventLog")
52            .field("path", &self.path)
53            .finish()
54    }
55}
56
57/// Per-topic retention policy. Topics not in the map default to 30 days.
58#[derive(Clone, Debug)]
59pub struct RetentionPolicy {
60    /// Default retention for topics not explicitly listed.
61    pub default_days: i64,
62    /// Days for `orders`.
63    pub orders_days: i64,
64    /// Days for `pnl`.
65    pub pnl_days: i64,
66    /// Days for `marketdata:*` topics.
67    pub marketdata_days: i64,
68    /// Days for `gap`.
69    pub gap_days: i64,
70}
71
72impl Default for RetentionPolicy {
73    fn default() -> Self {
74        Self {
75            default_days: 30,
76            orders_days: 90,
77            pnl_days: 90,
78            marketdata_days: 14,
79            gap_days: 365,
80        }
81    }
82}
83
84impl RetentionPolicy {
85    /// How many days to keep events for `topic`.
86    #[must_use]
87    pub fn days_for(&self, topic: &str) -> i64 {
88        if topic == "orders" {
89            self.orders_days
90        } else if topic == "pnl" {
91            self.pnl_days
92        } else if topic == "gap" {
93            self.gap_days
94        } else if topic.starts_with("marketdata:") {
95            self.marketdata_days
96        } else {
97            self.default_days
98        }
99    }
100}
101
102impl EventLog {
103    /// Open or create a sqlite database at `path`. Runs the schema
104    /// migration on first call (idempotent).
105    pub fn open(path: impl AsRef<Path>) -> rusqlite::Result<Self> {
106        let conn = Connection::open(path.as_ref())?;
107        conn.pragma_update(None, "journal_mode", "WAL")?;
108        conn.pragma_update(None, "synchronous", "NORMAL")?;
109        conn.execute_batch(
110            r"
111            CREATE TABLE IF NOT EXISTS events (
112                id           INTEGER PRIMARY KEY AUTOINCREMENT,
113                cursor       INTEGER NOT NULL,
114                topic        TEXT    NOT NULL,
115                received_at  TEXT    NOT NULL,
116                reset_epoch  INTEGER NOT NULL,
117                payload      TEXT    NOT NULL
118            );
119            CREATE INDEX IF NOT EXISTS events_topic_received_at
120                ON events(topic, received_at);
121            ",
122        )?;
123        Ok(Self {
124            conn: Mutex::new(conn),
125            path: path.as_ref().to_path_buf(),
126        })
127    }
128
129    /// Open an in-memory database — used by tests.
130    pub fn open_in_memory() -> rusqlite::Result<Self> {
131        let conn = Connection::open_in_memory()?;
132        conn.execute_batch(
133            r"
134            CREATE TABLE events (
135                id           INTEGER PRIMARY KEY AUTOINCREMENT,
136                cursor       INTEGER NOT NULL,
137                topic        TEXT    NOT NULL,
138                received_at  TEXT    NOT NULL,
139                reset_epoch  INTEGER NOT NULL,
140                payload      TEXT    NOT NULL
141            );
142            CREATE INDEX events_topic_received_at
143                ON events(topic, received_at);
144            ",
145        )?;
146        Ok(Self {
147            conn: Mutex::new(conn),
148            path: PathBuf::from(":memory:"),
149        })
150    }
151
152    /// The connection. A poisoned lock is recovered rather than propagated:
153    /// a panic on another thread mid-write leaves sqlite itself consistent
154    /// (the statement either committed or it did not), and a history log
155    /// that panics every caller forever after one bad write is worse than
156    /// one that carries on.
157    fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
158        self.conn.lock().unwrap_or_else(PoisonError::into_inner)
159    }
160
161    /// Path the log was opened against.
162    #[must_use]
163    pub fn path(&self) -> &Path {
164        &self.path
165    }
166
167    /// Append an event. Returns the row id.
168    pub fn append(&self, event: &ObservedEvent) -> rusqlite::Result<i64> {
169        let payload_str = serde_json::to_string(&event.payload)
170            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
171        let conn = self.conn();
172        conn.execute(
173            "INSERT INTO events (cursor, topic, received_at, reset_epoch, payload) \
174             VALUES (?1, ?2, ?3, ?4, ?5)",
175            params![
176                event.cursor as i64,
177                event.topic,
178                event.received_at,
179                event.reset_epoch as i64,
180                payload_str,
181            ],
182        )?;
183        Ok(conn.last_insert_rowid())
184    }
185
186    /// Read events for a topic since `since_ts` (ISO 8601). Newest-last.
187    pub fn query_since(
188        &self,
189        topic: &str,
190        since_ts: &str,
191        limit: usize,
192    ) -> rusqlite::Result<Vec<ObservedEvent>> {
193        let conn = self.conn();
194        let mut stmt = conn.prepare(
195            "SELECT cursor, topic, received_at, reset_epoch, payload \
196             FROM events WHERE topic = ?1 AND received_at > ?2 \
197             ORDER BY received_at ASC LIMIT ?3",
198        )?;
199        let rows = stmt.query_map(params![topic, since_ts, limit as i64], |row| {
200            let cursor: i64 = row.get(0)?;
201            let topic: String = row.get(1)?;
202            let received_at: String = row.get(2)?;
203            let reset_epoch: i64 = row.get(3)?;
204            let payload_str: String = row.get(4)?;
205            let payload: serde_json::Value =
206                serde_json::from_str(&payload_str).unwrap_or(serde_json::Value::Null);
207            Ok(ObservedEvent {
208                cursor: cursor as u64,
209                topic,
210                received_at,
211                reset_epoch: reset_epoch as u64,
212                payload,
213            })
214        })?;
215        let mut out = Vec::new();
216        for r in rows {
217            out.push(r?);
218        }
219        Ok(out)
220    }
221
222    /// Drop events older than the topic's retention cutoff. Returns
223    /// total rows deleted across all topics.
224    pub fn prune(&self, policy: &RetentionPolicy) -> rusqlite::Result<usize> {
225        let conn = self.conn();
226        let now: chrono::DateTime<chrono::Utc> = std::time::SystemTime::now().into();
227        let mut total = 0usize;
228        let topics: Vec<String> = {
229            let mut stmt = conn.prepare("SELECT DISTINCT topic FROM events")?;
230            let mapped = stmt.query_map([], |r| r.get::<_, String>(0))?;
231            let mut v = Vec::new();
232            for m in mapped {
233                v.push(m?);
234            }
235            v
236        };
237        for topic in topics {
238            let days = policy.days_for(&topic);
239            let cutoff = now - chrono::Duration::days(days);
240            let cutoff_str = cutoff.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
241            let n = conn.execute(
242                "DELETE FROM events WHERE topic = ?1 AND received_at < ?2",
243                params![topic, cutoff_str],
244            )?;
245            total += n;
246        }
247        Ok(total)
248    }
249
250    /// The largest `(cursor, reset_epoch)` ever logged, or `None` for an
251    /// empty log. A restarting connector seeds above both so its cursors
252    /// and epoch never fall back below what a client may still hold, even
253    /// when the host clock came up behind the previous run.
254    pub fn high_water(&self) -> rusqlite::Result<Option<(u64, u64)>> {
255        let conn = self.conn();
256        let (cursor, epoch): (Option<i64>, Option<i64>) = conn.query_row(
257            "SELECT MAX(cursor), MAX(reset_epoch) FROM events",
258            [],
259            |r| Ok((r.get(0)?, r.get(1)?)),
260        )?;
261        Ok(match (cursor, epoch) {
262            (Some(c), Some(e)) => {
263                Some((u64::try_from(c).unwrap_or(0), u64::try_from(e).unwrap_or(0)))
264            }
265            _ => None,
266        })
267    }
268
269    /// Total row count. Used by tests + diagnostics.
270    pub fn count(&self) -> rusqlite::Result<usize> {
271        let conn = self.conn();
272        let n: i64 = conn
273            .query_row("SELECT COUNT(*) FROM events", [], |r| r.get(0))
274            .optional()?
275            .unwrap_or(0);
276        Ok(n as usize)
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use serde_json::json;
284
285    fn evt(cursor: u64, topic: &str, ts: &str) -> ObservedEvent {
286        ObservedEvent {
287            cursor,
288            topic: topic.to_string(),
289            received_at: ts.to_string(),
290            reset_epoch: 1,
291            payload: json!({"id": cursor}),
292        }
293    }
294
295    #[test]
296    fn append_and_query_round_trip() {
297        let log = EventLog::open_in_memory().unwrap();
298        log.append(&evt(1, "orders", "2026-05-06T13:30:00Z"))
299            .unwrap();
300        log.append(&evt(2, "orders", "2026-05-06T13:31:00Z"))
301            .unwrap();
302        log.append(&evt(3, "pnl", "2026-05-06T13:32:00Z")).unwrap();
303
304        let orders = log
305            .query_since("orders", "1970-01-01T00:00:00Z", 100)
306            .unwrap();
307        assert_eq!(orders.len(), 2);
308        assert_eq!(orders[0].cursor, 1);
309        assert_eq!(orders[1].cursor, 2);
310
311        let pnl = log.query_since("pnl", "1970-01-01T00:00:00Z", 100).unwrap();
312        assert_eq!(pnl.len(), 1);
313        assert_eq!(pnl[0].topic, "pnl");
314    }
315
316    #[test]
317    fn query_filters_by_since_ts() {
318        let log = EventLog::open_in_memory().unwrap();
319        log.append(&evt(1, "orders", "2026-05-06T13:30:00Z"))
320            .unwrap();
321        log.append(&evt(2, "orders", "2026-05-06T13:31:00Z"))
322            .unwrap();
323        log.append(&evt(3, "orders", "2026-05-06T13:32:00Z"))
324            .unwrap();
325
326        let recent = log
327            .query_since("orders", "2026-05-06T13:30:30Z", 100)
328            .unwrap();
329        assert_eq!(recent.len(), 2);
330        assert_eq!(recent[0].cursor, 2);
331    }
332
333    #[test]
334    fn query_respects_limit() {
335        let log = EventLog::open_in_memory().unwrap();
336        for i in 1..=10 {
337            let ts = format!("2026-05-06T13:{:02}:00Z", 30 + i);
338            log.append(&evt(i, "orders", &ts)).unwrap();
339        }
340        let result = log
341            .query_since("orders", "1970-01-01T00:00:00Z", 3)
342            .unwrap();
343        assert_eq!(result.len(), 3);
344        assert_eq!(result[0].cursor, 1);
345    }
346
347    #[test]
348    fn prune_drops_old_events_per_topic_policy() {
349        let log = EventLog::open_in_memory().unwrap();
350        // Old events (>90 days for orders / >14 days for marketdata)
351        let ancient = (chrono::Utc::now() - chrono::Duration::days(120))
352            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
353        let mid = (chrono::Utc::now() - chrono::Duration::days(20))
354            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
355        let recent = (chrono::Utc::now() - chrono::Duration::days(1))
356            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
357
358        log.append(&evt(1, "orders", &ancient)).unwrap();
359        log.append(&evt(2, "orders", &mid)).unwrap();
360        log.append(&evt(3, "orders", &recent)).unwrap();
361        log.append(&evt(10, "marketdata:1", &mid)).unwrap();
362        log.append(&evt(11, "marketdata:1", &recent)).unwrap();
363
364        let policy = RetentionPolicy::default();
365        let dropped = log.prune(&policy).unwrap();
366        // orders: 120-day-old dropped; marketdata: 20-day-old dropped
367        assert_eq!(dropped, 2);
368
369        assert_eq!(log.count().unwrap(), 3);
370    }
371
372    #[test]
373    fn high_water_is_the_largest_cursor_and_epoch() {
374        let log = EventLog::open_in_memory().unwrap();
375        assert_eq!(log.high_water().unwrap(), None);
376        log.append(&evt(7, "orders", "2026-05-06T13:30:00Z"))
377            .unwrap();
378        let mut late = evt(3, "pnl", "2026-05-06T13:31:00Z");
379        late.reset_epoch = 9;
380        log.append(&late).unwrap();
381        assert_eq!(log.high_water().unwrap(), Some((7, 9)));
382    }
383
384    #[test]
385    fn a_poisoned_lock_does_not_take_the_log_down() {
386        let log = std::sync::Arc::new(EventLog::open_in_memory().unwrap());
387        let poisoner = log.clone();
388        let _ = std::thread::spawn(move || {
389            let _guard = poisoner.conn.lock().unwrap();
390            panic!("poison the lock");
391        })
392        .join();
393        assert!(log.conn.is_poisoned());
394        log.append(&evt(1, "orders", "2026-05-06T13:30:00Z"))
395            .unwrap();
396        assert_eq!(log.count().unwrap(), 1);
397    }
398
399    #[test]
400    fn retention_policy_picks_per_topic_days() {
401        let p = RetentionPolicy::default();
402        assert_eq!(p.days_for("orders"), 90);
403        assert_eq!(p.days_for("pnl"), 90);
404        assert_eq!(p.days_for("gap"), 365);
405        assert_eq!(p.days_for("marketdata:265598"), 14);
406        assert_eq!(p.days_for("unknown"), 30);
407    }
408}