Skip to main content

ironflow_store/
store.rs

1//! The [`RunStore`] trait — async storage abstraction for runs and steps.
2//!
3//! Implement this trait to plug in any backing store. Built-in implementations:
4//!
5//! - [`InMemoryStore`](crate::memory::InMemoryStore) — development and testing.
6//! - `PostgresStore` — production (behind the `store-postgres` feature).
7
8use std::future::Future;
9use std::pin::Pin;
10
11use chrono::{DateTime, Utc};
12use uuid::Uuid;
13
14use crate::api_key_store::ApiKeyStore;
15use crate::artifact_store::ArtifactStore;
16use crate::audit_log_store::AuditLogStore;
17use crate::entities::{
18    LeaseRequest, NewRun, NewStep, NewStepDependency, Page, PurgePolicy, PurgeableRun, ReapedRun,
19    Run, RunCreation, RunFilter, RunStats, RunStatus, RunUpdate, Step, StepDependency, StepUpdate,
20};
21use crate::error::StoreError;
22use crate::log_store::LogStore;
23use crate::secret_store::SecretStore;
24use crate::user_store::UserStore;
25
26/// Boxed future for [`RunStore`] methods — ensures object safety for `dyn RunStore`.
27pub type StoreFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, StoreError>> + Send + 'a>>;
28
29/// Error recorded on a run that exhausted its retries through lease expiries.
30///
31/// Set by [`RunStore::reap_expired_leases`] when a run has been recovered more
32/// than `max_retries` times.
33pub const LEASE_EXPIRED_ERROR: &str = "worker lease expired";
34
35/// Async storage abstraction for workflow runs and steps.
36///
37/// All methods return a [`StoreFuture`] (boxed future) to maintain object safety,
38/// allowing the store to be used as `Arc<dyn RunStore>`.
39///
40/// # Examples
41///
42/// ```no_run
43/// use std::collections::HashMap;
44/// use ironflow_store::prelude::*;
45/// use serde_json::json;
46/// use uuid::Uuid;
47///
48/// # async fn example() -> Result<(), ironflow_store::error::StoreError> {
49/// let store = InMemoryStore::new();
50///
51/// let run = store.create_run(NewRun {
52///     workflow_name: "deploy".to_string(),
53///     trigger: TriggerKind::Manual,
54///     payload: json!({}),
55///     max_retries: 3,
56///     handler_version: None,
57///     labels: HashMap::new(),
58///     scheduled_at: None,
59///     created_by: None,
60///     idempotency_key: None,
61///     max_cost_usd: None,
62/// }).await?.into_run();
63///
64/// let fetched = store.get_run(run.id).await?;
65/// assert!(fetched.is_some());
66/// # Ok(())
67/// # }
68/// ```
69pub trait RunStore: Send + Sync {
70    /// Create a new run in `Pending` status.
71    ///
72    /// When [`NewRun::idempotency_key`] is set and already bound to a run created
73    /// within [`IDEMPOTENCY_WINDOW`](crate::entities::IDEMPOTENCY_WINDOW), nothing is
74    /// inserted and that run is returned as [`RunCreation::Existing`]. A key bound to
75    /// an older run is released and reused for the new one.
76    ///
77    /// Concurrent calls sharing the same key resolve to a single run: exactly one
78    /// receives [`RunCreation::Created`], the others [`RunCreation::Existing`].
79    fn create_run(&self, req: NewRun) -> StoreFuture<'_, RunCreation>;
80
81    /// Look up the run bound to an idempotency key.
82    ///
83    /// Returns `None` when the key is unknown, or when the run holding it is older
84    /// than [`IDEMPOTENCY_WINDOW`](crate::entities::IDEMPOTENCY_WINDOW).
85    fn find_run_by_idempotency_key(&self, key: &str) -> StoreFuture<'_, Option<Run>>;
86
87    /// Get a run by ID. Returns `None` if not found.
88    fn get_run(&self, id: Uuid) -> StoreFuture<'_, Option<Run>>;
89
90    /// List runs matching the given filter, with pagination.
91    ///
92    /// Results are ordered by `created_at` descending (newest first).
93    fn list_runs(&self, filter: RunFilter, page: u32, per_page: u32) -> StoreFuture<'_, Page<Run>>;
94
95    /// Update a run's status with FSM validation.
96    ///
97    /// # Errors
98    ///
99    /// Returns [`StoreError::InvalidTransition`] if the transition is not allowed.
100    /// Returns [`StoreError::RunNotFound`] if the run does not exist.
101    fn update_run_status(&self, id: Uuid, new_status: RunStatus) -> StoreFuture<'_, ()>;
102
103    /// Apply a partial update to a run.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`StoreError::RunNotFound`] if the run does not exist.
108    fn update_run(&self, id: Uuid, update: RunUpdate) -> StoreFuture<'_, ()>;
109
110    /// Atomically pick the oldest pending run and transition it to `Running`.
111    ///
112    /// In PostgreSQL, this uses `SELECT FOR UPDATE SKIP LOCKED` for safe
113    /// multi-worker concurrency. The in-memory implementation uses a write lock.
114    ///
115    /// When `lease` is `Some`, the worker lease is attached in the same
116    /// transaction as the status change, so a run is never `Running` without an
117    /// owner. Pass `None` for callers that execute runs in-process and cannot
118    /// refresh a lease (inline execution, API-side resume): those runs are never
119    /// recovered by [`reap_expired_leases`](Self::reap_expired_leases).
120    ///
121    /// Returns `None` if no pending runs are available.
122    fn pick_next_pending(&self, lease: Option<LeaseRequest>) -> StoreFuture<'_, Option<Run>>;
123
124    /// Extend the worker lease on a run and return the new expiry.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`StoreError::RunNotFound`] if the run does not exist.
129    /// Returns [`StoreError::LeaseLost`] if the run is no longer `Running` or if
130    /// the lease belongs to another worker — the caller must stop executing it.
131    fn renew_lease(&self, id: Uuid, lease: LeaseRequest) -> StoreFuture<'_, DateTime<Utc>>;
132
133    /// Recover runs whose worker lease expired, at most `limit` per call.
134    ///
135    /// Each recovered run has its retry count incremented and its lease cleared,
136    /// then goes back to `Pending` — or to `Failed` with `worker lease expired`
137    /// once `max_retries` is exhausted. Runs without a lease are never touched.
138    ///
139    /// The whole batch is atomic per run (`FOR UPDATE SKIP LOCKED` in
140    /// PostgreSQL), so concurrent reapers never recover the same run twice.
141    ///
142    /// Callers are responsible for the side effects that follow a recovery:
143    /// failing orphaned steps and publishing status-change events.
144    fn reap_expired_leases(&self, limit: u32) -> StoreFuture<'_, Vec<ReapedRun>>;
145
146    /// Create a new step for a run.
147    ///
148    /// # Errors
149    ///
150    /// Returns [`StoreError::RunNotFound`] if the parent run does not exist.
151    fn create_step(&self, step: NewStep) -> StoreFuture<'_, Step>;
152
153    /// Apply a partial update to a step after execution.
154    ///
155    /// # Errors
156    ///
157    /// Returns [`StoreError::StepNotFound`] if the step does not exist.
158    fn update_step(&self, id: Uuid, update: StepUpdate) -> StoreFuture<'_, ()>;
159
160    /// Get a single step by ID. Returns `None` if not found.
161    fn get_step(&self, id: Uuid) -> StoreFuture<'_, Option<Step>>;
162
163    /// List all steps for a run, ordered by position ascending.
164    fn list_steps(&self, run_id: Uuid) -> StoreFuture<'_, Vec<Step>>;
165
166    /// Get aggregated statistics across runs matching the filter.
167    ///
168    /// Returns counts of runs by terminal state, counts of active runs,
169    /// and totals for cost and duration. Computed efficiently by the store
170    /// implementation (single SQL query in PostgreSQL).
171    ///
172    /// Pass [`RunFilter::default()`] to get stats across all runs.
173    fn get_stats(&self, filter: RunFilter) -> StoreFuture<'_, RunStats>;
174
175    /// Create step dependency edges in batch.
176    ///
177    /// Each entry records that `step_id` depends on `depends_on`.
178    /// Duplicate edges are silently ignored.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`StoreError`] if a referenced step does not exist.
183    fn create_step_dependencies(&self, deps: Vec<NewStepDependency>) -> StoreFuture<'_, ()>;
184
185    /// List all step dependencies for a given run.
186    ///
187    /// Returns every edge where either `step_id` or `depends_on` belongs
188    /// to the run. Ordered by `created_at` ascending.
189    fn list_step_dependencies(&self, run_id: Uuid) -> StoreFuture<'_, Vec<StepDependency>>;
190
191    /// List runs eligible for purging according to the given policy.
192    ///
193    /// A run is eligible when it is in a terminal state ([`RunStatus::is_terminal`])
194    /// **and** either older than `policy.max_age_days` or exceeding
195    /// `policy.max_runs_per_workflow` for its workflow (oldest first).
196    ///
197    /// Runs in non-terminal states (`Pending`, `Running`, `Retrying`,
198    /// `AwaitingApproval`) are never returned.
199    ///
200    /// # Examples
201    ///
202    /// ```no_run
203    /// use ironflow_store::entities::PurgePolicy;
204    /// use ironflow_store::store::RunStore;
205    ///
206    /// # async fn example(store: &dyn RunStore) -> Result<(), ironflow_store::error::StoreError> {
207    /// let policy = PurgePolicy { max_age_days: 90, max_runs_per_workflow: 1000, dry_run: false };
208    /// let purgeable = store.list_purgeable_runs(&policy, 100).await?;
209    /// for p in &purgeable {
210    ///     println!("purge {} ({}): {}", p.run_id, p.workflow_name, p.reason);
211    /// }
212    /// # Ok(())
213    /// # }
214    /// ```
215    fn list_purgeable_runs(
216        &self,
217        policy: &PurgePolicy,
218        batch_size: u32,
219    ) -> StoreFuture<'_, Vec<PurgeableRun>>;
220
221    /// Delete a run and all its associated data (steps, step dependencies).
222    ///
223    /// Returns the `storage_key` of every artifact that belonged to the run,
224    /// so the caller can delete the corresponding blobs from the blob store.
225    ///
226    /// # Errors
227    ///
228    /// Returns [`StoreError::RunNotFound`] if the run does not exist.
229    ///
230    /// # Examples
231    ///
232    /// ```no_run
233    /// use ironflow_store::store::RunStore;
234    /// use uuid::Uuid;
235    ///
236    /// # async fn example(store: &dyn RunStore, run_id: Uuid) -> Result<(), ironflow_store::error::StoreError> {
237    /// let storage_keys = store.delete_run(run_id).await?;
238    /// // Caller deletes blobs from the blob store using these keys.
239    /// # Ok(())
240    /// # }
241    /// ```
242    fn delete_run(&self, id: Uuid) -> StoreFuture<'_, Vec<String>>;
243
244    /// Apply a partial update to a run and return the updated run.
245    ///
246    /// Combines [`update_run`](Self::update_run) and [`get_run`](Self::get_run) in
247    /// a single operation to avoid an extra round-trip. Store implementations
248    /// may override this for efficiency (e.g. reading within the same transaction).
249    ///
250    /// The default implementation calls `update_run` followed by `get_run`.
251    ///
252    /// # Errors
253    ///
254    /// Returns [`StoreError::RunNotFound`] if the run does not exist.
255    /// Returns [`StoreError::InvalidTransition`] if the status transition is not allowed.
256    fn update_run_returning(&self, id: Uuid, update: RunUpdate) -> StoreFuture<'_, Run> {
257        Box::pin(async move {
258            self.update_run(id, update).await?;
259            self.get_run(id).await?.ok_or(StoreError::RunNotFound(id))
260        })
261    }
262}
263
264/// Unified storage abstraction combining all store capabilities.
265///
266/// Implementors provide runs, steps, users, API keys, and secrets
267/// through a single type. Pick one backend (in-memory or PostgreSQL)
268/// and it handles everything.
269///
270/// Both [`InMemoryStore`](crate::memory::InMemoryStore) and
271/// [`PostgresStore`](crate::postgres::PostgresStore) implement this trait.
272///
273/// # Examples
274///
275/// ```no_run
276/// use std::collections::HashMap;
277/// use std::sync::Arc;
278/// use ironflow_store::prelude::*;
279///
280/// # async fn example() -> Result<(), ironflow_store::error::StoreError> {
281/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
282///
283/// // All capabilities through one reference
284/// let _run = store.create_run(NewRun {
285///     workflow_name: "deploy".to_string(),
286///     trigger: TriggerKind::Manual,
287///     payload: serde_json::json!({}),
288///     max_retries: 3,
289///     handler_version: None,
290///     labels: HashMap::new(),
291///     scheduled_at: None,
292///     created_by: None,
293///     idempotency_key: None,
294///     max_cost_usd: None,
295/// }).await?.into_run();
296/// let _users = store.count_users().await?;
297/// # Ok(())
298/// # }
299/// ```
300pub trait Store:
301    RunStore + UserStore + ApiKeyStore + SecretStore + AuditLogStore + ArtifactStore + LogStore
302{
303}
304
305impl<T: RunStore + UserStore + ApiKeyStore + SecretStore + AuditLogStore + ArtifactStore + LogStore>
306    Store for T
307{
308}