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 Completed,
35 Failed,
36 Cancelled,
37}
38
39impl RunStatus {
40 pub fn is_terminal(self) -> bool {
41 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
42 }
43 pub fn as_str(self) -> &'static str {
44 match self {
45 Self::Queued => "queued",
46 Self::Pending => "pending",
47 Self::Running => "running",
48 Self::Completed => "completed",
49 Self::Failed => "failed",
50 Self::Cancelled => "cancelled",
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct InvocationRecord {
58 pub row_id: String,
59 pub parent_record_key: Option<String>,
60 pub records_written: usize,
61 pub error: Option<String>,
62}
63
64impl From<&InvocationOutcome> for InvocationRecord {
65 fn from(o: &InvocationOutcome) -> Self {
66 Self {
67 row_id: o.row_id.clone(),
68 parent_record_key: o.parent_record_key.clone(),
69 records_written: o.records_written,
70 error: o.error.clone(),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct RunRecord {
78 pub run_id: String,
79 pub name: Option<String>,
80 pub labels: BTreeMap<String, String>,
81 pub status: RunStatus,
82 pub submitted_at: DateTime<Utc>,
83 pub started_at: Option<DateTime<Utc>>,
84 pub finished_at: Option<DateTime<Utc>>,
85 pub elapsed_secs: Option<f64>,
86 pub records_written: u64,
87 pub invocations: Vec<InvocationRecord>,
88 pub error: Option<String>,
89 pub idempotency_key: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub doctor_report: Option<serde_json::Value>,
92 #[serde(skip_serializing_if = "Option::is_none")]
95 pub config_body: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub config_format: Option<crate::serve::load::ConfigFormat>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub timeout_secs: Option<u64>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub clock: Option<String>,
102 #[serde(default)]
104 pub attempt: u32,
105}
106
107impl RunRecord {
108 pub fn queued(
110 run_id: String,
111 name: Option<String>,
112 labels: BTreeMap<String, String>,
113 idempotency_key: Option<String>,
114 submitted_at: DateTime<Utc>,
115 ) -> Self {
116 Self {
117 run_id,
118 name,
119 labels,
120 status: RunStatus::Queued,
121 submitted_at,
122 started_at: None,
123 finished_at: None,
124 elapsed_secs: None,
125 records_written: 0,
126 invocations: Vec::new(),
127 error: None,
128 idempotency_key,
129 doctor_report: None,
130 config_body: None,
131 config_format: None,
132 timeout_secs: None,
133 clock: None,
134 attempt: 0,
135 }
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum Claim {
142 Fresh,
144 Replay(String),
146 Conflict,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum DeleteOutcome {
153 Deleted,
154 NotFound,
155 StillRunning,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
160pub struct ReclaimReport {
161 pub requeued: usize,
163 pub failed: usize,
165}
166
167#[derive(Debug, Clone)]
171pub struct InstanceHeartbeat {
172 pub started_at: DateTime<Utc>,
173 pub listen: Option<String>,
174 pub max_concurrent: u32,
175 pub in_flight: u32,
176}
177
178#[derive(Debug, Clone, Serialize)]
180pub struct InstanceRecord {
181 pub instance_id: String,
182 pub started_at: DateTime<Utc>,
183 pub last_heartbeat: DateTime<Utc>,
184 pub listen: Option<String>,
185 pub max_concurrent: u32,
186 pub in_flight: u32,
187}
188
189#[derive(Debug, Default, Clone)]
191pub struct ListFilter {
192 pub status: Option<RunStatus>,
193 pub name: Option<String>,
194 pub since: Option<DateTime<Utc>>,
195 pub until: Option<DateTime<Utc>>,
196 pub limit: usize,
197 pub cursor: Option<String>,
198}
199
200#[derive(Debug)]
202pub struct ListPage {
203 pub runs: Vec<RunRecord>,
204 pub next_cursor: Option<String>,
205}
206
207#[derive(Debug, thiserror::Error)]
210pub enum HistoryError {
211 #[error("run-history backend error: {0}")]
212 Backend(String),
213 #[error("{0}")]
217 Degraded(String),
218}
219
220#[async_trait]
221pub trait RunHistory: Send + Sync {
222 async fn claim_idempotency(
225 &self,
226 key: &str,
227 fingerprint: &str,
228 run_id: &str,
229 window: Duration,
230 ) -> Result<Claim, HistoryError>;
231
232 async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError>;
234
235 async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError>;
236
237 async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError>;
238
239 async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError>;
241
242 async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError>;
245
246 async fn recover_orphans(&self) -> Result<usize, HistoryError>;
251
252 async fn renew_leases(&self) -> Result<usize, HistoryError> {
257 Ok(0)
258 }
259
260 async fn claim_pending(&self, limit: usize) -> Result<Vec<RunRecord>, HistoryError> {
266 let _ = limit;
267 Ok(Vec::new())
268 }
269
270 async fn reclaim_orphans(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
274 let _ = max_attempts;
275 Ok(ReclaimReport::default())
276 }
277
278 async fn finalize_owned(&self, rec: &RunRecord) -> Result<bool, HistoryError> {
283 self.upsert(rec).await.map(|_| true)
284 }
285
286 async fn cancel_pending(&self, run_id: &str) -> Result<bool, HistoryError> {
291 let _ = run_id;
292 Ok(false)
293 }
294
295 async fn request_cancel(&self, run_id: &str) -> Result<(), HistoryError> {
298 let _ = run_id;
299 Ok(())
300 }
301
302 async fn pending_cancellations(&self) -> Result<Vec<String>, HistoryError> {
305 Ok(Vec::new())
306 }
307
308 async fn heartbeat_instance(&self, beat: &InstanceHeartbeat) -> Result<(), HistoryError> {
310 let _ = beat;
311 Ok(())
312 }
313
314 async fn live_instances(&self, ttl: Duration) -> Result<Vec<InstanceRecord>, HistoryError> {
316 let _ = ttl;
317 Ok(Vec::new())
318 }
319
320 fn degraded(&self) -> bool;
323}
324
325pub async fn connect(
330 spec: &HistoryBackendSpec,
331 idem_retention: Duration,
332 lease_ttl: Duration,
333 instance_id: &str,
334) -> CliResult<Arc<dyn RunHistory>> {
335 match spec {
336 HistoryBackendSpec::Memory => {
337 Ok(Arc::new(memory::MemoryHistory::new(idem_retention)) as Arc<dyn RunHistory>)
338 }
339 HistoryBackendSpec::Postgres(url) => {
340 connect_postgres(url, idem_retention, lease_ttl, instance_id).await
341 }
342 HistoryBackendSpec::Sqlite(url) => {
343 connect_sqlite(url, idem_retention, lease_ttl, instance_id).await
344 }
345 }
346}
347
348#[cfg(feature = "serve-history-postgres")]
349async fn connect_postgres(
350 url: &str,
351 idem: Duration,
352 lease_ttl: Duration,
353 instance_id: &str,
354) -> CliResult<Arc<dyn RunHistory>> {
355 let result = connect_with_retry("postgres", || {
356 postgres::PostgresHistory::connect(url, idem, lease_ttl, instance_id.to_string())
357 })
358 .await;
359 Ok(into_history(result, idem, "postgres"))
360}
361
362#[cfg(not(feature = "serve-history-postgres"))]
363async fn connect_postgres(
364 _url: &str,
365 _idem: Duration,
366 _lease_ttl: Duration,
367 _instance_id: &str,
368) -> CliResult<Arc<dyn RunHistory>> {
369 Err(crate::error::CliError::Serve(
370 "persistent Postgres run history requires building faucet with the \
371 `serve-history-postgres` feature"
372 .into(),
373 ))
374}
375
376#[cfg(feature = "serve-history-sqlite")]
377async fn connect_sqlite(
378 url: &str,
379 idem: Duration,
380 lease_ttl: Duration,
381 instance_id: &str,
382) -> CliResult<Arc<dyn RunHistory>> {
383 let result = connect_with_retry("sqlite", || {
384 sqlite::SqliteHistory::connect(url, idem, lease_ttl, instance_id.to_string())
385 })
386 .await;
387 Ok(into_history(result, idem, "sqlite"))
388}
389
390#[cfg(not(feature = "serve-history-sqlite"))]
391async fn connect_sqlite(
392 _url: &str,
393 _idem: Duration,
394 _lease_ttl: Duration,
395 _instance_id: &str,
396) -> CliResult<Arc<dyn RunHistory>> {
397 Err(crate::error::CliError::Serve(
398 "persistent SQLite run history requires building faucet with the \
399 `serve-history-sqlite` feature"
400 .into(),
401 ))
402}
403
404#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
410const CONNECT_ATTEMPTS: usize = 8;
411
412#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
421async fn connect_with_retry<H, F, Fut>(label: &str, mut make: F) -> Result<H, HistoryError>
422where
423 F: FnMut() -> Fut,
424 Fut: std::future::Future<Output = Result<H, HistoryError>>,
425{
426 let mut delay = Duration::from_millis(100);
427 for attempt in 1..=CONNECT_ATTEMPTS {
428 match make().await {
429 Ok(backend) => return Ok(backend),
430 Err(e) if attempt < CONNECT_ATTEMPTS && is_transient_connect_error(&e) => {
431 tracing::warn!(
432 backend = label,
433 attempt,
434 error = %e,
435 "run-history backend connect failed transiently; retrying before degrading"
436 );
437 tokio::time::sleep(delay).await;
438 delay = (delay * 2).min(Duration::from_secs(1));
439 }
440 Err(e) => return Err(e),
441 }
442 }
443 unreachable!("the final attempt returns Ok or Err rather than looping")
444}
445
446#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
450fn is_transient_connect_error(e: &HistoryError) -> bool {
451 let msg = e.to_string().to_ascii_lowercase();
452 [
453 "database is locked", "busy", "connection refused", "connection reset",
457 "timed out",
458 "timeout",
459 "starting up", "too many connections", ]
462 .iter()
463 .any(|needle| msg.contains(needle))
464}
465
466#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
469fn into_history<H: RunHistory + 'static>(
470 result: Result<H, HistoryError>,
471 idem: Duration,
472 label: &'static str,
473) -> Arc<dyn RunHistory> {
474 match result {
475 Ok(backend) => Arc::new(fallback::FallbackHistory::healthy(
476 Box::new(backend),
477 idem,
478 label,
479 )),
480 Err(e) => {
481 tracing::error!(
482 backend = label, error = %e,
483 "run-history backend unavailable at startup; starting DEGRADED on in-memory store"
484 );
485 Arc::new(fallback::FallbackHistory::degraded_at_startup(idem, label))
486 }
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
495 fn terminal_classification() {
496 assert!(!RunStatus::Queued.is_terminal());
497 assert!(!RunStatus::Pending.is_terminal());
498 assert!(!RunStatus::Running.is_terminal());
499 assert!(RunStatus::Completed.is_terminal());
500 assert!(RunStatus::Failed.is_terminal());
501 assert!(RunStatus::Cancelled.is_terminal());
502 }
503
504 #[test]
505 fn run_record_serializes_status_snake_case() {
506 let rec = RunRecord::queued(
507 "r1".into(),
508 Some("n".into()),
509 Default::default(),
510 None,
511 Utc::now(),
512 );
513 let v = serde_json::to_value(&rec).unwrap();
514 assert_eq!(v["status"], "queued");
515 assert_eq!(v["run_id"], "r1");
516 assert!(v.get("doctor_report").is_none());
518 }
519
520 #[test]
521 fn pending_is_non_terminal_and_serializes_snake_case() {
522 assert!(!RunStatus::Pending.is_terminal());
523 assert_eq!(RunStatus::Pending.as_str(), "pending");
524 let mut rec = RunRecord::queued("r".into(), None, Default::default(), None, Utc::now());
525 rec.status = RunStatus::Pending;
526 rec.attempt = 2;
527 let v = serde_json::to_value(&rec).unwrap();
528 assert_eq!(v["status"], "pending");
529 assert_eq!(v["attempt"], 2);
530 assert!(v.get("config_body").is_none());
532 }
533
534 #[tokio::test]
535 async fn memory_backend_cluster_methods_are_inert() {
536 use crate::serve::history::memory::MemoryHistory;
537 let h = MemoryHistory::new(Duration::from_secs(60));
538 assert!(h.claim_pending(8).await.unwrap().is_empty());
539 assert_eq!(
540 h.reclaim_orphans(3).await.unwrap(),
541 ReclaimReport::default()
542 );
543 assert!(!h.cancel_pending("x").await.unwrap());
544 h.request_cancel("x").await.unwrap();
545 assert!(h.pending_cancellations().await.unwrap().is_empty());
546 assert!(
547 h.live_instances(Duration::from_secs(60))
548 .await
549 .unwrap()
550 .is_empty()
551 );
552
553 let rec = RunRecord::queued("fo".into(), None, Default::default(), None, Utc::now());
555 assert!(h.finalize_owned(&rec).await.unwrap());
556 assert_eq!(h.get("fo").await.unwrap().unwrap().run_id, "fo");
557 }
558}
559
560#[cfg(all(
561 test,
562 any(feature = "serve-history-postgres", feature = "serve-history-sqlite")
563))]
564mod connect_retry_tests {
565 use super::*;
566 use std::cell::Cell;
567
568 #[test]
569 fn classifies_transient_vs_permanent_connect_errors() {
570 assert!(is_transient_connect_error(&HistoryError::Backend(
572 "SQLite connection failed: error returned from database: (code: 5) \
573 database is locked"
574 .into()
575 )));
576 assert!(is_transient_connect_error(&HistoryError::Backend(
578 "connection refused (os error 111)".into()
579 )));
580 assert!(!is_transient_connect_error(&HistoryError::Backend(
582 "invalid sqlite url 'sqlite::nonsense': ParseError".into()
583 )));
584 }
585
586 #[tokio::test]
587 async fn retries_a_transient_failure_then_succeeds() {
588 let calls = Cell::new(0usize);
589 let result: Result<u32, HistoryError> = connect_with_retry("test", || {
590 let n = calls.get() + 1;
591 calls.set(n);
592 async move {
593 if n < 3 {
594 Err(HistoryError::Backend("database is locked".into()))
595 } else {
596 Ok(42u32)
597 }
598 }
599 })
600 .await;
601 assert_eq!(result.unwrap(), 42);
602 assert_eq!(
603 calls.get(),
604 3,
605 "two transient failures retried, third succeeds"
606 );
607 }
608
609 #[tokio::test]
610 async fn does_not_retry_a_permanent_error() {
611 let calls = Cell::new(0usize);
612 let result: Result<u32, HistoryError> = connect_with_retry("test", || {
613 calls.set(calls.get() + 1);
614 async move { Err::<u32, _>(HistoryError::Backend("invalid sqlite url 'x'".into())) }
615 })
616 .await;
617 assert!(result.is_err());
618 assert_eq!(
619 calls.get(),
620 1,
621 "a permanent error degrades immediately, no retry"
622 );
623 }
624}