#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
pub mod fallback;
pub mod memory;
#[cfg(feature = "serve-history-postgres")]
pub mod postgres;
#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
pub mod sql;
#[cfg(feature = "serve-history-sqlite")]
pub mod sqlite;
use crate::error::CliResult;
use crate::executor::InvocationOutcome;
use crate::serve::config::HistoryBackendSpec;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Queued,
Running,
Completed,
Failed,
Cancelled,
}
impl RunStatus {
pub fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
}
pub fn as_str(self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvocationRecord {
pub row_id: String,
pub parent_record_key: Option<String>,
pub records_written: usize,
pub error: Option<String>,
}
impl From<&InvocationOutcome> for InvocationRecord {
fn from(o: &InvocationOutcome) -> Self {
Self {
row_id: o.row_id.clone(),
parent_record_key: o.parent_record_key.clone(),
records_written: o.records_written,
error: o.error.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
pub run_id: String,
pub name: Option<String>,
pub labels: BTreeMap<String, String>,
pub status: RunStatus,
pub submitted_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub finished_at: Option<DateTime<Utc>>,
pub elapsed_secs: Option<f64>,
pub records_written: u64,
pub invocations: Vec<InvocationRecord>,
pub error: Option<String>,
pub idempotency_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub doctor_report: Option<serde_json::Value>,
}
impl RunRecord {
pub fn queued(
run_id: String,
name: Option<String>,
labels: BTreeMap<String, String>,
idempotency_key: Option<String>,
submitted_at: DateTime<Utc>,
) -> Self {
Self {
run_id,
name,
labels,
status: RunStatus::Queued,
submitted_at,
started_at: None,
finished_at: None,
elapsed_secs: None,
records_written: 0,
invocations: Vec::new(),
error: None,
idempotency_key,
doctor_report: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Claim {
Fresh,
Replay(String),
Conflict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteOutcome {
Deleted,
NotFound,
StillRunning,
}
#[derive(Debug, Default, Clone)]
pub struct ListFilter {
pub status: Option<RunStatus>,
pub name: Option<String>,
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub limit: usize,
pub cursor: Option<String>,
}
#[derive(Debug)]
pub struct ListPage {
pub runs: Vec<RunRecord>,
pub next_cursor: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum HistoryError {
#[error("run-history backend error: {0}")]
Backend(String),
#[error("{0}")]
Degraded(String),
}
#[async_trait]
pub trait RunHistory: Send + Sync {
async fn claim_idempotency(
&self,
key: &str,
fingerprint: &str,
run_id: &str,
window: Duration,
) -> Result<Claim, HistoryError>;
async fn upsert(&self, rec: &RunRecord) -> Result<(), HistoryError>;
async fn get(&self, id: &str) -> Result<Option<RunRecord>, HistoryError>;
async fn list(&self, filter: &ListFilter) -> Result<ListPage, HistoryError>;
async fn delete(&self, id: &str) -> Result<DeleteOutcome, HistoryError>;
async fn purge_expired(&self, retain_for: Duration) -> Result<usize, HistoryError>;
async fn recover_orphans(&self) -> Result<usize, HistoryError>;
async fn renew_leases(&self) -> Result<usize, HistoryError> {
Ok(0)
}
fn degraded(&self) -> bool;
}
pub async fn connect(
spec: &HistoryBackendSpec,
idem_retention: Duration,
lease_ttl: Duration,
instance_id: &str,
) -> CliResult<Arc<dyn RunHistory>> {
match spec {
HistoryBackendSpec::Memory => {
Ok(Arc::new(memory::MemoryHistory::new(idem_retention)) as Arc<dyn RunHistory>)
}
HistoryBackendSpec::Postgres(url) => {
connect_postgres(url, idem_retention, lease_ttl, instance_id).await
}
HistoryBackendSpec::Sqlite(url) => {
connect_sqlite(url, idem_retention, lease_ttl, instance_id).await
}
}
}
#[cfg(feature = "serve-history-postgres")]
async fn connect_postgres(
url: &str,
idem: Duration,
lease_ttl: Duration,
instance_id: &str,
) -> CliResult<Arc<dyn RunHistory>> {
Ok(into_history(
postgres::PostgresHistory::connect(url, idem, lease_ttl, instance_id.to_string()).await,
idem,
"postgres",
))
}
#[cfg(not(feature = "serve-history-postgres"))]
async fn connect_postgres(
_url: &str,
_idem: Duration,
_lease_ttl: Duration,
_instance_id: &str,
) -> CliResult<Arc<dyn RunHistory>> {
Err(crate::error::CliError::Serve(
"persistent Postgres run history requires building faucet with the \
`serve-history-postgres` feature"
.into(),
))
}
#[cfg(feature = "serve-history-sqlite")]
async fn connect_sqlite(
url: &str,
idem: Duration,
lease_ttl: Duration,
instance_id: &str,
) -> CliResult<Arc<dyn RunHistory>> {
Ok(into_history(
sqlite::SqliteHistory::connect(url, idem, lease_ttl, instance_id.to_string()).await,
idem,
"sqlite",
))
}
#[cfg(not(feature = "serve-history-sqlite"))]
async fn connect_sqlite(
_url: &str,
_idem: Duration,
_lease_ttl: Duration,
_instance_id: &str,
) -> CliResult<Arc<dyn RunHistory>> {
Err(crate::error::CliError::Serve(
"persistent SQLite run history requires building faucet with the \
`serve-history-sqlite` feature"
.into(),
))
}
#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
fn into_history<H: RunHistory + 'static>(
result: Result<H, HistoryError>,
idem: Duration,
label: &'static str,
) -> Arc<dyn RunHistory> {
match result {
Ok(backend) => Arc::new(fallback::FallbackHistory::healthy(
Box::new(backend),
idem,
label,
)),
Err(e) => {
tracing::error!(
backend = label, error = %e,
"run-history backend unavailable at startup; starting DEGRADED on in-memory store"
);
Arc::new(fallback::FallbackHistory::degraded_at_startup(idem, label))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terminal_classification() {
assert!(!RunStatus::Queued.is_terminal());
assert!(!RunStatus::Running.is_terminal());
assert!(RunStatus::Completed.is_terminal());
assert!(RunStatus::Failed.is_terminal());
assert!(RunStatus::Cancelled.is_terminal());
}
#[test]
fn run_record_serializes_status_snake_case() {
let rec = RunRecord::queued(
"r1".into(),
Some("n".into()),
Default::default(),
None,
Utc::now(),
);
let v = serde_json::to_value(&rec).unwrap();
assert_eq!(v["status"], "queued");
assert_eq!(v["run_id"], "r1");
assert!(v.get("doctor_report").is_none());
}
}