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}