Skip to main content

klieo_workflow_api/
run_store.rs

1//! Non-authoritative run-status cache over a [`KvStore`].
2//!
3//! This is a volatile poll cache — the authoritative, durable run outcome
4//! lives in the provenance chain (wired in a later slice). Idempotency
5//! claims use compare-and-set (`expected = None`) so concurrent identical
6//! submits collapse to one run without a get-then-put race (CWE-367).
7
8use bytes::Bytes;
9use klieo_core::{BusError, KvStore};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use std::sync::Arc;
13
14/// KV bucket holding both idempotency claims and run records.
15pub const RUNS_BUCKET: &str = "workflow_runs";
16
17const IDEM_PREFIX: &str = "idem-";
18const RUN_PREFIX: &str = "run-";
19/// Marks a run as non-terminal (`Pending`/`Running`). Written by `create`,
20/// deleted by every terminal transition and by `rollback_claim`, so
21/// [`RunStore::list_active`] tracks the live-run count instead of a
22/// backend's total history.
23const ACTIVE_PREFIX: &str = "active-";
24
25/// Lifecycle status of a submitted run.
26#[non_exhaustive]
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum RunStatus {
30    /// Accepted, not yet started.
31    Pending,
32    /// Executor is running the flow.
33    Running,
34    /// Flow completed successfully.
35    Succeeded,
36    /// Flow returned an error, panicked, or its record could not be finalised.
37    Failed,
38    /// Flow exceeded the per-run timeout and was cancelled.
39    Aborted,
40}
41
42/// The client-visible snapshot of a run returned by `GET /runs/{id}`.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44pub struct RunRecordView {
45    /// Server-minted run id.
46    pub run_id: String,
47    /// The workflow this run executes; the run's provenance `resource_ref`.
48    pub workflow_id: String,
49    /// Current lifecycle status.
50    pub status: RunStatus,
51    /// Output envelope, present once `Succeeded`.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub result: Option<Value>,
54    /// Failure summary, present once `Failed`/`Aborted`.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub error: Option<String>,
57    /// Server-stamped author (authenticated caller), never client-supplied.
58    pub author: String,
59    /// RFC-3339 submit time, stamped at the handler boundary.
60    pub created_at: String,
61}
62
63impl RunRecordView {
64    /// A freshly-accepted run, before the executor starts it.
65    pub fn pending(
66        run_id: impl Into<String>,
67        workflow_id: impl Into<String>,
68        author: impl Into<String>,
69        created_at: impl Into<String>,
70    ) -> Self {
71        Self {
72            run_id: run_id.into(),
73            workflow_id: workflow_id.into(),
74            status: RunStatus::Pending,
75            result: None,
76            error: None,
77            author: author.into(),
78            created_at: created_at.into(),
79        }
80    }
81}
82
83/// Outcome of a CAS idempotency claim.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum ClaimOutcome {
86    /// This caller won the claim; proceed to run.
87    Created,
88    /// The key was already claimed; the winning run's id.
89    AlreadyExists(String),
90}
91
92/// Failure modes of the run store.
93#[non_exhaustive]
94#[derive(Debug, thiserror::Error)]
95pub enum StoreError {
96    /// The underlying KV store failed.
97    #[error("kv store error: {0}")]
98    Kv(#[from] BusError),
99    /// A stored record could not be (de)serialised.
100    #[error("run record serialisation error: {0}")]
101    Serde(#[from] serde_json::Error),
102    /// A transition targeted a run id with no record.
103    #[error("run record not found: {0}")]
104    NotFound(String),
105}
106
107/// Volatile run-status cache keyed by run id, with CAS-based idempotency.
108#[derive(Clone)]
109pub struct RunStore {
110    kv: Arc<dyn KvStore>,
111}
112
113impl RunStore {
114    /// Wrap a KV store as a run-status cache.
115    pub fn new(kv: Arc<dyn KvStore>) -> Self {
116        Self { kv }
117    }
118
119    /// Atomically claim `idempotency_key` for `run_id` via `cas(expected =
120    /// None)`. The first caller gets [`ClaimOutcome::Created`]; a concurrent
121    /// or later identical submit reads the winner's id
122    /// ([`ClaimOutcome::AlreadyExists`]).
123    pub async fn claim_idempotent(
124        &self,
125        idempotency_key: &str,
126        run_id: &str,
127    ) -> Result<ClaimOutcome, StoreError> {
128        let key = format!("{IDEM_PREFIX}{idempotency_key}");
129        let value = Bytes::from(run_id.to_string());
130        match self.kv.cas(RUNS_BUCKET, &key, value, None).await {
131            Ok(_) => Ok(ClaimOutcome::Created),
132            Err(BusError::CasConflict { .. }) => {
133                let winner = self
134                    .kv
135                    .get(RUNS_BUCKET, &key)
136                    .await?
137                    .map(|e| String::from_utf8_lossy(&e.value).into_owned())
138                    .ok_or_else(|| StoreError::NotFound(key.clone()))?;
139                Ok(ClaimOutcome::AlreadyExists(winner))
140            }
141            Err(other) => Err(StoreError::Kv(other)),
142        }
143    }
144
145    /// Persist a fresh run record and mark it active.
146    ///
147    /// Writes the active marker *before* the run record. If the process
148    /// dies between the two writes, a stray marker with no record is left
149    /// behind — `Self::list_active` tolerates that (skips it). The
150    /// reverse order would risk the opposite, worse failure: a Pending
151    /// record with no marker, which the sweep would silently never see.
152    pub async fn create(&self, view: &RunRecordView) -> Result<(), StoreError> {
153        self.put_active_marker(&view.run_id).await?;
154        self.put_view(view).await
155    }
156
157    /// Undo a claim + its pending record + active marker after a post-claim
158    /// failure (e.g. the run-start audit append failed), so a genuine retry
159    /// with the same key re-runs instead of resolving to a dead run.
160    /// Best-effort — a delete failure leaves the sweep as the backstop.
161    pub async fn rollback_claim(
162        &self,
163        idempotency_key: &str,
164        run_id: &str,
165    ) -> Result<(), StoreError> {
166        self.kv
167            .delete(RUNS_BUCKET, &format!("{IDEM_PREFIX}{idempotency_key}"))
168            .await?;
169        self.kv
170            .delete(RUNS_BUCKET, &format!("{RUN_PREFIX}{run_id}"))
171            .await?;
172        self.delete_active_marker(run_id).await
173    }
174
175    /// Mark the run `Running`.
176    pub async fn set_running(&self, run_id: &str) -> Result<(), StoreError> {
177        self.update(run_id, |v| v.status = RunStatus::Running).await
178    }
179
180    /// Mark the run `Succeeded` with its output envelope, then retire its
181    /// active marker.
182    pub async fn set_succeeded(&self, run_id: &str, result: Value) -> Result<(), StoreError> {
183        self.update(run_id, move |v| {
184            v.status = RunStatus::Succeeded;
185            v.result = Some(result);
186        })
187        .await?;
188        self.delete_active_marker(run_id).await
189    }
190
191    /// Mark the run `Failed` with a summary, then retire its active marker.
192    pub async fn set_failed(&self, run_id: &str, error: String) -> Result<(), StoreError> {
193        self.update(run_id, move |v| {
194            v.status = RunStatus::Failed;
195            v.error = Some(error);
196        })
197        .await?;
198        self.delete_active_marker(run_id).await
199    }
200
201    /// Mark the run `Aborted` (timed out) with a summary, then retire its
202    /// active marker.
203    pub async fn set_aborted(&self, run_id: &str, error: String) -> Result<(), StoreError> {
204        self.update(run_id, move |v| {
205            v.status = RunStatus::Aborted;
206            v.error = Some(error);
207        })
208        .await?;
209        self.delete_active_marker(run_id).await
210    }
211
212    /// Read a run record, or `None` if unknown.
213    pub async fn get(&self, run_id: &str) -> Result<Option<RunRecordView>, StoreError> {
214        let key = format!("{RUN_PREFIX}{run_id}");
215        match self.kv.get(RUNS_BUCKET, &key).await? {
216            Some(entry) => Ok(Some(serde_json::from_slice(&entry.value)?)),
217            None => Ok(None),
218        }
219    }
220
221    /// Every non-terminal (`Pending`/`Running`) run, read through the
222    /// active-run marker index. Used by the startup orphan sweep. Terminal
223    /// records are never removed but their markers are, so the per-record
224    /// fetches are bounded to the live-run count rather than total history.
225    /// (A backend without prefix-filtered key enumeration — e.g. NATS — still
226    /// lists all keys once, but skips fetching the terminal records.)
227    ///
228    /// On a backend that cannot enumerate keys this returns an empty list
229    /// rather than erroring (an ephemeral store has nothing durable to
230    /// sweep). A marker with no matching run record — the crash window
231    /// documented on [`Self::create`] — is tolerated: skipped rather than
232    /// surfaced as an error.
233    ///
234    /// Crate-internal: the host drives the sweep via [`crate::sweep_orphaned_runs`],
235    /// never this method directly, so it is not part of the public API.
236    pub(crate) async fn list_active(&self) -> Result<Vec<RunRecordView>, StoreError> {
237        let keys = match self.kv.keys(RUNS_BUCKET).await {
238            Ok(keys) => keys,
239            Err(BusError::Unsupported(_)) => return Ok(Vec::new()),
240            Err(other) => return Err(StoreError::Kv(other)),
241        };
242        let mut runs = Vec::new();
243        for key in keys.iter().filter(|k| k.starts_with(ACTIVE_PREFIX)) {
244            let run_id = &key[ACTIVE_PREFIX.len()..];
245            if let Some(view) = self.get(run_id).await? {
246                runs.push(view);
247            }
248        }
249        Ok(runs)
250    }
251
252    /// Read-modify-write a run record. Sound because each run has a single
253    /// writer: the CAS idempotency claim admits exactly one executor per
254    /// `run_id`, and that executor's writes (`create` → `set_running` →
255    /// terminal) are sequential. The startup `sweep_orphaned_runs` is a
256    /// documented second writer, but it runs only at startup before serving
257    /// traffic, so it never overlaps a live executor. A future concurrent
258    /// writer on a live run (e.g. a cancel/retry endpoint) would need CAS
259    /// here to avoid a lost update.
260    async fn update(
261        &self,
262        run_id: &str,
263        mutate: impl FnOnce(&mut RunRecordView),
264    ) -> Result<(), StoreError> {
265        let mut view = self
266            .get(run_id)
267            .await?
268            .ok_or_else(|| StoreError::NotFound(run_id.to_string()))?;
269        mutate(&mut view);
270        self.put_view(&view).await
271    }
272
273    async fn put_view(&self, view: &RunRecordView) -> Result<(), StoreError> {
274        let key = format!("{RUN_PREFIX}{}", view.run_id);
275        let bytes = Bytes::from(serde_json::to_vec(view)?);
276        self.kv.put(RUNS_BUCKET, &key, bytes).await?;
277        Ok(())
278    }
279
280    async fn put_active_marker(&self, run_id: &str) -> Result<(), StoreError> {
281        let key = format!("{ACTIVE_PREFIX}{run_id}");
282        self.kv.put(RUNS_BUCKET, &key, Bytes::new()).await?;
283        Ok(())
284    }
285
286    async fn delete_active_marker(&self, run_id: &str) -> Result<(), StoreError> {
287        self.kv
288            .delete(RUNS_BUCKET, &format!("{ACTIVE_PREFIX}{run_id}"))
289            .await?;
290        Ok(())
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use klieo_bus_memory::MemoryBus;
298
299    fn store() -> RunStore {
300        RunStore::new(MemoryBus::new().kv)
301    }
302
303    #[tokio::test]
304    async fn claim_returns_created_once_then_already_exists() {
305        let store = store();
306        assert_eq!(
307            store.claim_idempotent("k", "run-1").await.unwrap(),
308            ClaimOutcome::Created
309        );
310        assert_eq!(
311            store.claim_idempotent("k", "run-2").await.unwrap(),
312            ClaimOutcome::AlreadyExists("run-1".to_string()),
313        );
314    }
315
316    #[tokio::test]
317    async fn status_transitions_persist() {
318        let store = store();
319        store
320            .create(&RunRecordView::pending(
321                "run-1",
322                "wf-1",
323                "alice",
324                "2026-07-13T00:00:00Z",
325            ))
326            .await
327            .unwrap();
328        store.set_running("run-1").await.unwrap();
329        assert_eq!(
330            store.get("run-1").await.unwrap().unwrap().status,
331            RunStatus::Running
332        );
333        store
334            .set_succeeded("run-1", serde_json::json!({"ok": true}))
335            .await
336            .unwrap();
337        let view = store.get("run-1").await.unwrap().unwrap();
338        assert_eq!(view.status, RunStatus::Succeeded);
339        assert_eq!(view.result, Some(serde_json::json!({"ok": true})));
340        assert_eq!(view.author, "alice");
341    }
342
343    #[tokio::test]
344    async fn missing_run_reads_none() {
345        assert!(store().get("nope").await.unwrap().is_none());
346    }
347
348    #[tokio::test]
349    async fn transition_on_missing_run_errors() {
350        let err = store().set_running("ghost").await.unwrap_err();
351        assert!(matches!(err, StoreError::NotFound(_)));
352    }
353
354    /// A backend that cannot enumerate keys (the trait's default `keys()`
355    /// returns `Unsupported`) makes `list_active()` a no-op empty rather than
356    /// an error, so the startup sweep skips an ephemeral store cleanly.
357    #[tokio::test]
358    async fn list_active_maps_unsupported_keys_to_empty() {
359        use async_trait::async_trait;
360        use klieo_core::bus::{KvEntry, Lease, Revision};
361        use std::time::Duration;
362
363        struct NoKeysKv;
364        #[async_trait]
365        impl KvStore for NoKeysKv {
366            async fn get(&self, _: &str, _: &str) -> Result<Option<KvEntry>, BusError> {
367                unreachable!("list_active() must not read values when keys() is unsupported")
368            }
369            async fn put(&self, _: &str, _: &str, _: Bytes) -> Result<Revision, BusError> {
370                unreachable!()
371            }
372            async fn cas(
373                &self,
374                _: &str,
375                _: &str,
376                _: Bytes,
377                _: Option<Revision>,
378            ) -> Result<Revision, BusError> {
379                unreachable!()
380            }
381            async fn delete(&self, _: &str, _: &str) -> Result<(), BusError> {
382                unreachable!()
383            }
384            async fn lease(&self, _: &str, _: &str, _: Duration) -> Result<Lease, BusError> {
385                unreachable!()
386            }
387            // keys() uses the trait default → BusError::Unsupported.
388        }
389
390        let store = RunStore::new(std::sync::Arc::new(NoKeysKv));
391        assert!(store.list_active().await.unwrap().is_empty());
392    }
393
394    #[tokio::test]
395    async fn create_writes_active_marker() {
396        let store = store();
397        store
398            .create(&RunRecordView::pending(
399                "run-1",
400                "wf-1",
401                "alice",
402                "2026-07-13T00:00:00Z",
403            ))
404            .await
405            .unwrap();
406        let active = store.list_active().await.unwrap();
407        assert_eq!(active.len(), 1);
408        assert_eq!(active[0].run_id, "run-1");
409    }
410
411    #[tokio::test]
412    async fn set_succeeded_deletes_active_marker() {
413        let store = store();
414        store
415            .create(&RunRecordView::pending(
416                "run-1",
417                "wf-1",
418                "alice",
419                "2026-07-13T00:00:00Z",
420            ))
421            .await
422            .unwrap();
423        store
424            .set_succeeded("run-1", serde_json::json!({"ok": true}))
425            .await
426            .unwrap();
427        assert!(store.list_active().await.unwrap().is_empty());
428        // The record itself survives — only the active marker is retired.
429        assert_eq!(
430            store.get("run-1").await.unwrap().unwrap().status,
431            RunStatus::Succeeded
432        );
433    }
434
435    #[tokio::test]
436    async fn set_failed_deletes_active_marker() {
437        let store = store();
438        store
439            .create(&RunRecordView::pending(
440                "run-1",
441                "wf-1",
442                "alice",
443                "2026-07-13T00:00:00Z",
444            ))
445            .await
446            .unwrap();
447        store.set_failed("run-1", "boom".into()).await.unwrap();
448        assert!(store.list_active().await.unwrap().is_empty());
449    }
450
451    #[tokio::test]
452    async fn set_aborted_deletes_active_marker() {
453        let store = store();
454        store
455            .create(&RunRecordView::pending(
456                "run-1",
457                "wf-1",
458                "alice",
459                "2026-07-13T00:00:00Z",
460            ))
461            .await
462            .unwrap();
463        store
464            .set_aborted("run-1", "timed out".into())
465            .await
466            .unwrap();
467        assert!(store.list_active().await.unwrap().is_empty());
468    }
469
470    #[tokio::test]
471    async fn rollback_claim_deletes_active_marker() {
472        let store = store();
473        store
474            .create(&RunRecordView::pending(
475                "run-1",
476                "wf-1",
477                "alice",
478                "2026-07-13T00:00:00Z",
479            ))
480            .await
481            .unwrap();
482        store.rollback_claim("idem-key", "run-1").await.unwrap();
483        assert!(store.list_active().await.unwrap().is_empty());
484        assert!(store.get("run-1").await.unwrap().is_none());
485    }
486
487    #[tokio::test]
488    async fn list_active_excludes_terminal_runs() {
489        let store = store();
490        store
491            .create(&RunRecordView::pending(
492                "done",
493                "wf-1",
494                "alice",
495                "2026-07-13T00:00:00Z",
496            ))
497            .await
498            .unwrap();
499        store
500            .set_succeeded("done", serde_json::json!({}))
501            .await
502            .unwrap();
503        store
504            .create(&RunRecordView::pending(
505                "live",
506                "wf-1",
507                "alice",
508                "2026-07-13T00:00:00Z",
509            ))
510            .await
511            .unwrap();
512
513        let active = store.list_active().await.unwrap();
514        assert_eq!(active.len(), 1);
515        assert_eq!(active[0].run_id, "live");
516    }
517
518    /// The crash window documented on `create`: a marker written with no
519    /// matching run record is skipped, not surfaced as an error.
520    #[tokio::test]
521    async fn list_active_tolerates_marker_without_record() {
522        let store = store();
523        store.put_active_marker("ghost").await.unwrap();
524        assert!(store.list_active().await.unwrap().is_empty());
525    }
526}