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