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