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::approval_delegation_store::ApprovalDelegationStore;
16use crate::artifact_store::ArtifactStore;
17use crate::audit_log_store::AuditLogStore;
18use crate::entities::{
19 LeaseRequest, NewRun, NewStep, NewStepDependency, Page, PurgePolicy, PurgeableRun, ReapedRun,
20 Run, RunCreation, RunFilter, RunStats, RunStatus, RunUpdate, StatsHistoryBucket,
21 StatsHistoryFilter, Step, StepApproval, StepDependency, StepUpdate,
22};
23use crate::error::StoreError;
24use crate::log_store::LogStore;
25use crate::provider_account_store::ProviderAccountStore;
26use crate::schedule_store::ScheduleStore;
27use crate::secret_store::SecretStore;
28use crate::signal_store::SignalStore;
29use crate::user_store::UserStore;
30
31/// Boxed future for [`RunStore`] methods — ensures object safety for `dyn RunStore`.
32pub type StoreFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, StoreError>> + Send + 'a>>;
33
34/// Error recorded on a run that exhausted its retries through lease expiries.
35///
36/// Set by [`RunStore::reap_expired_leases`] when a run has been recovered more
37/// than `max_retries` times.
38pub const LEASE_EXPIRED_ERROR: &str = "worker lease expired";
39
40/// Error recorded on a step that was running when its worker lost the lease.
41///
42/// Set by the reaper on the `Running` steps of a run that
43/// [`RunStore::reap_expired_leases`] requeued. The engine executes such a step
44/// again at the same position when the run is picked up, keeping the
45/// interrupted record in the step history.
46///
47/// # Examples
48///
49/// ```
50/// use ironflow_store::store::{LEASE_EXPIRED_ERROR, STEP_INTERRUPTED_ERROR};
51///
52/// assert_eq!(STEP_INTERRUPTED_ERROR, "interrupted: worker lease lost");
53/// assert_ne!(STEP_INTERRUPTED_ERROR, LEASE_EXPIRED_ERROR);
54/// ```
55pub const STEP_INTERRUPTED_ERROR: &str = "interrupted: worker lease lost";
56
57/// Async storage abstraction for workflow runs and steps.
58///
59/// All methods return a [`StoreFuture`] (boxed future) to maintain object safety,
60/// allowing the store to be used as `Arc<dyn RunStore>`.
61///
62/// # Examples
63///
64/// ```no_run
65/// use std::collections::HashMap;
66/// use ironflow_store::prelude::*;
67/// use serde_json::json;
68/// use uuid::Uuid;
69///
70/// # async fn example() -> Result<(), ironflow_store::error::StoreError> {
71/// let store = InMemoryStore::new();
72///
73/// let run = store.create_run(NewRun {
74/// workflow_name: "deploy".to_string(),
75/// trigger: TriggerKind::Manual,
76/// payload: json!({}),
77/// max_retries: 3,
78/// handler_version: None,
79/// labels: HashMap::new(),
80/// scheduled_at: None,
81/// created_by: None,
82/// idempotency_key: None,
83/// concurrency_key: None,
84/// max_cost_usd: None,
85/// }).await?.into_run();
86///
87/// let fetched = store.get_run(run.id).await?;
88/// assert!(fetched.is_some());
89/// # Ok(())
90/// # }
91/// ```
92pub trait RunStore: Send + Sync {
93 /// Create a new run in `Pending` status.
94 ///
95 /// When [`NewRun::idempotency_key`] is set and already bound to a run created
96 /// within [`IDEMPOTENCY_WINDOW`](crate::entities::IDEMPOTENCY_WINDOW), nothing is
97 /// inserted and that run is returned as [`RunCreation::Existing`]. A key bound to
98 /// an older run is released and reused for the new one.
99 ///
100 /// Concurrent calls sharing the same key resolve to a single run: exactly one
101 /// receives [`RunCreation::Created`], the others [`RunCreation::Existing`].
102 ///
103 /// When [`NewRun::concurrency_key`] is set, the idempotency lookup runs first,
104 /// then the key is checked: concurrent calls sharing it are serialized, and
105 /// at most one non-terminal run holds it at a time.
106 ///
107 /// # Errors
108 ///
109 /// Returns [`StoreError::ConcurrencyConflict`](crate::error::StoreError::ConcurrencyConflict)
110 /// when a run that is not Completed, Failed, Warning or Cancelled already
111 /// holds [`NewRun::concurrency_key`], and a database error when the backing
112 /// store fails.
113 fn create_run(&self, req: NewRun) -> StoreFuture<'_, RunCreation>;
114
115 /// Look up the run bound to an idempotency key.
116 ///
117 /// Returns `None` when the key is unknown, or when the run holding it is older
118 /// than [`IDEMPOTENCY_WINDOW`](crate::entities::IDEMPOTENCY_WINDOW).
119 fn find_run_by_idempotency_key(&self, key: &str) -> StoreFuture<'_, Option<Run>>;
120
121 /// Get a run by ID. Returns `None` if not found.
122 fn get_run(&self, id: Uuid) -> StoreFuture<'_, Option<Run>>;
123
124 /// List runs matching the given filter, with pagination.
125 ///
126 /// Results are ordered by `created_at` descending (newest first).
127 fn list_runs(&self, filter: RunFilter, page: u32, per_page: u32) -> StoreFuture<'_, Page<Run>>;
128
129 /// Update a run's status with FSM validation.
130 ///
131 /// # Errors
132 ///
133 /// Returns [`StoreError::InvalidTransition`] if the transition is not allowed.
134 /// Returns [`StoreError::RunNotFound`] if the run does not exist.
135 fn update_run_status(&self, id: Uuid, new_status: RunStatus) -> StoreFuture<'_, ()>;
136
137 /// Apply a partial update to a run.
138 ///
139 /// # Errors
140 ///
141 /// Returns [`StoreError::RunNotFound`] if the run does not exist.
142 fn update_run(&self, id: Uuid, update: RunUpdate) -> StoreFuture<'_, ()>;
143
144 /// Atomically pick the oldest pending run and transition it to `Running`.
145 ///
146 /// In PostgreSQL, this uses `SELECT FOR UPDATE SKIP LOCKED` for safe
147 /// multi-worker concurrency. The in-memory implementation uses a write lock.
148 ///
149 /// When `lease` is `Some`, the worker lease is attached in the same
150 /// transaction as the status change, so a run is never `Running` without an
151 /// owner. Pass `None` for callers that execute runs in-process and cannot
152 /// refresh a lease (inline execution, API-side resume): those runs are never
153 /// recovered by [`reap_expired_leases`](Self::reap_expired_leases).
154 ///
155 /// Returns `None` if no pending runs are available.
156 fn pick_next_pending(&self, lease: Option<LeaseRequest>) -> StoreFuture<'_, Option<Run>>;
157
158 /// Extend the worker lease on a run and return the new expiry.
159 ///
160 /// # Errors
161 ///
162 /// Returns [`StoreError::RunNotFound`] if the run does not exist.
163 /// Returns [`StoreError::LeaseLost`] if the run is no longer `Running` or if
164 /// the lease belongs to another worker — the caller must stop executing it.
165 fn renew_lease(&self, id: Uuid, lease: LeaseRequest) -> StoreFuture<'_, DateTime<Utc>>;
166
167 /// Recover runs whose worker lease expired, at most `limit` per call.
168 ///
169 /// Each recovered run has [`Run::lease_recoveries`] incremented and its lease
170 /// cleared, then goes back to `Pending` — or to `Failed` with
171 /// [`LEASE_EXPIRED_ERROR`] once more than `max_retries` recoveries happened.
172 /// Runs without a lease are never touched.
173 ///
174 /// [`Run::retry_count`], and so the attempt number of the steps created
175 /// afterwards, is left unchanged: a requeued run resumes in the same attempt
176 /// and replays the steps it already finished.
177 ///
178 /// The whole batch is atomic per run (`FOR UPDATE SKIP LOCKED` in
179 /// PostgreSQL), so concurrent reapers never recover the same run twice.
180 ///
181 /// Callers are responsible for the side effects that follow a recovery:
182 /// failing orphaned steps and publishing status-change events.
183 fn reap_expired_leases(&self, limit: u32) -> StoreFuture<'_, Vec<ReapedRun>>;
184
185 /// Atomically claim approval steps whose SLA deadline has passed.
186 ///
187 /// Returns the claimed steps with their *pre-claim* `approval_deadline_at`
188 /// still populated, so the caller can report which deadline fired. The
189 /// timer is cleared in the same transaction, so a deadline fires at most
190 /// once even with several API instances running the escalator (the
191 /// PostgreSQL implementation uses `FOR UPDATE SKIP LOCKED`).
192 ///
193 /// Only steps still in [`StepStatus::AwaitingApproval`](crate::entities::StepStatus::AwaitingApproval)
194 /// are returned.
195 ///
196 /// Delivery is at most once: a caller that crashes between the claim and
197 /// the escalation leaves the gate open with no timer, the same trade-off
198 /// [`reap_expired_leases`](Self::reap_expired_leases) accepts.
199 fn claim_due_approval_deadlines(&self, limit: u32) -> StoreFuture<'_, Vec<Step>>;
200
201 /// Atomically wake the `Sleeping` runs whose `scheduled_at` has passed, at
202 /// most `limit` per call.
203 ///
204 /// Each claimed run goes `Sleeping -> Pending` (`delay_elapsed`) and has
205 /// its `scheduled_at` cleared in the same transaction, so a run is woken
206 /// exactly once even with several API instances running the waker (the
207 /// PostgreSQL implementation uses `FOR UPDATE SKIP LOCKED`). Runs are
208 /// claimed oldest `scheduled_at` first.
209 ///
210 /// Returns the runs as they are after the transition. Callers decide how
211 /// the requeued runs resume: a worker picks them up, or the API resumes
212 /// them in-process when it has no worker.
213 ///
214 /// # Errors
215 ///
216 /// Returns [`StoreError`] on storage failure.
217 fn claim_due_sleeping_runs(&self, limit: u32) -> StoreFuture<'_, Vec<Run>>;
218
219 /// Create a new step for a run.
220 ///
221 /// # Errors
222 ///
223 /// Returns [`StoreError::RunNotFound`] if the parent run does not exist.
224 fn create_step(&self, step: NewStep) -> StoreFuture<'_, Step>;
225
226 /// Apply a partial update to a step after execution.
227 ///
228 /// # Errors
229 ///
230 /// Returns [`StoreError::StepNotFound`] if the step does not exist.
231 fn update_step(&self, id: Uuid, update: StepUpdate) -> StoreFuture<'_, ()>;
232
233 /// Get a single step by ID. Returns `None` if not found.
234 fn get_step(&self, id: Uuid) -> StoreFuture<'_, Option<Step>>;
235
236 /// List all steps for a run, ordered by position ascending.
237 fn list_steps(&self, run_id: Uuid) -> StoreFuture<'_, Vec<Step>>;
238
239 /// Record a vote on an approval gate and return the updated step.
240 ///
241 /// The vote is appended atomically to [`Step::approvals`] unless the same
242 /// [`StepApproval::user_id`] already voted, in which case the step is
243 /// returned unchanged. Recording a vote never resolves the gate: the
244 /// caller compares the vote count against the step's
245 /// [`approval_requirement`](Step::approval_requirement).
246 ///
247 /// # Errors
248 ///
249 /// Returns [`StoreError::StepNotFound`] if the step does not exist.
250 fn record_step_approval(&self, step_id: Uuid, approval: StepApproval) -> StoreFuture<'_, Step>;
251
252 /// Get aggregated statistics across runs matching the filter.
253 ///
254 /// Returns counts of runs by terminal state, counts of active runs
255 /// (`Pending`, `Running`, `Retrying`, `AwaitingApproval` or `Sleeping`),
256 /// the number of runs awaiting approval, and totals for cost and duration.
257 /// Computed efficiently by the store implementation (single SQL query in
258 /// PostgreSQL).
259 ///
260 /// Pass [`RunFilter::default()`] to get stats across all runs.
261 fn get_stats(&self, filter: RunFilter) -> StoreFuture<'_, RunStats>;
262
263 /// Get time-bucketed historical statistics for trend charts.
264 ///
265 /// Aggregates runs created during the filter's period into time buckets
266 /// based on its granularity, counting every run status and computing
267 /// duration percentiles. Applies the same run filters as
268 /// [`get_stats`](Self::get_stats) (workflow substring, status, labels,
269 /// steps, author). Bucket boundaries are UTC and weeks start on Monday
270 /// (see [`HistoryGranularity::bucket_start`](crate::entities::HistoryGranularity::bucket_start)).
271 /// Returns buckets ordered by time ascending; empty buckets are omitted.
272 ///
273 /// # Errors
274 ///
275 /// Returns [`StoreError::Database`] on underlying store failures.
276 ///
277 /// # Examples
278 ///
279 /// ```no_run
280 /// use ironflow_store::entities::{StatsHistoryFilter, HistoryPeriod, HistoryGranularity};
281 /// use ironflow_store::store::RunStore;
282 ///
283 /// # async fn example(store: &dyn RunStore) -> Result<(), ironflow_store::error::StoreError> {
284 /// let filter = StatsHistoryFilter {
285 /// period: HistoryPeriod::SevenDays,
286 /// granularity: HistoryGranularity::OneDay,
287 /// ..StatsHistoryFilter::default()
288 /// };
289 /// let buckets = store.get_stats_history(filter).await?;
290 /// for b in &buckets {
291 /// println!("{}: {} completed, {} failed", b.time, b.completed, b.failed);
292 /// }
293 /// # Ok(())
294 /// # }
295 /// ```
296 fn get_stats_history(
297 &self,
298 filter: StatsHistoryFilter,
299 ) -> StoreFuture<'_, Vec<StatsHistoryBucket>>;
300
301 /// Create step dependency edges in batch.
302 ///
303 /// Each entry records that `step_id` depends on `depends_on`.
304 /// Duplicate edges are silently ignored.
305 ///
306 /// # Errors
307 ///
308 /// Returns [`StoreError`] if a referenced step does not exist.
309 fn create_step_dependencies(&self, deps: Vec<NewStepDependency>) -> StoreFuture<'_, ()>;
310
311 /// List all step dependencies for a given run.
312 ///
313 /// Returns every edge where either `step_id` or `depends_on` belongs
314 /// to the run. Ordered by `created_at` ascending.
315 fn list_step_dependencies(&self, run_id: Uuid) -> StoreFuture<'_, Vec<StepDependency>>;
316
317 /// List runs eligible for purging according to the given policy.
318 ///
319 /// A run is eligible when it is in a terminal state ([`RunStatus::is_terminal`])
320 /// **and** either older than `policy.max_age_days` or exceeding
321 /// `policy.max_runs_per_workflow` for its workflow (oldest first).
322 ///
323 /// Runs in non-terminal states (`Pending`, `Running`, `Retrying`,
324 /// `AwaitingApproval`) are never returned.
325 ///
326 /// # Examples
327 ///
328 /// ```no_run
329 /// use ironflow_store::entities::PurgePolicy;
330 /// use ironflow_store::store::RunStore;
331 ///
332 /// # async fn example(store: &dyn RunStore) -> Result<(), ironflow_store::error::StoreError> {
333 /// let policy = PurgePolicy { max_age_days: 90, max_runs_per_workflow: 1000, dry_run: false };
334 /// let purgeable = store.list_purgeable_runs(&policy, 100).await?;
335 /// for p in &purgeable {
336 /// println!("purge {} ({}): {}", p.run_id, p.workflow_name, p.reason);
337 /// }
338 /// # Ok(())
339 /// # }
340 /// ```
341 fn list_purgeable_runs(
342 &self,
343 policy: &PurgePolicy,
344 batch_size: u32,
345 ) -> StoreFuture<'_, Vec<PurgeableRun>>;
346
347 /// Delete a run and all its associated data (steps, step dependencies).
348 ///
349 /// Returns the `storage_key` of every artifact that belonged to the run,
350 /// so the caller can delete the corresponding blobs from the blob store.
351 ///
352 /// # Errors
353 ///
354 /// Returns [`StoreError::RunNotFound`] if the run does not exist.
355 ///
356 /// # Examples
357 ///
358 /// ```no_run
359 /// use ironflow_store::store::RunStore;
360 /// use uuid::Uuid;
361 ///
362 /// # async fn example(store: &dyn RunStore, run_id: Uuid) -> Result<(), ironflow_store::error::StoreError> {
363 /// let storage_keys = store.delete_run(run_id).await?;
364 /// // Caller deletes blobs from the blob store using these keys.
365 /// # Ok(())
366 /// # }
367 /// ```
368 fn delete_run(&self, id: Uuid) -> StoreFuture<'_, Vec<String>>;
369
370 /// Apply a partial update to a run and return the updated run.
371 ///
372 /// Combines [`update_run`](Self::update_run) and [`get_run`](Self::get_run) in
373 /// a single operation to avoid an extra round-trip. Store implementations
374 /// may override this for efficiency (e.g. reading within the same transaction).
375 ///
376 /// The default implementation calls `update_run` followed by `get_run`.
377 ///
378 /// # Errors
379 ///
380 /// Returns [`StoreError::RunNotFound`] if the run does not exist.
381 /// Returns [`StoreError::InvalidTransition`] if the status transition is not allowed.
382 fn update_run_returning(&self, id: Uuid, update: RunUpdate) -> StoreFuture<'_, Run> {
383 Box::pin(async move {
384 self.update_run(id, update).await?;
385 self.get_run(id).await?.ok_or(StoreError::RunNotFound(id))
386 })
387 }
388}
389
390/// Unified storage abstraction combining all store capabilities.
391///
392/// Implementors provide runs, steps, users, API keys, and secrets
393/// through a single type. Pick one backend (in-memory or PostgreSQL)
394/// and it handles everything.
395///
396/// Both [`InMemoryStore`](crate::memory::InMemoryStore) and
397/// [`PostgresStore`](crate::postgres::PostgresStore) implement this trait.
398///
399/// # Examples
400///
401/// ```no_run
402/// use std::collections::HashMap;
403/// use std::sync::Arc;
404/// use ironflow_store::prelude::*;
405///
406/// # async fn example() -> Result<(), ironflow_store::error::StoreError> {
407/// let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
408///
409/// // All capabilities through one reference
410/// let _run = store.create_run(NewRun {
411/// workflow_name: "deploy".to_string(),
412/// trigger: TriggerKind::Manual,
413/// payload: serde_json::json!({}),
414/// max_retries: 3,
415/// handler_version: None,
416/// labels: HashMap::new(),
417/// scheduled_at: None,
418/// created_by: None,
419/// idempotency_key: None,
420/// concurrency_key: None,
421/// max_cost_usd: None,
422/// }).await?.into_run();
423/// let _users = store.count_users().await?;
424/// # Ok(())
425/// # }
426/// ```
427pub trait Store:
428 RunStore
429 + UserStore
430 + ApiKeyStore
431 + SecretStore
432 + AuditLogStore
433 + ArtifactStore
434 + LogStore
435 + ScheduleStore
436 + ApprovalDelegationStore
437 + ProviderAccountStore
438 + SignalStore
439{
440}
441
442impl<
443 T: RunStore
444 + UserStore
445 + ApiKeyStore
446 + SecretStore
447 + AuditLogStore
448 + ArtifactStore
449 + LogStore
450 + ScheduleStore
451 + ApprovalDelegationStore
452 + ProviderAccountStore
453 + SignalStore,
454> Store for T
455{
456}