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;
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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum RunStatus {
33 Queued,
34 Pending,
35 Running,
36 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#[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#[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 #[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 #[serde(default)]
113 pub attempt: u32,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub replay_of: Option<String>,
120}
121
122impl RunRecord {
123 pub fn queued(
125 run_id: String,
126 name: Option<String>,
127 labels: BTreeMap<String, String>,
128 idempotency_key: Option<String>,
129 submitted_at: DateTime<Utc>,
130 ) -> Self {
131 Self {
132 run_id,
133 name,
134 labels,
135 status: RunStatus::Queued,
136 submitted_at,
137 started_at: None,
138 finished_at: None,
139 elapsed_secs: None,
140 records_written: 0,
141 invocations: Vec::new(),
142 error: None,
143 idempotency_key,
144 doctor_report: None,
145 config_body: None,
146 config_format: None,
147 timeout_secs: None,
148 clock: None,
149 attempt: 0,
150 replay_of: None,
151 }
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum Claim {
158 Fresh,
160 Replay(String),
162 Conflict,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum DeleteOutcome {
169 Deleted,
170 NotFound,
171 StillRunning,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
176pub struct ReclaimReport {
177 pub requeued: usize,
179 pub failed: usize,
181}
182
183#[derive(Debug, Clone)]
187pub struct InstanceHeartbeat {
188 pub started_at: DateTime<Utc>,
189 pub listen: Option<String>,
190 pub max_concurrent: u32,
191 pub in_flight: u32,
192}
193
194#[derive(Debug, Clone, Serialize)]
196pub struct InstanceRecord {
197 pub instance_id: String,
198 pub started_at: DateTime<Utc>,
199 pub last_heartbeat: DateTime<Utc>,
200 pub listen: Option<String>,
201 pub max_concurrent: u32,
202 pub in_flight: u32,
203}
204
205#[derive(Debug, Default, Clone)]
207pub struct ListFilter {
208 pub status: Option<RunStatus>,
209 pub name: Option<String>,
210 pub since: Option<DateTime<Utc>>,
211 pub until: Option<DateTime<Utc>>,
212 pub limit: usize,
213 pub cursor: Option<String>,
214}
215
216#[derive(Debug)]
218pub struct ListPage {
219 pub runs: Vec<RunRecord>,
220 pub next_cursor: Option<String>,
221}
222
223#[derive(Debug, thiserror::Error)]
226pub enum HistoryError {
227 #[error("run-history backend error: {0}")]
228 Backend(String),
229 #[error("{0}")]
233 Degraded(String),
234}
235
236#[derive(Debug, Clone)]
238pub struct ShardInsert {
239 pub shard_id: String,
241 pub descriptor: serde_json::Value,
244 pub size_estimate: Option<u64>,
246}
247
248#[derive(Debug, Clone)]
251pub struct ClaimedShard {
252 pub run_id: String,
253 pub shard_id: String,
254 pub descriptor: serde_json::Value,
255 pub run: RunRecord,
257}
258
259#[derive(Debug, Clone, Default, PartialEq, Eq)]
262pub struct ShardProgress {
263 pub total: usize,
264 pub completed: usize,
265 pub failed: usize,
266 pub running: usize,
267 pub pending: usize,
268}
269
270impl ShardProgress {
271 pub fn all_terminal(&self) -> bool {
274 self.total > 0 && self.completed + self.failed == self.total
275 }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct AuditEntry {
283 pub id: String,
285 pub timestamp: DateTime<Utc>,
286 pub principal: String,
289 pub role: String,
291 pub action: String,
294 #[serde(skip_serializing_if = "Option::is_none")]
295 pub run_id: Option<String>,
296 #[serde(skip_serializing_if = "Option::is_none")]
298 pub config_fingerprint: Option<String>,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 pub source_ip: Option<String>,
301 pub result: String,
303}
304
305#[derive(Debug, Default, Clone)]
308pub struct AuditFilter {
309 pub principal: Option<String>,
310 pub action: Option<String>,
311 pub since: Option<DateTime<Utc>>,
312 pub until: Option<DateTime<Utc>>,
313 pub limit: usize,
314}
315
316#[async_trait]
317pub trait RunHistory: Send + Sync {
318 async fn claim_idempotency(
321 &self,
322 key: &str,
323 fingerprint: &str,
324 run_id: &str,
325 window: Duration,
326 ) -> Result<Claim, HistoryError>;
327
328 async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError>;
330
331 async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError>;
332
333 async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError>;
334
335 async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError>;
337
338 async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError>;
341
342 async fn release_idempotency(&self, run_id: &str) -> Result<(), HistoryError> {
351 let _ = run_id;
352 Ok(())
353 }
354
355 async fn recover_orphans(&self) -> Result<usize, HistoryError>;
360
361 async fn renew_leases(&self) -> Result<usize, HistoryError> {
366 Ok(0)
367 }
368
369 async fn claim_pending(&self, limit: usize) -> Result<Vec<RunRecord>, HistoryError> {
375 let _ = limit;
376 Ok(Vec::new())
377 }
378
379 async fn reclaim_orphans(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
383 let _ = max_attempts;
384 Ok(ReclaimReport::default())
385 }
386
387 async fn finalize_owned(&self, rec: &RunRecord) -> Result<bool, HistoryError> {
392 self.upsert(rec).await.map(|_| true)
393 }
394
395 async fn finalize_sharded_parent(
406 &self,
407 run_id: &str,
408 status: RunStatus,
409 finished_at: DateTime<Utc>,
410 error: Option<String>,
411 ) -> Result<bool, HistoryError> {
412 match self.get(run_id).await? {
413 Some(mut r) if r.status == RunStatus::Sharded => {
414 r.status = status;
415 r.finished_at = Some(finished_at);
416 r.error = error;
417 self.upsert(&r).await?;
418 Ok(true)
419 }
420 _ => Ok(false),
421 }
422 }
423
424 async fn cancel_pending(&self, run_id: &str) -> Result<bool, HistoryError> {
429 let _ = run_id;
430 Ok(false)
431 }
432
433 async fn request_cancel(&self, run_id: &str) -> Result<(), HistoryError> {
436 let _ = run_id;
437 Ok(())
438 }
439
440 async fn pending_cancellations(&self) -> Result<Vec<String>, HistoryError> {
443 Ok(Vec::new())
444 }
445
446 async fn heartbeat_instance(&self, beat: &InstanceHeartbeat) -> Result<(), HistoryError> {
448 let _ = beat;
449 Ok(())
450 }
451
452 async fn live_instances(&self, ttl: Duration) -> Result<Vec<InstanceRecord>, HistoryError> {
454 let _ = ttl;
455 Ok(Vec::new())
456 }
457
458 async fn insert_shards(
469 &self,
470 run_id: &str,
471 shards: &[ShardInsert],
472 ) -> Result<usize, HistoryError> {
473 let _ = (run_id, shards);
474 Ok(0)
475 }
476
477 async fn claim_shards(&self, limit: usize) -> Result<Vec<ClaimedShard>, HistoryError> {
482 let _ = limit;
483 Ok(Vec::new())
484 }
485
486 async fn renew_shard_leases(&self) -> Result<usize, HistoryError> {
490 Ok(0)
491 }
492
493 async fn reclaim_shards(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
497 let _ = max_attempts;
498 Ok(ReclaimReport::default())
499 }
500
501 async fn finalize_shard(
505 &self,
506 run_id: &str,
507 shard_id: &str,
508 success: bool,
509 ) -> Result<bool, HistoryError> {
510 let _ = (run_id, shard_id, success);
511 Ok(false)
512 }
513
514 async fn shard_progress(&self, run_id: &str) -> Result<ShardProgress, HistoryError> {
517 let _ = run_id;
518 Ok(ShardProgress::default())
519 }
520
521 async fn pending_shard_cancellations(&self) -> Result<Vec<String>, HistoryError> {
527 Ok(Vec::new())
528 }
529
530 async fn finalize_completed_sharded_parents(&self) -> Result<usize, HistoryError> {
538 Ok(0)
539 }
540
541 async fn record_audit(&self, entry: &AuditEntry) -> Result<(), HistoryError> {
548 let _ = entry;
549 Ok(())
550 }
551
552 async fn list_audit(&self, filter: &AuditFilter) -> Result<Vec<AuditEntry>, HistoryError> {
554 let _ = filter;
555 Ok(Vec::new())
556 }
557
558 async fn catalog_record(&self, update: &catalog::CatalogUpdate) -> Result<(), HistoryError> {
572 let _ = update;
573 Ok(())
574 }
575
576 async fn catalog_list_datasets(
579 &self,
580 filter: &catalog::CatalogListFilter,
581 ) -> Result<catalog::CatalogDatasetPage, HistoryError> {
582 let _ = filter;
583 Ok(catalog::CatalogDatasetPage {
584 datasets: Vec::new(),
585 next_cursor: None,
586 })
587 }
588
589 async fn catalog_get_dataset(
592 &self,
593 id: &str,
594 ) -> Result<Option<catalog::CatalogDatasetDetail>, HistoryError> {
595 let _ = id;
596 Ok(None)
597 }
598
599 async fn catalog_lineage(
602 &self,
603 root: Option<&str>,
604 depth: u32,
605 ) -> Result<Vec<catalog::CatalogLineageEdge>, HistoryError> {
606 let _ = (root, depth);
607 Ok(Vec::new())
608 }
609
610 async fn catalog_record_config_snapshot(
614 &self,
615 snapshot: &catalog::ConfigSnapshot,
616 ) -> Result<(), HistoryError> {
617 let _ = snapshot;
618 Ok(())
619 }
620
621 async fn catalog_last_config_snapshot(
625 &self,
626 pipeline: &str,
627 ) -> Result<Option<catalog::ConfigSnapshot>, HistoryError> {
628 let _ = pipeline;
629 Ok(None)
630 }
631
632 async fn template_register(
644 &self,
645 draft: &templates::TemplateDraft,
646 ) -> Result<templates::TemplateRecord, HistoryError> {
647 let _ = draft;
648 Err(HistoryError::Backend(
649 "this run-history backend does not support the pipeline-template registry".into(),
650 ))
651 }
652
653 async fn template_get(
655 &self,
656 id: &str,
657 version: Option<u32>,
658 ) -> Result<Option<templates::TemplateRecord>, HistoryError> {
659 let _ = (id, version);
660 Ok(None)
661 }
662
663 async fn template_list(&self) -> Result<Vec<templates::TemplateSummary>, HistoryError> {
666 Ok(Vec::new())
667 }
668
669 async fn template_versions(&self, id: &str) -> Result<Vec<u32>, HistoryError> {
671 let _ = id;
672 Ok(Vec::new())
673 }
674
675 async fn template_delete(&self, id: &str, version: Option<u32>) -> Result<usize, HistoryError> {
680 let _ = (id, version);
681 Ok(0)
682 }
683
684 async fn template_set_tag(
688 &self,
689 id: &str,
690 tag: &str,
691 version: u32,
692 ) -> Result<(), HistoryError> {
693 let _ = (id, tag, version);
694 Err(HistoryError::Backend(
695 "this run-history backend does not support pipeline-template channels".into(),
696 ))
697 }
698
699 async fn template_tags(&self, id: &str) -> Result<BTreeMap<String, u32>, HistoryError> {
702 let _ = id;
703 Ok(BTreeMap::new())
704 }
705
706 async fn template_delete_tag(&self, id: &str, tag: &str) -> Result<bool, HistoryError> {
708 let _ = (id, tag);
709 Ok(false)
710 }
711
712 async fn template_launch(
718 &self,
719 id: &str,
720 version: u32,
721 launched_by: Option<&str>,
722 ) -> Result<Option<u32>, HistoryError> {
723 let _ = (id, version, launched_by);
724 Err(HistoryError::Backend(
725 "this run-history backend does not support pipeline-template launches".into(),
726 ))
727 }
728
729 async fn template_launches(
732 &self,
733 id: &str,
734 ) -> Result<Vec<templates::LaunchRecord>, HistoryError> {
735 let _ = id;
736 Ok(Vec::new())
737 }
738
739 async fn template_set_deprecation(
742 &self,
743 id: &str,
744 record: Option<&templates::DeprecationRecord>,
745 ) -> Result<(), HistoryError> {
746 let _ = (id, record);
747 Err(HistoryError::Backend(
748 "this run-history backend does not support pipeline-template deprecation".into(),
749 ))
750 }
751
752 async fn template_deprecation(
754 &self,
755 id: &str,
756 ) -> Result<Option<templates::DeprecationRecord>, HistoryError> {
757 let _ = id;
758 Ok(None)
759 }
760
761 async fn template_state(&self, id: &str) -> Result<templates::TemplateState, HistoryError> {
768 Ok(templates::TemplateState::assemble(
769 self.template_versions(id).await?,
770 &self.template_launches(id).await?,
771 self.template_tags(id).await?,
772 self.template_deprecation(id).await?,
773 ))
774 }
775
776 fn degraded(&self) -> bool;
779}
780
781pub async fn connect(
786 spec: &HistoryBackendSpec,
787 idem_retention: Duration,
788 lease_ttl: Duration,
789 instance_id: &str,
790) -> CliResult<Arc<dyn RunHistory>> {
791 match spec {
792 HistoryBackendSpec::Memory => {
793 Ok(Arc::new(memory::MemoryHistory::new(idem_retention)) as Arc<dyn RunHistory>)
794 }
795 HistoryBackendSpec::Postgres(url) => {
796 connect_postgres(url, idem_retention, lease_ttl, instance_id).await
797 }
798 HistoryBackendSpec::Sqlite(url) => {
799 connect_sqlite(url, idem_retention, lease_ttl, instance_id).await
800 }
801 }
802}
803
804#[cfg(feature = "serve-history-postgres")]
805async fn connect_postgres(
806 url: &str,
807 idem: Duration,
808 lease_ttl: Duration,
809 instance_id: &str,
810) -> CliResult<Arc<dyn RunHistory>> {
811 let result = connect_with_retry("postgres", || {
812 postgres::PostgresHistory::connect(url, idem, lease_ttl, instance_id.to_string())
813 })
814 .await;
815 Ok(into_history(result, idem, "postgres"))
816}
817
818#[cfg(not(feature = "serve-history-postgres"))]
819async fn connect_postgres(
820 _url: &str,
821 _idem: Duration,
822 _lease_ttl: Duration,
823 _instance_id: &str,
824) -> CliResult<Arc<dyn RunHistory>> {
825 Err(crate::error::CliError::Serve(
826 "persistent Postgres run history requires building faucet with the \
827 `serve-history-postgres` feature"
828 .into(),
829 ))
830}
831
832#[cfg(feature = "serve-history-sqlite")]
833async fn connect_sqlite(
834 url: &str,
835 idem: Duration,
836 lease_ttl: Duration,
837 instance_id: &str,
838) -> CliResult<Arc<dyn RunHistory>> {
839 let result = connect_with_retry("sqlite", || {
840 sqlite::SqliteHistory::connect(url, idem, lease_ttl, instance_id.to_string())
841 })
842 .await;
843 Ok(into_history(result, idem, "sqlite"))
844}
845
846#[cfg(not(feature = "serve-history-sqlite"))]
847async fn connect_sqlite(
848 _url: &str,
849 _idem: Duration,
850 _lease_ttl: Duration,
851 _instance_id: &str,
852) -> CliResult<Arc<dyn RunHistory>> {
853 Err(crate::error::CliError::Serve(
854 "persistent SQLite run history requires building faucet with the \
855 `serve-history-sqlite` feature"
856 .into(),
857 ))
858}
859
860#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
866const CONNECT_ATTEMPTS: usize = 8;
867
868#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
877async fn connect_with_retry<H, F, Fut>(label: &str, mut make: F) -> Result<H, HistoryError>
878where
879 F: FnMut() -> Fut,
880 Fut: std::future::Future<Output = Result<H, HistoryError>>,
881{
882 let mut delay = Duration::from_millis(100);
883 for attempt in 1..=CONNECT_ATTEMPTS {
884 match make().await {
885 Ok(backend) => return Ok(backend),
886 Err(e) if attempt < CONNECT_ATTEMPTS && is_transient_connect_error(&e) => {
887 tracing::warn!(
888 backend = label,
889 attempt,
890 error = %e,
891 "run-history backend connect failed transiently; retrying before degrading"
892 );
893 tokio::time::sleep(delay).await;
894 delay = (delay * 2).min(Duration::from_secs(1));
895 }
896 Err(e) => return Err(e),
897 }
898 }
899 unreachable!("the final attempt returns Ok or Err rather than looping")
900}
901
902#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
906fn is_transient_connect_error(e: &HistoryError) -> bool {
907 let msg = e.to_string().to_ascii_lowercase();
908 [
909 "database is locked", "busy", "connection refused", "connection reset",
913 "timed out",
914 "timeout",
915 "starting up", "too many connections", ]
918 .iter()
919 .any(|needle| msg.contains(needle))
920}
921
922#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
925fn into_history<H: RunHistory + 'static>(
926 result: Result<H, HistoryError>,
927 idem: Duration,
928 label: &'static str,
929) -> Arc<dyn RunHistory> {
930 match result {
931 Ok(backend) => Arc::new(fallback::FallbackHistory::healthy(
932 Box::new(backend),
933 idem,
934 label,
935 )),
936 Err(e) => {
937 tracing::error!(
938 backend = label, error = %e,
939 "run-history backend unavailable at startup; starting DEGRADED on in-memory store"
940 );
941 Arc::new(fallback::FallbackHistory::degraded_at_startup(idem, label))
942 }
943 }
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949
950 #[test]
951 fn terminal_classification() {
952 assert!(!RunStatus::Queued.is_terminal());
953 assert!(!RunStatus::Pending.is_terminal());
954 assert!(!RunStatus::Running.is_terminal());
955 assert!(RunStatus::Completed.is_terminal());
956 assert!(RunStatus::Failed.is_terminal());
957 assert!(RunStatus::Cancelled.is_terminal());
958 }
959
960 #[test]
961 fn run_record_serializes_status_snake_case() {
962 let rec = RunRecord::queued(
963 "r1".into(),
964 Some("n".into()),
965 Default::default(),
966 None,
967 Utc::now(),
968 );
969 let v = serde_json::to_value(&rec).unwrap();
970 assert_eq!(v["status"], "queued");
971 assert_eq!(v["run_id"], "r1");
972 assert!(v.get("doctor_report").is_none());
974 }
975
976 #[test]
977 fn pending_is_non_terminal_and_serializes_snake_case() {
978 assert!(!RunStatus::Pending.is_terminal());
979 assert_eq!(RunStatus::Pending.as_str(), "pending");
980 let mut rec = RunRecord::queued("r".into(), None, Default::default(), None, Utc::now());
981 rec.status = RunStatus::Pending;
982 rec.attempt = 2;
983 let v = serde_json::to_value(&rec).unwrap();
984 assert_eq!(v["status"], "pending");
985 assert_eq!(v["attempt"], 2);
986 assert!(v.get("config_body").is_none());
988 }
989
990 #[test]
991 fn shard_progress_all_terminal() {
992 assert!(!ShardProgress::default().all_terminal());
994 let mut p = ShardProgress {
996 total: 3,
997 completed: 1,
998 failed: 0,
999 running: 1,
1000 pending: 1,
1001 };
1002 assert!(!p.all_terminal());
1003 p = ShardProgress {
1005 total: 3,
1006 completed: 2,
1007 failed: 1,
1008 running: 0,
1009 pending: 0,
1010 };
1011 assert!(p.all_terminal());
1012 }
1013
1014 #[tokio::test]
1015 async fn memory_backend_shard_methods_are_inert() {
1016 use crate::serve::history::memory::MemoryHistory;
1017 let h = MemoryHistory::new(Duration::from_secs(60));
1018 assert_eq!(h.insert_shards("r", &[]).await.unwrap(), 0);
1019 assert!(h.claim_shards(8).await.unwrap().is_empty());
1020 assert_eq!(h.renew_shard_leases().await.unwrap(), 0);
1021 assert!(!h.finalize_shard("r", "0", true).await.unwrap());
1022 assert_eq!(
1023 h.shard_progress("r").await.unwrap(),
1024 ShardProgress::default()
1025 );
1026 }
1027
1028 #[tokio::test]
1029 async fn memory_backend_cluster_methods_are_inert() {
1030 use crate::serve::history::memory::MemoryHistory;
1031 let h = MemoryHistory::new(Duration::from_secs(60));
1032 assert!(h.claim_pending(8).await.unwrap().is_empty());
1033 assert_eq!(
1034 h.reclaim_orphans(3).await.unwrap(),
1035 ReclaimReport::default()
1036 );
1037 assert!(!h.cancel_pending("x").await.unwrap());
1038 h.request_cancel("x").await.unwrap();
1039 assert!(h.pending_cancellations().await.unwrap().is_empty());
1040 assert!(
1041 h.live_instances(Duration::from_secs(60))
1042 .await
1043 .unwrap()
1044 .is_empty()
1045 );
1046
1047 let rec = RunRecord::queued("fo".into(), None, Default::default(), None, Utc::now());
1049 assert!(h.finalize_owned(&rec).await.unwrap());
1050 assert_eq!(h.get("fo").await.unwrap().unwrap().run_id, "fo");
1051 }
1052}
1053
1054#[cfg(all(
1055 test,
1056 any(feature = "serve-history-postgres", feature = "serve-history-sqlite")
1057))]
1058mod connect_retry_tests {
1059 use super::*;
1060 use std::cell::Cell;
1061
1062 #[test]
1063 fn classifies_transient_vs_permanent_connect_errors() {
1064 assert!(is_transient_connect_error(&HistoryError::Backend(
1066 "SQLite connection failed: error returned from database: (code: 5) \
1067 database is locked"
1068 .into()
1069 )));
1070 assert!(is_transient_connect_error(&HistoryError::Backend(
1072 "connection refused (os error 111)".into()
1073 )));
1074 assert!(!is_transient_connect_error(&HistoryError::Backend(
1076 "invalid sqlite url 'sqlite::nonsense': ParseError".into()
1077 )));
1078 }
1079
1080 #[tokio::test]
1081 async fn retries_a_transient_failure_then_succeeds() {
1082 let calls = Cell::new(0usize);
1083 let result: Result<u32, HistoryError> = connect_with_retry("test", || {
1084 let n = calls.get() + 1;
1085 calls.set(n);
1086 async move {
1087 if n < 3 {
1088 Err(HistoryError::Backend("database is locked".into()))
1089 } else {
1090 Ok(42u32)
1091 }
1092 }
1093 })
1094 .await;
1095 assert_eq!(result.unwrap(), 42);
1096 assert_eq!(
1097 calls.get(),
1098 3,
1099 "two transient failures retried, third succeeds"
1100 );
1101 }
1102
1103 #[tokio::test]
1104 async fn does_not_retry_a_permanent_error() {
1105 let calls = Cell::new(0usize);
1106 let result: Result<u32, HistoryError> = connect_with_retry("test", || {
1107 calls.set(calls.get() + 1);
1108 async move { Err::<u32, _>(HistoryError::Backend("invalid sqlite url 'x'".into())) }
1109 })
1110 .await;
1111 assert!(result.is_err());
1112 assert_eq!(
1113 calls.get(),
1114 1,
1115 "a permanent error degrades immediately, no retry"
1116 );
1117 }
1118}