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