pub mod catalog;
#[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;
pub mod templates;
pub mod tenants;
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, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Queued,
Pending,
Running,
Sharded,
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::Pending => "pending",
Self::Running => "running",
Self::Sharded => "sharded",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
}
}
pub fn parse(s: &str) -> Option<Self> {
Some(match s.trim() {
"queued" => Self::Queued,
"pending" => Self::Pending,
"running" => Self::Running,
"sharded" => Self::Sharded,
"completed" => Self::Completed,
"failed" => Self::Failed,
"cancelled" => Self::Cancelled,
_ => return None,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InvocationRecord {
pub row_id: String,
pub parent_record_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
pub records_written: usize,
#[serde(default)]
pub duration_ms: u64,
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<crate::usage::UsageRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub batches: Option<faucet_core::BatchOutcomes>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_lag: Option<faucet_core::SourceLag>,
}
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(),
run_id: o.run_id.clone(),
records_written: o.records_written,
duration_ms: o.metrics.as_ref().map(|m| m.duration_ms).unwrap_or(0),
error: o.error.clone(),
usage: o.usage.clone(),
batches: o.metrics.as_ref().and_then(|m| m.batches),
source_lag: o.metrics.as_ref().and_then(|m| m.source_lag),
}
}
}
#[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>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config_body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config_format: Option<crate::serve::load::ConfigFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub clock: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub concurrency: Option<usize>,
#[serde(default)]
pub attempt: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replay_of: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub callback: Option<crate::serve::callback::CallbackSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection: Option<crate::select::SelectionRequest>,
}
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,
config_body: None,
config_format: None,
timeout_secs: None,
clock: None,
concurrency: None,
attempt: 0,
replay_of: None,
callback: None,
tenant: None,
selection: 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, Clone, Copy, PartialEq, Eq, Default)]
pub struct ReclaimReport {
pub requeued: usize,
pub failed: usize,
}
#[derive(Debug, Clone)]
pub struct InstanceHeartbeat {
pub started_at: DateTime<Utc>,
pub listen: Option<String>,
pub max_concurrent: u32,
pub in_flight: u32,
pub state_format: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct InstanceRecord {
pub instance_id: String,
pub started_at: DateTime<Utc>,
pub last_heartbeat: DateTime<Utc>,
pub listen: Option<String>,
pub max_concurrent: u32,
pub in_flight: u32,
pub state_format: u32,
}
pub fn cluster_state_format(members: &[InstanceRecord]) -> u32 {
members
.iter()
.map(|m| m.state_format)
.min()
.unwrap_or(faucet_core::state_version::STATE_FORMAT)
}
#[derive(Debug, Default, Clone)]
pub struct ListFilter {
pub status: Vec<RunStatus>,
pub name: Option<String>,
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub limit: usize,
pub cursor: Option<String>,
pub tenant: Option<String>,
}
#[derive(Debug)]
pub struct ListPage {
pub runs: Vec<RunRecord>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transience {
Transient,
Permanent,
Unknown,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum HistoryError {
#[error("run-history backend error: {0}")]
Backend(String),
#[error("run-history backend error: {message}")]
BackendClassified {
message: String,
transience: Transience,
},
#[error("{0}")]
Degraded(String),
}
impl HistoryError {
pub fn transience(&self) -> Transience {
match self {
Self::BackendClassified { transience, .. } => *transience,
Self::Degraded(_) => Transience::Transient,
_ => Transience::Unknown,
}
}
pub fn is_retriable(&self) -> bool {
self.transience() != Transience::Permanent
}
}
#[derive(Debug, Clone)]
pub struct ShardInsert {
pub shard_id: String,
pub descriptor: serde_json::Value,
pub size_estimate: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct ClaimedShard {
pub run_id: String,
pub shard_id: String,
pub descriptor: serde_json::Value,
pub run: RunRecord,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ShardProgress {
pub total: usize,
pub completed: usize,
pub failed: usize,
pub running: usize,
pub pending: usize,
}
impl ShardProgress {
pub fn all_terminal(&self) -> bool {
self.total > 0 && self.completed + self.failed == self.total
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: String,
pub timestamp: DateTime<Utc>,
pub principal: String,
pub role: String,
pub action: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub config_fingerprint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source_ip: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant: Option<String>,
pub result: String,
}
#[derive(Debug, Default, Clone)]
pub struct AuditFilter {
pub principal: Option<String>,
pub tenant: Option<String>,
pub action: Option<String>,
pub since: Option<DateTime<Utc>>,
pub until: Option<DateTime<Utc>>,
pub limit: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunLogLine {
pub seq: u64,
pub ts: String,
pub level: String,
pub line: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunLogPage {
pub lines: Vec<RunLogLine>,
pub truncated: bool,
}
pub const RUN_LOG_TRUNCATED_SEQ: u64 = u64::MAX;
#[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 release_idempotency(&self, run_id: &str) -> Result<(), HistoryError> {
let _ = run_id;
Ok(())
}
async fn recover_orphans(&self) -> Result<usize, HistoryError>;
async fn renew_leases(&self) -> Result<usize, HistoryError> {
Ok(0)
}
async fn claim_pending(&self, limit: usize) -> Result<Vec<RunRecord>, HistoryError> {
let _ = limit;
Ok(Vec::new())
}
async fn reclaim_orphans(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
let _ = max_attempts;
Ok(ReclaimReport::default())
}
async fn finalize_owned(&self, rec: &RunRecord) -> Result<bool, HistoryError> {
self.upsert(rec).await.map(|_| true)
}
async fn finalize_sharded_parent(
&self,
run_id: &str,
status: RunStatus,
finished_at: DateTime<Utc>,
error: Option<String>,
) -> Result<bool, HistoryError> {
match self.get(run_id).await? {
Some(mut r) if r.status == RunStatus::Sharded => {
r.status = status;
r.finished_at = Some(finished_at);
r.error = error;
self.upsert(&r).await?;
Ok(true)
}
_ => Ok(false),
}
}
async fn cancel_pending(&self, run_id: &str) -> Result<bool, HistoryError> {
let _ = run_id;
Ok(false)
}
async fn request_cancel(&self, run_id: &str) -> Result<(), HistoryError> {
let _ = run_id;
Ok(())
}
async fn pending_cancellations(&self) -> Result<Vec<String>, HistoryError> {
Ok(Vec::new())
}
async fn heartbeat_instance(&self, beat: &InstanceHeartbeat) -> Result<(), HistoryError> {
let _ = beat;
Ok(())
}
async fn live_instances(&self, ttl: Duration) -> Result<Vec<InstanceRecord>, HistoryError> {
let _ = ttl;
Ok(Vec::new())
}
async fn insert_shards(
&self,
run_id: &str,
shards: &[ShardInsert],
) -> Result<usize, HistoryError> {
let _ = (run_id, shards);
Ok(0)
}
async fn claim_shards(&self, limit: usize) -> Result<Vec<ClaimedShard>, HistoryError> {
let _ = limit;
Ok(Vec::new())
}
async fn renew_shard_leases(&self) -> Result<usize, HistoryError> {
Ok(0)
}
async fn reclaim_shards(&self, max_attempts: u32) -> Result<ReclaimReport, HistoryError> {
let _ = max_attempts;
Ok(ReclaimReport::default())
}
async fn finalize_shard(
&self,
run_id: &str,
shard_id: &str,
success: bool,
) -> Result<bool, HistoryError> {
let _ = (run_id, shard_id, success);
Ok(false)
}
async fn shard_progress(&self, run_id: &str) -> Result<ShardProgress, HistoryError> {
let _ = run_id;
Ok(ShardProgress::default())
}
async fn pending_shard_cancellations(&self) -> Result<Vec<String>, HistoryError> {
Ok(Vec::new())
}
async fn finalize_completed_sharded_parents(&self) -> Result<usize, HistoryError> {
Ok(0)
}
async fn record_audit(&self, entry: &AuditEntry) -> Result<(), HistoryError> {
let _ = entry;
Ok(())
}
async fn list_audit(&self, filter: &AuditFilter) -> Result<Vec<AuditEntry>, HistoryError> {
let _ = filter;
Ok(Vec::new())
}
async fn record_run_logs(
&self,
run_id: &str,
lines: &[RunLogLine],
) -> Result<(), HistoryError> {
let _ = (run_id, lines);
Ok(())
}
async fn list_run_logs(
&self,
run_id: &str,
after_seq: Option<u64>,
limit: usize,
) -> Result<RunLogPage, HistoryError> {
let _ = (run_id, after_seq, limit);
Ok(RunLogPage::default())
}
async fn purge_run_logs(&self, older_than: Duration) -> Result<usize, HistoryError> {
let _ = older_than;
Ok(0)
}
async fn catalog_record(&self, update: &catalog::CatalogUpdate) -> Result<(), HistoryError> {
let _ = update;
Ok(())
}
async fn catalog_list_datasets(
&self,
filter: &catalog::CatalogListFilter,
) -> Result<catalog::CatalogDatasetPage, HistoryError> {
let _ = filter;
Ok(catalog::CatalogDatasetPage {
datasets: Vec::new(),
next_cursor: None,
})
}
async fn catalog_get_dataset(
&self,
id: &str,
) -> Result<Option<catalog::CatalogDatasetDetail>, HistoryError> {
let _ = id;
Ok(None)
}
async fn catalog_lineage(
&self,
root: Option<&str>,
depth: u32,
) -> Result<Vec<catalog::CatalogLineageEdge>, HistoryError> {
let _ = (root, depth);
Ok(Vec::new())
}
async fn catalog_record_profile(
&self,
dataset_id: &str,
record: &catalog::CatalogProfileRecord,
) -> Result<(), HistoryError> {
let _ = (dataset_id, record);
Ok(())
}
async fn catalog_profile_history(
&self,
dataset_id: &str,
limit: usize,
) -> Result<Vec<catalog::CatalogProfileRecord>, HistoryError> {
let _ = (dataset_id, limit);
Ok(Vec::new())
}
async fn catalog_annotate(
&self,
dataset_id: &str,
annotation: &catalog::CatalogAnnotation,
) -> Result<bool, HistoryError> {
let _ = (dataset_id, annotation);
Ok(false)
}
async fn catalog_record_config_snapshot(
&self,
snapshot: &catalog::ConfigSnapshot,
) -> Result<(), HistoryError> {
let _ = snapshot;
Ok(())
}
async fn catalog_last_config_snapshot(
&self,
pipeline: &str,
) -> Result<Option<catalog::ConfigSnapshot>, HistoryError> {
let _ = pipeline;
Ok(None)
}
async fn local_output_record(
&self,
obs: &crate::local_outputs::LocalOutputObservation,
) -> Result<(), HistoryError> {
let _ = obs;
Ok(())
}
async fn local_output_list(
&self,
filter: &crate::local_outputs::LocalOutputFilter,
) -> Result<Vec<crate::local_outputs::LocalOutputRecord>, HistoryError> {
let _ = filter;
Ok(Vec::new())
}
async fn local_output_get(
&self,
id: &str,
) -> Result<Option<crate::local_outputs::LocalOutputRecord>, HistoryError> {
let _ = id;
Ok(None)
}
async fn local_output_mark_deleted(
&self,
id: &str,
at: DateTime<Utc>,
bytes: u64,
) -> Result<bool, HistoryError> {
let _ = (id, at, bytes);
Ok(false)
}
async fn change_upsert(
&self,
change: &crate::serve::changes::ChangeRequest,
) -> Result<(), HistoryError> {
let _ = change;
Err(HistoryError::Backend(
"this run-history backend does not support change requests".into(),
))
}
async fn change_get(
&self,
id: &str,
) -> Result<Option<crate::serve::changes::ChangeRequest>, HistoryError> {
let _ = id;
Ok(None)
}
async fn change_list(
&self,
filter: &crate::serve::changes::ChangeListFilter,
) -> Result<Vec<crate::serve::changes::ChangeRequest>, HistoryError> {
let _ = filter;
Ok(Vec::new())
}
async fn tenant_upsert(&self, tenant: &tenants::TenantRecord) -> Result<(), HistoryError> {
let _ = tenant;
Err(HistoryError::Backend(
"this run-history backend does not support tenants".into(),
))
}
async fn tenant_get(&self, id: &str) -> Result<Option<tenants::TenantRecord>, HistoryError> {
let _ = id;
Ok(None)
}
async fn tenant_list(&self) -> Result<Vec<tenants::TenantRecord>, HistoryError> {
Ok(Vec::new())
}
async fn tenant_delete(&self, id: &str) -> Result<bool, HistoryError> {
let _ = id;
Ok(false)
}
async fn connection_upsert(
&self,
connection: &tenants::ConnectionRecord,
) -> Result<(), HistoryError> {
let _ = connection;
Err(HistoryError::Backend(
"this run-history backend does not support tenant connections".into(),
))
}
async fn connection_get(
&self,
tenant: &str,
name: &str,
) -> Result<Option<tenants::ConnectionRecord>, HistoryError> {
let _ = (tenant, name);
Ok(None)
}
async fn connection_list(
&self,
tenant: &str,
) -> Result<Vec<tenants::ConnectionRecord>, HistoryError> {
let _ = tenant;
Ok(Vec::new())
}
async fn connection_delete(&self, tenant: &str, name: &str) -> Result<bool, HistoryError> {
let _ = (tenant, name);
Ok(false)
}
async fn connect_session_put(
&self,
session: &tenants::ConnectSession,
) -> Result<(), HistoryError> {
let _ = session;
Err(HistoryError::Backend(
"this run-history backend does not support connect sessions".into(),
))
}
async fn connect_session_take(
&self,
state: &str,
) -> Result<Option<tenants::ConnectSession>, HistoryError> {
let _ = state;
Ok(None)
}
async fn tenant_run_link(&self, run_id: &str, tenant: &str) -> Result<(), HistoryError> {
let _ = (run_id, tenant);
Ok(())
}
async fn tenant_state_ref_add(
&self,
state_ref: &tenants::TenantStateRef,
) -> Result<(), HistoryError> {
let _ = state_ref;
Ok(())
}
async fn tenant_state_refs(
&self,
tenant: &str,
) -> Result<Vec<tenants::TenantStateRef>, HistoryError> {
let _ = tenant;
Ok(Vec::new())
}
async fn change_delete(&self, id: &str) -> Result<bool, HistoryError> {
let _ = id;
Ok(false)
}
async fn usage_delete_runs(&self, run_ids: &[String]) -> Result<usize, HistoryError> {
let _ = run_ids;
Ok(0)
}
async fn usage_record(&self, record: &crate::usage::UsageRecord) -> Result<(), HistoryError> {
let _ = record;
Ok(())
}
async fn usage_list(
&self,
filter: &crate::usage::UsageFilter,
) -> Result<Vec<crate::usage::UsageRecord>, HistoryError> {
let _ = filter;
Ok(Vec::new())
}
async fn template_register(
&self,
draft: &templates::TemplateDraft,
) -> Result<templates::TemplateRecord, HistoryError> {
let _ = draft;
Err(HistoryError::Backend(
"this run-history backend does not support the pipeline-template registry".into(),
))
}
async fn template_get(
&self,
id: &str,
version: Option<u32>,
) -> Result<Option<templates::TemplateRecord>, HistoryError> {
let _ = (id, version);
Ok(None)
}
async fn template_list(&self) -> Result<Vec<templates::TemplateSummary>, HistoryError> {
Ok(Vec::new())
}
async fn template_versions(&self, id: &str) -> Result<Vec<u32>, HistoryError> {
let _ = id;
Ok(Vec::new())
}
async fn template_delete(&self, id: &str, version: Option<u32>) -> Result<usize, HistoryError> {
let _ = (id, version);
Ok(0)
}
async fn template_set_tag(
&self,
id: &str,
tag: &str,
version: u32,
) -> Result<(), HistoryError> {
let _ = (id, tag, version);
Err(HistoryError::Backend(
"this run-history backend does not support pipeline-template channels".into(),
))
}
async fn template_tags(&self, id: &str) -> Result<BTreeMap<String, u32>, HistoryError> {
let _ = id;
Ok(BTreeMap::new())
}
async fn template_delete_tag(&self, id: &str, tag: &str) -> Result<bool, HistoryError> {
let _ = (id, tag);
Ok(false)
}
async fn template_launch(
&self,
id: &str,
version: u32,
launched_by: Option<&str>,
) -> Result<Option<u32>, HistoryError> {
let _ = (id, version, launched_by);
Err(HistoryError::Backend(
"this run-history backend does not support pipeline-template launches".into(),
))
}
async fn template_launches(
&self,
id: &str,
) -> Result<Vec<templates::LaunchRecord>, HistoryError> {
let _ = id;
Ok(Vec::new())
}
async fn template_set_deprecation(
&self,
id: &str,
record: Option<&templates::DeprecationRecord>,
) -> Result<(), HistoryError> {
let _ = (id, record);
Err(HistoryError::Backend(
"this run-history backend does not support pipeline-template deprecation".into(),
))
}
async fn template_deprecation(
&self,
id: &str,
) -> Result<Option<templates::DeprecationRecord>, HistoryError> {
let _ = id;
Ok(None)
}
async fn template_set_version_deprecation(
&self,
id: &str,
version: u32,
record: Option<&templates::DeprecationRecord>,
) -> Result<(), HistoryError> {
let _ = (id, version, record);
Err(HistoryError::Backend(
"this run-history backend does not support deprecating a template version".into(),
))
}
async fn template_version_deprecations(
&self,
id: &str,
) -> Result<Vec<templates::VersionDeprecation>, HistoryError> {
let _ = id;
Ok(Vec::new())
}
async fn template_state(&self, id: &str) -> Result<templates::TemplateState, HistoryError> {
Ok(templates::TemplateState::assemble(
self.template_versions(id).await?,
&self.template_launches(id).await?,
self.template_tags(id).await?,
self.template_deprecation(id).await?,
self.template_version_deprecations(id).await?,
))
}
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>> {
let result = connect_with_retry("postgres", || {
postgres::PostgresHistory::connect(url, idem, lease_ttl, instance_id.to_string())
})
.await;
Ok(into_history(result, 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>> {
let result = connect_with_retry("sqlite", || {
sqlite::SqliteHistory::connect(url, idem, lease_ttl, instance_id.to_string())
})
.await;
Ok(into_history(result, 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"))]
const CONNECT_ATTEMPTS: usize = 8;
#[cfg(any(feature = "serve-history-postgres", feature = "serve-history-sqlite"))]
async fn connect_with_retry<H, F, Fut>(label: &str, mut make: F) -> Result<H, HistoryError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<H, HistoryError>>,
{
let mut delay = Duration::from_millis(100);
for attempt in 1..=CONNECT_ATTEMPTS {
match make().await {
Ok(backend) => return Ok(backend),
Err(e) if attempt < CONNECT_ATTEMPTS && e.is_retriable() => {
tracing::warn!(
backend = label,
attempt,
error = %e,
"run-history backend connect failed transiently; retrying before degrading"
);
tokio::time::sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(1));
}
Err(e) => return Err(e),
}
}
unreachable!("the final attempt returns Ok or Err rather than looping")
}
#[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::*;
struct Bare;
#[async_trait]
impl RunHistory for Bare {
async fn claim_idempotency(
&self,
_key: &str,
_fingerprint: &str,
_run_id: &str,
_window: Duration,
) -> Result<Claim, HistoryError> {
Ok(Claim::Fresh)
}
async fn upsert(&self, _rec: &RunRecord) -> Result<(), HistoryError> {
Ok(())
}
async fn get(&self, _id: &str) -> Result<Option<RunRecord>, HistoryError> {
Ok(None)
}
async fn list(&self, _filter: &ListFilter) -> Result<ListPage, HistoryError> {
Ok(ListPage {
runs: Vec::new(),
next_cursor: None,
})
}
async fn delete(&self, _id: &str) -> Result<DeleteOutcome, HistoryError> {
Ok(DeleteOutcome::NotFound)
}
async fn purge_expired(&self, _retain_for: Duration) -> Result<usize, HistoryError> {
Ok(0)
}
async fn recover_orphans(&self) -> Result<usize, HistoryError> {
Ok(0)
}
fn degraded(&self) -> bool {
false
}
}
#[tokio::test]
async fn tenant_defaults_refuse_writes_and_read_empty() {
let h = Bare;
let now = Utc::now();
let t = tenants::TenantRecord {
id: "acme".into(),
name: None,
labels: BTreeMap::new(),
limits: Default::default(),
notifications: Vec::new(),
suspended: false,
created_at: now,
updated_at: now,
created_by: "x".into(),
};
assert!(h.tenant_upsert(&t).await.is_err());
assert!(h.tenant_get("acme").await.unwrap().is_none());
assert!(h.tenant_list().await.unwrap().is_empty());
assert!(!h.tenant_delete("acme").await.unwrap());
let c = tenants::ConnectionRecord {
tenant: "acme".into(),
name: "c".into(),
provider_type: "static".into(),
connect_provider: None,
sealed: "s".into(),
status: Default::default(),
reauth_reason: None,
created_at: now,
updated_at: now,
updated_by: "x".into(),
};
assert!(h.connection_upsert(&c).await.is_err());
assert!(h.connection_get("acme", "c").await.unwrap().is_none());
assert!(h.connection_list("acme").await.unwrap().is_empty());
assert!(!h.connection_delete("acme", "c").await.unwrap());
let sess = tenants::ConnectSession {
state: "st".into(),
tenant: "acme".into(),
provider: "p".into(),
connection: "c".into(),
redirect: "https://x".into(),
sealed_verifier: "v".into(),
created_by: "x".into(),
created_at: now,
expires_at: now,
};
assert!(h.connect_session_put(&sess).await.is_err());
assert!(h.connect_session_take("st").await.unwrap().is_none());
h.tenant_run_link("r", "acme").await.unwrap();
let r = tenants::TenantStateRef {
tenant: "acme".into(),
key: "k".into(),
spec: None,
};
h.tenant_state_ref_add(&r).await.unwrap();
assert!(h.tenant_state_refs("acme").await.unwrap().is_empty());
assert!(!h.change_delete("c1").await.unwrap());
assert_eq!(h.usage_delete_runs(&["r".to_string()]).await.unwrap(), 0);
}
#[test]
fn run_status_parse_round_trips_every_variant() {
fn all_variants() -> Vec<RunStatus> {
[
RunStatus::Queued,
RunStatus::Pending,
RunStatus::Running,
RunStatus::Sharded,
RunStatus::Completed,
RunStatus::Failed,
RunStatus::Cancelled,
]
.into_iter()
.inspect(|s| match s {
RunStatus::Queued
| RunStatus::Pending
| RunStatus::Running
| RunStatus::Sharded
| RunStatus::Completed
| RunStatus::Failed
| RunStatus::Cancelled => {}
})
.collect()
}
for s in all_variants() {
assert_eq!(RunStatus::parse(s.as_str()), Some(s), "{}", s.as_str());
}
assert_eq!(RunStatus::parse("faild"), None);
assert_eq!(RunStatus::parse(" failed "), Some(RunStatus::Failed));
}
#[test]
fn terminal_classification() {
assert!(!RunStatus::Queued.is_terminal());
assert!(!RunStatus::Pending.is_terminal());
assert!(!RunStatus::Running.is_terminal());
assert!(RunStatus::Completed.is_terminal());
assert!(RunStatus::Failed.is_terminal());
assert!(RunStatus::Cancelled.is_terminal());
}
#[test]
fn invocation_record_carries_duration_and_is_backward_compatible() {
use crate::executor::{InvocationMetrics, InvocationOutcome};
let o = InvocationOutcome {
row_id: "contact".into(),
parent_record_key: None,
run_id: None,
records_written: 483,
error: None,
error_kind: None,
metrics: Some(InvocationMetrics {
source_kind: "rest".into(),
sink_kind: "bigquery".into(),
duration_ms: 1_910_000,
..Default::default()
}),
usage: None,
};
assert_eq!(InvocationRecord::from(&o).duration_ms, 1_910_000);
let old: InvocationRecord = serde_json::from_str(
r#"{"row_id":"r","parent_record_key":null,"records_written":1,"error":null}"#,
)
.unwrap();
assert_eq!(old.duration_ms, 0);
}
#[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());
}
#[test]
fn pending_is_non_terminal_and_serializes_snake_case() {
assert!(!RunStatus::Pending.is_terminal());
assert_eq!(RunStatus::Pending.as_str(), "pending");
let mut rec = RunRecord::queued("r".into(), None, Default::default(), None, Utc::now());
rec.status = RunStatus::Pending;
rec.attempt = 2;
let v = serde_json::to_value(&rec).unwrap();
assert_eq!(v["status"], "pending");
assert_eq!(v["attempt"], 2);
assert!(v.get("config_body").is_none());
}
#[test]
fn shard_progress_all_terminal() {
assert!(!ShardProgress::default().all_terminal());
let mut p = ShardProgress {
total: 3,
completed: 1,
failed: 0,
running: 1,
pending: 1,
};
assert!(!p.all_terminal());
p = ShardProgress {
total: 3,
completed: 2,
failed: 1,
running: 0,
pending: 0,
};
assert!(p.all_terminal());
}
#[tokio::test]
async fn memory_backend_shard_methods_are_inert() {
use crate::serve::history::memory::MemoryHistory;
let h = MemoryHistory::new(Duration::from_secs(60));
assert_eq!(h.insert_shards("r", &[]).await.unwrap(), 0);
assert!(h.claim_shards(8).await.unwrap().is_empty());
assert_eq!(h.renew_shard_leases().await.unwrap(), 0);
assert!(!h.finalize_shard("r", "0", true).await.unwrap());
assert_eq!(
h.shard_progress("r").await.unwrap(),
ShardProgress::default()
);
}
#[tokio::test]
async fn memory_backend_cluster_methods_are_inert() {
use crate::serve::history::memory::MemoryHistory;
let h = MemoryHistory::new(Duration::from_secs(60));
assert!(h.claim_pending(8).await.unwrap().is_empty());
assert_eq!(
h.reclaim_orphans(3).await.unwrap(),
ReclaimReport::default()
);
assert!(!h.cancel_pending("x").await.unwrap());
h.request_cancel("x").await.unwrap();
assert!(h.pending_cancellations().await.unwrap().is_empty());
assert!(
h.live_instances(Duration::from_secs(60))
.await
.unwrap()
.is_empty()
);
let rec = RunRecord::queued("fo".into(), None, Default::default(), None, Utc::now());
assert!(h.finalize_owned(&rec).await.unwrap());
assert_eq!(h.get("fo").await.unwrap().unwrap().run_id, "fo");
}
}
#[cfg(all(
test,
any(feature = "serve-history-postgres", feature = "serve-history-sqlite")
))]
mod connect_retry_tests {
use super::*;
use std::cell::Cell;
fn classified(message: &str, transience: Transience) -> HistoryError {
HistoryError::BackendClassified {
message: message.into(),
transience,
}
}
#[test]
fn the_retry_gate_reads_the_typed_verdict_not_the_message() {
assert!(
classified(
"creating run-history schema: (code: 5)",
Transience::Transient
)
.is_retriable()
);
assert!(
!classified(
"invalid sqlite url 'sqlite::nonsense': database is locked, timed out, busy",
Transience::Permanent
)
.is_retriable(),
"a permanent verdict must win over transient-sounding prose"
);
assert!(HistoryError::Backend("lock poisoned".into()).is_retriable());
assert_eq!(
HistoryError::Backend("lock poisoned".into()).transience(),
Transience::Unknown
);
assert!(HistoryError::Degraded("degraded".into()).is_retriable());
}
#[tokio::test]
async fn retries_a_transient_failure_then_succeeds() {
let calls = Cell::new(0usize);
let result: Result<u32, HistoryError> = connect_with_retry("test", || {
let n = calls.get() + 1;
calls.set(n);
async move {
if n < 3 {
Err(classified("database is locked", Transience::Transient))
} else {
Ok(42u32)
}
}
})
.await;
assert_eq!(result.unwrap(), 42);
assert_eq!(
calls.get(),
3,
"two transient failures retried, third succeeds"
);
}
#[tokio::test]
async fn does_not_retry_a_permanent_error() {
let calls = Cell::new(0usize);
let result: Result<u32, HistoryError> = connect_with_retry("test", || {
calls.set(calls.get() + 1);
async move {
Err::<u32, _>(classified(
"invalid sqlite url 'x'",
Transience::Permanent,
))
}
})
.await;
assert!(result.is_err());
assert_eq!(
calls.get(),
1,
"a permanent error degrades immediately, no retry"
);
}
#[tokio::test(start_paused = true)]
async fn gives_up_after_the_attempt_budget() {
let calls = Cell::new(0usize);
let result: Result<u32, HistoryError> = connect_with_retry("test", || {
calls.set(calls.get() + 1);
async move { Err::<u32, _>(classified("connection refused", Transience::Transient)) }
})
.await;
assert!(result.is_err());
assert_eq!(calls.get(), CONNECT_ATTEMPTS);
}
}