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 fn degraded(&self) -> bool;
612}
613
614pub async fn connect(
619 spec: &HistoryBackendSpec,
620 idem_retention: Duration,
621 lease_ttl: Duration,
622 instance_id: &str,
623) -> CliResult<Arc<dyn RunHistory>> {
624 match spec {
625 HistoryBackendSpec::Memory => {
626 Ok(Arc::new(memory::MemoryHistory::new(idem_retention)) as Arc<dyn RunHistory>)
627 }
628 HistoryBackendSpec::Postgres(url) => {
629 connect_postgres(url, idem_retention, lease_ttl, instance_id).await
630 }
631 HistoryBackendSpec::Sqlite(url) => {
632 connect_sqlite(url, idem_retention, lease_ttl, instance_id).await
633 }
634 }
635}
636
637#[cfg(feature = "serve-history-postgres")]
638async fn connect_postgres(
639 url: &str,
640 idem: Duration,
641 lease_ttl: Duration,
642 instance_id: &str,
643) -> CliResult<Arc<dyn RunHistory>> {
644 let result = connect_with_retry("postgres", || {
645 postgres::PostgresHistory::connect(url, idem, lease_ttl, instance_id.to_string())
646 })
647 .await;
648 Ok(into_history(result, idem, "postgres"))
649}
650
651#[cfg(not(feature = "serve-history-postgres"))]
652async fn connect_postgres(
653 _url: &str,
654 _idem: Duration,
655 _lease_ttl: Duration,
656 _instance_id: &str,
657) -> CliResult<Arc<dyn RunHistory>> {
658 Err(crate::error::CliError::Serve(
659 "persistent Postgres run history requires building faucet with the \
660 `serve-history-postgres` feature"
661 .into(),
662 ))
663}
664
665#[cfg(feature = "serve-history-sqlite")]
666async fn connect_sqlite(
667 url: &str,
668 idem: Duration,
669 lease_ttl: Duration,
670 instance_id: &str,
671) -> CliResult<Arc<dyn RunHistory>> {
672 let result = connect_with_retry("sqlite", || {
673 sqlite::SqliteHistory::connect(url, idem, lease_ttl, instance_id.to_string())
674 })
675 .await;
676 Ok(into_history(result, idem, "sqlite"))
677}
678
679#[cfg(not(feature = "serve-history-sqlite"))]
680async fn connect_sqlite(
681 _url: &str,
682 _idem: Duration,
683 _lease_ttl: Duration,
684 _instance_id: &str,
685) -> CliResult<Arc<dyn RunHistory>> {
686 Err(crate::error::CliError::Serve(
687 "persistent SQLite run history requires building faucet with the \
688 `serve-history-sqlite` feature"
689 .into(),
690 ))
691}
692
693#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
699const CONNECT_ATTEMPTS: usize = 8;
700
701#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
710async fn connect_with_retry<H, F, Fut>(label: &str, mut make: F) -> Result<H, HistoryError>
711where
712 F: FnMut() -> Fut,
713 Fut: std::future::Future<Output = Result<H, HistoryError>>,
714{
715 let mut delay = Duration::from_millis(100);
716 for attempt in 1..=CONNECT_ATTEMPTS {
717 match make().await {
718 Ok(backend) => return Ok(backend),
719 Err(e) if attempt < CONNECT_ATTEMPTS && is_transient_connect_error(&e) => {
720 tracing::warn!(
721 backend = label,
722 attempt,
723 error = %e,
724 "run-history backend connect failed transiently; retrying before degrading"
725 );
726 tokio::time::sleep(delay).await;
727 delay = (delay * 2).min(Duration::from_secs(1));
728 }
729 Err(e) => return Err(e),
730 }
731 }
732 unreachable!("the final attempt returns Ok or Err rather than looping")
733}
734
735#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
739fn is_transient_connect_error(e: &HistoryError) -> bool {
740 let msg = e.to_string().to_ascii_lowercase();
741 [
742 "database is locked", "busy", "connection refused", "connection reset",
746 "timed out",
747 "timeout",
748 "starting up", "too many connections", ]
751 .iter()
752 .any(|needle| msg.contains(needle))
753}
754
755#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
758fn into_history<H: RunHistory + 'static>(
759 result: Result<H, HistoryError>,
760 idem: Duration,
761 label: &'static str,
762) -> Arc<dyn RunHistory> {
763 match result {
764 Ok(backend) => Arc::new(fallback::FallbackHistory::healthy(
765 Box::new(backend),
766 idem,
767 label,
768 )),
769 Err(e) => {
770 tracing::error!(
771 backend = label, error = %e,
772 "run-history backend unavailable at startup; starting DEGRADED on in-memory store"
773 );
774 Arc::new(fallback::FallbackHistory::degraded_at_startup(idem, label))
775 }
776 }
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782
783 #[test]
784 fn terminal_classification() {
785 assert!(!RunStatus::Queued.is_terminal());
786 assert!(!RunStatus::Pending.is_terminal());
787 assert!(!RunStatus::Running.is_terminal());
788 assert!(RunStatus::Completed.is_terminal());
789 assert!(RunStatus::Failed.is_terminal());
790 assert!(RunStatus::Cancelled.is_terminal());
791 }
792
793 #[test]
794 fn run_record_serializes_status_snake_case() {
795 let rec = RunRecord::queued(
796 "r1".into(),
797 Some("n".into()),
798 Default::default(),
799 None,
800 Utc::now(),
801 );
802 let v = serde_json::to_value(&rec).unwrap();
803 assert_eq!(v["status"], "queued");
804 assert_eq!(v["run_id"], "r1");
805 assert!(v.get("doctor_report").is_none());
807 }
808
809 #[test]
810 fn pending_is_non_terminal_and_serializes_snake_case() {
811 assert!(!RunStatus::Pending.is_terminal());
812 assert_eq!(RunStatus::Pending.as_str(), "pending");
813 let mut rec = RunRecord::queued("r".into(), None, Default::default(), None, Utc::now());
814 rec.status = RunStatus::Pending;
815 rec.attempt = 2;
816 let v = serde_json::to_value(&rec).unwrap();
817 assert_eq!(v["status"], "pending");
818 assert_eq!(v["attempt"], 2);
819 assert!(v.get("config_body").is_none());
821 }
822
823 #[test]
824 fn shard_progress_all_terminal() {
825 assert!(!ShardProgress::default().all_terminal());
827 let mut p = ShardProgress {
829 total: 3,
830 completed: 1,
831 failed: 0,
832 running: 1,
833 pending: 1,
834 };
835 assert!(!p.all_terminal());
836 p = ShardProgress {
838 total: 3,
839 completed: 2,
840 failed: 1,
841 running: 0,
842 pending: 0,
843 };
844 assert!(p.all_terminal());
845 }
846
847 #[tokio::test]
848 async fn memory_backend_shard_methods_are_inert() {
849 use crate::serve::history::memory::MemoryHistory;
850 let h = MemoryHistory::new(Duration::from_secs(60));
851 assert_eq!(h.insert_shards("r", &[]).await.unwrap(), 0);
852 assert!(h.claim_shards(8).await.unwrap().is_empty());
853 assert_eq!(h.renew_shard_leases().await.unwrap(), 0);
854 assert!(!h.finalize_shard("r", "0", true).await.unwrap());
855 assert_eq!(
856 h.shard_progress("r").await.unwrap(),
857 ShardProgress::default()
858 );
859 }
860
861 #[tokio::test]
862 async fn memory_backend_cluster_methods_are_inert() {
863 use crate::serve::history::memory::MemoryHistory;
864 let h = MemoryHistory::new(Duration::from_secs(60));
865 assert!(h.claim_pending(8).await.unwrap().is_empty());
866 assert_eq!(
867 h.reclaim_orphans(3).await.unwrap(),
868 ReclaimReport::default()
869 );
870 assert!(!h.cancel_pending("x").await.unwrap());
871 h.request_cancel("x").await.unwrap();
872 assert!(h.pending_cancellations().await.unwrap().is_empty());
873 assert!(
874 h.live_instances(Duration::from_secs(60))
875 .await
876 .unwrap()
877 .is_empty()
878 );
879
880 let rec = RunRecord::queued("fo".into(), None, Default::default(), None, Utc::now());
882 assert!(h.finalize_owned(&rec).await.unwrap());
883 assert_eq!(h.get("fo").await.unwrap().unwrap().run_id, "fo");
884 }
885}
886
887#[cfg(all(
888 test,
889 any(feature = "serve-history-postgres", feature = "serve-history-sqlite")
890))]
891mod connect_retry_tests {
892 use super::*;
893 use std::cell::Cell;
894
895 #[test]
896 fn classifies_transient_vs_permanent_connect_errors() {
897 assert!(is_transient_connect_error(&HistoryError::Backend(
899 "SQLite connection failed: error returned from database: (code: 5) \
900 database is locked"
901 .into()
902 )));
903 assert!(is_transient_connect_error(&HistoryError::Backend(
905 "connection refused (os error 111)".into()
906 )));
907 assert!(!is_transient_connect_error(&HistoryError::Backend(
909 "invalid sqlite url 'sqlite::nonsense': ParseError".into()
910 )));
911 }
912
913 #[tokio::test]
914 async fn retries_a_transient_failure_then_succeeds() {
915 let calls = Cell::new(0usize);
916 let result: Result<u32, HistoryError> = connect_with_retry("test", || {
917 let n = calls.get() + 1;
918 calls.set(n);
919 async move {
920 if n < 3 {
921 Err(HistoryError::Backend("database is locked".into()))
922 } else {
923 Ok(42u32)
924 }
925 }
926 })
927 .await;
928 assert_eq!(result.unwrap(), 42);
929 assert_eq!(
930 calls.get(),
931 3,
932 "two transient failures retried, third succeeds"
933 );
934 }
935
936 #[tokio::test]
937 async fn does_not_retry_a_permanent_error() {
938 let calls = Cell::new(0usize);
939 let result: Result<u32, HistoryError> = connect_with_retry("test", || {
940 calls.set(calls.get() + 1);
941 async move { Err::<u32, _>(HistoryError::Backend("invalid sqlite url 'x'".into())) }
942 })
943 .await;
944 assert!(result.is_err());
945 assert_eq!(
946 calls.get(),
947 1,
948 "a permanent error degrades immediately, no retry"
949 );
950 }
951}