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