Skip to main content

mlua_swarm/store/replay/
sqlite.rs

1//! SQLite-backed [`ReplayStore`] using [`rusqlite-isle`].
2//!
3//! The [`crate::store::run::sqlite::SqliteRunStore`] pattern is the same:
4//! the `Connection` is confined to a dedicated OS thread by `AsyncIsle`
5//! and every call is a typed closure dispatched over a bounded channel.
6//! `ctx_snapshot_json` and `step_output_json` are stored verbatim as
7//! `TEXT`; the caller (the dispatcher) is what canonicalizes shape via
8//! [`super::hash_input_value`].
9//!
10//! ## Schema
11//!
12//! ```sql
13//! CREATE TABLE IF NOT EXISTS replay_log (
14//!   seq                 INTEGER PRIMARY KEY AUTOINCREMENT,
15//!   run_id              TEXT NOT NULL,
16//!   step_ref            TEXT NOT NULL,
17//!   input_hash          TEXT NOT NULL,
18//!   occurrence          INTEGER NOT NULL,
19//!   ctx_snapshot_json   TEXT NOT NULL,
20//!   step_output_json    TEXT NOT NULL,
21//!   created_at          INTEGER NOT NULL,
22//!   UNIQUE (run_id, step_ref, input_hash, occurrence)
23//! );
24//! CREATE INDEX IF NOT EXISTS ix_replay_run ON replay_log(run_id, seq);
25//! ```
26//!
27//! ## Schema versioning
28//!
29//! The current schema is tracked with SQLite's `PRAGMA user_version` as the
30//! single source of truth (1 = the two-column split above). [`init_schema`]
31//! reads the value on every open and dispatches:
32//!
33//! - `user_version = 0` (fresh DB, or a legacy file created by mse
34//!   ≤ v0.10.0 / pre-Core-primitive that used a `value_json` single column):
35//!   if a legacy `replay_log` table is present it is `DROP`ed before the
36//!   current schema is created, then `user_version` is stamped to `1`.
37//!   Legacy rows carry no `ctx_snapshot_json`, so they cannot be replayed
38//!   against the current wire anyway — dropping them is safe.
39//! - `user_version = 1`: the current schema; `CREATE ... IF NOT EXISTS` is
40//!   still run defensively.
41//! - `user_version > 1`: reject with an error — an older mse binary must
42//!   never touch a store written by a newer one.
43//!
44//! Future migrations follow the same pattern: add a `1 => migrate_v1_to_v2`
45//! arm and stamp `user_version = 2`.
46
47use super::{ReplayEntry, ReplayStore, ReplayStoreError};
48use crate::types::RunId;
49use async_trait::async_trait;
50use rusqlite::params;
51use rusqlite_isle::{AsyncIsle, AsyncIsleDriver, IsleError};
52use std::path::Path;
53
54const SCHEMA_SQL: &str = "\
55CREATE TABLE IF NOT EXISTS replay_log (\
56  seq                 INTEGER PRIMARY KEY AUTOINCREMENT, \
57  run_id              TEXT NOT NULL, \
58  step_ref            TEXT NOT NULL, \
59  input_hash          TEXT NOT NULL, \
60  occurrence          INTEGER NOT NULL, \
61  ctx_snapshot_json   TEXT NOT NULL, \
62  step_output_json    TEXT NOT NULL, \
63  created_at          INTEGER NOT NULL, \
64  UNIQUE (run_id, step_ref, input_hash, occurrence)\
65);\
66CREATE INDEX IF NOT EXISTS ix_replay_run ON replay_log(run_id, seq);\
67";
68
69/// Latest schema version. Bumped whenever [`SCHEMA_SQL`] changes shape in a
70/// way older binaries cannot read.
71const CURRENT_SCHEMA_VERSION: i64 = 1;
72
73/// Detect and migrate the `replay_log` schema on open. See the module-level
74/// `Schema versioning` doc for the state-machine.
75///
76/// Returns `rusqlite::Result<()>` so it plugs straight into
77/// `AsyncIsle::spawn` / `AsyncIsle::open_in_memory`; errors surface via
78/// [`map_isle_err`] as [`ReplayStoreError::Other`] with the message
79/// preserved verbatim.
80fn init_schema(conn: &mut rusqlite::Connection) -> rusqlite::Result<()> {
81    // Read current schema version. Fresh DB reports 0.
82    let user_version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
83
84    match user_version {
85        0 => {
86            // Detect legacy schema: a `replay_log` table exists but lacks
87            // the `ctx_snapshot_json` column. If so, drop it — legacy rows
88            // carry no Ctx snapshot, so resume cursor hits cannot use them
89            // anyway; data loss is safe.
90            let table_present: i64 = conn.query_row(
91                "SELECT COUNT(*) FROM sqlite_master \
92                 WHERE type = 'table' AND name = 'replay_log'",
93                [],
94                |r| r.get(0),
95            )?;
96            let has_ctx_column: i64 = if table_present > 0 {
97                conn.query_row(
98                    "SELECT COUNT(*) FROM pragma_table_info('replay_log') \
99                     WHERE name = 'ctx_snapshot_json'",
100                    [],
101                    |r| r.get(0),
102                )?
103            } else {
104                0
105            };
106            let is_legacy = table_present > 0 && has_ctx_column == 0;
107            if is_legacy {
108                conn.execute("DROP TABLE replay_log", [])?;
109            }
110            conn.execute_batch(SCHEMA_SQL)?;
111            // PRAGMA cannot bind parameters; the value is a compile-time
112            // constant, so string-substitute it directly.
113            conn.execute_batch(&format!("PRAGMA user_version = {CURRENT_SCHEMA_VERSION}"))?;
114            Ok(())
115        }
116        v if v == CURRENT_SCHEMA_VERSION => {
117            // Current schema. Still run CREATE ... IF NOT EXISTS in case the
118            // table was manually deleted while user_version stayed at 1.
119            conn.execute_batch(SCHEMA_SQL)
120        }
121        v => Err(rusqlite::Error::SqliteFailure(
122            rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR),
123            Some(format!(
124                "replay_log schema version {v} is newer than supported \
125                 ({CURRENT_SCHEMA_VERSION}); running an older mse binary \
126                 against a store written by a newer one is not supported"
127            )),
128        )),
129    }
130}
131
132/// SQLite-backed persistent [`ReplayStore`].
133///
134/// Open with [`SqliteReplayStore::open`] (file path) or
135/// [`SqliteReplayStore::open_in_memory`] (tests). Both return the store
136/// plus an [`AsyncIsleDriver`] the caller must `shutdown().await` when
137/// done — dropping the driver without a shutdown call leaves the SQLite
138/// thread as-is until the process exits.
139pub struct SqliteReplayStore {
140    isle: AsyncIsle,
141}
142
143impl SqliteReplayStore {
144    /// Open (or create) a SQLite database file and run the schema
145    /// migrations. See [`init_schema`] and the module-level `Schema
146    /// versioning` doc.
147    pub async fn open(path: impl AsRef<Path>) -> Result<(Self, AsyncIsleDriver), ReplayStoreError> {
148        let (isle, driver) = AsyncIsle::spawn(path.as_ref().to_path_buf(), init_schema)
149            .await
150            .map_err(map_isle_err)?;
151        Ok((Self { isle }, driver))
152    }
153
154    /// Open an ephemeral in-memory database (tests, doctests). In-memory
155    /// databases start with `user_version = 0` and are always fresh, so
156    /// [`init_schema`] takes the version-0 arm and stamps
157    /// `CURRENT_SCHEMA_VERSION` immediately.
158    pub async fn open_in_memory() -> Result<(Self, AsyncIsleDriver), ReplayStoreError> {
159        let (isle, driver) = AsyncIsle::open_in_memory(init_schema)
160            .await
161            .map_err(map_isle_err)?;
162        Ok((Self { isle }, driver))
163    }
164}
165
166fn map_isle_err(e: IsleError) -> ReplayStoreError {
167    ReplayStoreError::Other(format!("sqlite: {e}"))
168}
169
170#[async_trait]
171impl ReplayStore for SqliteReplayStore {
172    fn name(&self) -> &str {
173        "sqlite"
174    }
175
176    async fn append(&self, entry: ReplayEntry) -> Result<(), ReplayStoreError> {
177        let ReplayEntry {
178            run_id,
179            step_ref,
180            input_hash,
181            occurrence,
182            ctx_snapshot_json,
183            step_output_json,
184            created_at,
185        } = entry;
186        let run_id_for_err = run_id.clone();
187        let step_ref_for_err = step_ref.clone();
188        let input_hash_for_err = input_hash.clone();
189        let run_id_str = run_id.to_string();
190
191        self.isle
192            .call(move |conn| {
193                conn.execute(
194                    "INSERT INTO replay_log \
195                     (run_id, step_ref, input_hash, occurrence, ctx_snapshot_json, \
196                      step_output_json, created_at) \
197                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
198                    params![
199                        run_id_str,
200                        step_ref,
201                        input_hash,
202                        occurrence as i64,
203                        ctx_snapshot_json,
204                        step_output_json,
205                        created_at as i64,
206                    ],
207                )?;
208                Ok(())
209            })
210            .await
211            .map_err(|e| match &e {
212                IsleError::Sqlite(rusqlite::Error::SqliteFailure(err, _))
213                    if err.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE
214                        || err.code == rusqlite::ErrorCode::ConstraintViolation =>
215                {
216                    ReplayStoreError::Duplicate {
217                        run_id: run_id_for_err.clone(),
218                        step_ref: step_ref_for_err.clone(),
219                        input_hash: input_hash_for_err.clone(),
220                        occurrence,
221                    }
222                }
223                _ => map_isle_err(e),
224            })
225    }
226
227    async fn list_by_run(&self, run_id: &RunId) -> Result<Vec<ReplayEntry>, ReplayStoreError> {
228        let run_id_str = run_id.to_string();
229        let run_id_owned = run_id.clone();
230        let rows: Vec<(String, String, i64, String, String, i64)> = self
231            .isle
232            .call(move |conn| {
233                let mut stmt = conn.prepare(
234                    "SELECT step_ref, input_hash, occurrence, ctx_snapshot_json, \
235                     step_output_json, created_at FROM replay_log \
236                     WHERE run_id = ?1 ORDER BY seq ASC",
237                )?;
238                let iter = stmt.query_map(params![run_id_str], |row| {
239                    Ok((
240                        row.get::<_, String>(0)?,
241                        row.get::<_, String>(1)?,
242                        row.get::<_, i64>(2)?,
243                        row.get::<_, String>(3)?,
244                        row.get::<_, String>(4)?,
245                        row.get::<_, i64>(5)?,
246                    ))
247                })?;
248                let mut out = Vec::new();
249                for r in iter {
250                    out.push(r?);
251                }
252                Ok(out)
253            })
254            .await
255            .map_err(map_isle_err)?;
256
257        Ok(rows
258            .into_iter()
259            .map(
260                |(
261                    step_ref,
262                    input_hash,
263                    occurrence,
264                    ctx_snapshot_json,
265                    step_output_json,
266                    created_at,
267                )| {
268                    ReplayEntry {
269                        run_id: run_id_owned.clone(),
270                        step_ref,
271                        input_hash,
272                        occurrence: occurrence as u32,
273                        ctx_snapshot_json,
274                        step_output_json,
275                        created_at: created_at as u64,
276                    }
277                },
278            )
279            .collect())
280    }
281}
282
283// ──────────────────────────────────────────────────────────────────────────
284// Tests.
285// ──────────────────────────────────────────────────────────────────────────
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::core::ctx::Ctx;
291    use crate::store::replay::ReplayCursor;
292    use crate::types::StepId;
293    use serde_json::json;
294
295    fn mk_ctx() -> Ctx {
296        let mut ctx = Ctx::new(StepId::new(), 1, "step-a");
297        ctx.meta.observer.insert("k".into(), json!("v"));
298        ctx
299    }
300
301    #[tokio::test]
302    async fn sqlite_append_and_list() {
303        let (store, driver) = SqliteReplayStore::open_in_memory().await.unwrap();
304        let run_id = RunId::new();
305        let ctx = mk_ctx();
306
307        let e0 = ReplayEntry::from_completion(
308            run_id.clone(),
309            "step-a",
310            "hash-a",
311            0,
312            &ctx,
313            &json!({ "n": 1 }),
314        )
315        .unwrap();
316        store.append(e0).await.unwrap();
317
318        let listed = store.list_by_run(&run_id).await.unwrap();
319        assert_eq!(listed.len(), 1);
320        assert_eq!(listed[0].step_ref, "step-a");
321        assert_eq!(listed[0].occurrence, 0);
322        assert_eq!(listed[0].decode_step_output().unwrap(), json!({ "n": 1 }));
323
324        // Ctx round-trip through the SQLite backend.
325        let restored = listed[0].decode_ctx_snapshot().unwrap();
326        assert_eq!(restored.agent, ctx.agent);
327        assert_eq!(restored.attempt, ctx.attempt);
328        assert_eq!(restored.meta.observer.get("k"), Some(&json!("v")));
329
330        driver.shutdown().await.unwrap();
331    }
332
333    #[tokio::test]
334    async fn sqlite_unique_4_tuple_is_enforced() {
335        let (store, driver) = SqliteReplayStore::open_in_memory().await.unwrap();
336        let run_id = RunId::new();
337        let ctx = mk_ctx();
338
339        let e0 = ReplayEntry::from_completion(
340            run_id.clone(),
341            "step-a",
342            "hash-a",
343            0,
344            &ctx,
345            &json!("first"),
346        )
347        .unwrap();
348        store.append(e0.clone()).await.unwrap();
349
350        // Same 4-tuple → Duplicate.
351        let dup_err = store.append(e0).await.unwrap_err();
352        assert!(
353            matches!(dup_err, ReplayStoreError::Duplicate { .. }),
354            "same (run_id, step_ref, input_hash, occurrence) must collide"
355        );
356
357        // occurrence=1 must NOT collide with occurrence=0 (loop replay
358        // discipline).
359        let e1 = ReplayEntry::from_completion(
360            run_id.clone(),
361            "step-a",
362            "hash-a",
363            1,
364            &ctx,
365            &json!("second"),
366        )
367        .unwrap();
368        store
369            .append(e1)
370            .await
371            .expect("occurrence=1 must not collide with occurrence=0");
372
373        let listed = store.list_by_run(&run_id).await.unwrap();
374        assert_eq!(listed.len(), 2);
375        let cursor = ReplayCursor::from_entries(listed);
376        assert_eq!(cursor.find("step-a", "hash-a", 0), Some(json!("first")));
377        assert_eq!(cursor.find("step-a", "hash-a", 1), Some(json!("second")));
378
379        driver.shutdown().await.unwrap();
380    }
381
382    #[tokio::test]
383    async fn sqlite_list_by_run_returns_in_seq_order() {
384        let (store, driver) = SqliteReplayStore::open_in_memory().await.unwrap();
385        let run_id = RunId::new();
386        let ctx = mk_ctx();
387
388        for (i, (step, occ)) in [("a", 0), ("b", 0), ("a", 1)].iter().enumerate() {
389            store
390                .append(
391                    ReplayEntry::from_completion(
392                        run_id.clone(),
393                        *step,
394                        "h",
395                        *occ,
396                        &ctx,
397                        &json!({ "idx": i }),
398                    )
399                    .unwrap(),
400                )
401                .await
402                .unwrap();
403        }
404
405        let listed = store.list_by_run(&run_id).await.unwrap();
406        let steps: Vec<(String, u32)> = listed
407            .iter()
408            .map(|e| (e.step_ref.clone(), e.occurrence))
409            .collect();
410        assert_eq!(
411            steps,
412            vec![
413                ("a".to_string(), 0),
414                ("b".to_string(), 0),
415                ("a".to_string(), 1),
416            ]
417        );
418
419        driver.shutdown().await.unwrap();
420    }
421
422    #[tokio::test]
423    async fn sqlite_name() {
424        let (store, driver) = SqliteReplayStore::open_in_memory().await.unwrap();
425        assert_eq!(store.name(), "sqlite");
426        driver.shutdown().await.unwrap();
427    }
428
429    // ──────────────────────────────────────────────────────────────────────
430    // Schema-migration tests (see `init_schema`).
431    // ──────────────────────────────────────────────────────────────────────
432
433    /// Read `PRAGMA user_version` from a raw synchronous rusqlite handle so
434    /// we can inspect the file after the isle driver has been shut down.
435    fn read_user_version(path: &std::path::Path) -> i64 {
436        let conn = rusqlite::Connection::open(path).unwrap();
437        conn.query_row("PRAGMA user_version", [], |r| r.get(0))
438            .unwrap()
439    }
440
441    #[tokio::test]
442    async fn sqlite_fresh_open_stamps_current_schema_version() {
443        let tmp = tempfile::TempDir::new().unwrap();
444        let path = tmp.path().join("replay.sqlite");
445
446        let (store, driver) = SqliteReplayStore::open(&path).await.unwrap();
447
448        // The new schema is usable: round-trip an entry through it.
449        let run_id = RunId::new();
450        let ctx = mk_ctx();
451        let entry = ReplayEntry::from_completion(
452            run_id.clone(),
453            "step-a",
454            "hash-a",
455            0,
456            &ctx,
457            &json!({ "n": 1 }),
458        )
459        .unwrap();
460        store.append(entry).await.unwrap();
461        let listed = store.list_by_run(&run_id).await.unwrap();
462        assert_eq!(listed.len(), 1);
463        assert_eq!(listed[0].decode_step_output().unwrap(), json!({ "n": 1 }));
464
465        driver.shutdown().await.unwrap();
466
467        assert_eq!(read_user_version(&path), CURRENT_SCHEMA_VERSION);
468    }
469
470    #[tokio::test]
471    async fn sqlite_legacy_schema_is_dropped_and_rebuilt() {
472        let tmp = tempfile::TempDir::new().unwrap();
473        let path = tmp.path().join("replay.sqlite");
474
475        // Seed a legacy-shape DB: single `value_json` column and
476        // `user_version = 0` (default). Populate one row so we can prove
477        // the migration drops the whole legacy log.
478        {
479            let conn = rusqlite::Connection::open(&path).unwrap();
480            conn.execute_batch(
481                "CREATE TABLE replay_log (\
482                    seq         INTEGER PRIMARY KEY AUTOINCREMENT, \
483                    run_id      TEXT NOT NULL, \
484                    step_ref    TEXT NOT NULL, \
485                    input_hash  TEXT NOT NULL, \
486                    occurrence  INTEGER NOT NULL, \
487                    value_json  TEXT NOT NULL, \
488                    created_at  INTEGER NOT NULL, \
489                    UNIQUE (run_id, step_ref, input_hash, occurrence)\
490                );",
491            )
492            .unwrap();
493            conn.execute(
494                "INSERT INTO replay_log \
495                 (run_id, step_ref, input_hash, occurrence, value_json, created_at) \
496                 VALUES ('legacy-run', 'legacy-step', 'legacy-hash', 0, 'legacy-value', 0)",
497                [],
498            )
499            .unwrap();
500            // `user_version` stays at 0 (the default), which is what a real
501            // legacy file would carry.
502        }
503
504        assert_eq!(read_user_version(&path), 0, "seed sanity: user_version=0");
505
506        // Open — should drop the legacy table, create the new schema, and
507        // stamp `user_version = 1`.
508        let (store, driver) = SqliteReplayStore::open(&path).await.unwrap();
509
510        // The new schema accepts writes that the legacy schema could not
511        // have held (the `ctx_snapshot_json` column exists).
512        let run_id = RunId::new();
513        let ctx = mk_ctx();
514        let entry = ReplayEntry::from_completion(
515            run_id.clone(),
516            "step-a",
517            "hash-a",
518            0,
519            &ctx,
520            &json!({ "n": 42 }),
521        )
522        .unwrap();
523        store.append(entry).await.unwrap();
524        let listed = store.list_by_run(&run_id).await.unwrap();
525        assert_eq!(listed.len(), 1);
526
527        driver.shutdown().await.unwrap();
528
529        assert_eq!(read_user_version(&path), CURRENT_SCHEMA_VERSION);
530
531        // Legacy row must be gone (whole table was dropped).
532        let conn = rusqlite::Connection::open(&path).unwrap();
533        let legacy_count: i64 = conn
534            .query_row(
535                "SELECT COUNT(*) FROM replay_log WHERE run_id = 'legacy-run'",
536                [],
537                |r| r.get(0),
538            )
539            .unwrap();
540        assert_eq!(legacy_count, 0, "legacy rows must be dropped");
541
542        // And the new-shape column exists.
543        let ctx_col_count: i64 = conn
544            .query_row(
545                "SELECT COUNT(*) FROM pragma_table_info('replay_log') \
546                 WHERE name = 'ctx_snapshot_json'",
547                [],
548                |r| r.get(0),
549            )
550            .unwrap();
551        assert_eq!(ctx_col_count, 1, "ctx_snapshot_json column must be present");
552    }
553
554    #[tokio::test]
555    async fn sqlite_current_schema_open_is_idempotent() {
556        let tmp = tempfile::TempDir::new().unwrap();
557        let path = tmp.path().join("replay.sqlite");
558
559        // First open: creates the schema and stamps user_version = 1.
560        let run_id = {
561            let (store, driver) = SqliteReplayStore::open(&path).await.unwrap();
562            let run_id = RunId::new();
563            let ctx = mk_ctx();
564            let entry = ReplayEntry::from_completion(
565                run_id.clone(),
566                "step-a",
567                "hash-a",
568                0,
569                &ctx,
570                &json!({ "n": 7 }),
571            )
572            .unwrap();
573            store.append(entry).await.unwrap();
574            driver.shutdown().await.unwrap();
575            run_id
576        };
577
578        assert_eq!(read_user_version(&path), CURRENT_SCHEMA_VERSION);
579
580        // Second open: should be a no-op migration-wise; prior rows must
581        // survive.
582        let (store, driver) = SqliteReplayStore::open(&path).await.unwrap();
583        let listed = store.list_by_run(&run_id).await.unwrap();
584        assert_eq!(listed.len(), 1, "prior rows must survive re-open");
585        assert_eq!(listed[0].step_ref, "step-a");
586        assert_eq!(listed[0].decode_step_output().unwrap(), json!({ "n": 7 }));
587        driver.shutdown().await.unwrap();
588
589        // Still at the current schema version.
590        assert_eq!(read_user_version(&path), CURRENT_SCHEMA_VERSION);
591    }
592
593    #[tokio::test]
594    async fn sqlite_future_schema_version_is_rejected() {
595        let tmp = tempfile::TempDir::new().unwrap();
596        let path = tmp.path().join("replay.sqlite");
597
598        // Seed a file whose `user_version` is one ahead of what this binary
599        // knows how to read.
600        {
601            let conn = rusqlite::Connection::open(&path).unwrap();
602            conn.execute_batch(&format!(
603                "PRAGMA user_version = {}",
604                CURRENT_SCHEMA_VERSION + 1
605            ))
606            .unwrap();
607        }
608
609        let res = SqliteReplayStore::open(&path).await;
610        let err = res
611            .err()
612            .expect("future user_version must be rejected by init_schema");
613        // The error should carry the migration message verbatim through
614        // `map_isle_err`.
615        let msg = err.to_string();
616        assert!(
617            msg.contains("newer than supported"),
618            "unexpected error message: {msg}"
619        );
620    }
621}