Skip to main content

mlua_swarm/store/trace/
sqlite.rs

1//! `SqliteRunTraceStore` — SQLite-backed [`RunTraceStore`] using
2//! [`rusqlite-isle`], sharing the same database FILE as
3//! [`crate::store::run::SqliteRunStore`] (`~/.mse/store/run.sqlite` by
4//! default) in its own `run_trace` table. Sharing the file keeps "one
5//! Run's persistence" a single artifact on disk; each store still owns
6//! its own confined `Connection` (its own `AsyncIsle` thread), so both
7//! connections set `busy_timeout` to ride out each other's short write
8//! transactions.
9//!
10//! ## Schema
11//!
12//! ```sql
13//! CREATE TABLE IF NOT EXISTS run_trace (
14//!   run_id       TEXT NOT NULL,
15//!   seq          INTEGER NOT NULL,   -- per-run, 1-based, monotonic
16//!   ts_ms        INTEGER NOT NULL,
17//!   kind         TEXT NOT NULL,      -- namespaced open set
18//!   step_ref     TEXT,
19//!   attempt      INTEGER,
20//!   payload_json TEXT NOT NULL,
21//!   PRIMARY KEY (run_id, seq)
22//! );
23//! ```
24//!
25//! `seq` assignment and retention pruning run inside one transaction per
26//! append, so concurrent appenders to the same Run cannot collide on a
27//! seq or over-prune. Filtering (kind prefix / step_ref / attempt) is
28//! applied in Rust over the per-Run rows (bounded by the retention
29//! ceiling) — the query axes are too dynamic to be worth SQL-side
30//! predicates at this scale.
31
32use super::{
33    cap_payload, now_unix_ms, RunTraceStore, TraceEvent, TraceEventDraft, TraceQuery,
34    TraceStoreError, DEFAULT_TRACE_MAX_EVENTS_PER_RUN,
35};
36use crate::types::RunId;
37use async_trait::async_trait;
38use rusqlite::params;
39use rusqlite_isle::{AsyncIsle, AsyncIsleDriver, IsleError};
40use std::path::Path;
41
42const SCHEMA_SQL: &str = "\
43CREATE TABLE IF NOT EXISTS run_trace (\
44  run_id       TEXT NOT NULL, \
45  seq          INTEGER NOT NULL, \
46  ts_ms        INTEGER NOT NULL, \
47  kind         TEXT NOT NULL, \
48  step_ref     TEXT, \
49  attempt      INTEGER, \
50  payload_json TEXT NOT NULL, \
51  PRIMARY KEY (run_id, seq)\
52);\
53";
54
55/// SQLite-backed persistent [`RunTraceStore`].
56///
57/// Open with [`SqliteRunTraceStore::open`] (file path — typically the
58/// SAME path as the `SqliteRunStore`) or
59/// [`SqliteRunTraceStore::open_in_memory`] (tests). Both return the
60/// store plus an [`AsyncIsleDriver`] the caller must `shutdown().await`
61/// when done.
62pub struct SqliteRunTraceStore {
63    isle: AsyncIsle,
64    max_events_per_run: usize,
65}
66
67fn init_conn(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> {
68    // Two isles (runs / run_trace) share one database file. The busy
69    // wait only helps when the busy handler is actually invoked — a
70    // DEFERRED read-then-upgrade transaction racing another writer gets
71    // an IMMEDIATE `SQLITE_BUSY` (deadlock avoidance bypasses the
72    // handler), which is why every write transaction on this file uses
73    // `TransactionBehavior::Immediate` (RESERVED up front → the busy
74    // wait applies). See `RunTraceStore::append` and the sibling
75    // `SqliteRunStore` write paths.
76    conn.busy_timeout(std::time::Duration::from_millis(5_000))?;
77    conn.execute_batch(SCHEMA_SQL)
78}
79
80impl SqliteRunTraceStore {
81    /// Open (or create) a SQLite database file and ensure the
82    /// `run_trace` table exists. Safe to point at the file another
83    /// store already owns — `CREATE TABLE IF NOT EXISTS` never touches
84    /// foreign tables.
85    pub async fn open(path: impl AsRef<Path>) -> Result<(Self, AsyncIsleDriver), TraceStoreError> {
86        let (isle, driver) = AsyncIsle::spawn(path.as_ref().to_path_buf(), init_conn)
87            .await
88            .map_err(map_isle_err)?;
89        Ok((
90            Self {
91                isle,
92                max_events_per_run: DEFAULT_TRACE_MAX_EVENTS_PER_RUN,
93            },
94            driver,
95        ))
96    }
97
98    /// Open an ephemeral in-memory database (tests, doctests).
99    pub async fn open_in_memory() -> Result<(Self, AsyncIsleDriver), TraceStoreError> {
100        let (isle, driver) = AsyncIsle::open_in_memory(init_conn)
101            .await
102            .map_err(map_isle_err)?;
103        Ok((
104            Self {
105                isle,
106                max_events_per_run: DEFAULT_TRACE_MAX_EVENTS_PER_RUN,
107            },
108            driver,
109        ))
110    }
111
112    /// Override the per-Run retention ceiling (tests).
113    pub fn with_max_events_per_run(mut self, max: usize) -> Self {
114        self.max_events_per_run = max;
115        self
116    }
117}
118
119fn map_isle_err(e: IsleError) -> TraceStoreError {
120    TraceStoreError::Other(format!("sqlite: {e}"))
121}
122
123/// One `run_trace` SELECT row in column order: seq, ts_ms, kind,
124/// step_ref, attempt, payload_json.
125type TraceRow = (i64, i64, String, Option<String>, Option<i64>, String);
126
127fn row_to_event(run_id: &RunId, row: TraceRow) -> Result<TraceEvent, TraceStoreError> {
128    let (seq, ts_ms, kind, step_ref, attempt, payload_json) = row;
129    let payload = serde_json::from_str(&payload_json)
130        .map_err(|e| TraceStoreError::Other(format!("decode payload: {e}")))?;
131    Ok(TraceEvent {
132        run_id: run_id.clone(),
133        seq: seq as u64,
134        ts_ms,
135        kind,
136        step_ref,
137        attempt: attempt.map(|a| a as u32),
138        payload,
139    })
140}
141
142#[async_trait]
143impl RunTraceStore for SqliteRunTraceStore {
144    fn name(&self) -> &str {
145        "sqlite"
146    }
147
148    async fn append(
149        &self,
150        run_id: &RunId,
151        draft: TraceEventDraft,
152    ) -> Result<TraceEvent, TraceStoreError> {
153        let run_id_str = run_id.to_string();
154        let ts_ms = now_unix_ms();
155        let payload = cap_payload(draft.payload);
156        let payload_json = payload.to_string();
157        let kind = draft.kind.clone();
158        let step_ref = draft.step_ref.clone();
159        let attempt = draft.attempt.map(|a| a as i64);
160        let max = self.max_events_per_run as i64;
161
162        let seq = self
163            .isle
164            .call(move |conn| {
165                // Immediate (not DEFERRED): grab RESERVED before the
166                // MAX(seq) read so a concurrent `runs`-table writer on the
167                // shared file triggers the busy WAIT, not an instant
168                // SQLITE_BUSY from the deadlock-avoidance path.
169                let tx =
170                    conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
171                let next: i64 = tx.query_row(
172                    "SELECT COALESCE(MAX(seq), 0) + 1 FROM run_trace WHERE run_id = ?1",
173                    params![run_id_str],
174                    |row| row.get(0),
175                )?;
176                tx.execute(
177                    "INSERT INTO run_trace \
178                     (run_id, seq, ts_ms, kind, step_ref, attempt, payload_json) \
179                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
180                    params![
181                        run_id_str,
182                        next,
183                        ts_ms,
184                        kind,
185                        step_ref,
186                        attempt,
187                        payload_json
188                    ],
189                )?;
190                // Retention: prune the oldest rows beyond the ceiling
191                // (same transaction, so a concurrent appender can't
192                // observe an over-long stream).
193                tx.execute(
194                    "DELETE FROM run_trace WHERE run_id = ?1 AND seq <= ?2 - ?3",
195                    params![run_id_str, next, max],
196                )?;
197                tx.commit()?;
198                Ok(next)
199            })
200            .await
201            .map_err(map_isle_err)?;
202
203        Ok(TraceEvent {
204            run_id: run_id.clone(),
205            seq: seq as u64,
206            ts_ms,
207            kind: draft.kind,
208            step_ref: draft.step_ref,
209            attempt: draft.attempt,
210            payload,
211        })
212    }
213
214    async fn list(
215        &self,
216        run_id: &RunId,
217        query: &TraceQuery,
218    ) -> Result<Vec<TraceEvent>, TraceStoreError> {
219        let run_id_str = run_id.to_string();
220        // Fast path (holistic finding): the unfiltered case — notably the
221        // `latest=N` `log_tail` poll — pages in SQL instead of loading the
222        // whole retained stream (up to the 10k ceiling) to return N rows.
223        // Filtered queries (kind/step/attempt) keep the Rust-side scan;
224        // their predicate set is too dynamic to be worth SQL predicates.
225        // Every branch binds the same `(?1, ?2, ?3)` triple so one
226        // `query_map` call serves all three statements; the full-scan
227        // branch neutralizes `?2`/`?3` (`?2 >= 0` always true, `LIMIT -1`
228        // = no cap).
229        let unfiltered =
230            query.kinds.is_empty() && query.step_ref.is_none() && query.attempt.is_none();
231        let (sql, p2, p3): (&str, i64, i64) = if !unfiltered {
232            (
233                "SELECT seq, ts_ms, kind, step_ref, attempt, payload_json \
234                 FROM run_trace WHERE run_id = ?1 AND ?2 >= 0 ORDER BY seq ASC LIMIT ?3",
235                0,
236                -1,
237            )
238        } else if let Some(n) = query.latest {
239            (
240                "SELECT seq, ts_ms, kind, step_ref, attempt, payload_json FROM \
241                 (SELECT * FROM run_trace WHERE run_id = ?1 AND ?2 >= 0 \
242                  ORDER BY seq DESC LIMIT ?3) ORDER BY seq ASC",
243                0,
244                n as i64,
245            )
246        } else {
247            (
248                "SELECT seq, ts_ms, kind, step_ref, attempt, payload_json \
249                 FROM run_trace WHERE run_id = ?1 AND seq > ?2 ORDER BY seq ASC LIMIT ?3",
250                query.after.unwrap_or(0) as i64,
251                query.limit.unwrap_or(super::DEFAULT_TRACE_LIST_LIMIT) as i64,
252            )
253        };
254        let rows = self
255            .isle
256            .call(move |conn| {
257                let mut stmt = conn.prepare(sql)?;
258                let iter = stmt.query_map(params![run_id_str, p2, p3], |row| {
259                    Ok((
260                        row.get::<_, i64>(0)?,
261                        row.get::<_, i64>(1)?,
262                        row.get::<_, String>(2)?,
263                        row.get::<_, Option<String>>(3)?,
264                        row.get::<_, Option<i64>>(4)?,
265                        row.get::<_, String>(5)?,
266                    ))
267                })?;
268                let mut out = Vec::new();
269                for r in iter {
270                    out.push(r?);
271                }
272                Ok(out)
273            })
274            .await
275            .map_err(map_isle_err)?;
276
277        let events = rows
278            .into_iter()
279            .map(|row| row_to_event(run_id, row))
280            .collect::<Result<Vec<_>, _>>()?;
281        if unfiltered {
282            // Paging already applied in SQL.
283            return Ok(events);
284        }
285        let filtered: Vec<TraceEvent> = events.into_iter().filter(|e| query.matches(e)).collect();
286        Ok(query.page(filtered))
287    }
288
289    async fn delete_run(&self, run_id: &RunId) -> Result<u64, TraceStoreError> {
290        let run_id_str = run_id.to_string();
291        let n = self
292            .isle
293            .call(move |conn| {
294                conn.execute(
295                    "DELETE FROM run_trace WHERE run_id = ?1",
296                    params![run_id_str],
297                )
298            })
299            .await
300            .map_err(map_isle_err)?;
301        Ok(n as u64)
302    }
303}
304
305// ──────────────────────────────────────────────────────────────────────────
306// tests
307// ──────────────────────────────────────────────────────────────────────────
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use serde_json::json;
313
314    fn rid(s: &str) -> RunId {
315        RunId::parse(s).unwrap()
316    }
317
318    fn draft(kind: &str) -> TraceEventDraft {
319        TraceEventDraft {
320            kind: kind.to_string(),
321            step_ref: Some("w".into()),
322            attempt: Some(1),
323            payload: json!({"k": kind}),
324        }
325    }
326
327    #[tokio::test]
328    async fn append_assigns_monotonic_seq_and_roundtrips() {
329        let (s, driver) = SqliteRunTraceStore::open_in_memory().await.unwrap();
330        let e1 = s
331            .append(&rid("R-1"), draft("core.run_started"))
332            .await
333            .unwrap();
334        let e2 = s
335            .append(&rid("R-1"), draft("core.step_dispatched"))
336            .await
337            .unwrap();
338        let other = s
339            .append(&rid("R-2"), draft("core.run_started"))
340            .await
341            .unwrap();
342        assert_eq!(e1.seq, 1);
343        assert_eq!(e2.seq, 2);
344        assert_eq!(other.seq, 1, "seq is per-Run");
345
346        let got = s.list(&rid("R-1"), &TraceQuery::default()).await.unwrap();
347        assert_eq!(got.len(), 2);
348        assert_eq!(got[0].kind, "core.run_started");
349        assert_eq!(got[1].kind, "core.step_dispatched");
350        assert_eq!(got[1].step_ref.as_deref(), Some("w"));
351        assert_eq!(got[1].attempt, Some(1));
352        assert_eq!(got[1].payload, json!({"k": "core.step_dispatched"}));
353        drop(s);
354        driver.shutdown().await.unwrap();
355    }
356
357    #[tokio::test]
358    async fn list_filters_and_pages() {
359        let (s, driver) = SqliteRunTraceStore::open_in_memory().await.unwrap();
360        let r = rid("R-1");
361        s.append(&r, draft("core.step_dispatched")).await.unwrap();
362        s.append(&r, draft("mw.long_hold_warn")).await.unwrap();
363        s.append(&r, draft("core.step_completed")).await.unwrap();
364
365        let core_only = s
366            .list(
367                &r,
368                &TraceQuery {
369                    kinds: vec!["core.".into()],
370                    ..Default::default()
371                },
372            )
373            .await
374            .unwrap();
375        assert_eq!(core_only.len(), 2);
376
377        let latest = s
378            .list(
379                &r,
380                &TraceQuery {
381                    latest: Some(1),
382                    ..Default::default()
383                },
384            )
385            .await
386            .unwrap();
387        assert_eq!(latest.len(), 1);
388        assert_eq!(latest[0].kind, "core.step_completed");
389
390        let after = s
391            .list(
392                &r,
393                &TraceQuery {
394                    after: Some(1),
395                    limit: Some(1),
396                    ..Default::default()
397                },
398            )
399            .await
400            .unwrap();
401        assert_eq!(after.len(), 1);
402        assert_eq!(after[0].seq, 2);
403        drop(s);
404        driver.shutdown().await.unwrap();
405    }
406
407    #[tokio::test]
408    async fn retention_prunes_oldest_in_same_transaction() {
409        let (s, driver) = SqliteRunTraceStore::open_in_memory().await.unwrap();
410        let s = s.with_max_events_per_run(3);
411        let r = rid("R-1");
412        for i in 0..5 {
413            s.append(&r, draft(&format!("core.e{i}"))).await.unwrap();
414        }
415        let all = s.list(&r, &TraceQuery::default()).await.unwrap();
416        assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4, 5]);
417        let e6 = s.append(&r, draft("core.e5")).await.unwrap();
418        assert_eq!(e6.seq, 6, "pruning never recycles seqs");
419        drop(s);
420        driver.shutdown().await.unwrap();
421    }
422
423    #[tokio::test]
424    async fn delete_run_removes_only_that_run() {
425        let (s, driver) = SqliteRunTraceStore::open_in_memory().await.unwrap();
426        s.append(&rid("R-1"), draft("core.a")).await.unwrap();
427        s.append(&rid("R-1"), draft("core.b")).await.unwrap();
428        s.append(&rid("R-2"), draft("core.c")).await.unwrap();
429        assert_eq!(s.delete_run(&rid("R-1")).await.unwrap(), 2);
430        assert!(s
431            .list(&rid("R-1"), &TraceQuery::default())
432            .await
433            .unwrap()
434            .is_empty());
435        assert_eq!(
436            s.list(&rid("R-2"), &TraceQuery::default())
437                .await
438                .unwrap()
439                .len(),
440            1
441        );
442        assert_eq!(s.delete_run(&rid("R-nope")).await.unwrap(), 0);
443        drop(s);
444        driver.shutdown().await.unwrap();
445    }
446
447    #[tokio::test]
448    async fn persists_across_reopen() {
449        let dir = tempfile::tempdir().unwrap();
450        let path = dir.path().join("run.sqlite");
451        {
452            let (s, driver) = SqliteRunTraceStore::open(&path).await.unwrap();
453            s.append(&rid("R-keep"), draft("core.run_started"))
454                .await
455                .unwrap();
456            drop(s);
457            driver.shutdown().await.unwrap();
458        }
459        let (s, driver) = SqliteRunTraceStore::open(&path).await.unwrap();
460        let got = s
461            .list(&rid("R-keep"), &TraceQuery::default())
462            .await
463            .unwrap();
464        assert_eq!(got.len(), 1);
465        assert_eq!(got[0].kind, "core.run_started");
466        drop(s);
467        driver.shutdown().await.unwrap();
468    }
469
470    #[tokio::test]
471    async fn shares_file_with_run_store_tables() {
472        // The design intent: run.sqlite carries BOTH the `runs` table
473        // (SqliteRunStore) and `run_trace` (this store) — prove the two
474        // stores coexist on one file without clobbering each other.
475        let dir = tempfile::tempdir().unwrap();
476        let path = dir.path().join("run.sqlite");
477
478        let (run_store, run_driver) = crate::store::run::SqliteRunStore::open(&path)
479            .await
480            .unwrap();
481        let (trace_store, trace_driver) = SqliteRunTraceStore::open(&path).await.unwrap();
482
483        let record = crate::store::run::RunRecord {
484            id: rid("R-1"),
485            task_id: crate::types::TaskId::parse("T-1").unwrap(),
486            status: crate::store::run::RunStatus::Pending,
487            step_entries: vec![],
488            degradations: vec![],
489            operator_sid: None,
490            result_ref: None,
491            input_json: None,
492            created_at: 1,
493            updated_at: 1,
494        };
495        use crate::store::run::RunStore as _;
496        run_store.create(record).await.unwrap();
497        trace_store
498            .append(&rid("R-1"), draft("core.run_started"))
499            .await
500            .unwrap();
501
502        assert!(run_store.get(&rid("R-1")).await.is_ok());
503        assert_eq!(
504            trace_store
505                .list(&rid("R-1"), &TraceQuery::default())
506                .await
507                .unwrap()
508                .len(),
509            1
510        );
511
512        drop(run_store);
513        drop(trace_store);
514        run_driver.shutdown().await.unwrap();
515        trace_driver.shutdown().await.unwrap();
516    }
517}