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/// One persisted log line for a completed run (#529). Stored in
326/// `faucet_serve_run_logs` (SQL backends) or an in-memory map (memory backend),
327/// and surfaced by `GET /v1/runs/{id}/logs?format=jsonl|text` after the run's
328/// ephemeral SSE buffer has drained. `seq` is the run-scoped monotonic sequence
329/// (from the capture ring), and is the ordering + pagination key.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct RunLogLine {
332    pub seq: u64,
333    /// RFC 3339 capture timestamp.
334    pub ts: String,
335    /// Log level (`INFO` / `WARN` / …).
336    pub level: String,
337    /// The redacted, formatted log line.
338    pub line: String,
339}
340
341/// A page of persisted run logs (#529), oldest-first. `truncated` is `true` when
342/// earlier lines for the run were dropped by the per-run cap
343/// (`--log-max-lines-per-run`), so the caller can flag the gap.
344#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
345pub struct RunLogPage {
346    pub lines: Vec<RunLogLine>,
347    pub truncated: bool,
348}
349
350/// Sentinel `seq` marking a per-run truncation record (the per-run cap was hit).
351/// Stored like a normal line but excluded from `lines`, setting `truncated`.
352pub const RUN_LOG_TRUNCATED_SEQ: u64 = u64::MAX;
353
354#[async_trait]
355pub trait RunHistory: Send + Sync {
356    /// Atomically claim `key` for `run_id` (or report a replay/conflict). A prior
357    /// claim older than `window` is treated as expired and re-claimable.
358    async fn claim_idempotency(
359        &self,
360        key: &str,
361        fingerprint: &str,
362        run_id: &str,
363        window: Duration,
364    ) -> Result<Claim, HistoryError>;
365
366    /// Insert or replace a run record.
367    async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError>;
368
369    async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError>;
370
371    async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError>;
372
373    /// Delete a terminal run. Non-terminal → `StillRunning` (caller maps to 409).
374    async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError>;
375
376    /// Drop terminal records finished longer than `retain_for` ago. Returns the
377    /// number removed.
378    async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError>;
379
380    /// Release the idempotency claim(s) pointing at `run_id`. Called on the
381    /// submit path when the run-record write that should immediately follow a
382    /// `Fresh` claim fails — without it, a fallible (SQL) backend would orphan
383    /// the claim, so every replay of the key 404s until the claim self-expires
384    /// within the retention window (F21). Scoped by `run_id`, so a newer run
385    /// that re-claimed the same key keeps its claim. Best-effort. Default:
386    /// no-op — the in-memory backend's `upsert` is infallible, so a `Fresh`
387    /// claim is always paired with a record.
388    async fn release_idempotency(&self, run_id: &str) -> Result<(), HistoryError> {
389        let _ = run_id;
390        Ok(())
391    }
392
393    /// Mark non-terminal records whose owning instance's lease has expired as
394    /// failed (instance-fenced orphan recovery — never touches a live peer's
395    /// heartbeated runs, #146 H7). Returns the number recovered. The memory
396    /// backend has nothing to recover (returns 0).
397    async fn recover_orphans(&self) -> Result<usize, HistoryError>;
398
399    /// Heartbeat: extend the lease of *this* instance's own non-terminal runs so
400    /// a peer's [`recover_orphans`](Self::recover_orphans) won't reclaim them.
401    /// Returns the number of leases renewed. The memory backend (single-process,
402    /// unshared) is a no-op returning 0.
403    async fn renew_leases(&self) -> Result<usize, HistoryError> {
404        Ok(0)
405    }
406
407    /// Atomically claim up to `limit` oldest `Pending` runs for *this* instance,
408    /// moving them `Pending` → `Running` with a fresh lease, and return the
409    /// claimed records (with `config_body`) for the caller to execute. Exclusive:
410    /// a run claimed by one caller is never returned to another. Default: none
411    /// (memory is single-process and never writes `Pending`).
412    async fn claim_pending(&self, limit: usize) -> Result<Vec<RunRecord>, HistoryError> {
413        let _ = limit;
414        Ok(Vec::new())
415    }
416
417    /// Cluster failover: expired-lease `Running` runs whose `attempt < max_attempts`
418    /// go back to `Pending` (owner/lease cleared, `attempt++`); the rest are
419    /// `Failed` (poison). Returns the counts. Default: nothing to reclaim.
420    async fn reclaim_orphans(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
421        let _ = max_attempts;
422        Ok(ReclaimReport::default())
423    }
424
425    /// Owner-fenced terminal write: persist `rec` only if this instance still owns
426    /// the run. Returns `true` if the write landed, `false` if another instance
427    /// reclaimed it (the caller should discard its result). Default: delegate to
428    /// `upsert` (memory/single-process always owns its runs).
429    async fn finalize_owned(&self, rec: &RunRecord) -> Result<bool, HistoryError> {
430        self.upsert(rec).await.map(|_| true)
431    }
432
433    /// Status-fenced finalize of a `Sharded` parent run: set the terminal
434    /// `status` / `finished_at` / `error` only while the run is still `Sharded`,
435    /// and — crucially — WITHOUT re-stamping `owner` / `lease_expires_at`. A
436    /// terminal record must not re-arm a lease, and two shards finishing on two
437    /// instances at once must not last-writer-wins overwrite each other via the
438    /// owner-stamping `upsert` (F45). Returns `true` if *this* call performed the
439    /// transition (the first finalizer wins; a concurrent second call is a
440    /// no-op). Default: read-guard-write via `upsert` — correct for the
441    /// single-process in-memory backend, which has no cross-instance race and no
442    /// lease columns. The SQL backends override this with one conditional UPDATE.
443    async fn finalize_sharded_parent(
444        &self,
445        run_id: &str,
446        status: RunStatus,
447        finished_at: DateTime<Utc>,
448        error: Option<String>,
449    ) -> Result<bool, HistoryError> {
450        match self.get(run_id).await? {
451            Some(mut r) if r.status == RunStatus::Sharded => {
452                r.status = status;
453                r.finished_at = Some(finished_at);
454                r.error = error;
455                self.upsert(&r).await?;
456                Ok(true)
457            }
458            _ => Ok(false),
459        }
460    }
461
462    /// Cancel a still-`Pending` (unclaimed) run directly. Returns `true` if it was
463    /// pending and is now `Cancelled`; `false` if it had already been claimed (the
464    /// caller should fall back to [`request_cancel`](Self::request_cancel)).
465    /// Default: `false`.
466    async fn cancel_pending(&self, run_id: &str) -> Result<bool, HistoryError> {
467        let _ = run_id;
468        Ok(false)
469    }
470
471    /// Flag a `Running` run for cross-instance cancellation; its owning instance
472    /// fires the local cancel on its next claim-loop tick. Default: no-op.
473    async fn request_cancel(&self, run_id: &str) -> Result<(), HistoryError> {
474        let _ = run_id;
475        Ok(())
476    }
477
478    /// This instance's own `Running` runs that have a pending cancel request.
479    /// Default: none.
480    async fn pending_cancellations(&self) -> Result<Vec<String>, HistoryError> {
481        Ok(Vec::new())
482    }
483
484    /// Membership heartbeat: upsert this instance's liveness row. Default: no-op.
485    async fn heartbeat_instance(&self, beat: &InstanceHeartbeat) -> Result<(), HistoryError> {
486        let _ = beat;
487        Ok(())
488    }
489
490    /// Live cluster members (last heartbeat within `ttl`). Default: none.
491    async fn live_instances(&self, ttl: Duration) -> Result<Vec<InstanceRecord>, HistoryError> {
492        let _ = ttl;
493        Ok(Vec::new())
494    }
495
496    // ── Source-shard coordination (Mode B, #230) ────────────────────────────
497    //
498    // All default to inert so the in-memory (single-process, unsharded) backend
499    // and any non-cluster deployment are unaffected. Implemented by the SQL
500    // backends, which share one `faucet_serve_shards` table.
501
502    /// Idempotently insert the shard set for `run_id` (`INSERT … ON CONFLICT
503    /// (run_id, shard_id) DO NOTHING`), so concurrent coordinators converge on
504    /// the same set without a leader. Returns the number of rows newly inserted.
505    /// Default: no-op.
506    async fn insert_shards(
507        &self,
508        run_id: &str,
509        shards: &[ShardInsert],
510    ) -> Result<usize, HistoryError> {
511        let _ = (run_id, shards);
512        Ok(0)
513    }
514
515    /// Atomically claim up to `limit` `pending` shards for *this* instance
516    /// (`pending` → `running` with a fresh lease), largest-estimated-size first
517    /// for skew-aware balancing, returning each with its parent run record.
518    /// Exclusive, like [`claim_pending`](Self::claim_pending). Default: none.
519    async fn claim_shards(&self, limit: usize) -> Result<Vec<ClaimedShard>, HistoryError> {
520        let _ = limit;
521        Ok(Vec::new())
522    }
523
524    /// Heartbeat: extend the lease of this instance's own `running` shards so a
525    /// peer's [`reclaim_shards`](Self::reclaim_shards) won't reassign them.
526    /// Returns the number renewed. Default: no-op.
527    async fn renew_shard_leases(&self) -> Result<usize, HistoryError> {
528        Ok(0)
529    }
530
531    /// Rebalance: expired-lease `running` shards whose `attempt < max_attempts`
532    /// go back to `pending` (owner cleared, `attempt++`) for another worker to
533    /// claim; the rest are `failed` (poison). Returns the counts. Default: none.
534    async fn reclaim_shards(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
535        let _ = max_attempts;
536        Ok(ReclaimReport::default())
537    }
538
539    /// Owner-fenced terminal write for one shard (`running` → `completed`/`failed`),
540    /// only if this instance still owns it. Returns `true` if the write landed.
541    /// Default: `false`.
542    async fn finalize_shard(
543        &self,
544        run_id: &str,
545        shard_id: &str,
546        success: bool,
547    ) -> Result<bool, HistoryError> {
548        let _ = (run_id, shard_id, success);
549        Ok(false)
550    }
551
552    /// Aggregate shard status counts for a run (drives parent-run finalization).
553    /// Default: empty.
554    async fn shard_progress(&self, run_id: &str) -> Result<ShardProgress, HistoryError> {
555        let _ = run_id;
556        Ok(ShardProgress::default())
557    }
558
559    /// Distinct run_ids for which THIS instance owns a `running` shard whose
560    /// parent run has a pending cancellation request (F10). The claim loop fires
561    /// each returned run's local shard tokens via
562    /// [`Registry::cancel_run_shards`](crate::serve::registry::Registry::cancel_run_shards).
563    /// Default: none (single-process / memory owns no cross-instance shards).
564    async fn pending_shard_cancellations(&self) -> Result<Vec<String>, HistoryError> {
565        Ok(Vec::new())
566    }
567
568    /// Sweep `sharded` parent runs whose shards are ALL terminal and finalize
569    /// each to `Completed` (no failures) or `Failed`, stamping `finished_at`
570    /// (F11). Recovers a parent that no shard task finalized inline (e.g. the
571    /// coordinator crashed after the last shard completed elsewhere). Returns the
572    /// number finalized. Status-fenced, so a concurrent inline finalize is a
573    /// benign no-op and the run-finished metric is never double-counted. Default:
574    /// nothing to finalize.
575    async fn finalize_completed_sharded_parents(&self) -> Result<usize, HistoryError> {
576        Ok(0)
577    }
578
579    // ── Audit log (RBAC, #205) ───────────────────────────────────────────────
580
581    /// Append one audit record. Best-effort but visible: the caller logs a
582    /// warning on failure (audit writes must never silently vanish, and must
583    /// never fail the underlying action). Default: no-op — overridden by the
584    /// memory + SQL backends.
585    async fn record_audit(&self, entry: &AuditEntry) -> Result<(), HistoryError> {
586        let _ = entry;
587        Ok(())
588    }
589
590    /// Most-recent audit records matching `filter`, newest first. Default: empty.
591    async fn list_audit(&self, filter: &AuditFilter) -> Result<Vec<AuditEntry>, HistoryError> {
592        let _ = filter;
593        Ok(Vec::new())
594    }
595
596    // ── Persistent run logs (#529) ────────────────────────────────────────────
597    //
598    // Durable, fetch-anytime logs for a completed run, so `GET
599    // /v1/runs/{id}/logs?format=jsonl|text` works past the ephemeral SSE drain
600    // window. Defaulted to inert so third-party impls are unaffected; implemented
601    // by the memory + SQL backends and forwarded by the fallback wrapper. Purged
602    // on their own retention window (`--log-retention-secs`), independent of run
603    // records.
604
605    /// Append a batch of captured log lines for a run (already redacted). A line
606    /// with `seq == RUN_LOG_TRUNCATED_SEQ` marks a per-run cap truncation.
607    /// Best-effort: failures are logged, never fatal. Default: no-op.
608    async fn record_run_logs(
609        &self,
610        run_id: &str,
611        lines: &[RunLogLine],
612    ) -> Result<(), HistoryError> {
613        let _ = (run_id, lines);
614        Ok(())
615    }
616
617    /// A page of a run's persisted logs, oldest-first, `seq > after_seq`, capped
618    /// at `limit`. Default: empty.
619    async fn list_run_logs(
620        &self,
621        run_id: &str,
622        after_seq: Option<u64>,
623        limit: usize,
624    ) -> Result<RunLogPage, HistoryError> {
625        let _ = (run_id, after_seq, limit);
626        Ok(RunLogPage::default())
627    }
628
629    /// Drop persisted log lines older than `older_than`. Returns the number
630    /// removed. Independent of run-record retention. Default: nothing purged.
631    async fn purge_run_logs(&self, older_than: Duration) -> Result<usize, HistoryError> {
632        let _ = older_than;
633        Ok(0)
634    }
635
636    // ── Data Movement Catalog (#279) ─────────────────────────────────────────
637    //
638    // Accumulating, cross-run picture of every dataset a pipeline touches:
639    // identity, schema timeline, volume/freshness stats, lineage edges. All
640    // defaulted to inert so third-party `RunHistory` impls are unaffected;
641    // implemented by the memory + SQL backends and forwarded by the fallback
642    // wrapper. Catalog rows are deliberately NOT purged by `purge_expired` —
643    // the accumulated history is the point (only per-dataset stats are capped,
644    // at [`catalog::STATS_RETAIN`]).
645
646    /// Fold one run's catalog update (two dataset observations + the lineage
647    /// edge between them) into the store. Idempotent-ish last-write-wins per
648    /// dataset/edge, so concurrent cluster instances converge. Default: no-op.
649    async fn catalog_record(&self, update: &catalog::CatalogUpdate) -> Result<(), HistoryError> {
650        let _ = update;
651        Ok(())
652    }
653
654    /// List catalogued datasets, filtered + keyset-paginated
655    /// (`last_seen DESC, id DESC`). Default: empty.
656    async fn catalog_list_datasets(
657        &self,
658        filter: &catalog::CatalogListFilter,
659    ) -> Result<catalog::CatalogDatasetPage, HistoryError> {
660        let _ = filter;
661        Ok(catalog::CatalogDatasetPage {
662            datasets: Vec::new(),
663            next_cursor: None,
664        })
665    }
666
667    /// One dataset's full detail: current schema, schema timeline, recent
668    /// volume points, and upstream/downstream edges. Default: `None`.
669    async fn catalog_get_dataset(
670        &self,
671        id: &str,
672    ) -> Result<Option<catalog::CatalogDatasetDetail>, HistoryError> {
673        let _ = id;
674        Ok(None)
675    }
676
677    /// The lineage edge graph — everything, or a depth-bounded slice around
678    /// `root` (a dataset id). Default: empty.
679    async fn catalog_lineage(
680        &self,
681        root: Option<&str>,
682        depth: u32,
683    ) -> Result<Vec<catalog::CatalogLineageEdge>, HistoryError> {
684        let _ = (root, depth);
685        Ok(Vec::new())
686    }
687
688    /// Record the latest resolved+expanded config snapshot for a pipeline
689    /// (#374). Latest-wins per pipeline (upsert). Best-effort at the call site —
690    /// recording never fails a run. Default: no-op.
691    async fn catalog_record_config_snapshot(
692        &self,
693        snapshot: &catalog::ConfigSnapshot,
694    ) -> Result<(), HistoryError> {
695        let _ = snapshot;
696        Ok(())
697    }
698
699    /// The most recently recorded config snapshot for `pipeline`, or `None` if
700    /// nothing has been recorded yet (a first `faucet plan --diff`). Default:
701    /// `None`.
702    async fn catalog_last_config_snapshot(
703        &self,
704        pipeline: &str,
705    ) -> Result<Option<catalog::ConfigSnapshot>, HistoryError> {
706        let _ = pipeline;
707        Ok(None)
708    }
709
710    // ── Pipeline-template registry (#444) ────────────────────────────────────
711    //
712    // Register-once / trigger-by-id storage for parameterized configs. Defaulted
713    // inert (like the catalog methods) so a third-party `RunHistory` impl is
714    // unaffected; implemented by the memory + SQL backends and forwarded by the
715    // fallback wrapper. Template rows are NOT purged by run retention — a
716    // template outlives the runs it produced, by design.
717
718    /// Append a new version of a template, returning the stored record with the
719    /// assigned `version`. Versioning is atomic per id: two concurrent registers
720    /// produce two distinct versions, never a lost write. Default: unsupported.
721    async fn template_register(
722        &self,
723        draft: &templates::TemplateDraft,
724    ) -> Result<templates::TemplateRecord, HistoryError> {
725        let _ = draft;
726        Err(HistoryError::Backend(
727            "this run-history backend does not support the pipeline-template registry".into(),
728        ))
729    }
730
731    /// One template version — the latest when `version` is `None`. Default: none.
732    async fn template_get(
733        &self,
734        id: &str,
735        version: Option<u32>,
736    ) -> Result<Option<templates::TemplateRecord>, HistoryError> {
737        let _ = (id, version);
738        Ok(None)
739    }
740
741    /// The latest version of every registered template, newest-registered first.
742    /// Default: empty.
743    async fn template_list(&self) -> Result<Vec<templates::TemplateSummary>, HistoryError> {
744        Ok(Vec::new())
745    }
746
747    /// Every stored version number for one id, newest first. Default: empty.
748    async fn template_versions(&self, id: &str) -> Result<Vec<u32>, HistoryError> {
749        let _ = id;
750        Ok(Vec::new())
751    }
752
753    /// Delete one version (`Some`) or every version (`None`) of a template.
754    /// Returns how many rows were removed. Implementations must also drop any
755    /// named-channel pointer aimed at a deleted version, so a channel never
756    /// dangles. Default: 0.
757    async fn template_delete(&self, id: &str, version: Option<u32>) -> Result<usize, HistoryError> {
758        let _ = (id, version);
759        Ok(0)
760    }
761
762    /// Point a named channel (`dev`, `prod`, …) at an existing version, moving it
763    /// if it was already set. `latest` is derived from the version list and never
764    /// stored, so callers reject it before reaching here. Default: unsupported.
765    async fn template_set_tag(
766        &self,
767        id: &str,
768        tag: &str,
769        version: u32,
770    ) -> Result<(), HistoryError> {
771        let _ = (id, tag, version);
772        Err(HistoryError::Backend(
773            "this run-history backend does not support pipeline-template channels".into(),
774        ))
775    }
776
777    /// Every stored channel pointer for a template (`{tag: version}`), excluding
778    /// the derived `latest`. Default: empty.
779    async fn template_tags(&self, id: &str) -> Result<BTreeMap<String, u32>, HistoryError> {
780        let _ = id;
781        Ok(BTreeMap::new())
782    }
783
784    /// Remove one channel pointer. Returns whether it existed. Default: `false`.
785    async fn template_delete_tag(&self, id: &str, tag: &str) -> Result<bool, HistoryError> {
786        let _ = (id, tag);
787        Ok(false)
788    }
789
790    /// Append a launch to the template's log, making `version` the new `stable`.
791    /// Returns the assigned sequence number, or `None` when `version` is already
792    /// stable (a re-launch is a no-op, which keeps `previous` meaningful rather
793    /// than letting it degrade into a duplicate of `stable`). Default:
794    /// unsupported.
795    async fn template_launch(
796        &self,
797        id: &str,
798        version: u32,
799        launched_by: Option<&str>,
800    ) -> Result<Option<u32>, HistoryError> {
801        let _ = (id, version, launched_by);
802        Err(HistoryError::Backend(
803            "this run-history backend does not support pipeline-template launches".into(),
804        ))
805    }
806
807    /// The template's launch log, **newest first**. Drives `stable` / `previous`,
808    /// the derived template status, and the launch audit trail. Default: empty.
809    async fn template_launches(
810        &self,
811        id: &str,
812    ) -> Result<Vec<templates::LaunchRecord>, HistoryError> {
813        let _ = id;
814        Ok(Vec::new())
815    }
816
817    /// Set (`Some`) or clear (`None`) the template's deprecation marker — the only
818    /// stored part of the lifecycle status. Default: unsupported.
819    async fn template_set_deprecation(
820        &self,
821        id: &str,
822        record: Option<&templates::DeprecationRecord>,
823    ) -> Result<(), HistoryError> {
824        let _ = (id, record);
825        Err(HistoryError::Backend(
826            "this run-history backend does not support pipeline-template deprecation".into(),
827        ))
828    }
829
830    /// The template's deprecation marker, if it is deprecated. Default: `None`.
831    async fn template_deprecation(
832        &self,
833        id: &str,
834    ) -> Result<Option<templates::DeprecationRecord>, HistoryError> {
835        let _ = id;
836        Ok(None)
837    }
838
839    /// The template's full release state: versions, launch-derived `stable` /
840    /// `previous` / `newest`, channel pointers, and the derived status.
841    ///
842    /// Provided (not overridden) so every backend assembles it from the same four
843    /// primitives via the pure [`templates::TemplateState::assemble`] — the
844    /// memory and SQL stores cannot drift on what a set of rows means.
845    async fn template_state(&self, id: &str) -> Result<templates::TemplateState, HistoryError> {
846        Ok(templates::TemplateState::assemble(
847            self.template_versions(id).await?,
848            &self.template_launches(id).await?,
849            self.template_tags(id).await?,
850            self.template_deprecation(id).await?,
851        ))
852    }
853
854    /// True when the backend is in fallback mode (drives `/readyz`). Always false
855    /// for memory.
856    fn degraded(&self) -> bool;
857}
858
859/// Build the configured run-history backend. `Memory` is always available; the
860/// SQL backends require their respective `serve-history-*` build features (a
861/// clear error otherwise). A SQL backend that fails to connect at startup
862/// degrades to in-memory (via `FallbackHistory`) rather than aborting boot.
863pub async fn connect(
864    spec: &HistoryBackendSpec,
865    idem_retention: Duration,
866    lease_ttl: Duration,
867    instance_id: &str,
868) -> CliResult<Arc<dyn RunHistory>> {
869    match spec {
870        HistoryBackendSpec::Memory => {
871            Ok(Arc::new(memory::MemoryHistory::new(idem_retention)) as Arc<dyn RunHistory>)
872        }
873        HistoryBackendSpec::Postgres(url) => {
874            connect_postgres(url, idem_retention, lease_ttl, instance_id).await
875        }
876        HistoryBackendSpec::Sqlite(url) => {
877            connect_sqlite(url, idem_retention, lease_ttl, instance_id).await
878        }
879    }
880}
881
882#[cfg(feature = "serve-history-postgres")]
883async fn connect_postgres(
884    url: &str,
885    idem: Duration,
886    lease_ttl: Duration,
887    instance_id: &str,
888) -> CliResult<Arc<dyn RunHistory>> {
889    let result = connect_with_retry("postgres", || {
890        postgres::PostgresHistory::connect(url, idem, lease_ttl, instance_id.to_string())
891    })
892    .await;
893    Ok(into_history(result, idem, "postgres"))
894}
895
896#[cfg(not(feature = "serve-history-postgres"))]
897async fn connect_postgres(
898    _url: &str,
899    _idem: Duration,
900    _lease_ttl: Duration,
901    _instance_id: &str,
902) -> CliResult<Arc<dyn RunHistory>> {
903    Err(crate::error::CliError::Serve(
904        "persistent Postgres run history requires building faucet with the \
905         `serve-history-postgres` feature"
906            .into(),
907    ))
908}
909
910#[cfg(feature = "serve-history-sqlite")]
911async fn connect_sqlite(
912    url: &str,
913    idem: Duration,
914    lease_ttl: Duration,
915    instance_id: &str,
916) -> CliResult<Arc<dyn RunHistory>> {
917    let result = connect_with_retry("sqlite", || {
918        sqlite::SqliteHistory::connect(url, idem, lease_ttl, instance_id.to_string())
919    })
920    .await;
921    Ok(into_history(result, idem, "sqlite"))
922}
923
924#[cfg(not(feature = "serve-history-sqlite"))]
925async fn connect_sqlite(
926    _url: &str,
927    _idem: Duration,
928    _lease_ttl: Duration,
929    _instance_id: &str,
930) -> CliResult<Arc<dyn RunHistory>> {
931    Err(crate::error::CliError::Serve(
932        "persistent SQLite run history requires building faucet with the \
933         `serve-history-sqlite` feature"
934            .into(),
935    ))
936}
937
938/// How many times `connect_with_retry` attempts a transient backend connect
939/// before giving up and degrading. Eight attempts with capped exponential
940/// backoff span a few seconds — long enough for two clustered instances to get
941/// past the WAL/DDL startup race on a shared SQLite file, short enough not to
942/// stall startup against a genuinely-down backend.
943#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
944const CONNECT_ATTEMPTS: usize = 8;
945
946/// Retry a *transient* backend-connect failure before falling back to degraded
947/// mode. Two clustered instances opening the same SQLite file at startup briefly
948/// race the WAL/DDL setup and surface `database is locked`; a freshly-booting
949/// Postgres can refuse connections for a moment. Both are self-resolving — but
950/// degrading permanently on the *first* blip strands a cluster instance on the
951/// in-memory store, which cannot serve cluster submits and returns `503` for
952/// every request (#235). A genuinely unreachable backend still degrades once the
953/// attempt budget is spent, preserving the stay-alive fallback.
954#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
955async fn connect_with_retry<H, F, Fut>(label: &str, mut make: F) -> Result<H, HistoryError>
956where
957    F: FnMut() -> Fut,
958    Fut: std::future::Future<Output = Result<H, HistoryError>>,
959{
960    let mut delay = Duration::from_millis(100);
961    for attempt in 1..=CONNECT_ATTEMPTS {
962        match make().await {
963            Ok(backend) => return Ok(backend),
964            Err(e) if attempt < CONNECT_ATTEMPTS && is_transient_connect_error(&e) => {
965                tracing::warn!(
966                    backend = label,
967                    attempt,
968                    error = %e,
969                    "run-history backend connect failed transiently; retrying before degrading"
970                );
971                tokio::time::sleep(delay).await;
972                delay = (delay * 2).min(Duration::from_secs(1));
973            }
974            Err(e) => return Err(e),
975        }
976    }
977    unreachable!("the final attempt returns Ok or Err rather than looping")
978}
979
980/// Whether a connect error is worth retrying: transient contention or a
981/// still-booting backend, as opposed to a permanent misconfiguration (e.g. a
982/// malformed URL) that no amount of retrying will fix.
983#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
984fn is_transient_connect_error(e: &HistoryError) -> bool {
985    let msg = e.to_string().to_ascii_lowercase();
986    [
987        "database is locked", // SQLite: two cluster instances race WAL/DDL at startup
988        "busy",               // SQLITE_BUSY
989        "connection refused", // backend still binding its listener
990        "connection reset",
991        "timed out",
992        "timeout",
993        "starting up",          // Postgres: "the database system is starting up"
994        "too many connections", // transient connection saturation
995    ]
996    .iter()
997    .any(|needle| msg.contains(needle))
998}
999
1000/// Wrap a SQL backend in `FallbackHistory`: healthy on success; degraded-on-
1001/// in-memory (server stays up, `/readyz` reports 503) on a connect failure.
1002#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
1003fn into_history<H: RunHistory + 'static>(
1004    result: Result<H, HistoryError>,
1005    idem: Duration,
1006    label: &'static str,
1007) -> Arc<dyn RunHistory> {
1008    match result {
1009        Ok(backend) => Arc::new(fallback::FallbackHistory::healthy(
1010            Box::new(backend),
1011            idem,
1012            label,
1013        )),
1014        Err(e) => {
1015            tracing::error!(
1016                backend = label, error = %e,
1017                "run-history backend unavailable at startup; starting DEGRADED on in-memory store"
1018            );
1019            Arc::new(fallback::FallbackHistory::degraded_at_startup(idem, label))
1020        }
1021    }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026    use super::*;
1027
1028    #[test]
1029    fn terminal_classification() {
1030        assert!(!RunStatus::Queued.is_terminal());
1031        assert!(!RunStatus::Pending.is_terminal());
1032        assert!(!RunStatus::Running.is_terminal());
1033        assert!(RunStatus::Completed.is_terminal());
1034        assert!(RunStatus::Failed.is_terminal());
1035        assert!(RunStatus::Cancelled.is_terminal());
1036    }
1037
1038    #[test]
1039    fn run_record_serializes_status_snake_case() {
1040        let rec = RunRecord::queued(
1041            "r1".into(),
1042            Some("n".into()),
1043            Default::default(),
1044            None,
1045            Utc::now(),
1046        );
1047        let v = serde_json::to_value(&rec).unwrap();
1048        assert_eq!(v["status"], "queued");
1049        assert_eq!(v["run_id"], "r1");
1050        // doctor_report is skipped when None.
1051        assert!(v.get("doctor_report").is_none());
1052    }
1053
1054    #[test]
1055    fn pending_is_non_terminal_and_serializes_snake_case() {
1056        assert!(!RunStatus::Pending.is_terminal());
1057        assert_eq!(RunStatus::Pending.as_str(), "pending");
1058        let mut rec = RunRecord::queued("r".into(), None, Default::default(), None, Utc::now());
1059        rec.status = RunStatus::Pending;
1060        rec.attempt = 2;
1061        let v = serde_json::to_value(&rec).unwrap();
1062        assert_eq!(v["status"], "pending");
1063        assert_eq!(v["attempt"], 2);
1064        // Cluster config fields are skipped when absent.
1065        assert!(v.get("config_body").is_none());
1066    }
1067
1068    #[test]
1069    fn shard_progress_all_terminal() {
1070        // No shards yet → not terminal (don't finalize an unexpanded run).
1071        assert!(!ShardProgress::default().all_terminal());
1072        // Some still running.
1073        let mut p = ShardProgress {
1074            total: 3,
1075            completed: 1,
1076            failed: 0,
1077            running: 1,
1078            pending: 1,
1079        };
1080        assert!(!p.all_terminal());
1081        // All terminal (mix of completed + failed sums to total).
1082        p = ShardProgress {
1083            total: 3,
1084            completed: 2,
1085            failed: 1,
1086            running: 0,
1087            pending: 0,
1088        };
1089        assert!(p.all_terminal());
1090    }
1091
1092    #[tokio::test]
1093    async fn memory_backend_shard_methods_are_inert() {
1094        use crate::serve::history::memory::MemoryHistory;
1095        let h = MemoryHistory::new(Duration::from_secs(60));
1096        assert_eq!(h.insert_shards("r", &[]).await.unwrap(), 0);
1097        assert!(h.claim_shards(8).await.unwrap().is_empty());
1098        assert_eq!(h.renew_shard_leases().await.unwrap(), 0);
1099        assert!(!h.finalize_shard("r", "0", true).await.unwrap());
1100        assert_eq!(
1101            h.shard_progress("r").await.unwrap(),
1102            ShardProgress::default()
1103        );
1104    }
1105
1106    #[tokio::test]
1107    async fn memory_backend_cluster_methods_are_inert() {
1108        use crate::serve::history::memory::MemoryHistory;
1109        let h = MemoryHistory::new(Duration::from_secs(60));
1110        assert!(h.claim_pending(8).await.unwrap().is_empty());
1111        assert_eq!(
1112            h.reclaim_orphans(3).await.unwrap(),
1113            ReclaimReport::default()
1114        );
1115        assert!(!h.cancel_pending("x").await.unwrap());
1116        h.request_cancel("x").await.unwrap();
1117        assert!(h.pending_cancellations().await.unwrap().is_empty());
1118        assert!(
1119            h.live_instances(Duration::from_secs(60))
1120                .await
1121                .unwrap()
1122                .is_empty()
1123        );
1124
1125        // finalize_owned's default delegates to upsert (single-process always owns).
1126        let rec = RunRecord::queued("fo".into(), None, Default::default(), None, Utc::now());
1127        assert!(h.finalize_owned(&rec).await.unwrap());
1128        assert_eq!(h.get("fo").await.unwrap().unwrap().run_id, "fo");
1129    }
1130}
1131
1132#[cfg(all(
1133    test,
1134    any(feature = "serve-history-postgres", feature = "serve-history-sqlite")
1135))]
1136mod connect_retry_tests {
1137    use super::*;
1138    use std::cell::Cell;
1139
1140    #[test]
1141    fn classifies_transient_vs_permanent_connect_errors() {
1142        // SQLite concurrent-startup contention (#235) — retryable.
1143        assert!(is_transient_connect_error(&HistoryError::Backend(
1144            "SQLite connection failed: error returned from database: (code: 5) \
1145             database is locked"
1146                .into()
1147        )));
1148        // Booting Postgres — retryable.
1149        assert!(is_transient_connect_error(&HistoryError::Backend(
1150            "connection refused (os error 111)".into()
1151        )));
1152        // Permanent misconfiguration — not worth retrying.
1153        assert!(!is_transient_connect_error(&HistoryError::Backend(
1154            "invalid sqlite url 'sqlite::nonsense': ParseError".into()
1155        )));
1156    }
1157
1158    #[tokio::test]
1159    async fn retries_a_transient_failure_then_succeeds() {
1160        let calls = Cell::new(0usize);
1161        let result: Result<u32, HistoryError> = connect_with_retry("test", || {
1162            let n = calls.get() + 1;
1163            calls.set(n);
1164            async move {
1165                if n < 3 {
1166                    Err(HistoryError::Backend("database is locked".into()))
1167                } else {
1168                    Ok(42u32)
1169                }
1170            }
1171        })
1172        .await;
1173        assert_eq!(result.unwrap(), 42);
1174        assert_eq!(
1175            calls.get(),
1176            3,
1177            "two transient failures retried, third succeeds"
1178        );
1179    }
1180
1181    #[tokio::test]
1182    async fn does_not_retry_a_permanent_error() {
1183        let calls = Cell::new(0usize);
1184        let result: Result<u32, HistoryError> = connect_with_retry("test", || {
1185            calls.set(calls.get() + 1);
1186            async move { Err::<u32, _>(HistoryError::Backend("invalid sqlite url 'x'".into())) }
1187        })
1188        .await;
1189        assert!(result.is_err());
1190        assert_eq!(
1191            calls.get(),
1192            1,
1193            "a permanent error degrades immediately, no retry"
1194        );
1195    }
1196}