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#[derive(Debug, Error)]
50#[non_exhaustive]
51pub enum SqliteStoreError {
52 #[error("sqlite operation failed: {0}")]
54 Database(#[from] rusqlite::Error),
55 #[error("sqlite JSON decoding failed: {0}")]
57 Json(#[from] serde_json::Error),
58}
59
60#[derive(Clone)]
66pub struct SqliteStore {
67 connection: Arc<Mutex<Connection>>,
68}
69
70impl SqliteStore {
71 pub fn open(path: impl AsRef<Path>) -> Result<Self, SqliteStoreError> {
78 Self::from_connection(Connection::open(path)?)
79 }
80
81 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 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}