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