Skip to main content

runifold_store_sqlite/
workflow.rs

1//! SQLite-backed durable workflow control plane.
2
3mod schema;
4
5use std::{
6    path::Path,
7    sync::{Arc, Mutex},
8    time::Duration,
9};
10
11use futures_executor::block_on;
12use runifold_core::{
13    Budget, Checkpoint, CheckpointError, CheckpointErrorKind, CheckpointId, Usage,
14};
15use runifold_workflow::{
16    ClaimedWorkflow, InMemoryWorkflowStore, LeaseDuration, WorkerId, WorkflowBudgetAuditCursor,
17    WorkflowBudgetAuditEvent, WorkflowBudgetAuditLimit, WorkflowBudgetAuditProjectionId,
18    WorkflowBudgetAuditProjectionLease, WorkflowBudgetReservationOutcome, WorkflowCancelOutcome,
19    WorkflowCheckpointHistoryLimit, WorkflowCheckpointRevision, WorkflowClock, WorkflowDisposition,
20    WorkflowForkCommand, WorkflowForkOutcome, WorkflowLease, WorkflowSignal, WorkflowSignalId,
21    WorkflowSignalOutcome, WorkflowSignalRetention, WorkflowSignalSnapshot, WorkflowStore,
22    WorkflowStoreError, WorkflowStoreErrorKind, WorkflowStoreFuture, WorkflowTask,
23    WorkflowTaskSnapshot, WorkflowTenantBudgetPolicy, WorkflowTenantBudgetSnapshot,
24    WorkflowTenantId, WorkflowTenantListLimit, WorkflowTenantPolicy,
25};
26use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
27use thiserror::Error;
28
29use self::schema::{SCHEMA, SNAPSHOT_FORMAT_VERSION};
30
31/// Failure while opening or initializing a `SQLite` workflow store.
32#[derive(Debug, Error)]
33#[non_exhaustive]
34pub enum SqliteWorkflowStoreError {
35    /// `SQLite` rejected connection or schema initialization.
36    #[error("sqlite workflow store initialization failed: {0}")]
37    Database(#[from] rusqlite::Error),
38}
39
40/// Durable local implementation of Runifold's complete workflow control plane.
41///
42/// Operations execute the shared workflow state machine inside an immediate
43/// `SQLite` transaction. This deliberately serializes writers: `SQLite` is the
44/// local and edge adapter, while `PostgreSQL` remains the horizontally scaled
45/// coordination backend.
46#[derive(Clone)]
47pub struct SqliteWorkflowStore {
48    connection: Arc<Mutex<Connection>>,
49}
50
51impl SqliteWorkflowStore {
52    /// Opens or creates a file-backed workflow store.
53    ///
54    /// # Errors
55    ///
56    /// Returns an error when `SQLite` cannot open the database or initialize the
57    /// workflow snapshot schema.
58    pub fn open(path: impl AsRef<Path>) -> Result<Self, SqliteWorkflowStoreError> {
59        let connection = Connection::open(path)?;
60        connection.pragma_update(None, "journal_mode", "WAL")?;
61        Self::from_connection(connection)
62    }
63
64    /// Creates a process-local workflow store, primarily for tests.
65    ///
66    /// # Errors
67    ///
68    /// Returns an error when `SQLite` initialization fails.
69    pub fn open_in_memory() -> Result<Self, SqliteWorkflowStoreError> {
70        Self::from_connection(Connection::open_in_memory()?)
71    }
72
73    fn from_connection(connection: Connection) -> Result<Self, SqliteWorkflowStoreError> {
74        connection.busy_timeout(Duration::from_secs(5))?;
75        connection.pragma_update(None, "foreign_keys", true)?;
76        connection.execute_batch(SCHEMA)?;
77        Ok(Self {
78            connection: Arc::new(Mutex::new(connection)),
79        })
80    }
81
82    fn execute<T, F>(&self, operation: F) -> WorkflowStoreFuture<'_, Result<T, WorkflowStoreError>>
83    where
84        T: Send + 'static,
85        F: FnOnce(&InMemoryWorkflowStore) -> Result<T, WorkflowStoreError> + Send + 'static,
86    {
87        let connection = Arc::clone(&self.connection);
88        Box::pin(async move {
89            let runtime = tokio::runtime::Handle::try_current().map_err(|_| {
90                WorkflowStoreError::new(
91                    WorkflowStoreErrorKind::Storage,
92                    "SQLite workflow operations require a Tokio runtime",
93                )
94            })?;
95            runtime
96                .spawn_blocking(move || execute_transaction(&connection, operation))
97                .await
98                .map_err(|error| storage_error(format!("SQLite workflow task failed: {error}")))?
99        })
100    }
101}
102
103impl std::fmt::Debug for SqliteWorkflowStore {
104    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        formatter
106            .debug_struct("SqliteWorkflowStore")
107            .finish_non_exhaustive()
108    }
109}
110
111#[derive(Clone, Copy, Debug)]
112struct FixedClock(u64);
113
114impl WorkflowClock for FixedClock {
115    fn now_ms(&self) -> u64 {
116        self.0
117    }
118}
119
120fn execute_transaction<T, F>(
121    connection: &Mutex<Connection>,
122    operation: F,
123) -> Result<T, WorkflowStoreError>
124where
125    F: FnOnce(&InMemoryWorkflowStore) -> Result<T, WorkflowStoreError>,
126{
127    let mut connection = connection
128        .lock()
129        .unwrap_or_else(std::sync::PoisonError::into_inner);
130    let transaction = connection
131        .transaction_with_behavior(TransactionBehavior::Immediate)
132        .map_err(|error| database_error(&error))?;
133    let now = database_now_ms(&transaction)?;
134    let state = load_state(&transaction, now)?;
135    let output = operation(&state)?;
136    save_state(&transaction, &state, now)?;
137    transaction
138        .commit()
139        .map_err(|error| database_error(&error))?;
140    Ok(output)
141}
142
143fn database_now_ms(transaction: &Transaction<'_>) -> Result<u64, WorkflowStoreError> {
144    let value = transaction
145        .query_row(
146            "SELECT CAST(strftime('%s', 'now') AS INTEGER) * 1000
147                    + CAST(substr(strftime('%f', 'now'), 4, 3) AS INTEGER)",
148            [],
149            |row| row.get::<_, i64>(0),
150        )
151        .map_err(|error| database_error(&error))?;
152    u64::try_from(value).map_err(|_| storage_error("SQLite returned a negative workflow clock"))
153}
154
155fn load_state(
156    transaction: &Transaction<'_>,
157    now: u64,
158) -> Result<InMemoryWorkflowStore, WorkflowStoreError> {
159    let stored = transaction
160        .query_row(
161            "SELECT format_version, state_blob
162             FROM runifold_workflow_state
163             WHERE singleton_id = 1",
164            [],
165            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?)),
166        )
167        .optional()
168        .map_err(|error| database_error(&error))?;
169    let clock: Arc<dyn WorkflowClock> = Arc::new(FixedClock(now));
170    match stored {
171        Some((format_version, encoded)) if format_version == SNAPSHOT_FORMAT_VERSION => {
172            InMemoryWorkflowStore::from_persistent_snapshot(&encoded, clock)
173        }
174        Some((format_version, _)) => Err(storage_error(format!(
175            "unsupported SQLite workflow state format version {format_version}"
176        ))),
177        None => Ok(InMemoryWorkflowStore::with_clock(clock)),
178    }
179}
180
181fn save_state(
182    transaction: &Transaction<'_>,
183    state: &InMemoryWorkflowStore,
184    now: u64,
185) -> Result<(), WorkflowStoreError> {
186    let encoded = state.export_persistent_snapshot()?;
187    let now = i64::try_from(now)
188        .map_err(|_| storage_error("workflow clock exceeds SQLite integer range"))?;
189    transaction
190        .execute(
191            "INSERT INTO runifold_workflow_state (
192                 singleton_id, format_version, state_blob, updated_at_ms
193             ) VALUES (1, ?1, ?2, ?3)
194             ON CONFLICT(singleton_id) DO UPDATE SET
195                 format_version = excluded.format_version,
196                 state_blob = excluded.state_blob,
197                 updated_at_ms = excluded.updated_at_ms",
198            params![SNAPSHOT_FORMAT_VERSION, encoded, now],
199        )
200        .map_err(|error| database_error(&error))?;
201    Ok(())
202}
203
204fn database_error(error: &rusqlite::Error) -> WorkflowStoreError {
205    storage_error(format!("SQLite workflow operation failed: {error}"))
206}
207
208fn storage_error(message: impl Into<String>) -> WorkflowStoreError {
209    WorkflowStoreError::new(WorkflowStoreErrorKind::Storage, message)
210}
211
212fn checkpoint_to_workflow(error: CheckpointError) -> WorkflowStoreError {
213    let kind = match error.kind {
214        CheckpointErrorKind::NotFound => WorkflowStoreErrorKind::NotFound,
215        CheckpointErrorKind::Conflict => WorkflowStoreErrorKind::Conflict,
216        CheckpointErrorKind::InvalidPayload => WorkflowStoreErrorKind::InvalidInput,
217        _ => WorkflowStoreErrorKind::Storage,
218    };
219    WorkflowStoreError::new(kind, error.message)
220}
221
222fn workflow_to_checkpoint(error: WorkflowStoreError) -> CheckpointError {
223    let kind = match error.kind {
224        WorkflowStoreErrorKind::NotFound => CheckpointErrorKind::NotFound,
225        WorkflowStoreErrorKind::Conflict
226        | WorkflowStoreErrorKind::LeaseLost
227        | WorkflowStoreErrorKind::AdmissionDenied
228        | WorkflowStoreErrorKind::TenantMismatch => CheckpointErrorKind::Conflict,
229        WorkflowStoreErrorKind::InvalidInput => CheckpointErrorKind::InvalidPayload,
230        _ => CheckpointErrorKind::Storage,
231    };
232    CheckpointError::new(kind, error.message)
233}
234
235impl WorkflowStore for SqliteWorkflowStore {
236    fn current_time_ms(&self) -> WorkflowStoreFuture<'_, Result<u64, WorkflowStoreError>> {
237        self.execute(|store| block_on(store.current_time_ms()))
238    }
239
240    fn set_tenant_policy(
241        &self,
242        tenant_id: WorkflowTenantId,
243        policy: WorkflowTenantPolicy,
244    ) -> WorkflowStoreFuture<'_, Result<(), WorkflowStoreError>> {
245        self.execute(move |store| block_on(store.set_tenant_policy(tenant_id, policy)))
246    }
247
248    fn set_tenant_budget_policy(
249        &self,
250        tenant_id: WorkflowTenantId,
251        policy: WorkflowTenantBudgetPolicy,
252    ) -> WorkflowStoreFuture<'_, Result<(), WorkflowStoreError>> {
253        self.execute(move |store| block_on(store.set_tenant_budget_policy(tenant_id, policy)))
254    }
255
256    fn list_tenant_budgets(
257        &self,
258        after: Option<WorkflowTenantId>,
259        limit: WorkflowTenantListLimit,
260    ) -> WorkflowStoreFuture<'_, Result<Vec<WorkflowTenantId>, WorkflowStoreError>> {
261        self.execute(move |store| block_on(store.list_tenant_budgets(after, limit)))
262    }
263
264    fn inspect_tenant_budget(
265        &self,
266        tenant_id: WorkflowTenantId,
267    ) -> WorkflowStoreFuture<'_, Result<WorkflowTenantBudgetSnapshot, WorkflowStoreError>> {
268        self.execute(move |store| block_on(store.inspect_tenant_budget(tenant_id)))
269    }
270
271    fn list_tenant_budget_audit(
272        &self,
273        tenant_id: WorkflowTenantId,
274        after: Option<WorkflowBudgetAuditCursor>,
275        limit: WorkflowBudgetAuditLimit,
276    ) -> WorkflowStoreFuture<'_, Result<Vec<WorkflowBudgetAuditEvent>, WorkflowStoreError>> {
277        self.execute(move |store| block_on(store.list_tenant_budget_audit(tenant_id, after, limit)))
278    }
279
280    fn compact_tenant_budget_audit(
281        &self,
282        tenant_id: WorkflowTenantId,
283        through: WorkflowBudgetAuditCursor,
284    ) -> WorkflowStoreFuture<'_, Result<u64, WorkflowStoreError>> {
285        self.execute(move |store| block_on(store.compact_tenant_budget_audit(tenant_id, through)))
286    }
287
288    fn load_or_create_tenant_budget_audit_projection(
289        &self,
290        tenant_id: WorkflowTenantId,
291        projection_id: WorkflowBudgetAuditProjectionId,
292    ) -> WorkflowStoreFuture<'_, Result<WorkflowBudgetAuditCursor, WorkflowStoreError>> {
293        self.execute(move |store| {
294            block_on(store.load_or_create_tenant_budget_audit_projection(tenant_id, projection_id))
295        })
296    }
297
298    fn advance_tenant_budget_audit_projection(
299        &self,
300        tenant_id: WorkflowTenantId,
301        projection_id: WorkflowBudgetAuditProjectionId,
302        expected: WorkflowBudgetAuditCursor,
303        next: WorkflowBudgetAuditCursor,
304    ) -> WorkflowStoreFuture<'_, Result<bool, WorkflowStoreError>> {
305        self.execute(move |store| {
306            block_on(store.advance_tenant_budget_audit_projection(
307                tenant_id,
308                projection_id,
309                expected,
310                next,
311            ))
312        })
313    }
314
315    fn claim_tenant_budget_audit_projection(
316        &self,
317        tenant_id: WorkflowTenantId,
318        projection_id: WorkflowBudgetAuditProjectionId,
319        owner: WorkerId,
320        lease: LeaseDuration,
321    ) -> WorkflowStoreFuture<
322        '_,
323        Result<Option<WorkflowBudgetAuditProjectionLease>, WorkflowStoreError>,
324    > {
325        self.execute(move |store| {
326            block_on(store.claim_tenant_budget_audit_projection(
327                tenant_id,
328                projection_id,
329                owner,
330                lease,
331            ))
332        })
333    }
334
335    fn heartbeat_tenant_budget_audit_projection(
336        &self,
337        lease: WorkflowBudgetAuditProjectionLease,
338        extension: LeaseDuration,
339    ) -> WorkflowStoreFuture<'_, Result<WorkflowBudgetAuditProjectionLease, WorkflowStoreError>>
340    {
341        self.execute(move |store| {
342            block_on(store.heartbeat_tenant_budget_audit_projection(lease, extension))
343        })
344    }
345
346    fn advance_tenant_budget_audit_projection_lease(
347        &self,
348        lease: WorkflowBudgetAuditProjectionLease,
349        next: WorkflowBudgetAuditCursor,
350    ) -> WorkflowStoreFuture<'_, Result<WorkflowBudgetAuditProjectionLease, WorkflowStoreError>>
351    {
352        self.execute(move |store| {
353            block_on(store.advance_tenant_budget_audit_projection_lease(lease, next))
354        })
355    }
356
357    fn release_tenant_budget_audit_projection(
358        &self,
359        lease: WorkflowBudgetAuditProjectionLease,
360    ) -> WorkflowStoreFuture<'_, Result<(), WorkflowStoreError>> {
361        self.execute(move |store| block_on(store.release_tenant_budget_audit_projection(lease)))
362    }
363
364    fn reserve_budget(
365        &self,
366        lease: WorkflowLease,
367        workflow_limit: Budget,
368        baseline: Usage,
369    ) -> WorkflowStoreFuture<'_, Result<WorkflowBudgetReservationOutcome, WorkflowStoreError>> {
370        self.execute(move |store| block_on(store.reserve_budget(lease, workflow_limit, baseline)))
371    }
372
373    fn settle_budget(
374        &self,
375        lease: WorkflowLease,
376        cumulative: Usage,
377    ) -> WorkflowStoreFuture<'_, Result<(), WorkflowStoreError>> {
378        self.execute(move |store| block_on(store.settle_budget(lease, cumulative)))
379    }
380
381    fn enqueue(
382        &self,
383        task: WorkflowTask,
384    ) -> WorkflowStoreFuture<'_, Result<(), WorkflowStoreError>> {
385        self.execute(move |store| block_on(store.enqueue(task)))
386    }
387
388    fn claim(
389        &self,
390        worker: WorkerId,
391        lease: LeaseDuration,
392    ) -> WorkflowStoreFuture<'_, Result<Option<ClaimedWorkflow>, WorkflowStoreError>> {
393        self.execute(move |store| block_on(store.claim(worker, lease)))
394    }
395
396    fn heartbeat(
397        &self,
398        lease: WorkflowLease,
399        extension: LeaseDuration,
400    ) -> WorkflowStoreFuture<'_, Result<WorkflowLease, WorkflowStoreError>> {
401        self.execute(move |store| block_on(store.heartbeat(lease, extension)))
402    }
403
404    fn finish(
405        &self,
406        lease: WorkflowLease,
407        disposition: WorkflowDisposition,
408    ) -> WorkflowStoreFuture<'_, Result<(), WorkflowStoreError>> {
409        self.execute(move |store| block_on(store.finish(lease, disposition)))
410    }
411
412    fn publish_signal(
413        &self,
414        tenant_id: WorkflowTenantId,
415        signal: WorkflowSignal,
416    ) -> WorkflowStoreFuture<'_, Result<WorkflowSignalOutcome, WorkflowStoreError>> {
417        self.execute(move |store| block_on(store.publish_signal(tenant_id, signal)))
418    }
419
420    fn publish_control_signal(
421        &self,
422        tenant_id: WorkflowTenantId,
423        signal: WorkflowSignal,
424    ) -> WorkflowStoreFuture<'_, Result<WorkflowSignalOutcome, WorkflowStoreError>> {
425        self.execute(move |store| block_on(store.publish_control_signal(tenant_id, signal)))
426    }
427
428    fn cancel(
429        &self,
430        tenant_id: WorkflowTenantId,
431        checkpoint_id: CheckpointId,
432    ) -> WorkflowStoreFuture<'_, Result<WorkflowCancelOutcome, WorkflowStoreError>> {
433        self.execute(move |store| block_on(store.cancel(tenant_id, checkpoint_id)))
434    }
435
436    fn inspect_signal(
437        &self,
438        tenant_id: WorkflowTenantId,
439        signal_id: WorkflowSignalId,
440    ) -> WorkflowStoreFuture<'_, Result<WorkflowSignalSnapshot, WorkflowStoreError>> {
441        self.execute(move |store| block_on(store.inspect_signal(tenant_id, signal_id)))
442    }
443
444    fn load_signal_payload(
445        &self,
446        tenant_id: WorkflowTenantId,
447        signal_id: WorkflowSignalId,
448    ) -> WorkflowStoreFuture<'_, Result<serde_json::Value, WorkflowStoreError>> {
449        self.execute(move |store| block_on(store.load_signal_payload(tenant_id, signal_id)))
450    }
451
452    fn compact_signals(
453        &self,
454        tenant_id: WorkflowTenantId,
455        retention: WorkflowSignalRetention,
456    ) -> WorkflowStoreFuture<'_, Result<u64, WorkflowStoreError>> {
457        self.execute(move |store| block_on(store.compact_signals(tenant_id, retention)))
458    }
459
460    fn inspect(
461        &self,
462        tenant_id: WorkflowTenantId,
463        checkpoint_id: CheckpointId,
464    ) -> WorkflowStoreFuture<'_, Result<WorkflowTaskSnapshot, WorkflowStoreError>> {
465        self.execute(move |store| block_on(store.inspect(tenant_id, checkpoint_id)))
466    }
467
468    fn load_task_input(
469        &self,
470        tenant_id: WorkflowTenantId,
471        checkpoint_id: CheckpointId,
472    ) -> WorkflowStoreFuture<'_, Result<serde_json::Value, WorkflowStoreError>> {
473        self.execute(move |store| block_on(store.load_task_input(tenant_id, checkpoint_id)))
474    }
475
476    fn list_checkpoint_history(
477        &self,
478        tenant_id: WorkflowTenantId,
479        checkpoint_id: CheckpointId,
480        after_revision: Option<u64>,
481        limit: WorkflowCheckpointHistoryLimit,
482    ) -> WorkflowStoreFuture<'_, Result<Vec<WorkflowCheckpointRevision>, WorkflowStoreError>> {
483        self.execute(move |store| {
484            block_on(store.list_checkpoint_history(tenant_id, checkpoint_id, after_revision, limit))
485        })
486    }
487
488    fn load_checkpoint_revision(
489        &self,
490        tenant_id: WorkflowTenantId,
491        checkpoint_id: CheckpointId,
492        revision: u64,
493    ) -> WorkflowStoreFuture<'_, Result<WorkflowCheckpointRevision, WorkflowStoreError>> {
494        self.execute(move |store| {
495            block_on(store.load_checkpoint_revision(tenant_id, checkpoint_id, revision))
496        })
497    }
498
499    fn fork_workflow(
500        &self,
501        tenant_id: WorkflowTenantId,
502        command: WorkflowForkCommand,
503    ) -> WorkflowStoreFuture<'_, Result<WorkflowForkOutcome, WorkflowStoreError>> {
504        self.execute(move |store| block_on(store.fork_workflow(tenant_id, command)))
505    }
506
507    fn load_checkpoint(
508        &self,
509        lease: WorkflowLease,
510    ) -> WorkflowStoreFuture<'_, Result<Checkpoint, CheckpointError>> {
511        let future = self.execute(move |store| {
512            block_on(store.load_checkpoint(lease)).map_err(checkpoint_to_workflow)
513        });
514        Box::pin(async move { future.await.map_err(workflow_to_checkpoint) })
515    }
516
517    fn compare_and_swap_checkpoint(
518        &self,
519        lease: WorkflowLease,
520        checkpoint: Checkpoint,
521        expected_revision: Option<u64>,
522    ) -> WorkflowStoreFuture<'_, Result<(), CheckpointError>> {
523        let future = self.execute(move |store| {
524            block_on(store.compare_and_swap_checkpoint(lease, checkpoint, expected_revision))
525                .map_err(checkpoint_to_workflow)
526        });
527        Box::pin(async move { future.await.map_err(workflow_to_checkpoint) })
528    }
529}