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}