1#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
8pub mod fallback;
9pub mod memory;
10#[cfg(feature = "serve-history-postgres")]
11pub mod postgres;
12#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
13pub mod sql;
14#[cfg(feature = "serve-history-sqlite")]
15pub mod sqlite;
16
17use crate::error::CliResult;
18use crate::executor::InvocationOutcome;
19use crate::serve::config::HistoryBackendSpec;
20use async_trait::async_trait;
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use std::collections::BTreeMap;
24use std::sync::Arc;
25use std::time::Duration;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum RunStatus {
31 Queued,
32 Pending,
33 Running,
34 Sharded,
40 Completed,
41 Failed,
42 Cancelled,
43}
44
45impl RunStatus {
46 pub fn is_terminal(self) -> bool {
47 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
48 }
49 pub fn as_str(self) -> &'static str {
50 match self {
51 Self::Queued => "queued",
52 Self::Pending => "pending",
53 Self::Running => "running",
54 Self::Sharded => "sharded",
55 Self::Completed => "completed",
56 Self::Failed => "failed",
57 Self::Cancelled => "cancelled",
58 }
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct InvocationRecord {
65 pub row_id: String,
66 pub parent_record_key: Option<String>,
67 pub records_written: usize,
68 pub error: Option<String>,
69}
70
71impl From<&InvocationOutcome> for InvocationRecord {
72 fn from(o: &InvocationOutcome) -> Self {
73 Self {
74 row_id: o.row_id.clone(),
75 parent_record_key: o.parent_record_key.clone(),
76 records_written: o.records_written,
77 error: o.error.clone(),
78 }
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct RunRecord {
85 pub run_id: String,
86 pub name: Option<String>,
87 pub labels: BTreeMap<String, String>,
88 pub status: RunStatus,
89 pub submitted_at: DateTime<Utc>,
90 pub started_at: Option<DateTime<Utc>>,
91 pub finished_at: Option<DateTime<Utc>>,
92 pub elapsed_secs: Option<f64>,
93 pub records_written: u64,
94 pub invocations: Vec<InvocationRecord>,
95 pub error: Option<String>,
96 pub idempotency_key: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub doctor_report: Option<serde_json::Value>,
99 #[serde(skip_serializing_if = "Option::is_none")]
102 pub config_body: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub config_format: Option<crate::serve::load::ConfigFormat>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub timeout_secs: Option<u64>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub clock: Option<String>,
109 #[serde(default)]
111 pub attempt: u32,
112}
113
114impl RunRecord {
115 pub fn queued(
117 run_id: String,
118 name: Option<String>,
119 labels: BTreeMap<String, String>,
120 idempotency_key: Option<String>,
121 submitted_at: DateTime<Utc>,
122 ) -> Self {
123 Self {
124 run_id,
125 name,
126 labels,
127 status: RunStatus::Queued,
128 submitted_at,
129 started_at: None,
130 finished_at: None,
131 elapsed_secs: None,
132 records_written: 0,
133 invocations: Vec::new(),
134 error: None,
135 idempotency_key,
136 doctor_report: None,
137 config_body: None,
138 config_format: None,
139 timeout_secs: None,
140 clock: None,
141 attempt: 0,
142 }
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub enum Claim {
149 Fresh,
151 Replay(String),
153 Conflict,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum DeleteOutcome {
160 Deleted,
161 NotFound,
162 StillRunning,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
167pub struct ReclaimReport {
168 pub requeued: usize,
170 pub failed: usize,
172}
173
174#[derive(Debug, Clone)]
178pub struct InstanceHeartbeat {
179 pub started_at: DateTime<Utc>,
180 pub listen: Option<String>,
181 pub max_concurrent: u32,
182 pub in_flight: u32,
183}
184
185#[derive(Debug, Clone, Serialize)]
187pub struct InstanceRecord {
188 pub instance_id: String,
189 pub started_at: DateTime<Utc>,
190 pub last_heartbeat: DateTime<Utc>,
191 pub listen: Option<String>,
192 pub max_concurrent: u32,
193 pub in_flight: u32,
194}
195
196#[derive(Debug, Default, Clone)]
198pub struct ListFilter {
199 pub status: Option<RunStatus>,
200 pub name: Option<String>,
201 pub since: Option<DateTime<Utc>>,
202 pub until: Option<DateTime<Utc>>,
203 pub limit: usize,
204 pub cursor: Option<String>,
205}
206
207#[derive(Debug)]
209pub struct ListPage {
210 pub runs: Vec<RunRecord>,
211 pub next_cursor: Option<String>,
212}
213
214#[derive(Debug, thiserror::Error)]
217pub enum HistoryError {
218 #[error("run-history backend error: {0}")]
219 Backend(String),
220 #[error("{0}")]
224 Degraded(String),
225}
226
227#[derive(Debug, Clone)]
229pub struct ShardInsert {
230 pub shard_id: String,
232 pub descriptor: serde_json::Value,
235 pub size_estimate: Option<u64>,
237}
238
239#[derive(Debug, Clone)]
242pub struct ClaimedShard {
243 pub run_id: String,
244 pub shard_id: String,
245 pub descriptor: serde_json::Value,
246 pub run: RunRecord,
248}
249
250#[derive(Debug, Clone, Default, PartialEq, Eq)]
253pub struct ShardProgress {
254 pub total: usize,
255 pub completed: usize,
256 pub failed: usize,
257 pub running: usize,
258 pub pending: usize,
259}
260
261impl ShardProgress {
262 pub fn all_terminal(&self) -> bool {
265 self.total > 0 && self.completed + self.failed == self.total
266 }
267}
268
269#[async_trait]
270pub trait RunHistory: Send + Sync {
271 async fn claim_idempotency(
274 &self,
275 key: &str,
276 fingerprint: &str,
277 run_id: &str,
278 window: Duration,
279 ) -> Result<Claim, HistoryError>;
280
281 async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError>;
283
284 async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError>;
285
286 async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError>;
287
288 async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError>;
290
291 async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError>;
294
295 async fn release_idempotency(&self, run_id: &str) -> Result<(), HistoryError> {
304 let _ = run_id;
305 Ok(())
306 }
307
308 async fn recover_orphans(&self) -> Result<usize, HistoryError>;
313
314 async fn renew_leases(&self) -> Result<usize, HistoryError> {
319 Ok(0)
320 }
321
322 async fn claim_pending(&self, limit: usize) -> Result<Vec<RunRecord>, HistoryError> {
328 let _ = limit;
329 Ok(Vec::new())
330 }
331
332 async fn reclaim_orphans(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
336 let _ = max_attempts;
337 Ok(ReclaimReport::default())
338 }
339
340 async fn finalize_owned(&self, rec: &RunRecord) -> Result<bool, HistoryError> {
345 self.upsert(rec).await.map(|_| true)
346 }
347
348 async fn finalize_sharded_parent(
359 &self,
360 run_id: &str,
361 status: RunStatus,
362 finished_at: DateTime<Utc>,
363 error: Option<String>,
364 ) -> Result<bool, HistoryError> {
365 match self.get(run_id).await? {
366 Some(mut r) if r.status == RunStatus::Sharded => {
367 r.status = status;
368 r.finished_at = Some(finished_at);
369 r.error = error;
370 self.upsert(&r).await?;
371 Ok(true)
372 }
373 _ => Ok(false),
374 }
375 }
376
377 async fn cancel_pending(&self, run_id: &str) -> Result<bool, HistoryError> {
382 let _ = run_id;
383 Ok(false)
384 }
385
386 async fn request_cancel(&self, run_id: &str) -> Result<(), HistoryError> {
389 let _ = run_id;
390 Ok(())
391 }
392
393 async fn pending_cancellations(&self) -> Result<Vec<String>, HistoryError> {
396 Ok(Vec::new())
397 }
398
399 async fn heartbeat_instance(&self, beat: &InstanceHeartbeat) -> Result<(), HistoryError> {
401 let _ = beat;
402 Ok(())
403 }
404
405 async fn live_instances(&self, ttl: Duration) -> Result<Vec<InstanceRecord>, HistoryError> {
407 let _ = ttl;
408 Ok(Vec::new())
409 }
410
411 async fn insert_shards(
422 &self,
423 run_id: &str,
424 shards: &[ShardInsert],
425 ) -> Result<usize, HistoryError> {
426 let _ = (run_id, shards);
427 Ok(0)
428 }
429
430 async fn claim_shards(&self, limit: usize) -> Result<Vec<ClaimedShard>, HistoryError> {
435 let _ = limit;
436 Ok(Vec::new())
437 }
438
439 async fn renew_shard_leases(&self) -> Result<usize, HistoryError> {
443 Ok(0)
444 }
445
446 async fn reclaim_shards(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
450 let _ = max_attempts;
451 Ok(ReclaimReport::default())
452 }
453
454 async fn finalize_shard(
458 &self,
459 run_id: &str,
460 shard_id: &str,
461 success: bool,
462 ) -> Result<bool, HistoryError> {
463 let _ = (run_id, shard_id, success);
464 Ok(false)
465 }
466
467 async fn shard_progress(&self, run_id: &str) -> Result<ShardProgress, HistoryError> {
470 let _ = run_id;
471 Ok(ShardProgress::default())
472 }
473
474 async fn pending_shard_cancellations(&self) -> Result<Vec<String>, HistoryError> {
480 Ok(Vec::new())
481 }
482
483 async fn finalize_completed_sharded_parents(&self) -> Result<usize, HistoryError> {
491 Ok(0)
492 }
493
494 fn degraded(&self) -> bool;
497}
498
499pub async fn connect(
504 spec: &HistoryBackendSpec,
505 idem_retention: Duration,
506 lease_ttl: Duration,
507 instance_id: &str,
508) -> CliResult<Arc<dyn RunHistory>> {
509 match spec {
510 HistoryBackendSpec::Memory => {
511 Ok(Arc::new(memory::MemoryHistory::new(idem_retention)) as Arc<dyn RunHistory>)
512 }
513 HistoryBackendSpec::Postgres(url) => {
514 connect_postgres(url, idem_retention, lease_ttl, instance_id).await
515 }
516 HistoryBackendSpec::Sqlite(url) => {
517 connect_sqlite(url, idem_retention, lease_ttl, instance_id).await
518 }
519 }
520}
521
522#[cfg(feature = "serve-history-postgres")]
523async fn connect_postgres(
524 url: &str,
525 idem: Duration,
526 lease_ttl: Duration,
527 instance_id: &str,
528) -> CliResult<Arc<dyn RunHistory>> {
529 let result = connect_with_retry("postgres", || {
530 postgres::PostgresHistory::connect(url, idem, lease_ttl, instance_id.to_string())
531 })
532 .await;
533 Ok(into_history(result, idem, "postgres"))
534}
535
536#[cfg(not(feature = "serve-history-postgres"))]
537async fn connect_postgres(
538 _url: &str,
539 _idem: Duration,
540 _lease_ttl: Duration,
541 _instance_id: &str,
542) -> CliResult<Arc<dyn RunHistory>> {
543 Err(crate::error::CliError::Serve(
544 "persistent Postgres run history requires building faucet with the \
545 `serve-history-postgres` feature"
546 .into(),
547 ))
548}
549
550#[cfg(feature = "serve-history-sqlite")]
551async fn connect_sqlite(
552 url: &str,
553 idem: Duration,
554 lease_ttl: Duration,
555 instance_id: &str,
556) -> CliResult<Arc<dyn RunHistory>> {
557 let result = connect_with_retry("sqlite", || {
558 sqlite::SqliteHistory::connect(url, idem, lease_ttl, instance_id.to_string())
559 })
560 .await;
561 Ok(into_history(result, idem, "sqlite"))
562}
563
564#[cfg(not(feature = "serve-history-sqlite"))]
565async fn connect_sqlite(
566 _url: &str,
567 _idem: Duration,
568 _lease_ttl: Duration,
569 _instance_id: &str,
570) -> CliResult<Arc<dyn RunHistory>> {
571 Err(crate::error::CliError::Serve(
572 "persistent SQLite run history requires building faucet with the \
573 `serve-history-sqlite` feature"
574 .into(),
575 ))
576}
577
578#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
584const CONNECT_ATTEMPTS: usize = 8;
585
586#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
595async fn connect_with_retry<H, F, Fut>(label: &str, mut make: F) -> Result<H, HistoryError>
596where
597 F: FnMut() -> Fut,
598 Fut: std::future::Future<Output = Result<H, HistoryError>>,
599{
600 let mut delay = Duration::from_millis(100);
601 for attempt in 1..=CONNECT_ATTEMPTS {
602 match make().await {
603 Ok(backend) => return Ok(backend),
604 Err(e) if attempt < CONNECT_ATTEMPTS && is_transient_connect_error(&e) => {
605 tracing::warn!(
606 backend = label,
607 attempt,
608 error = %e,
609 "run-history backend connect failed transiently; retrying before degrading"
610 );
611 tokio::time::sleep(delay).await;
612 delay = (delay * 2).min(Duration::from_secs(1));
613 }
614 Err(e) => return Err(e),
615 }
616 }
617 unreachable!("the final attempt returns Ok or Err rather than looping")
618}
619
620#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
624fn is_transient_connect_error(e: &HistoryError) -> bool {
625 let msg = e.to_string().to_ascii_lowercase();
626 [
627 "database is locked", "busy", "connection refused", "connection reset",
631 "timed out",
632 "timeout",
633 "starting up", "too many connections", ]
636 .iter()
637 .any(|needle| msg.contains(needle))
638}
639
640#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
643fn into_history<H: RunHistory + 'static>(
644 result: Result<H, HistoryError>,
645 idem: Duration,
646 label: &'static str,
647) -> Arc<dyn RunHistory> {
648 match result {
649 Ok(backend) => Arc::new(fallback::FallbackHistory::healthy(
650 Box::new(backend),
651 idem,
652 label,
653 )),
654 Err(e) => {
655 tracing::error!(
656 backend = label, error = %e,
657 "run-history backend unavailable at startup; starting DEGRADED on in-memory store"
658 );
659 Arc::new(fallback::FallbackHistory::degraded_at_startup(idem, label))
660 }
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667
668 #[test]
669 fn terminal_classification() {
670 assert!(!RunStatus::Queued.is_terminal());
671 assert!(!RunStatus::Pending.is_terminal());
672 assert!(!RunStatus::Running.is_terminal());
673 assert!(RunStatus::Completed.is_terminal());
674 assert!(RunStatus::Failed.is_terminal());
675 assert!(RunStatus::Cancelled.is_terminal());
676 }
677
678 #[test]
679 fn run_record_serializes_status_snake_case() {
680 let rec = RunRecord::queued(
681 "r1".into(),
682 Some("n".into()),
683 Default::default(),
684 None,
685 Utc::now(),
686 );
687 let v = serde_json::to_value(&rec).unwrap();
688 assert_eq!(v["status"], "queued");
689 assert_eq!(v["run_id"], "r1");
690 assert!(v.get("doctor_report").is_none());
692 }
693
694 #[test]
695 fn pending_is_non_terminal_and_serializes_snake_case() {
696 assert!(!RunStatus::Pending.is_terminal());
697 assert_eq!(RunStatus::Pending.as_str(), "pending");
698 let mut rec = RunRecord::queued("r".into(), None, Default::default(), None, Utc::now());
699 rec.status = RunStatus::Pending;
700 rec.attempt = 2;
701 let v = serde_json::to_value(&rec).unwrap();
702 assert_eq!(v["status"], "pending");
703 assert_eq!(v["attempt"], 2);
704 assert!(v.get("config_body").is_none());
706 }
707
708 #[test]
709 fn shard_progress_all_terminal() {
710 assert!(!ShardProgress::default().all_terminal());
712 let mut p = ShardProgress {
714 total: 3,
715 completed: 1,
716 failed: 0,
717 running: 1,
718 pending: 1,
719 };
720 assert!(!p.all_terminal());
721 p = ShardProgress {
723 total: 3,
724 completed: 2,
725 failed: 1,
726 running: 0,
727 pending: 0,
728 };
729 assert!(p.all_terminal());
730 }
731
732 #[tokio::test]
733 async fn memory_backend_shard_methods_are_inert() {
734 use crate::serve::history::memory::MemoryHistory;
735 let h = MemoryHistory::new(Duration::from_secs(60));
736 assert_eq!(h.insert_shards("r", &[]).await.unwrap(), 0);
737 assert!(h.claim_shards(8).await.unwrap().is_empty());
738 assert_eq!(h.renew_shard_leases().await.unwrap(), 0);
739 assert!(!h.finalize_shard("r", "0", true).await.unwrap());
740 assert_eq!(
741 h.shard_progress("r").await.unwrap(),
742 ShardProgress::default()
743 );
744 }
745
746 #[tokio::test]
747 async fn memory_backend_cluster_methods_are_inert() {
748 use crate::serve::history::memory::MemoryHistory;
749 let h = MemoryHistory::new(Duration::from_secs(60));
750 assert!(h.claim_pending(8).await.unwrap().is_empty());
751 assert_eq!(
752 h.reclaim_orphans(3).await.unwrap(),
753 ReclaimReport::default()
754 );
755 assert!(!h.cancel_pending("x").await.unwrap());
756 h.request_cancel("x").await.unwrap();
757 assert!(h.pending_cancellations().await.unwrap().is_empty());
758 assert!(
759 h.live_instances(Duration::from_secs(60))
760 .await
761 .unwrap()
762 .is_empty()
763 );
764
765 let rec = RunRecord::queued("fo".into(), None, Default::default(), None, Utc::now());
767 assert!(h.finalize_owned(&rec).await.unwrap());
768 assert_eq!(h.get("fo").await.unwrap().unwrap().run_id, "fo");
769 }
770}
771
772#[cfg(all(
773 test,
774 any(feature = "serve-history-postgres", feature = "serve-history-sqlite")
775))]
776mod connect_retry_tests {
777 use super::*;
778 use std::cell::Cell;
779
780 #[test]
781 fn classifies_transient_vs_permanent_connect_errors() {
782 assert!(is_transient_connect_error(&HistoryError::Backend(
784 "SQLite connection failed: error returned from database: (code: 5) \
785 database is locked"
786 .into()
787 )));
788 assert!(is_transient_connect_error(&HistoryError::Backend(
790 "connection refused (os error 111)".into()
791 )));
792 assert!(!is_transient_connect_error(&HistoryError::Backend(
794 "invalid sqlite url 'sqlite::nonsense': ParseError".into()
795 )));
796 }
797
798 #[tokio::test]
799 async fn retries_a_transient_failure_then_succeeds() {
800 let calls = Cell::new(0usize);
801 let result: Result<u32, HistoryError> = connect_with_retry("test", || {
802 let n = calls.get() + 1;
803 calls.set(n);
804 async move {
805 if n < 3 {
806 Err(HistoryError::Backend("database is locked".into()))
807 } else {
808 Ok(42u32)
809 }
810 }
811 })
812 .await;
813 assert_eq!(result.unwrap(), 42);
814 assert_eq!(
815 calls.get(),
816 3,
817 "two transient failures retried, third succeeds"
818 );
819 }
820
821 #[tokio::test]
822 async fn does_not_retry_a_permanent_error() {
823 let calls = Cell::new(0usize);
824 let result: Result<u32, HistoryError> = connect_with_retry("test", || {
825 calls.set(calls.get() + 1);
826 async move { Err::<u32, _>(HistoryError::Backend("invalid sqlite url 'x'".into())) }
827 })
828 .await;
829 assert!(result.is_err());
830 assert_eq!(
831 calls.get(),
832 1,
833 "a permanent error degrades immediately, no retry"
834 );
835 }
836}