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 set_tenant_policy(
237        &self,
238        tenant_id: WorkflowTenantId,
239        policy: WorkflowTenantPolicy,
240    ) -> WorkflowStoreFuture<'_, Result<(), WorkflowStoreError>> {
241        self.execute(move |store| block_on(store.set_tenant_policy(tenant_id, policy)))
242    }
243
244    fn set_tenant_budget_policy(
245        &self,
246        tenant_id: WorkflowTenantId,
247        policy: WorkflowTenantBudgetPolicy,
248    ) -> WorkflowStoreFuture<'_, Result<(), WorkflowStoreError>> {
249        self.execute(move |store| block_on(store.set_tenant_budget_policy(tenant_id, policy)))
250    }
251
252    fn list_tenant_budgets(
253        &self,
254        after: Option<WorkflowTenantId>,
255        limit: WorkflowTenantListLimit,
256    ) -> WorkflowStoreFuture<'_, Result<Vec<WorkflowTenantId>, WorkflowStoreError>> {
257        self.execute(move |store| block_on(store.list_tenant_budgets(after, limit)))
258    }
259
260    fn inspect_tenant_budget(
261        &self,
262        tenant_id: WorkflowTenantId,
263    ) -> WorkflowStoreFuture<'_, Result<WorkflowTenantBudgetSnapshot, WorkflowStoreError>> {
264        self.execute(move |store| block_on(store.inspect_tenant_budget(tenant_id)))
265    }
266
267    fn list_tenant_budget_audit(
268        &self,
269        tenant_id: WorkflowTenantId,
270        after: Option<WorkflowBudgetAuditCursor>,
271        limit: WorkflowBudgetAuditLimit,
272    ) -> WorkflowStoreFuture<'_, Result<Vec<WorkflowBudgetAuditEvent>, WorkflowStoreError>> {
273        self.execute(move |store| block_on(store.list_tenant_budget_audit(tenant_id, after, limit)))
274    }
275
276    fn compact_tenant_budget_audit(
277        &self,
278        tenant_id: WorkflowTenantId,
279        through: WorkflowBudgetAuditCursor,
280    ) -> WorkflowStoreFuture<'_, Result<u64, WorkflowStoreError>> {
281        self.execute(move |store| block_on(store.compact_tenant_budget_audit(tenant_id, through)))
282    }
283
284    fn load_or_create_tenant_budget_audit_projection(
285        &self,
286        tenant_id: WorkflowTenantId,
287        projection_id: WorkflowBudgetAuditProjectionId,
288    ) -> WorkflowStoreFuture<'_, Result<WorkflowBudgetAuditCursor, WorkflowStoreError>> {
289        self.execute(move |store| {
290            block_on(store.load_or_create_tenant_budget_audit_projection(tenant_id, projection_id))
291        })
292    }
293
294    fn advance_tenant_budget_audit_projection(
295        &self,
296        tenant_id: WorkflowTenantId,
297        projection_id: WorkflowBudgetAuditProjectionId,
298        expected: WorkflowBudgetAuditCursor,
299        next: WorkflowBudgetAuditCursor,
300    ) -> WorkflowStoreFuture<'_, Result<bool, WorkflowStoreError>> {
301        self.execute(move |store| {
302            block_on(store.advance_tenant_budget_audit_projection(
303                tenant_id,
304                projection_id,
305                expected,
306                next,
307            ))
308        })
309    }
310
311    fn claim_tenant_budget_audit_projection(
312        &self,
313        tenant_id: WorkflowTenantId,
314        projection_id: WorkflowBudgetAuditProjectionId,
315        owner: WorkerId,
316        lease: LeaseDuration,
317    ) -> WorkflowStoreFuture<
318        '_,
319        Result<Option<WorkflowBudgetAuditProjectionLease>, WorkflowStoreError>,
320    > {
321        self.execute(move |store| {
322            block_on(store.claim_tenant_budget_audit_projection(
323                tenant_id,
324                projection_id,
325                owner,
326                lease,
327            ))
328        })
329    }
330
331    fn heartbeat_tenant_budget_audit_projection(
332        &self,
333        lease: WorkflowBudgetAuditProjectionLease,
334        extension: LeaseDuration,
335    ) -> WorkflowStoreFuture<'_, Result<WorkflowBudgetAuditProjectionLease, WorkflowStoreError>>
336    {
337        self.execute(move |store| {
338            block_on(store.heartbeat_tenant_budget_audit_projection(lease, extension))
339        })
340    }
341
342    fn advance_tenant_budget_audit_projection_lease(
343        &self,
344        lease: WorkflowBudgetAuditProjectionLease,
345        next: WorkflowBudgetAuditCursor,
346    ) -> WorkflowStoreFuture<'_, Result<WorkflowBudgetAuditProjectionLease, WorkflowStoreError>>
347    {
348        self.execute(move |store| {
349            block_on(store.advance_tenant_budget_audit_projection_lease(lease, next))
350        })
351    }
352
353    fn release_tenant_budget_audit_projection(
354        &self,
355        lease: WorkflowBudgetAuditProjectionLease,
356    ) -> WorkflowStoreFuture<'_, Result<(), WorkflowStoreError>> {
357        self.execute(move |store| block_on(store.release_tenant_budget_audit_projection(lease)))
358    }
359
360    fn reserve_budget(
361        &self,
362        lease: WorkflowLease,
363        workflow_limit: Budget,
364        baseline: Usage,
365    ) -> WorkflowStoreFuture<'_, Result<WorkflowBudgetReservationOutcome, WorkflowStoreError>> {
366        self.execute(move |store| block_on(store.reserve_budget(lease, workflow_limit, baseline)))
367    }
368
369    fn settle_budget(
370        &self,
371        lease: WorkflowLease,
372        cumulative: Usage,
373    ) -> WorkflowStoreFuture<'_, Result<(), WorkflowStoreError>> {
374        self.execute(move |store| block_on(store.settle_budget(lease, cumulative)))
375    }
376
377    fn enqueue(
378        &self,
379        task: WorkflowTask,
380    ) -> WorkflowStoreFuture<'_, Result<(), WorkflowStoreError>> {
381        self.execute(move |store| block_on(store.enqueue(task)))
382    }
383
384    fn claim(
385        &self,
386        worker: WorkerId,
387        lease: LeaseDuration,
388    ) -> WorkflowStoreFuture<'_, Result<Option<ClaimedWorkflow>, WorkflowStoreError>> {
389        self.execute(move |store| block_on(store.claim(worker, lease)))
390    }
391
392    fn heartbeat(
393        &self,
394        lease: WorkflowLease,
395        extension: LeaseDuration,
396    ) -> WorkflowStoreFuture<'_, Result<WorkflowLease, WorkflowStoreError>> {
397        self.execute(move |store| block_on(store.heartbeat(lease, extension)))
398    }
399
400    fn finish(
401        &self,
402        lease: WorkflowLease,
403        disposition: WorkflowDisposition,
404    ) -> WorkflowStoreFuture<'_, Result<(), WorkflowStoreError>> {
405        self.execute(move |store| block_on(store.finish(lease, disposition)))
406    }
407
408    fn publish_signal(
409        &self,
410        tenant_id: WorkflowTenantId,
411        signal: WorkflowSignal,
412    ) -> WorkflowStoreFuture<'_, Result<WorkflowSignalOutcome, WorkflowStoreError>> {
413        self.execute(move |store| block_on(store.publish_signal(tenant_id, signal)))
414    }
415
416    fn cancel(
417        &self,
418        tenant_id: WorkflowTenantId,
419        checkpoint_id: CheckpointId,
420    ) -> WorkflowStoreFuture<'_, Result<WorkflowCancelOutcome, WorkflowStoreError>> {
421        self.execute(move |store| block_on(store.cancel(tenant_id, checkpoint_id)))
422    }
423
424    fn inspect_signal(
425        &self,
426        tenant_id: WorkflowTenantId,
427        signal_id: WorkflowSignalId,
428    ) -> WorkflowStoreFuture<'_, Result<WorkflowSignalSnapshot, WorkflowStoreError>> {
429        self.execute(move |store| block_on(store.inspect_signal(tenant_id, signal_id)))
430    }
431
432    fn compact_signals(
433        &self,
434        tenant_id: WorkflowTenantId,
435        retention: WorkflowSignalRetention,
436    ) -> WorkflowStoreFuture<'_, Result<u64, WorkflowStoreError>> {
437        self.execute(move |store| block_on(store.compact_signals(tenant_id, retention)))
438    }
439
440    fn inspect(
441        &self,
442        tenant_id: WorkflowTenantId,
443        checkpoint_id: CheckpointId,
444    ) -> WorkflowStoreFuture<'_, Result<WorkflowTaskSnapshot, WorkflowStoreError>> {
445        self.execute(move |store| block_on(store.inspect(tenant_id, checkpoint_id)))
446    }
447
448    fn list_checkpoint_history(
449        &self,
450        tenant_id: WorkflowTenantId,
451        checkpoint_id: CheckpointId,
452        after_revision: Option<u64>,
453        limit: WorkflowCheckpointHistoryLimit,
454    ) -> WorkflowStoreFuture<'_, Result<Vec<WorkflowCheckpointRevision>, WorkflowStoreError>> {
455        self.execute(move |store| {
456            block_on(store.list_checkpoint_history(tenant_id, checkpoint_id, after_revision, limit))
457        })
458    }
459
460    fn load_checkpoint_revision(
461        &self,
462        tenant_id: WorkflowTenantId,
463        checkpoint_id: CheckpointId,
464        revision: u64,
465    ) -> WorkflowStoreFuture<'_, Result<WorkflowCheckpointRevision, WorkflowStoreError>> {
466        self.execute(move |store| {
467            block_on(store.load_checkpoint_revision(tenant_id, checkpoint_id, revision))
468        })
469    }
470
471    fn fork_workflow(
472        &self,
473        tenant_id: WorkflowTenantId,
474        command: WorkflowForkCommand,
475    ) -> WorkflowStoreFuture<'_, Result<WorkflowForkOutcome, WorkflowStoreError>> {
476        self.execute(move |store| block_on(store.fork_workflow(tenant_id, command)))
477    }
478
479    fn load_checkpoint(
480        &self,
481        lease: WorkflowLease,
482    ) -> WorkflowStoreFuture<'_, Result<Checkpoint, CheckpointError>> {
483        let future = self.execute(move |store| {
484            block_on(store.load_checkpoint(lease)).map_err(checkpoint_to_workflow)
485        });
486        Box::pin(async move { future.await.map_err(workflow_to_checkpoint) })
487    }
488
489    fn compare_and_swap_checkpoint(
490        &self,
491        lease: WorkflowLease,
492        checkpoint: Checkpoint,
493        expected_revision: Option<u64>,
494    ) -> WorkflowStoreFuture<'_, Result<(), CheckpointError>> {
495        let future = self.execute(move |store| {
496            block_on(store.compare_and_swap_checkpoint(lease, checkpoint, expected_revision))
497                .map_err(checkpoint_to_workflow)
498        });
499        Box::pin(async move { future.await.map_err(workflow_to_checkpoint) })
500    }
501}