Skip to main content

runifold_store_sqlite/
store.rs

1use std::{
2    path::Path,
3    sync::{Arc, Mutex, MutexGuard},
4    time::Duration,
5};
6
7use runifold_core::{
8    CapabilityId, Checkpoint, CheckpointError, CheckpointErrorKind, CheckpointId, CheckpointStore,
9    EffectId, Journal, JournalError, RunEvent, RunId,
10};
11use runifold_effect::{EffectExecutorError, EffectExecutorErrorKind, EffectRecord, EffectStore};
12use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
13use thiserror::Error;
14
15const SCHEMA: &str = "
16CREATE TABLE IF NOT EXISTS runifold_checkpoints (
17    checkpoint_id TEXT PRIMARY KEY NOT NULL,
18    revision      INTEGER NOT NULL CHECK (revision >= 0),
19    record_json   TEXT NOT NULL
20);
21
22CREATE TABLE IF NOT EXISTS runifold_effects (
23    effect_id       TEXT PRIMARY KEY NOT NULL,
24    capability_id   TEXT NOT NULL,
25    idempotency_key TEXT,
26    revision        INTEGER NOT NULL CHECK (revision >= 0),
27    record_json     TEXT NOT NULL,
28    UNIQUE (capability_id, idempotency_key)
29);
30
31CREATE INDEX IF NOT EXISTS runifold_effects_capability_key
32    ON runifold_effects (capability_id, idempotency_key);
33
34CREATE TABLE IF NOT EXISTS runifold_events (
35    event_id   TEXT PRIMARY KEY NOT NULL,
36    run_id     TEXT NOT NULL,
37    sequence   INTEGER NOT NULL CHECK (sequence >= 0),
38    event_json TEXT NOT NULL,
39    UNIQUE (run_id, sequence)
40);
41
42CREATE INDEX IF NOT EXISTS runifold_events_run_sequence
43    ON runifold_events (run_id, sequence);
44
45PRAGMA user_version = 1;
46";
47
48/// Failure while opening, initializing, or directly querying a `SQLite` store.
49#[derive(Debug, Error)]
50#[non_exhaustive]
51pub enum SqliteStoreError {
52    /// `SQLite` rejected an operation.
53    #[error("sqlite operation failed: {0}")]
54    Database(#[from] rusqlite::Error),
55    /// Persisted JSON did not satisfy its canonical Rust representation.
56    #[error("sqlite JSON decoding failed: {0}")]
57    Json(#[from] serde_json::Error),
58}
59
60/// Cloneable SQLite-backed effect, checkpoint, and journal store.
61///
62/// Clones share one connection and serialize operations at the connection
63/// boundary. `SQLite` transactions still provide persistence and CAS guarantees
64/// across separately opened store instances and processes.
65#[derive(Clone)]
66pub struct SqliteStore {
67    connection: Arc<Mutex<Connection>>,
68}
69
70impl SqliteStore {
71    /// Opens or creates a file-backed store and initializes its schema.
72    ///
73    /// # Errors
74    ///
75    /// Returns [`SqliteStoreError`] when the database cannot be opened or
76    /// initialized.
77    pub fn open(path: impl AsRef<Path>) -> Result<Self, SqliteStoreError> {
78        Self::from_connection(Connection::open(path)?)
79    }
80
81    /// Creates a process-local `SQLite` database, primarily for tests.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`SqliteStoreError`] when `SQLite` initialization fails.
86    pub fn open_in_memory() -> Result<Self, SqliteStoreError> {
87        Self::from_connection(Connection::open_in_memory()?)
88    }
89
90    fn from_connection(connection: Connection) -> Result<Self, SqliteStoreError> {
91        connection.busy_timeout(Duration::from_secs(5))?;
92        connection.execute_batch(SCHEMA)?;
93        Ok(Self {
94            connection: Arc::new(Mutex::new(connection)),
95        })
96    }
97
98    /// Loads all journal events for one Run in sequence order.
99    ///
100    /// # Errors
101    ///
102    /// Returns [`SqliteStoreError`] when querying or decoding fails.
103    pub fn events(&self, run_id: RunId) -> Result<Vec<RunEvent>, SqliteStoreError> {
104        let connection = self.lock();
105        let mut statement = connection.prepare(
106            "SELECT event_json
107             FROM runifold_events
108             WHERE run_id = ?1
109             ORDER BY sequence ASC",
110        )?;
111        let rows = statement.query_map([run_id.to_string()], |row| row.get::<_, String>(0))?;
112        decode_rows(rows)
113    }
114
115    fn lock(&self) -> MutexGuard<'_, Connection> {
116        self.connection
117            .lock()
118            .unwrap_or_else(std::sync::PoisonError::into_inner)
119    }
120}
121
122impl std::fmt::Debug for SqliteStore {
123    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        formatter
125            .debug_struct("SqliteStore")
126            .finish_non_exhaustive()
127    }
128}
129
130impl CheckpointStore for SqliteStore {
131    fn load(&self, id: CheckpointId) -> Result<Checkpoint, CheckpointError> {
132        let connection = self.lock();
133        let record = connection
134            .query_row(
135                "SELECT record_json
136                 FROM runifold_checkpoints
137                 WHERE checkpoint_id = ?1",
138                [id.to_string()],
139                |row| row.get::<_, String>(0),
140            )
141            .optional()
142            .map_err(|error| checkpoint_storage(&error))?;
143        let record = record.ok_or_else(|| {
144            CheckpointError::new(
145                CheckpointErrorKind::NotFound,
146                format!("checkpoint `{id}` does not exist"),
147            )
148        })?;
149        serde_json::from_str(&record).map_err(|error| {
150            CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
151        })
152    }
153
154    fn compare_and_swap(
155        &self,
156        checkpoint: &Checkpoint,
157        expected_revision: Option<u64>,
158    ) -> Result<(), CheckpointError> {
159        let revision = sqlite_revision(checkpoint.revision).map_err(checkpoint_invalid)?;
160        let expected = expected_revision
161            .map(sqlite_revision)
162            .transpose()
163            .map_err(checkpoint_invalid)?;
164        let record = serde_json::to_string(checkpoint).map_err(|error| {
165            CheckpointError::new(CheckpointErrorKind::InvalidPayload, error.to_string())
166        })?;
167        let mut connection = self.lock();
168        let transaction = connection
169            .transaction_with_behavior(TransactionBehavior::Immediate)
170            .map_err(|error| checkpoint_storage(&error))?;
171        let current = current_revision(
172            &transaction,
173            "runifold_checkpoints",
174            "checkpoint_id",
175            &checkpoint.id.to_string(),
176        )
177        .map_err(|error| checkpoint_storage(&error))?;
178
179        match (current, expected) {
180            (None, None) if revision == 0 => {
181                transaction
182                    .execute(
183                        "INSERT INTO runifold_checkpoints
184                         (checkpoint_id, revision, record_json)
185                         VALUES (?1, ?2, ?3)",
186                        params![checkpoint.id.to_string(), revision, record],
187                    )
188                    .map_err(|error| checkpoint_storage(&error))?;
189            }
190            (Some(current), Some(expected))
191                if current == expected
192                    && expected.checked_add(1).is_some_and(|next| revision == next) =>
193            {
194                let changed = transaction
195                    .execute(
196                        "UPDATE runifold_checkpoints
197                         SET revision = ?1, record_json = ?2
198                         WHERE checkpoint_id = ?3 AND revision = ?4",
199                        params![revision, record, checkpoint.id.to_string(), expected],
200                    )
201                    .map_err(|error| checkpoint_storage(&error))?;
202                if changed != 1 {
203                    return Err(checkpoint_conflict(checkpoint.id));
204                }
205            }
206            (None, Some(_)) => {
207                return Err(CheckpointError::new(
208                    CheckpointErrorKind::NotFound,
209                    format!("checkpoint `{}` does not exist", checkpoint.id),
210                ));
211            }
212            _ => return Err(checkpoint_conflict(checkpoint.id)),
213        }
214        transaction
215            .commit()
216            .map_err(|error| checkpoint_storage(&error))
217    }
218}
219
220impl EffectStore for SqliteStore {
221    fn load(&self, id: EffectId) -> Result<Option<EffectRecord>, EffectExecutorError> {
222        let connection = self.lock();
223        let record = connection
224            .query_row(
225                "SELECT record_json FROM runifold_effects WHERE effect_id = ?1",
226                [id.to_string()],
227                |row| row.get::<_, String>(0),
228            )
229            .optional()
230            .map_err(|error| effect_storage(&error))?;
231        record
232            .map(|record| serde_json::from_str(&record).map_err(|error| effect_protocol(&error)))
233            .transpose()
234    }
235
236    fn find_by_idempotency(
237        &self,
238        capability_id: CapabilityId,
239        key: &str,
240    ) -> Result<Option<EffectRecord>, EffectExecutorError> {
241        let connection = self.lock();
242        let record = connection
243            .query_row(
244                "SELECT record_json
245                 FROM runifold_effects
246                 WHERE capability_id = ?1 AND idempotency_key = ?2",
247                params![capability_id.to_string(), key],
248                |row| row.get::<_, String>(0),
249            )
250            .optional()
251            .map_err(|error| effect_storage(&error))?;
252        record
253            .map(|record| serde_json::from_str(&record).map_err(|error| effect_protocol(&error)))
254            .transpose()
255    }
256
257    fn compare_and_swap(
258        &self,
259        record: &EffectRecord,
260        expected_revision: Option<u64>,
261    ) -> Result<(), EffectExecutorError> {
262        let revision = sqlite_revision(record.revision).map_err(effect_store_message)?;
263        let expected = expected_revision
264            .map(sqlite_revision)
265            .transpose()
266            .map_err(effect_store_message)?;
267        let json = serde_json::to_string(record).map_err(|error| effect_protocol(&error))?;
268        let effect_id = record.request.effect_id.to_string();
269        let capability_id = record.request.capability_id.to_string();
270        let idempotency_key = record.request.idempotency_key.as_deref();
271        let mut connection = self.lock();
272        let transaction = connection
273            .transaction_with_behavior(TransactionBehavior::Immediate)
274            .map_err(|error| effect_storage(&error))?;
275        let current = current_revision(&transaction, "runifold_effects", "effect_id", &effect_id)
276            .map_err(|error| effect_storage(&error))?;
277
278        if let Some(key) = idempotency_key {
279            let owner = transaction
280                .query_row(
281                    "SELECT effect_id
282                     FROM runifold_effects
283                     WHERE capability_id = ?1 AND idempotency_key = ?2",
284                    params![capability_id, key],
285                    |row| row.get::<_, String>(0),
286                )
287                .optional()
288                .map_err(|error| effect_storage(&error))?;
289            if owner.is_some_and(|owner| owner != effect_id) {
290                return Err(EffectExecutorError::new(
291                    EffectExecutorErrorKind::IdempotencyConflict,
292                    "idempotency key already belongs to another effect",
293                ));
294            }
295        }
296
297        match (current, expected) {
298            (None, None) if revision == 0 => {
299                transaction
300                    .execute(
301                        "INSERT INTO runifold_effects
302                         (effect_id, capability_id, idempotency_key, revision, record_json)
303                         VALUES (?1, ?2, ?3, ?4, ?5)",
304                        params![effect_id, capability_id, idempotency_key, revision, json],
305                    )
306                    .map_err(|error| effect_storage(&error))?;
307            }
308            (Some(current), Some(expected))
309                if current == expected
310                    && expected.checked_add(1).is_some_and(|next| revision == next) =>
311            {
312                let changed = transaction
313                    .execute(
314                        "UPDATE runifold_effects
315                         SET capability_id = ?1, idempotency_key = ?2,
316                             revision = ?3, record_json = ?4
317                         WHERE effect_id = ?5 AND revision = ?6",
318                        params![
319                            capability_id,
320                            idempotency_key,
321                            revision,
322                            json,
323                            effect_id,
324                            expected
325                        ],
326                    )
327                    .map_err(|error| effect_storage(&error))?;
328                if changed != 1 {
329                    return Err(effect_conflict());
330                }
331            }
332            _ => return Err(effect_conflict()),
333        }
334        transaction.commit().map_err(|error| effect_storage(&error))
335    }
336}
337
338impl Journal for SqliteStore {
339    fn record(&self, event: &RunEvent) -> Result<(), JournalError> {
340        let sequence =
341            sqlite_revision(event.meta.sequence).map_err(|error| journal_message(&error))?;
342        let json = serde_json::to_string(event).map_err(|error| journal_message(&error))?;
343        self.lock()
344            .execute(
345                "INSERT INTO runifold_events
346                 (event_id, run_id, sequence, event_json)
347                 VALUES (?1, ?2, ?3, ?4)",
348                params![
349                    event.meta.event_id.to_string(),
350                    event.meta.run_id.to_string(),
351                    sequence,
352                    json
353                ],
354            )
355            .map_err(|error| journal_message(&error))?;
356        Ok(())
357    }
358}
359
360fn current_revision(
361    transaction: &Transaction<'_>,
362    table: &str,
363    id_column: &str,
364    id: &str,
365) -> rusqlite::Result<Option<i64>> {
366    let sql = format!("SELECT revision FROM {table} WHERE {id_column} = ?1");
367    transaction
368        .query_row(&sql, [id], |row| row.get(0))
369        .optional()
370}
371
372fn sqlite_revision(value: u64) -> Result<i64, String> {
373    i64::try_from(value).map_err(|_| "revision exceeds SQLite integer range".into())
374}
375
376fn checkpoint_invalid(message: String) -> CheckpointError {
377    CheckpointError::new(CheckpointErrorKind::InvalidPayload, message)
378}
379
380fn checkpoint_storage(error: &rusqlite::Error) -> CheckpointError {
381    CheckpointError::new(CheckpointErrorKind::Storage, error.to_string())
382}
383
384fn checkpoint_conflict(id: CheckpointId) -> CheckpointError {
385    CheckpointError::new(
386        CheckpointErrorKind::Conflict,
387        format!("checkpoint `{id}` revision precondition failed"),
388    )
389}
390
391fn effect_storage(error: &rusqlite::Error) -> EffectExecutorError {
392    effect_store_message(error.to_string())
393}
394
395fn effect_store_message(message: String) -> EffectExecutorError {
396    EffectExecutorError::new(EffectExecutorErrorKind::Store, message)
397}
398
399fn effect_protocol(error: &serde_json::Error) -> EffectExecutorError {
400    EffectExecutorError::new(EffectExecutorErrorKind::Protocol, error.to_string())
401}
402
403fn effect_conflict() -> EffectExecutorError {
404    EffectExecutorError::new(
405        EffectExecutorErrorKind::Store,
406        "effect record revision precondition failed",
407    )
408}
409
410fn journal_message(error: &impl ToString) -> JournalError {
411    JournalError {
412        message: error.to_string(),
413    }
414}
415
416fn decode_rows(
417    rows: impl Iterator<Item = rusqlite::Result<String>>,
418) -> Result<Vec<RunEvent>, SqliteStoreError> {
419    rows.map(|row| {
420        let json = row?;
421        Ok(serde_json::from_str(&json)?)
422    })
423    .collect()
424}
425
426#[cfg(test)]
427mod tests {
428    use std::fs;
429
430    use runifold_core::{
431        CapabilityId, Checkpoint, CheckpointErrorKind, CheckpointId, CheckpointStore, EffectClass,
432        EffectId, EffectKind, EffectRequest, EventFactory, InvocationId, Journal, LifecycleEvent,
433        RunEvent, RunEventKind, RunId,
434    };
435    use runifold_effect::{EffectExecutorErrorKind, EffectRecord, EffectStatus, EffectStore};
436    use serde_json::json;
437    use uuid::Uuid;
438
439    use super::{SqliteStore, SqliteStoreError};
440
441    #[test]
442    fn checkpoint_survives_reopen_and_rejects_stale_revision() {
443        let path = temporary_database_path();
444        let checkpoint = Checkpoint::initial(
445            CheckpointId::new(),
446            RunId::new(),
447            "test",
448            1,
449            json!({"step": 1}),
450        );
451        {
452            let store = SqliteStore::open(&path).unwrap();
453            CheckpointStore::compare_and_swap(&store, &checkpoint, None).unwrap();
454        }
455        let store = SqliteStore::open(&path).unwrap();
456        assert_eq!(
457            CheckpointStore::load(&store, checkpoint.id).unwrap(),
458            checkpoint
459        );
460
461        let next = checkpoint.next(json!({"step": 2})).unwrap();
462        CheckpointStore::compare_and_swap(&store, &next, Some(0)).unwrap();
463        let stale = checkpoint.next(json!({"step": 3})).unwrap();
464        let error = CheckpointStore::compare_and_swap(&store, &stale, Some(0)).unwrap_err();
465        assert_eq!(error.kind, CheckpointErrorKind::Conflict);
466        fs::remove_file(path).unwrap();
467    }
468
469    #[test]
470    fn effect_survives_reopen_and_preserves_idempotency_index() {
471        let path = temporary_database_path();
472        let capability_id = CapabilityId::new();
473        let request = effect_request(capability_id, "stable-key");
474        let completed = EffectRecord {
475            revision: 2,
476            request: request.clone(),
477            status: EffectStatus::Completed {
478                output: json!({"ok": true}),
479            },
480        };
481        {
482            let store = SqliteStore::open(&path).unwrap();
483            EffectStore::compare_and_swap(&store, &EffectRecord::prepared(request.clone()), None)
484                .unwrap();
485            let started = EffectRecord {
486                revision: 1,
487                request: request.clone(),
488                status: EffectStatus::Started,
489            };
490            EffectStore::compare_and_swap(&store, &started, Some(0)).unwrap();
491            EffectStore::compare_and_swap(&store, &completed, Some(1)).unwrap();
492        }
493
494        let store = SqliteStore::open(&path).unwrap();
495        assert_eq!(
496            store
497                .find_by_idempotency(capability_id, "stable-key")
498                .unwrap(),
499            Some(completed)
500        );
501
502        let conflicting = EffectRecord::prepared(effect_request(capability_id, "stable-key"));
503        let error = EffectStore::compare_and_swap(&store, &conflicting, None).unwrap_err();
504        assert_eq!(error.kind, EffectExecutorErrorKind::IdempotencyConflict);
505        fs::remove_file(path).unwrap();
506    }
507
508    #[test]
509    fn journal_round_trips_events_in_run_sequence_order() {
510        let store = SqliteStore::open_in_memory().unwrap();
511        let run_id = RunId::new();
512        let factory = EventFactory::new(run_id, None);
513        let first = factory.emit(RunEventKind::Lifecycle(LifecycleEvent::Started), None);
514        let second = factory.emit(
515            RunEventKind::Lifecycle(LifecycleEvent::Completed {
516                output: json!("done"),
517            }),
518            Some(first.meta.event_id),
519        );
520
521        store.record(&first).unwrap();
522        store.record(&second).unwrap();
523
524        assert_eq!(store.events(run_id).unwrap(), vec![first, second]);
525    }
526
527    #[test]
528    fn direct_store_error_preserves_json_source() {
529        use std::error::Error as _;
530
531        let error: SqliteStoreError = serde_json::from_str::<RunEvent>("{").unwrap_err().into();
532
533        assert!(matches!(error, SqliteStoreError::Json(_)));
534        assert!(error.source().is_some());
535    }
536
537    fn effect_request(capability_id: CapabilityId, key: &str) -> EffectRequest {
538        EffectRequest {
539            effect_id: EffectId::new(),
540            invocation_id: InvocationId::new(),
541            kind: EffectKind::Tool,
542            capability_id,
543            input: json!({"value": 1}),
544            effect_class: EffectClass::IdempotentWrite,
545            idempotency_key: Some(key.into()),
546        }
547    }
548
549    fn temporary_database_path() -> std::path::PathBuf {
550        std::env::temp_dir().join(format!("runifold-{}.sqlite3", Uuid::now_v7()))
551    }
552}