Skip to main content

faucet_cli/serve/history/
mod.rs

1//! Run-history storage. The trait is defined in full now; the in-memory backend
2//! lives in `memory.rs`, and the feature-gated SQL backends (`postgres.rs` /
3//! `sqlite.rs`, sharing `sql.rs`) wrap themselves in `fallback.rs` so an
4//! unreachable backend degrades to in-memory rather than refusing to start.
5//! See spec §11 + §20.
6
7#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
8pub mod fallback;
9pub mod memory;
10#[cfg(feature = "serve-history-postgres")]
11pub mod postgres;
12#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
13pub mod sql;
14#[cfg(feature = "serve-history-sqlite")]
15pub mod sqlite;
16
17use crate::error::CliResult;
18use crate::executor::InvocationOutcome;
19use crate::serve::config::HistoryBackendSpec;
20use async_trait::async_trait;
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeMap;
24use std::sync::Arc;
25use std::time::Duration;
26
27/// Lifecycle state of a submitted run.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum RunStatus {
31    Queued,
32    Pending,
33    Running,
34    Completed,
35    Failed,
36    Cancelled,
37}
38
39impl RunStatus {
40    pub fn is_terminal(self) -> bool {
41        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
42    }
43    pub fn as_str(self) -> &'static str {
44        match self {
45            Self::Queued => "queued",
46            Self::Pending => "pending",
47            Self::Running => "running",
48            Self::Completed => "completed",
49            Self::Failed => "failed",
50            Self::Cancelled => "cancelled",
51        }
52    }
53}
54
55/// Serializable mirror of one pipeline invocation's outcome.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct InvocationRecord {
58    pub row_id: String,
59    pub parent_record_key: Option<String>,
60    pub records_written: usize,
61    pub error: Option<String>,
62}
63
64impl From<&InvocationOutcome> for InvocationRecord {
65    fn from(o: &InvocationOutcome) -> Self {
66        Self {
67            row_id: o.row_id.clone(),
68            parent_record_key: o.parent_record_key.clone(),
69            records_written: o.records_written,
70            error: o.error.clone(),
71        }
72    }
73}
74
75/// One run's full record — the GET / list element (spec §6).
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct RunRecord {
78    pub run_id: String,
79    pub name: Option<String>,
80    pub labels: BTreeMap<String, String>,
81    pub status: RunStatus,
82    pub submitted_at: DateTime<Utc>,
83    pub started_at: Option<DateTime<Utc>>,
84    pub finished_at: Option<DateTime<Utc>>,
85    pub elapsed_secs: Option<f64>,
86    pub records_written: u64,
87    pub invocations: Vec<InvocationRecord>,
88    pub error: Option<String>,
89    pub idempotency_key: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub doctor_report: Option<serde_json::Value>,
92    /// Raw submitted config text — present only for cluster runs so any instance
93    /// can re-resolve + re-run it. `None` for single-instance runs.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub config_body: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub config_format: Option<crate::serve::load::ConfigFormat>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub timeout_secs: Option<u64>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub clock: Option<String>,
102    /// Failover re-run count (cluster mode). 0 on first submit.
103    #[serde(default)]
104    pub attempt: u32,
105}
106
107impl RunRecord {
108    /// A freshly-submitted run, before it acquires an execution slot.
109    pub fn queued(
110        run_id: String,
111        name: Option<String>,
112        labels: BTreeMap<String, String>,
113        idempotency_key: Option<String>,
114        submitted_at: DateTime<Utc>,
115    ) -> Self {
116        Self {
117            run_id,
118            name,
119            labels,
120            status: RunStatus::Queued,
121            submitted_at,
122            started_at: None,
123            finished_at: None,
124            elapsed_secs: None,
125            records_written: 0,
126            invocations: Vec::new(),
127            error: None,
128            idempotency_key,
129            doctor_report: None,
130            config_body: None,
131            config_format: None,
132            timeout_secs: None,
133            clock: None,
134            attempt: 0,
135        }
136    }
137}
138
139/// Result of an atomic idempotency-key claim.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum Claim {
142    /// Key is new (or its prior claim expired) — caller owns it for `run_id`.
143    Fresh,
144    /// Key was already claimed with a matching payload — replay this run id.
145    Replay(String),
146    /// Key was claimed with a *different* payload — 409.
147    Conflict,
148}
149
150/// Result of a delete attempt.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum DeleteOutcome {
153    Deleted,
154    NotFound,
155    StillRunning,
156}
157
158/// Result of a failover reclaim pass.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
160pub struct ReclaimReport {
161    /// Orphans re-queued to `Pending` for another instance to re-run.
162    pub requeued: usize,
163    /// Orphans that hit the attempt cap and were marked `Failed` (poison).
164    pub failed: usize,
165}
166
167/// Fields a serve instance heartbeats into the membership table. The
168/// `instance_id` is the backend's own id (stamped server-side), so it is not
169/// carried here.
170#[derive(Debug, Clone)]
171pub struct InstanceHeartbeat {
172    pub started_at: DateTime<Utc>,
173    pub listen: Option<String>,
174    pub max_concurrent: u32,
175    pub in_flight: u32,
176}
177
178/// One live cluster member (for `/readyz` + metrics).
179#[derive(Debug, Clone, Serialize)]
180pub struct InstanceRecord {
181    pub instance_id: String,
182    pub started_at: DateTime<Utc>,
183    pub last_heartbeat: DateTime<Utc>,
184    pub listen: Option<String>,
185    pub max_concurrent: u32,
186    pub in_flight: u32,
187}
188
189/// Filter + pagination for `list`. `limit`/`cursor` are resolved by the handler.
190#[derive(Debug, Default, Clone)]
191pub struct ListFilter {
192    pub status: Option<RunStatus>,
193    pub name: Option<String>,
194    pub since: Option<DateTime<Utc>>,
195    pub until: Option<DateTime<Utc>>,
196    pub limit: usize,
197    pub cursor: Option<String>,
198}
199
200/// One page of `list` results, ordered `(submitted_at DESC, run_id DESC)`.
201#[derive(Debug)]
202pub struct ListPage {
203    pub runs: Vec<RunRecord>,
204    pub next_cursor: Option<String>,
205}
206
207/// Backend failure. The memory backend never returns one; the variant exists so
208/// the async trait stays fallible for the Phase 5 SQL backends.
209#[derive(Debug, thiserror::Error)]
210pub enum HistoryError {
211    #[error("run-history backend error: {0}")]
212    Backend(String),
213    /// The backend is degraded and the operation can't be honored safely
214    /// (e.g. an idempotency claim that would risk a duplicate run). Maps to a
215    /// `503` so the caller can retry once the backend recovers (#146 M5).
216    #[error("{0}")]
217    Degraded(String),
218}
219
220#[async_trait]
221pub trait RunHistory: Send + Sync {
222    /// Atomically claim `key` for `run_id` (or report a replay/conflict). A prior
223    /// claim older than `window` is treated as expired and re-claimable.
224    async fn claim_idempotency(
225        &self,
226        key: &str,
227        fingerprint: &str,
228        run_id: &str,
229        window: Duration,
230    ) -> Result<Claim, HistoryError>;
231
232    /// Insert or replace a run record.
233    async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError>;
234
235    async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError>;
236
237    async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError>;
238
239    /// Delete a terminal run. Non-terminal → `StillRunning` (caller maps to 409).
240    async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError>;
241
242    /// Drop terminal records finished longer than `retain_for` ago. Returns the
243    /// number removed.
244    async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError>;
245
246    /// Mark non-terminal records whose owning instance's lease has expired as
247    /// failed (instance-fenced orphan recovery — never touches a live peer's
248    /// heartbeated runs, #146 H7). Returns the number recovered. The memory
249    /// backend has nothing to recover (returns 0).
250    async fn recover_orphans(&self) -> Result<usize, HistoryError>;
251
252    /// Heartbeat: extend the lease of *this* instance's own non-terminal runs so
253    /// a peer's [`recover_orphans`](Self::recover_orphans) won't reclaim them.
254    /// Returns the number of leases renewed. The memory backend (single-process,
255    /// unshared) is a no-op returning 0.
256    async fn renew_leases(&self) -> Result<usize, HistoryError> {
257        Ok(0)
258    }
259
260    /// Atomically claim up to `limit` oldest `Pending` runs for *this* instance,
261    /// moving them `Pending` → `Running` with a fresh lease, and return the
262    /// claimed records (with `config_body`) for the caller to execute. Exclusive:
263    /// a run claimed by one caller is never returned to another. Default: none
264    /// (memory is single-process and never writes `Pending`).
265    async fn claim_pending(&self, limit: usize) -> Result<Vec<RunRecord>, HistoryError> {
266        let _ = limit;
267        Ok(Vec::new())
268    }
269
270    /// Cluster failover: expired-lease `Running` runs whose `attempt < max_attempts`
271    /// go back to `Pending` (owner/lease cleared, `attempt++`); the rest are
272    /// `Failed` (poison). Returns the counts. Default: nothing to reclaim.
273    async fn reclaim_orphans(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
274        let _ = max_attempts;
275        Ok(ReclaimReport::default())
276    }
277
278    /// Owner-fenced terminal write: persist `rec` only if this instance still owns
279    /// the run. Returns `true` if the write landed, `false` if another instance
280    /// reclaimed it (the caller should discard its result). Default: delegate to
281    /// `upsert` (memory/single-process always owns its runs).
282    async fn finalize_owned(&self, rec: &RunRecord) -> Result<bool, HistoryError> {
283        self.upsert(rec).await.map(|_| true)
284    }
285
286    /// Cancel a still-`Pending` (unclaimed) run directly. Returns `true` if it was
287    /// pending and is now `Cancelled`; `false` if it had already been claimed (the
288    /// caller should fall back to [`request_cancel`](Self::request_cancel)).
289    /// Default: `false`.
290    async fn cancel_pending(&self, run_id: &str) -> Result<bool, HistoryError> {
291        let _ = run_id;
292        Ok(false)
293    }
294
295    /// Flag a `Running` run for cross-instance cancellation; its owning instance
296    /// fires the local cancel on its next claim-loop tick. Default: no-op.
297    async fn request_cancel(&self, run_id: &str) -> Result<(), HistoryError> {
298        let _ = run_id;
299        Ok(())
300    }
301
302    /// This instance's own `Running` runs that have a pending cancel request.
303    /// Default: none.
304    async fn pending_cancellations(&self) -> Result<Vec<String>, HistoryError> {
305        Ok(Vec::new())
306    }
307
308    /// Membership heartbeat: upsert this instance's liveness row. Default: no-op.
309    async fn heartbeat_instance(&self, beat: &InstanceHeartbeat) -> Result<(), HistoryError> {
310        let _ = beat;
311        Ok(())
312    }
313
314    /// Live cluster members (last heartbeat within `ttl`). Default: none.
315    async fn live_instances(&self, ttl: Duration) -> Result<Vec<InstanceRecord>, HistoryError> {
316        let _ = ttl;
317        Ok(Vec::new())
318    }
319
320    /// True when the backend is in fallback mode (drives `/readyz`). Always false
321    /// for memory.
322    fn degraded(&self) -> bool;
323}
324
325/// Build the configured run-history backend. `Memory` is always available; the
326/// SQL backends require their respective `serve-history-*` build features (a
327/// clear error otherwise). A SQL backend that fails to connect at startup
328/// degrades to in-memory (via `FallbackHistory`) rather than aborting boot.
329pub async fn connect(
330    spec: &HistoryBackendSpec,
331    idem_retention: Duration,
332    lease_ttl: Duration,
333    instance_id: &str,
334) -> CliResult<Arc<dyn RunHistory>> {
335    match spec {
336        HistoryBackendSpec::Memory => {
337            Ok(Arc::new(memory::MemoryHistory::new(idem_retention)) as Arc<dyn RunHistory>)
338        }
339        HistoryBackendSpec::Postgres(url) => {
340            connect_postgres(url, idem_retention, lease_ttl, instance_id).await
341        }
342        HistoryBackendSpec::Sqlite(url) => {
343            connect_sqlite(url, idem_retention, lease_ttl, instance_id).await
344        }
345    }
346}
347
348#[cfg(feature = "serve-history-postgres")]
349async fn connect_postgres(
350    url: &str,
351    idem: Duration,
352    lease_ttl: Duration,
353    instance_id: &str,
354) -> CliResult<Arc<dyn RunHistory>> {
355    let result = connect_with_retry("postgres", || {
356        postgres::PostgresHistory::connect(url, idem, lease_ttl, instance_id.to_string())
357    })
358    .await;
359    Ok(into_history(result, idem, "postgres"))
360}
361
362#[cfg(not(feature = "serve-history-postgres"))]
363async fn connect_postgres(
364    _url: &str,
365    _idem: Duration,
366    _lease_ttl: Duration,
367    _instance_id: &str,
368) -> CliResult<Arc<dyn RunHistory>> {
369    Err(crate::error::CliError::Serve(
370        "persistent Postgres run history requires building faucet with the \
371         `serve-history-postgres` feature"
372            .into(),
373    ))
374}
375
376#[cfg(feature = "serve-history-sqlite")]
377async fn connect_sqlite(
378    url: &str,
379    idem: Duration,
380    lease_ttl: Duration,
381    instance_id: &str,
382) -> CliResult<Arc<dyn RunHistory>> {
383    let result = connect_with_retry("sqlite", || {
384        sqlite::SqliteHistory::connect(url, idem, lease_ttl, instance_id.to_string())
385    })
386    .await;
387    Ok(into_history(result, idem, "sqlite"))
388}
389
390#[cfg(not(feature = "serve-history-sqlite"))]
391async fn connect_sqlite(
392    _url: &str,
393    _idem: Duration,
394    _lease_ttl: Duration,
395    _instance_id: &str,
396) -> CliResult<Arc<dyn RunHistory>> {
397    Err(crate::error::CliError::Serve(
398        "persistent SQLite run history requires building faucet with the \
399         `serve-history-sqlite` feature"
400            .into(),
401    ))
402}
403
404/// How many times `connect_with_retry` attempts a transient backend connect
405/// before giving up and degrading. Eight attempts with capped exponential
406/// backoff span a few seconds — long enough for two clustered instances to get
407/// past the WAL/DDL startup race on a shared SQLite file, short enough not to
408/// stall startup against a genuinely-down backend.
409#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
410const CONNECT_ATTEMPTS: usize = 8;
411
412/// Retry a *transient* backend-connect failure before falling back to degraded
413/// mode. Two clustered instances opening the same SQLite file at startup briefly
414/// race the WAL/DDL setup and surface `database is locked`; a freshly-booting
415/// Postgres can refuse connections for a moment. Both are self-resolving — but
416/// degrading permanently on the *first* blip strands a cluster instance on the
417/// in-memory store, which cannot serve cluster submits and returns `503` for
418/// every request (#235). A genuinely unreachable backend still degrades once the
419/// attempt budget is spent, preserving the stay-alive fallback.
420#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
421async fn connect_with_retry<H, F, Fut>(label: &str, mut make: F) -> Result<H, HistoryError>
422where
423    F: FnMut() -> Fut,
424    Fut: std::future::Future<Output = Result<H, HistoryError>>,
425{
426    let mut delay = Duration::from_millis(100);
427    for attempt in 1..=CONNECT_ATTEMPTS {
428        match make().await {
429            Ok(backend) => return Ok(backend),
430            Err(e) if attempt < CONNECT_ATTEMPTS && is_transient_connect_error(&e) => {
431                tracing::warn!(
432                    backend = label,
433                    attempt,
434                    error = %e,
435                    "run-history backend connect failed transiently; retrying before degrading"
436                );
437                tokio::time::sleep(delay).await;
438                delay = (delay * 2).min(Duration::from_secs(1));
439            }
440            Err(e) => return Err(e),
441        }
442    }
443    unreachable!("the final attempt returns Ok or Err rather than looping")
444}
445
446/// Whether a connect error is worth retrying: transient contention or a
447/// still-booting backend, as opposed to a permanent misconfiguration (e.g. a
448/// malformed URL) that no amount of retrying will fix.
449#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
450fn is_transient_connect_error(e: &HistoryError) -> bool {
451    let msg = e.to_string().to_ascii_lowercase();
452    [
453        "database is locked", // SQLite: two cluster instances race WAL/DDL at startup
454        "busy",               // SQLITE_BUSY
455        "connection refused", // backend still binding its listener
456        "connection reset",
457        "timed out",
458        "timeout",
459        "starting up",          // Postgres: "the database system is starting up"
460        "too many connections", // transient connection saturation
461    ]
462    .iter()
463    .any(|needle| msg.contains(needle))
464}
465
466/// Wrap a SQL backend in `FallbackHistory`: healthy on success; degraded-on-
467/// in-memory (server stays up, `/readyz` reports 503) on a connect failure.
468#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
469fn into_history<H: RunHistory + 'static>(
470    result: Result<H, HistoryError>,
471    idem: Duration,
472    label: &'static str,
473) -> Arc<dyn RunHistory> {
474    match result {
475        Ok(backend) => Arc::new(fallback::FallbackHistory::healthy(
476            Box::new(backend),
477            idem,
478            label,
479        )),
480        Err(e) => {
481            tracing::error!(
482                backend = label, error = %e,
483                "run-history backend unavailable at startup; starting DEGRADED on in-memory store"
484            );
485            Arc::new(fallback::FallbackHistory::degraded_at_startup(idem, label))
486        }
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn terminal_classification() {
496        assert!(!RunStatus::Queued.is_terminal());
497        assert!(!RunStatus::Pending.is_terminal());
498        assert!(!RunStatus::Running.is_terminal());
499        assert!(RunStatus::Completed.is_terminal());
500        assert!(RunStatus::Failed.is_terminal());
501        assert!(RunStatus::Cancelled.is_terminal());
502    }
503
504    #[test]
505    fn run_record_serializes_status_snake_case() {
506        let rec = RunRecord::queued(
507            "r1".into(),
508            Some("n".into()),
509            Default::default(),
510            None,
511            Utc::now(),
512        );
513        let v = serde_json::to_value(&rec).unwrap();
514        assert_eq!(v["status"], "queued");
515        assert_eq!(v["run_id"], "r1");
516        // doctor_report is skipped when None.
517        assert!(v.get("doctor_report").is_none());
518    }
519
520    #[test]
521    fn pending_is_non_terminal_and_serializes_snake_case() {
522        assert!(!RunStatus::Pending.is_terminal());
523        assert_eq!(RunStatus::Pending.as_str(), "pending");
524        let mut rec = RunRecord::queued("r".into(), None, Default::default(), None, Utc::now());
525        rec.status = RunStatus::Pending;
526        rec.attempt = 2;
527        let v = serde_json::to_value(&rec).unwrap();
528        assert_eq!(v["status"], "pending");
529        assert_eq!(v["attempt"], 2);
530        // Cluster config fields are skipped when absent.
531        assert!(v.get("config_body").is_none());
532    }
533
534    #[tokio::test]
535    async fn memory_backend_cluster_methods_are_inert() {
536        use crate::serve::history::memory::MemoryHistory;
537        let h = MemoryHistory::new(Duration::from_secs(60));
538        assert!(h.claim_pending(8).await.unwrap().is_empty());
539        assert_eq!(
540            h.reclaim_orphans(3).await.unwrap(),
541            ReclaimReport::default()
542        );
543        assert!(!h.cancel_pending("x").await.unwrap());
544        h.request_cancel("x").await.unwrap();
545        assert!(h.pending_cancellations().await.unwrap().is_empty());
546        assert!(
547            h.live_instances(Duration::from_secs(60))
548                .await
549                .unwrap()
550                .is_empty()
551        );
552
553        // finalize_owned's default delegates to upsert (single-process always owns).
554        let rec = RunRecord::queued("fo".into(), None, Default::default(), None, Utc::now());
555        assert!(h.finalize_owned(&rec).await.unwrap());
556        assert_eq!(h.get("fo").await.unwrap().unwrap().run_id, "fo");
557    }
558}
559
560#[cfg(all(
561    test,
562    any(feature = "serve-history-postgres", feature = "serve-history-sqlite")
563))]
564mod connect_retry_tests {
565    use super::*;
566    use std::cell::Cell;
567
568    #[test]
569    fn classifies_transient_vs_permanent_connect_errors() {
570        // SQLite concurrent-startup contention (#235) — retryable.
571        assert!(is_transient_connect_error(&HistoryError::Backend(
572            "SQLite connection failed: error returned from database: (code: 5) \
573             database is locked"
574                .into()
575        )));
576        // Booting Postgres — retryable.
577        assert!(is_transient_connect_error(&HistoryError::Backend(
578            "connection refused (os error 111)".into()
579        )));
580        // Permanent misconfiguration — not worth retrying.
581        assert!(!is_transient_connect_error(&HistoryError::Backend(
582            "invalid sqlite url 'sqlite::nonsense': ParseError".into()
583        )));
584    }
585
586    #[tokio::test]
587    async fn retries_a_transient_failure_then_succeeds() {
588        let calls = Cell::new(0usize);
589        let result: Result<u32, HistoryError> = connect_with_retry("test", || {
590            let n = calls.get() + 1;
591            calls.set(n);
592            async move {
593                if n < 3 {
594                    Err(HistoryError::Backend("database is locked".into()))
595                } else {
596                    Ok(42u32)
597                }
598            }
599        })
600        .await;
601        assert_eq!(result.unwrap(), 42);
602        assert_eq!(
603            calls.get(),
604            3,
605            "two transient failures retried, third succeeds"
606        );
607    }
608
609    #[tokio::test]
610    async fn does_not_retry_a_permanent_error() {
611        let calls = Cell::new(0usize);
612        let result: Result<u32, HistoryError> = connect_with_retry("test", || {
613            calls.set(calls.get() + 1);
614            async move { Err::<u32, _>(HistoryError::Backend("invalid sqlite url 'x'".into())) }
615        })
616        .await;
617        assert!(result.is_err());
618        assert_eq!(
619            calls.get(),
620            1,
621            "a permanent error degrades immediately, no retry"
622        );
623    }
624}