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