1pub 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;
17
18use crate::error::CliResult;
19use crate::executor::InvocationOutcome;
20use crate::serve::config::HistoryBackendSpec;
21use async_trait::async_trait;
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24use std::collections::BTreeMap;
25use std::sync::Arc;
26use std::time::Duration;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum RunStatus {
32 Queued,
33 Pending,
34 Running,
35 Sharded,
41 Completed,
42 Failed,
43 Cancelled,
44}
45
46impl RunStatus {
47 pub fn is_terminal(self) -> bool {
48 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
49 }
50 pub fn as_str(self) -> &'static str {
51 match self {
52 Self::Queued => "queued",
53 Self::Pending => "pending",
54 Self::Running => "running",
55 Self::Sharded => "sharded",
56 Self::Completed => "completed",
57 Self::Failed => "failed",
58 Self::Cancelled => "cancelled",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct InvocationRecord {
66 pub row_id: String,
67 pub parent_record_key: Option<String>,
68 pub records_written: usize,
69 pub error: Option<String>,
70}
71
72impl From<&InvocationOutcome> for InvocationRecord {
73 fn from(o: &InvocationOutcome) -> Self {
74 Self {
75 row_id: o.row_id.clone(),
76 parent_record_key: o.parent_record_key.clone(),
77 records_written: o.records_written,
78 error: o.error.clone(),
79 }
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct RunRecord {
86 pub run_id: String,
87 pub name: Option<String>,
88 pub labels: BTreeMap<String, String>,
89 pub status: RunStatus,
90 pub submitted_at: DateTime<Utc>,
91 pub started_at: Option<DateTime<Utc>>,
92 pub finished_at: Option<DateTime<Utc>>,
93 pub elapsed_secs: Option<f64>,
94 pub records_written: u64,
95 pub invocations: Vec<InvocationRecord>,
96 pub error: Option<String>,
97 pub idempotency_key: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub doctor_report: Option<serde_json::Value>,
100 #[serde(skip_serializing_if = "Option::is_none")]
103 pub config_body: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub config_format: Option<crate::serve::load::ConfigFormat>,
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub timeout_secs: Option<u64>,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub clock: Option<String>,
110 #[serde(default)]
112 pub attempt: u32,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub replay_of: Option<String>,
119}
120
121impl RunRecord {
122 pub fn queued(
124 run_id: String,
125 name: Option<String>,
126 labels: BTreeMap<String, String>,
127 idempotency_key: Option<String>,
128 submitted_at: DateTime<Utc>,
129 ) -> Self {
130 Self {
131 run_id,
132 name,
133 labels,
134 status: RunStatus::Queued,
135 submitted_at,
136 started_at: None,
137 finished_at: None,
138 elapsed_secs: None,
139 records_written: 0,
140 invocations: Vec::new(),
141 error: None,
142 idempotency_key,
143 doctor_report: None,
144 config_body: None,
145 config_format: None,
146 timeout_secs: None,
147 clock: None,
148 attempt: 0,
149 replay_of: None,
150 }
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum Claim {
157 Fresh,
159 Replay(String),
161 Conflict,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum DeleteOutcome {
168 Deleted,
169 NotFound,
170 StillRunning,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
175pub struct ReclaimReport {
176 pub requeued: usize,
178 pub failed: usize,
180}
181
182#[derive(Debug, Clone)]
186pub struct InstanceHeartbeat {
187 pub started_at: DateTime<Utc>,
188 pub listen: Option<String>,
189 pub max_concurrent: u32,
190 pub in_flight: u32,
191}
192
193#[derive(Debug, Clone, Serialize)]
195pub struct InstanceRecord {
196 pub instance_id: String,
197 pub started_at: DateTime<Utc>,
198 pub last_heartbeat: DateTime<Utc>,
199 pub listen: Option<String>,
200 pub max_concurrent: u32,
201 pub in_flight: u32,
202}
203
204#[derive(Debug, Default, Clone)]
206pub struct ListFilter {
207 pub status: Option<RunStatus>,
208 pub name: Option<String>,
209 pub since: Option<DateTime<Utc>>,
210 pub until: Option<DateTime<Utc>>,
211 pub limit: usize,
212 pub cursor: Option<String>,
213}
214
215#[derive(Debug)]
217pub struct ListPage {
218 pub runs: Vec<RunRecord>,
219 pub next_cursor: Option<String>,
220}
221
222#[derive(Debug, thiserror::Error)]
225pub enum HistoryError {
226 #[error("run-history backend error: {0}")]
227 Backend(String),
228 #[error("{0}")]
232 Degraded(String),
233}
234
235#[derive(Debug, Clone)]
237pub struct ShardInsert {
238 pub shard_id: String,
240 pub descriptor: serde_json::Value,
243 pub size_estimate: Option<u64>,
245}
246
247#[derive(Debug, Clone)]
250pub struct ClaimedShard {
251 pub run_id: String,
252 pub shard_id: String,
253 pub descriptor: serde_json::Value,
254 pub run: RunRecord,
256}
257
258#[derive(Debug, Clone, Default, PartialEq, Eq)]
261pub struct ShardProgress {
262 pub total: usize,
263 pub completed: usize,
264 pub failed: usize,
265 pub running: usize,
266 pub pending: usize,
267}
268
269impl ShardProgress {
270 pub fn all_terminal(&self) -> bool {
273 self.total > 0 && self.completed + self.failed == self.total
274 }
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct AuditEntry {
282 pub id: String,
284 pub timestamp: DateTime<Utc>,
285 pub principal: String,
288 pub role: String,
290 pub action: String,
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub run_id: Option<String>,
295 #[serde(skip_serializing_if = "Option::is_none")]
297 pub config_fingerprint: Option<String>,
298 #[serde(skip_serializing_if = "Option::is_none")]
299 pub source_ip: Option<String>,
300 pub result: String,
302}
303
304#[derive(Debug, Default, Clone)]
307pub struct AuditFilter {
308 pub principal: Option<String>,
309 pub action: Option<String>,
310 pub since: Option<DateTime<Utc>>,
311 pub until: Option<DateTime<Utc>>,
312 pub limit: usize,
313}
314
315#[async_trait]
316pub trait RunHistory: Send + Sync {
317 async fn claim_idempotency(
320 &self,
321 key: &str,
322 fingerprint: &str,
323 run_id: &str,
324 window: Duration,
325 ) -> Result<Claim, HistoryError>;
326
327 async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError>;
329
330 async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError>;
331
332 async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError>;
333
334 async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError>;
336
337 async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError>;
340
341 async fn release_idempotency(&self, run_id: &str) -> Result<(), HistoryError> {
350 let _ = run_id;
351 Ok(())
352 }
353
354 async fn recover_orphans(&self) -> Result<usize, HistoryError>;
359
360 async fn renew_leases(&self) -> Result<usize, HistoryError> {
365 Ok(0)
366 }
367
368 async fn claim_pending(&self, limit: usize) -> Result<Vec<RunRecord>, HistoryError> {
374 let _ = limit;
375 Ok(Vec::new())
376 }
377
378 async fn reclaim_orphans(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
382 let _ = max_attempts;
383 Ok(ReclaimReport::default())
384 }
385
386 async fn finalize_owned(&self, rec: &RunRecord) -> Result<bool, HistoryError> {
391 self.upsert(rec).await.map(|_| true)
392 }
393
394 async fn finalize_sharded_parent(
405 &self,
406 run_id: &str,
407 status: RunStatus,
408 finished_at: DateTime<Utc>,
409 error: Option<String>,
410 ) -> Result<bool, HistoryError> {
411 match self.get(run_id).await? {
412 Some(mut r) if r.status == RunStatus::Sharded => {
413 r.status = status;
414 r.finished_at = Some(finished_at);
415 r.error = error;
416 self.upsert(&r).await?;
417 Ok(true)
418 }
419 _ => Ok(false),
420 }
421 }
422
423 async fn cancel_pending(&self, run_id: &str) -> Result<bool, HistoryError> {
428 let _ = run_id;
429 Ok(false)
430 }
431
432 async fn request_cancel(&self, run_id: &str) -> Result<(), HistoryError> {
435 let _ = run_id;
436 Ok(())
437 }
438
439 async fn pending_cancellations(&self) -> Result<Vec<String>, HistoryError> {
442 Ok(Vec::new())
443 }
444
445 async fn heartbeat_instance(&self, beat: &InstanceHeartbeat) -> Result<(), HistoryError> {
447 let _ = beat;
448 Ok(())
449 }
450
451 async fn live_instances(&self, ttl: Duration) -> Result<Vec<InstanceRecord>, HistoryError> {
453 let _ = ttl;
454 Ok(Vec::new())
455 }
456
457 async fn insert_shards(
468 &self,
469 run_id: &str,
470 shards: &[ShardInsert],
471 ) -> Result<usize, HistoryError> {
472 let _ = (run_id, shards);
473 Ok(0)
474 }
475
476 async fn claim_shards(&self, limit: usize) -> Result<Vec<ClaimedShard>, HistoryError> {
481 let _ = limit;
482 Ok(Vec::new())
483 }
484
485 async fn renew_shard_leases(&self) -> Result<usize, HistoryError> {
489 Ok(0)
490 }
491
492 async fn reclaim_shards(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
496 let _ = max_attempts;
497 Ok(ReclaimReport::default())
498 }
499
500 async fn finalize_shard(
504 &self,
505 run_id: &str,
506 shard_id: &str,
507 success: bool,
508 ) -> Result<bool, HistoryError> {
509 let _ = (run_id, shard_id, success);
510 Ok(false)
511 }
512
513 async fn shard_progress(&self, run_id: &str) -> Result<ShardProgress, HistoryError> {
516 let _ = run_id;
517 Ok(ShardProgress::default())
518 }
519
520 async fn pending_shard_cancellations(&self) -> Result<Vec<String>, HistoryError> {
526 Ok(Vec::new())
527 }
528
529 async fn finalize_completed_sharded_parents(&self) -> Result<usize, HistoryError> {
537 Ok(0)
538 }
539
540 async fn record_audit(&self, entry: &AuditEntry) -> Result<(), HistoryError> {
547 let _ = entry;
548 Ok(())
549 }
550
551 async fn list_audit(&self, filter: &AuditFilter) -> Result<Vec<AuditEntry>, HistoryError> {
553 let _ = filter;
554 Ok(Vec::new())
555 }
556
557 async fn catalog_record(&self, update: &catalog::CatalogUpdate) -> Result<(), HistoryError> {
571 let _ = update;
572 Ok(())
573 }
574
575 async fn catalog_list_datasets(
578 &self,
579 filter: &catalog::CatalogListFilter,
580 ) -> Result<catalog::CatalogDatasetPage, HistoryError> {
581 let _ = filter;
582 Ok(catalog::CatalogDatasetPage {
583 datasets: Vec::new(),
584 next_cursor: None,
585 })
586 }
587
588 async fn catalog_get_dataset(
591 &self,
592 id: &str,
593 ) -> Result<Option<catalog::CatalogDatasetDetail>, HistoryError> {
594 let _ = id;
595 Ok(None)
596 }
597
598 async fn catalog_lineage(
601 &self,
602 root: Option<&str>,
603 depth: u32,
604 ) -> Result<Vec<catalog::CatalogLineageEdge>, HistoryError> {
605 let _ = (root, depth);
606 Ok(Vec::new())
607 }
608
609 async fn catalog_record_config_snapshot(
613 &self,
614 snapshot: &catalog::ConfigSnapshot,
615 ) -> Result<(), HistoryError> {
616 let _ = snapshot;
617 Ok(())
618 }
619
620 async fn catalog_last_config_snapshot(
624 &self,
625 pipeline: &str,
626 ) -> Result<Option<catalog::ConfigSnapshot>, HistoryError> {
627 let _ = pipeline;
628 Ok(None)
629 }
630
631 fn degraded(&self) -> bool;
634}
635
636pub async fn connect(
641 spec: &HistoryBackendSpec,
642 idem_retention: Duration,
643 lease_ttl: Duration,
644 instance_id: &str,
645) -> CliResult<Arc<dyn RunHistory>> {
646 match spec {
647 HistoryBackendSpec::Memory => {
648 Ok(Arc::new(memory::MemoryHistory::new(idem_retention)) as Arc<dyn RunHistory>)
649 }
650 HistoryBackendSpec::Postgres(url) => {
651 connect_postgres(url, idem_retention, lease_ttl, instance_id).await
652 }
653 HistoryBackendSpec::Sqlite(url) => {
654 connect_sqlite(url, idem_retention, lease_ttl, instance_id).await
655 }
656 }
657}
658
659#[cfg(feature = "serve-history-postgres")]
660async fn connect_postgres(
661 url: &str,
662 idem: Duration,
663 lease_ttl: Duration,
664 instance_id: &str,
665) -> CliResult<Arc<dyn RunHistory>> {
666 let result = connect_with_retry("postgres", || {
667 postgres::PostgresHistory::connect(url, idem, lease_ttl, instance_id.to_string())
668 })
669 .await;
670 Ok(into_history(result, idem, "postgres"))
671}
672
673#[cfg(not(feature = "serve-history-postgres"))]
674async fn connect_postgres(
675 _url: &str,
676 _idem: Duration,
677 _lease_ttl: Duration,
678 _instance_id: &str,
679) -> CliResult<Arc<dyn RunHistory>> {
680 Err(crate::error::CliError::Serve(
681 "persistent Postgres run history requires building faucet with the \
682 `serve-history-postgres` feature"
683 .into(),
684 ))
685}
686
687#[cfg(feature = "serve-history-sqlite")]
688async fn connect_sqlite(
689 url: &str,
690 idem: Duration,
691 lease_ttl: Duration,
692 instance_id: &str,
693) -> CliResult<Arc<dyn RunHistory>> {
694 let result = connect_with_retry("sqlite", || {
695 sqlite::SqliteHistory::connect(url, idem, lease_ttl, instance_id.to_string())
696 })
697 .await;
698 Ok(into_history(result, idem, "sqlite"))
699}
700
701#[cfg(not(feature = "serve-history-sqlite"))]
702async fn connect_sqlite(
703 _url: &str,
704 _idem: Duration,
705 _lease_ttl: Duration,
706 _instance_id: &str,
707) -> CliResult<Arc<dyn RunHistory>> {
708 Err(crate::error::CliError::Serve(
709 "persistent SQLite run history requires building faucet with the \
710 `serve-history-sqlite` feature"
711 .into(),
712 ))
713}
714
715#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
721const CONNECT_ATTEMPTS: usize = 8;
722
723#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
732async fn connect_with_retry<H, F, Fut>(label: &str, mut make: F) -> Result<H, HistoryError>
733where
734 F: FnMut() -> Fut,
735 Fut: std::future::Future<Output = Result<H, HistoryError>>,
736{
737 let mut delay = Duration::from_millis(100);
738 for attempt in 1..=CONNECT_ATTEMPTS {
739 match make().await {
740 Ok(backend) => return Ok(backend),
741 Err(e) if attempt < CONNECT_ATTEMPTS && is_transient_connect_error(&e) => {
742 tracing::warn!(
743 backend = label,
744 attempt,
745 error = %e,
746 "run-history backend connect failed transiently; retrying before degrading"
747 );
748 tokio::time::sleep(delay).await;
749 delay = (delay * 2).min(Duration::from_secs(1));
750 }
751 Err(e) => return Err(e),
752 }
753 }
754 unreachable!("the final attempt returns Ok or Err rather than looping")
755}
756
757#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
761fn is_transient_connect_error(e: &HistoryError) -> bool {
762 let msg = e.to_string().to_ascii_lowercase();
763 [
764 "database is locked", "busy", "connection refused", "connection reset",
768 "timed out",
769 "timeout",
770 "starting up", "too many connections", ]
773 .iter()
774 .any(|needle| msg.contains(needle))
775}
776
777#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
780fn into_history<H: RunHistory + 'static>(
781 result: Result<H, HistoryError>,
782 idem: Duration,
783 label: &'static str,
784) -> Arc<dyn RunHistory> {
785 match result {
786 Ok(backend) => Arc::new(fallback::FallbackHistory::healthy(
787 Box::new(backend),
788 idem,
789 label,
790 )),
791 Err(e) => {
792 tracing::error!(
793 backend = label, error = %e,
794 "run-history backend unavailable at startup; starting DEGRADED on in-memory store"
795 );
796 Arc::new(fallback::FallbackHistory::degraded_at_startup(idem, label))
797 }
798 }
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804
805 #[test]
806 fn terminal_classification() {
807 assert!(!RunStatus::Queued.is_terminal());
808 assert!(!RunStatus::Pending.is_terminal());
809 assert!(!RunStatus::Running.is_terminal());
810 assert!(RunStatus::Completed.is_terminal());
811 assert!(RunStatus::Failed.is_terminal());
812 assert!(RunStatus::Cancelled.is_terminal());
813 }
814
815 #[test]
816 fn run_record_serializes_status_snake_case() {
817 let rec = RunRecord::queued(
818 "r1".into(),
819 Some("n".into()),
820 Default::default(),
821 None,
822 Utc::now(),
823 );
824 let v = serde_json::to_value(&rec).unwrap();
825 assert_eq!(v["status"], "queued");
826 assert_eq!(v["run_id"], "r1");
827 assert!(v.get("doctor_report").is_none());
829 }
830
831 #[test]
832 fn pending_is_non_terminal_and_serializes_snake_case() {
833 assert!(!RunStatus::Pending.is_terminal());
834 assert_eq!(RunStatus::Pending.as_str(), "pending");
835 let mut rec = RunRecord::queued("r".into(), None, Default::default(), None, Utc::now());
836 rec.status = RunStatus::Pending;
837 rec.attempt = 2;
838 let v = serde_json::to_value(&rec).unwrap();
839 assert_eq!(v["status"], "pending");
840 assert_eq!(v["attempt"], 2);
841 assert!(v.get("config_body").is_none());
843 }
844
845 #[test]
846 fn shard_progress_all_terminal() {
847 assert!(!ShardProgress::default().all_terminal());
849 let mut p = ShardProgress {
851 total: 3,
852 completed: 1,
853 failed: 0,
854 running: 1,
855 pending: 1,
856 };
857 assert!(!p.all_terminal());
858 p = ShardProgress {
860 total: 3,
861 completed: 2,
862 failed: 1,
863 running: 0,
864 pending: 0,
865 };
866 assert!(p.all_terminal());
867 }
868
869 #[tokio::test]
870 async fn memory_backend_shard_methods_are_inert() {
871 use crate::serve::history::memory::MemoryHistory;
872 let h = MemoryHistory::new(Duration::from_secs(60));
873 assert_eq!(h.insert_shards("r", &[]).await.unwrap(), 0);
874 assert!(h.claim_shards(8).await.unwrap().is_empty());
875 assert_eq!(h.renew_shard_leases().await.unwrap(), 0);
876 assert!(!h.finalize_shard("r", "0", true).await.unwrap());
877 assert_eq!(
878 h.shard_progress("r").await.unwrap(),
879 ShardProgress::default()
880 );
881 }
882
883 #[tokio::test]
884 async fn memory_backend_cluster_methods_are_inert() {
885 use crate::serve::history::memory::MemoryHistory;
886 let h = MemoryHistory::new(Duration::from_secs(60));
887 assert!(h.claim_pending(8).await.unwrap().is_empty());
888 assert_eq!(
889 h.reclaim_orphans(3).await.unwrap(),
890 ReclaimReport::default()
891 );
892 assert!(!h.cancel_pending("x").await.unwrap());
893 h.request_cancel("x").await.unwrap();
894 assert!(h.pending_cancellations().await.unwrap().is_empty());
895 assert!(
896 h.live_instances(Duration::from_secs(60))
897 .await
898 .unwrap()
899 .is_empty()
900 );
901
902 let rec = RunRecord::queued("fo".into(), None, Default::default(), None, Utc::now());
904 assert!(h.finalize_owned(&rec).await.unwrap());
905 assert_eq!(h.get("fo").await.unwrap().unwrap().run_id, "fo");
906 }
907}
908
909#[cfg(all(
910 test,
911 any(feature = "serve-history-postgres", feature = "serve-history-sqlite")
912))]
913mod connect_retry_tests {
914 use super::*;
915 use std::cell::Cell;
916
917 #[test]
918 fn classifies_transient_vs_permanent_connect_errors() {
919 assert!(is_transient_connect_error(&HistoryError::Backend(
921 "SQLite connection failed: error returned from database: (code: 5) \
922 database is locked"
923 .into()
924 )));
925 assert!(is_transient_connect_error(&HistoryError::Backend(
927 "connection refused (os error 111)".into()
928 )));
929 assert!(!is_transient_connect_error(&HistoryError::Backend(
931 "invalid sqlite url 'sqlite::nonsense': ParseError".into()
932 )));
933 }
934
935 #[tokio::test]
936 async fn retries_a_transient_failure_then_succeeds() {
937 let calls = Cell::new(0usize);
938 let result: Result<u32, HistoryError> = connect_with_retry("test", || {
939 let n = calls.get() + 1;
940 calls.set(n);
941 async move {
942 if n < 3 {
943 Err(HistoryError::Backend("database is locked".into()))
944 } else {
945 Ok(42u32)
946 }
947 }
948 })
949 .await;
950 assert_eq!(result.unwrap(), 42);
951 assert_eq!(
952 calls.get(),
953 3,
954 "two transient failures retried, third succeeds"
955 );
956 }
957
958 #[tokio::test]
959 async fn does_not_retry_a_permanent_error() {
960 let calls = Cell::new(0usize);
961 let result: Result<u32, HistoryError> = connect_with_retry("test", || {
962 calls.set(calls.get() + 1);
963 async move { Err::<u32, _>(HistoryError::Backend("invalid sqlite url 'x'".into())) }
964 })
965 .await;
966 assert!(result.is_err());
967 assert_eq!(
968 calls.get(),
969 1,
970 "a permanent error degrades immediately, no retry"
971 );
972 }
973}