1use 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
55pub struct SqliteRunTraceStore {
63 isle: AsyncIsle,
64 max_events_per_run: usize,
65}
66
67fn init_conn(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> {
68 conn.busy_timeout(std::time::Duration::from_millis(5_000))?;
77 conn.execute_batch(SCHEMA_SQL)
78}
79
80impl SqliteRunTraceStore {
81 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 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 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
123type 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 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 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 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 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#[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 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}