1use std::path::{Path, PathBuf};
34use std::sync::{Mutex, PoisonError};
35
36use rusqlite::{params, Connection, OptionalExtension};
37
38use super::ObservedEvent;
39
40pub struct EventLog {
44 conn: Mutex<Connection>,
45 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#[derive(Clone, Debug)]
59pub struct RetentionPolicy {
60 pub default_days: i64,
62 pub orders_days: i64,
64 pub pnl_days: i64,
66 pub marketdata_days: i64,
68 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 #[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 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 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 fn conn(&self) -> std::sync::MutexGuard<'_, Connection> {
158 self.conn.lock().unwrap_or_else(PoisonError::into_inner)
159 }
160
161 #[must_use]
163 pub fn path(&self) -> &Path {
164 &self.path
165 }
166
167 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 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 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 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 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 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 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}