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    /// Mode B (#230): the run has been expanded into shards (rows in
35    /// `faucet_serve_shards`). It does not execute as a whole — its shards do,
36    /// each claimed/leased independently — and it is finalized to a terminal
37    /// state once every shard is terminal. Non-terminal, owner-less, and not
38    /// reclaimed by run-level orphan recovery (only its shards are).
39    Sharded,
40    Completed,
41    Failed,
42    Cancelled,
43}
44
45impl RunStatus {
46    pub fn is_terminal(self) -> bool {
47        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
48    }
49    pub fn as_str(self) -> &'static str {
50        match self {
51            Self::Queued => "queued",
52            Self::Pending => "pending",
53            Self::Running => "running",
54            Self::Sharded => "sharded",
55            Self::Completed => "completed",
56            Self::Failed => "failed",
57            Self::Cancelled => "cancelled",
58        }
59    }
60}
61
62/// Serializable mirror of one pipeline invocation's outcome.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct InvocationRecord {
65    pub row_id: String,
66    pub parent_record_key: Option<String>,
67    pub records_written: usize,
68    pub error: Option<String>,
69}
70
71impl From<&InvocationOutcome> for InvocationRecord {
72    fn from(o: &InvocationOutcome) -> Self {
73        Self {
74            row_id: o.row_id.clone(),
75            parent_record_key: o.parent_record_key.clone(),
76            records_written: o.records_written,
77            error: o.error.clone(),
78        }
79    }
80}
81
82/// One run's full record — the GET / list element (spec §6).
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct RunRecord {
85    pub run_id: String,
86    pub name: Option<String>,
87    pub labels: BTreeMap<String, String>,
88    pub status: RunStatus,
89    pub submitted_at: DateTime<Utc>,
90    pub started_at: Option<DateTime<Utc>>,
91    pub finished_at: Option<DateTime<Utc>>,
92    pub elapsed_secs: Option<f64>,
93    pub records_written: u64,
94    pub invocations: Vec<InvocationRecord>,
95    pub error: Option<String>,
96    pub idempotency_key: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub doctor_report: Option<serde_json::Value>,
99    /// Raw submitted config text — present only for cluster runs so any instance
100    /// can re-resolve + re-run it. `None` for single-instance runs.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub config_body: Option<String>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub config_format: Option<crate::serve::load::ConfigFormat>,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub timeout_secs: Option<u64>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub clock: Option<String>,
109    /// Failover re-run count (cluster mode). 0 on first submit.
110    #[serde(default)]
111    pub attempt: u32,
112}
113
114impl RunRecord {
115    /// A freshly-submitted run, before it acquires an execution slot.
116    pub fn queued(
117        run_id: String,
118        name: Option<String>,
119        labels: BTreeMap<String, String>,
120        idempotency_key: Option<String>,
121        submitted_at: DateTime<Utc>,
122    ) -> Self {
123        Self {
124            run_id,
125            name,
126            labels,
127            status: RunStatus::Queued,
128            submitted_at,
129            started_at: None,
130            finished_at: None,
131            elapsed_secs: None,
132            records_written: 0,
133            invocations: Vec::new(),
134            error: None,
135            idempotency_key,
136            doctor_report: None,
137            config_body: None,
138            config_format: None,
139            timeout_secs: None,
140            clock: None,
141            attempt: 0,
142        }
143    }
144}
145
146/// Result of an atomic idempotency-key claim.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum Claim {
149    /// Key is new (or its prior claim expired) — caller owns it for `run_id`.
150    Fresh,
151    /// Key was already claimed with a matching payload — replay this run id.
152    Replay(String),
153    /// Key was claimed with a *different* payload — 409.
154    Conflict,
155}
156
157/// Result of a delete attempt.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum DeleteOutcome {
160    Deleted,
161    NotFound,
162    StillRunning,
163}
164
165/// Result of a failover reclaim pass.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
167pub struct ReclaimReport {
168    /// Orphans re-queued to `Pending` for another instance to re-run.
169    pub requeued: usize,
170    /// Orphans that hit the attempt cap and were marked `Failed` (poison).
171    pub failed: usize,
172}
173
174/// Fields a serve instance heartbeats into the membership table. The
175/// `instance_id` is the backend's own id (stamped server-side), so it is not
176/// carried here.
177#[derive(Debug, Clone)]
178pub struct InstanceHeartbeat {
179    pub started_at: DateTime<Utc>,
180    pub listen: Option<String>,
181    pub max_concurrent: u32,
182    pub in_flight: u32,
183}
184
185/// One live cluster member (for `/readyz` + metrics).
186#[derive(Debug, Clone, Serialize)]
187pub struct InstanceRecord {
188    pub instance_id: String,
189    pub started_at: DateTime<Utc>,
190    pub last_heartbeat: DateTime<Utc>,
191    pub listen: Option<String>,
192    pub max_concurrent: u32,
193    pub in_flight: u32,
194}
195
196/// Filter + pagination for `list`. `limit`/`cursor` are resolved by the handler.
197#[derive(Debug, Default, Clone)]
198pub struct ListFilter {
199    pub status: Option<RunStatus>,
200    pub name: Option<String>,
201    pub since: Option<DateTime<Utc>>,
202    pub until: Option<DateTime<Utc>>,
203    pub limit: usize,
204    pub cursor: Option<String>,
205}
206
207/// One page of `list` results, ordered `(submitted_at DESC, run_id DESC)`.
208#[derive(Debug)]
209pub struct ListPage {
210    pub runs: Vec<RunRecord>,
211    pub next_cursor: Option<String>,
212}
213
214/// Backend failure. The memory backend never returns one; the variant exists so
215/// the async trait stays fallible for the Phase 5 SQL backends.
216#[derive(Debug, thiserror::Error)]
217pub enum HistoryError {
218    #[error("run-history backend error: {0}")]
219    Backend(String),
220    /// The backend is degraded and the operation can't be honored safely
221    /// (e.g. an idempotency claim that would risk a duplicate run). Maps to a
222    /// `503` so the caller can retry once the backend recovers (#146 M5).
223    #[error("{0}")]
224    Degraded(String),
225}
226
227/// One shard row to persist when a run is expanded into shards (Mode B, #230).
228#[derive(Debug, Clone)]
229pub struct ShardInsert {
230    /// Stable shard id, unique within the run (the [`ShardSpec`](faucet_core::ShardSpec) id).
231    pub shard_id: String,
232    /// Opaque connector descriptor, persisted verbatim and handed to
233    /// [`Source::apply_shard`](faucet_core::Source::apply_shard) on the worker.
234    pub descriptor: serde_json::Value,
235    /// Relative size estimate for skew-aware assignment, if the source provided one.
236    pub size_estimate: Option<u64>,
237}
238
239/// A shard claimed for execution, carrying its parent run's record (whose
240/// `config_body` the worker re-loads to build + shard the source).
241#[derive(Debug, Clone)]
242pub struct ClaimedShard {
243    pub run_id: String,
244    pub shard_id: String,
245    pub descriptor: serde_json::Value,
246    /// The parent run record (config body, name, etc.).
247    pub run: RunRecord,
248}
249
250/// Aggregate shard status for a run, used by the coordinator to decide when the
251/// parent run is finished.
252#[derive(Debug, Clone, Default, PartialEq, Eq)]
253pub struct ShardProgress {
254    pub total: usize,
255    pub completed: usize,
256    pub failed: usize,
257    pub running: usize,
258    pub pending: usize,
259}
260
261impl ShardProgress {
262    /// True when every shard has reached a terminal state (and at least one
263    /// exists) — i.e. the parent run can be finalized.
264    pub fn all_terminal(&self) -> bool {
265        self.total > 0 && self.completed + self.failed == self.total
266    }
267}
268
269#[async_trait]
270pub trait RunHistory: Send + Sync {
271    /// Atomically claim `key` for `run_id` (or report a replay/conflict). A prior
272    /// claim older than `window` is treated as expired and re-claimable.
273    async fn claim_idempotency(
274        &self,
275        key: &str,
276        fingerprint: &str,
277        run_id: &str,
278        window: Duration,
279    ) -> Result<Claim, HistoryError>;
280
281    /// Insert or replace a run record.
282    async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError>;
283
284    async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError>;
285
286    async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError>;
287
288    /// Delete a terminal run. Non-terminal → `StillRunning` (caller maps to 409).
289    async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError>;
290
291    /// Drop terminal records finished longer than `retain_for` ago. Returns the
292    /// number removed.
293    async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError>;
294
295    /// Release the idempotency claim(s) pointing at `run_id`. Called on the
296    /// submit path when the run-record write that should immediately follow a
297    /// `Fresh` claim fails — without it, a fallible (SQL) backend would orphan
298    /// the claim, so every replay of the key 404s until the claim self-expires
299    /// within the retention window (F21). Scoped by `run_id`, so a newer run
300    /// that re-claimed the same key keeps its claim. Best-effort. Default:
301    /// no-op — the in-memory backend's `upsert` is infallible, so a `Fresh`
302    /// claim is always paired with a record.
303    async fn release_idempotency(&self, run_id: &str) -> Result<(), HistoryError> {
304        let _ = run_id;
305        Ok(())
306    }
307
308    /// Mark non-terminal records whose owning instance's lease has expired as
309    /// failed (instance-fenced orphan recovery — never touches a live peer's
310    /// heartbeated runs, #146 H7). Returns the number recovered. The memory
311    /// backend has nothing to recover (returns 0).
312    async fn recover_orphans(&self) -> Result<usize, HistoryError>;
313
314    /// Heartbeat: extend the lease of *this* instance's own non-terminal runs so
315    /// a peer's [`recover_orphans`](Self::recover_orphans) won't reclaim them.
316    /// Returns the number of leases renewed. The memory backend (single-process,
317    /// unshared) is a no-op returning 0.
318    async fn renew_leases(&self) -> Result<usize, HistoryError> {
319        Ok(0)
320    }
321
322    /// Atomically claim up to `limit` oldest `Pending` runs for *this* instance,
323    /// moving them `Pending` → `Running` with a fresh lease, and return the
324    /// claimed records (with `config_body`) for the caller to execute. Exclusive:
325    /// a run claimed by one caller is never returned to another. Default: none
326    /// (memory is single-process and never writes `Pending`).
327    async fn claim_pending(&self, limit: usize) -> Result<Vec<RunRecord>, HistoryError> {
328        let _ = limit;
329        Ok(Vec::new())
330    }
331
332    /// Cluster failover: expired-lease `Running` runs whose `attempt < max_attempts`
333    /// go back to `Pending` (owner/lease cleared, `attempt++`); the rest are
334    /// `Failed` (poison). Returns the counts. Default: nothing to reclaim.
335    async fn reclaim_orphans(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
336        let _ = max_attempts;
337        Ok(ReclaimReport::default())
338    }
339
340    /// Owner-fenced terminal write: persist `rec` only if this instance still owns
341    /// the run. Returns `true` if the write landed, `false` if another instance
342    /// reclaimed it (the caller should discard its result). Default: delegate to
343    /// `upsert` (memory/single-process always owns its runs).
344    async fn finalize_owned(&self, rec: &RunRecord) -> Result<bool, HistoryError> {
345        self.upsert(rec).await.map(|_| true)
346    }
347
348    /// Status-fenced finalize of a `Sharded` parent run: set the terminal
349    /// `status` / `finished_at` / `error` only while the run is still `Sharded`,
350    /// and — crucially — WITHOUT re-stamping `owner` / `lease_expires_at`. A
351    /// terminal record must not re-arm a lease, and two shards finishing on two
352    /// instances at once must not last-writer-wins overwrite each other via the
353    /// owner-stamping `upsert` (F45). Returns `true` if *this* call performed the
354    /// transition (the first finalizer wins; a concurrent second call is a
355    /// no-op). Default: read-guard-write via `upsert` — correct for the
356    /// single-process in-memory backend, which has no cross-instance race and no
357    /// lease columns. The SQL backends override this with one conditional UPDATE.
358    async fn finalize_sharded_parent(
359        &self,
360        run_id: &str,
361        status: RunStatus,
362        finished_at: DateTime<Utc>,
363        error: Option<String>,
364    ) -> Result<bool, HistoryError> {
365        match self.get(run_id).await? {
366            Some(mut r) if r.status == RunStatus::Sharded => {
367                r.status = status;
368                r.finished_at = Some(finished_at);
369                r.error = error;
370                self.upsert(&r).await?;
371                Ok(true)
372            }
373            _ => Ok(false),
374        }
375    }
376
377    /// Cancel a still-`Pending` (unclaimed) run directly. Returns `true` if it was
378    /// pending and is now `Cancelled`; `false` if it had already been claimed (the
379    /// caller should fall back to [`request_cancel`](Self::request_cancel)).
380    /// Default: `false`.
381    async fn cancel_pending(&self, run_id: &str) -> Result<bool, HistoryError> {
382        let _ = run_id;
383        Ok(false)
384    }
385
386    /// Flag a `Running` run for cross-instance cancellation; its owning instance
387    /// fires the local cancel on its next claim-loop tick. Default: no-op.
388    async fn request_cancel(&self, run_id: &str) -> Result<(), HistoryError> {
389        let _ = run_id;
390        Ok(())
391    }
392
393    /// This instance's own `Running` runs that have a pending cancel request.
394    /// Default: none.
395    async fn pending_cancellations(&self) -> Result<Vec<String>, HistoryError> {
396        Ok(Vec::new())
397    }
398
399    /// Membership heartbeat: upsert this instance's liveness row. Default: no-op.
400    async fn heartbeat_instance(&self, beat: &InstanceHeartbeat) -> Result<(), HistoryError> {
401        let _ = beat;
402        Ok(())
403    }
404
405    /// Live cluster members (last heartbeat within `ttl`). Default: none.
406    async fn live_instances(&self, ttl: Duration) -> Result<Vec<InstanceRecord>, HistoryError> {
407        let _ = ttl;
408        Ok(Vec::new())
409    }
410
411    // ── Source-shard coordination (Mode B, #230) ────────────────────────────
412    //
413    // All default to inert so the in-memory (single-process, unsharded) backend
414    // and any non-cluster deployment are unaffected. Implemented by the SQL
415    // backends, which share one `faucet_serve_shards` table.
416
417    /// Idempotently insert the shard set for `run_id` (`INSERT … ON CONFLICT
418    /// (run_id, shard_id) DO NOTHING`), so concurrent coordinators converge on
419    /// the same set without a leader. Returns the number of rows newly inserted.
420    /// Default: no-op.
421    async fn insert_shards(
422        &self,
423        run_id: &str,
424        shards: &[ShardInsert],
425    ) -> Result<usize, HistoryError> {
426        let _ = (run_id, shards);
427        Ok(0)
428    }
429
430    /// Atomically claim up to `limit` `pending` shards for *this* instance
431    /// (`pending` → `running` with a fresh lease), largest-estimated-size first
432    /// for skew-aware balancing, returning each with its parent run record.
433    /// Exclusive, like [`claim_pending`](Self::claim_pending). Default: none.
434    async fn claim_shards(&self, limit: usize) -> Result<Vec<ClaimedShard>, HistoryError> {
435        let _ = limit;
436        Ok(Vec::new())
437    }
438
439    /// Heartbeat: extend the lease of this instance's own `running` shards so a
440    /// peer's [`reclaim_shards`](Self::reclaim_shards) won't reassign them.
441    /// Returns the number renewed. Default: no-op.
442    async fn renew_shard_leases(&self) -> Result<usize, HistoryError> {
443        Ok(0)
444    }
445
446    /// Rebalance: expired-lease `running` shards whose `attempt < max_attempts`
447    /// go back to `pending` (owner cleared, `attempt++`) for another worker to
448    /// claim; the rest are `failed` (poison). Returns the counts. Default: none.
449    async fn reclaim_shards(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
450        let _ = max_attempts;
451        Ok(ReclaimReport::default())
452    }
453
454    /// Owner-fenced terminal write for one shard (`running` → `completed`/`failed`),
455    /// only if this instance still owns it. Returns `true` if the write landed.
456    /// Default: `false`.
457    async fn finalize_shard(
458        &self,
459        run_id: &str,
460        shard_id: &str,
461        success: bool,
462    ) -> Result<bool, HistoryError> {
463        let _ = (run_id, shard_id, success);
464        Ok(false)
465    }
466
467    /// Aggregate shard status counts for a run (drives parent-run finalization).
468    /// Default: empty.
469    async fn shard_progress(&self, run_id: &str) -> Result<ShardProgress, HistoryError> {
470        let _ = run_id;
471        Ok(ShardProgress::default())
472    }
473
474    /// Distinct run_ids for which THIS instance owns a `running` shard whose
475    /// parent run has a pending cancellation request (F10). The claim loop fires
476    /// each returned run's local shard tokens via
477    /// [`Registry::cancel_run_shards`](crate::serve::registry::Registry::cancel_run_shards).
478    /// Default: none (single-process / memory owns no cross-instance shards).
479    async fn pending_shard_cancellations(&self) -> Result<Vec<String>, HistoryError> {
480        Ok(Vec::new())
481    }
482
483    /// Sweep `sharded` parent runs whose shards are ALL terminal and finalize
484    /// each to `Completed` (no failures) or `Failed`, stamping `finished_at`
485    /// (F11). Recovers a parent that no shard task finalized inline (e.g. the
486    /// coordinator crashed after the last shard completed elsewhere). Returns the
487    /// number finalized. Status-fenced, so a concurrent inline finalize is a
488    /// benign no-op and the run-finished metric is never double-counted. Default:
489    /// nothing to finalize.
490    async fn finalize_completed_sharded_parents(&self) -> Result<usize, HistoryError> {
491        Ok(0)
492    }
493
494    /// True when the backend is in fallback mode (drives `/readyz`). Always false
495    /// for memory.
496    fn degraded(&self) -> bool;
497}
498
499/// Build the configured run-history backend. `Memory` is always available; the
500/// SQL backends require their respective `serve-history-*` build features (a
501/// clear error otherwise). A SQL backend that fails to connect at startup
502/// degrades to in-memory (via `FallbackHistory`) rather than aborting boot.
503pub async fn connect(
504    spec: &HistoryBackendSpec,
505    idem_retention: Duration,
506    lease_ttl: Duration,
507    instance_id: &str,
508) -> CliResult<Arc<dyn RunHistory>> {
509    match spec {
510        HistoryBackendSpec::Memory => {
511            Ok(Arc::new(memory::MemoryHistory::new(idem_retention)) as Arc<dyn RunHistory>)
512        }
513        HistoryBackendSpec::Postgres(url) => {
514            connect_postgres(url, idem_retention, lease_ttl, instance_id).await
515        }
516        HistoryBackendSpec::Sqlite(url) => {
517            connect_sqlite(url, idem_retention, lease_ttl, instance_id).await
518        }
519    }
520}
521
522#[cfg(feature = "serve-history-postgres")]
523async fn connect_postgres(
524    url: &str,
525    idem: Duration,
526    lease_ttl: Duration,
527    instance_id: &str,
528) -> CliResult<Arc<dyn RunHistory>> {
529    let result = connect_with_retry("postgres", || {
530        postgres::PostgresHistory::connect(url, idem, lease_ttl, instance_id.to_string())
531    })
532    .await;
533    Ok(into_history(result, idem, "postgres"))
534}
535
536#[cfg(not(feature = "serve-history-postgres"))]
537async fn connect_postgres(
538    _url: &str,
539    _idem: Duration,
540    _lease_ttl: Duration,
541    _instance_id: &str,
542) -> CliResult<Arc<dyn RunHistory>> {
543    Err(crate::error::CliError::Serve(
544        "persistent Postgres run history requires building faucet with the \
545         `serve-history-postgres` feature"
546            .into(),
547    ))
548}
549
550#[cfg(feature = "serve-history-sqlite")]
551async fn connect_sqlite(
552    url: &str,
553    idem: Duration,
554    lease_ttl: Duration,
555    instance_id: &str,
556) -> CliResult<Arc<dyn RunHistory>> {
557    let result = connect_with_retry("sqlite", || {
558        sqlite::SqliteHistory::connect(url, idem, lease_ttl, instance_id.to_string())
559    })
560    .await;
561    Ok(into_history(result, idem, "sqlite"))
562}
563
564#[cfg(not(feature = "serve-history-sqlite"))]
565async fn connect_sqlite(
566    _url: &str,
567    _idem: Duration,
568    _lease_ttl: Duration,
569    _instance_id: &str,
570) -> CliResult<Arc<dyn RunHistory>> {
571    Err(crate::error::CliError::Serve(
572        "persistent SQLite run history requires building faucet with the \
573         `serve-history-sqlite` feature"
574            .into(),
575    ))
576}
577
578/// How many times `connect_with_retry` attempts a transient backend connect
579/// before giving up and degrading. Eight attempts with capped exponential
580/// backoff span a few seconds — long enough for two clustered instances to get
581/// past the WAL/DDL startup race on a shared SQLite file, short enough not to
582/// stall startup against a genuinely-down backend.
583#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
584const CONNECT_ATTEMPTS: usize = 8;
585
586/// Retry a *transient* backend-connect failure before falling back to degraded
587/// mode. Two clustered instances opening the same SQLite file at startup briefly
588/// race the WAL/DDL setup and surface `database is locked`; a freshly-booting
589/// Postgres can refuse connections for a moment. Both are self-resolving — but
590/// degrading permanently on the *first* blip strands a cluster instance on the
591/// in-memory store, which cannot serve cluster submits and returns `503` for
592/// every request (#235). A genuinely unreachable backend still degrades once the
593/// attempt budget is spent, preserving the stay-alive fallback.
594#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
595async fn connect_with_retry<H, F, Fut>(label: &str, mut make: F) -> Result<H, HistoryError>
596where
597    F: FnMut() -> Fut,
598    Fut: std::future::Future<Output = Result<H, HistoryError>>,
599{
600    let mut delay = Duration::from_millis(100);
601    for attempt in 1..=CONNECT_ATTEMPTS {
602        match make().await {
603            Ok(backend) => return Ok(backend),
604            Err(e) if attempt < CONNECT_ATTEMPTS && is_transient_connect_error(&e) => {
605                tracing::warn!(
606                    backend = label,
607                    attempt,
608                    error = %e,
609                    "run-history backend connect failed transiently; retrying before degrading"
610                );
611                tokio::time::sleep(delay).await;
612                delay = (delay * 2).min(Duration::from_secs(1));
613            }
614            Err(e) => return Err(e),
615        }
616    }
617    unreachable!("the final attempt returns Ok or Err rather than looping")
618}
619
620/// Whether a connect error is worth retrying: transient contention or a
621/// still-booting backend, as opposed to a permanent misconfiguration (e.g. a
622/// malformed URL) that no amount of retrying will fix.
623#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
624fn is_transient_connect_error(e: &HistoryError) -> bool {
625    let msg = e.to_string().to_ascii_lowercase();
626    [
627        "database is locked", // SQLite: two cluster instances race WAL/DDL at startup
628        "busy",               // SQLITE_BUSY
629        "connection refused", // backend still binding its listener
630        "connection reset",
631        "timed out",
632        "timeout",
633        "starting up",          // Postgres: "the database system is starting up"
634        "too many connections", // transient connection saturation
635    ]
636    .iter()
637    .any(|needle| msg.contains(needle))
638}
639
640/// Wrap a SQL backend in `FallbackHistory`: healthy on success; degraded-on-
641/// in-memory (server stays up, `/readyz` reports 503) on a connect failure.
642#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
643fn into_history<H: RunHistory + 'static>(
644    result: Result<H, HistoryError>,
645    idem: Duration,
646    label: &'static str,
647) -> Arc<dyn RunHistory> {
648    match result {
649        Ok(backend) => Arc::new(fallback::FallbackHistory::healthy(
650            Box::new(backend),
651            idem,
652            label,
653        )),
654        Err(e) => {
655            tracing::error!(
656                backend = label, error = %e,
657                "run-history backend unavailable at startup; starting DEGRADED on in-memory store"
658            );
659            Arc::new(fallback::FallbackHistory::degraded_at_startup(idem, label))
660        }
661    }
662}
663
664#[cfg(test)]
665mod tests {
666    use super::*;
667
668    #[test]
669    fn terminal_classification() {
670        assert!(!RunStatus::Queued.is_terminal());
671        assert!(!RunStatus::Pending.is_terminal());
672        assert!(!RunStatus::Running.is_terminal());
673        assert!(RunStatus::Completed.is_terminal());
674        assert!(RunStatus::Failed.is_terminal());
675        assert!(RunStatus::Cancelled.is_terminal());
676    }
677
678    #[test]
679    fn run_record_serializes_status_snake_case() {
680        let rec = RunRecord::queued(
681            "r1".into(),
682            Some("n".into()),
683            Default::default(),
684            None,
685            Utc::now(),
686        );
687        let v = serde_json::to_value(&rec).unwrap();
688        assert_eq!(v["status"], "queued");
689        assert_eq!(v["run_id"], "r1");
690        // doctor_report is skipped when None.
691        assert!(v.get("doctor_report").is_none());
692    }
693
694    #[test]
695    fn pending_is_non_terminal_and_serializes_snake_case() {
696        assert!(!RunStatus::Pending.is_terminal());
697        assert_eq!(RunStatus::Pending.as_str(), "pending");
698        let mut rec = RunRecord::queued("r".into(), None, Default::default(), None, Utc::now());
699        rec.status = RunStatus::Pending;
700        rec.attempt = 2;
701        let v = serde_json::to_value(&rec).unwrap();
702        assert_eq!(v["status"], "pending");
703        assert_eq!(v["attempt"], 2);
704        // Cluster config fields are skipped when absent.
705        assert!(v.get("config_body").is_none());
706    }
707
708    #[test]
709    fn shard_progress_all_terminal() {
710        // No shards yet → not terminal (don't finalize an unexpanded run).
711        assert!(!ShardProgress::default().all_terminal());
712        // Some still running.
713        let mut p = ShardProgress {
714            total: 3,
715            completed: 1,
716            failed: 0,
717            running: 1,
718            pending: 1,
719        };
720        assert!(!p.all_terminal());
721        // All terminal (mix of completed + failed sums to total).
722        p = ShardProgress {
723            total: 3,
724            completed: 2,
725            failed: 1,
726            running: 0,
727            pending: 0,
728        };
729        assert!(p.all_terminal());
730    }
731
732    #[tokio::test]
733    async fn memory_backend_shard_methods_are_inert() {
734        use crate::serve::history::memory::MemoryHistory;
735        let h = MemoryHistory::new(Duration::from_secs(60));
736        assert_eq!(h.insert_shards("r", &[]).await.unwrap(), 0);
737        assert!(h.claim_shards(8).await.unwrap().is_empty());
738        assert_eq!(h.renew_shard_leases().await.unwrap(), 0);
739        assert!(!h.finalize_shard("r", "0", true).await.unwrap());
740        assert_eq!(
741            h.shard_progress("r").await.unwrap(),
742            ShardProgress::default()
743        );
744    }
745
746    #[tokio::test]
747    async fn memory_backend_cluster_methods_are_inert() {
748        use crate::serve::history::memory::MemoryHistory;
749        let h = MemoryHistory::new(Duration::from_secs(60));
750        assert!(h.claim_pending(8).await.unwrap().is_empty());
751        assert_eq!(
752            h.reclaim_orphans(3).await.unwrap(),
753            ReclaimReport::default()
754        );
755        assert!(!h.cancel_pending("x").await.unwrap());
756        h.request_cancel("x").await.unwrap();
757        assert!(h.pending_cancellations().await.unwrap().is_empty());
758        assert!(
759            h.live_instances(Duration::from_secs(60))
760                .await
761                .unwrap()
762                .is_empty()
763        );
764
765        // finalize_owned's default delegates to upsert (single-process always owns).
766        let rec = RunRecord::queued("fo".into(), None, Default::default(), None, Utc::now());
767        assert!(h.finalize_owned(&rec).await.unwrap());
768        assert_eq!(h.get("fo").await.unwrap().unwrap().run_id, "fo");
769    }
770}
771
772#[cfg(all(
773    test,
774    any(feature = "serve-history-postgres", feature = "serve-history-sqlite")
775))]
776mod connect_retry_tests {
777    use super::*;
778    use std::cell::Cell;
779
780    #[test]
781    fn classifies_transient_vs_permanent_connect_errors() {
782        // SQLite concurrent-startup contention (#235) — retryable.
783        assert!(is_transient_connect_error(&HistoryError::Backend(
784            "SQLite connection failed: error returned from database: (code: 5) \
785             database is locked"
786                .into()
787        )));
788        // Booting Postgres — retryable.
789        assert!(is_transient_connect_error(&HistoryError::Backend(
790            "connection refused (os error 111)".into()
791        )));
792        // Permanent misconfiguration — not worth retrying.
793        assert!(!is_transient_connect_error(&HistoryError::Backend(
794            "invalid sqlite url 'sqlite::nonsense': ParseError".into()
795        )));
796    }
797
798    #[tokio::test]
799    async fn retries_a_transient_failure_then_succeeds() {
800        let calls = Cell::new(0usize);
801        let result: Result<u32, HistoryError> = connect_with_retry("test", || {
802            let n = calls.get() + 1;
803            calls.set(n);
804            async move {
805                if n < 3 {
806                    Err(HistoryError::Backend("database is locked".into()))
807                } else {
808                    Ok(42u32)
809                }
810            }
811        })
812        .await;
813        assert_eq!(result.unwrap(), 42);
814        assert_eq!(
815            calls.get(),
816            3,
817            "two transient failures retried, third succeeds"
818        );
819    }
820
821    #[tokio::test]
822    async fn does_not_retry_a_permanent_error() {
823        let calls = Cell::new(0usize);
824        let result: Result<u32, HistoryError> = connect_with_retry("test", || {
825            calls.set(calls.get() + 1);
826            async move { Err::<u32, _>(HistoryError::Backend("invalid sqlite url 'x'".into())) }
827        })
828        .await;
829        assert!(result.is_err());
830        assert_eq!(
831            calls.get(),
832            1,
833            "a permanent error degrades immediately, no retry"
834        );
835    }
836}