use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use uuid::Uuid;
use crate::runtime::canonical_store::system_store::{
CompensationStatus, SagaListFilter, SagaStatus, SagaStore,
};
use crate::runtime::canonical_store::{CanonicalStore, SystemStores};
use crate::runtime::config::SagaSettings;
use crate::runtime::system::SystemCatalogConfig;
const SAGA_WORKER_LEASE: &str = "udb-saga-worker";
pub const SAGA_STATUS_COMMITTED: &str = "committed";
pub const SAGA_STATUS_COMPENSATED: &str = "compensated";
pub const SAGA_STATUS_FAILED_COMPENSATION: &str = "failed_compensation";
pub const SAGA_STATUS_MANUAL_REVIEW: &str = "manual_review";
#[derive(Debug, Clone, Serialize)]
pub struct SagaAdminRecord {
pub saga_id: String,
pub tx_id: String,
pub tenant_id: String,
pub correlation_id: String,
pub status: String,
pub current_step: i32,
pub steps_json: serde_json::Value,
pub compensations_json: serde_json::Value,
pub last_error: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<crate::runtime::canonical_store::system_store::SagaRow> for SagaAdminRecord {
fn from(row: crate::runtime::canonical_store::system_store::SagaRow) -> Self {
Self {
saga_id: row.saga_id.to_string(),
tx_id: row.tx_id,
tenant_id: row.tenant_id,
correlation_id: row.correlation_id,
status: row.status.as_str().to_string(),
current_step: row.current_step,
steps_json: row.steps,
compensations_json: row.compensations,
last_error: row.last_error,
created_at: row.created_at,
updated_at: row.updated_at,
}
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct SagaRecoveryReport {
pub owner_id: String,
pub lease_acquired: bool,
pub scanned: usize,
pub compensated: usize,
pub marked_manual_review: usize,
pub errors: Vec<String>,
}
pub struct SagaRecoveryWorker {
store: Arc<dyn SystemStores>,
config: SystemCatalogConfig,
interval: Duration,
stale_threshold: Duration,
worker_lease_ttl: Duration,
recovery_batch_size: i64,
owner_id: String,
poison_threshold: i64,
compensators: Arc<crate::runtime::saga_compensators::CompensatorRegistry>,
quarantine_policy: crate::runtime::saga_compensators::QuarantinePolicy,
metrics: std::sync::Arc<dyn crate::metrics::MetricsRecorder>,
}
impl SagaRecoveryWorker {
pub fn new(store: Arc<dyn SystemStores>) -> Self {
let mut settings = SagaSettings::default();
settings.merge_env();
Self::with_settings(store, &settings)
}
pub fn with_settings(store: Arc<dyn SystemStores>, settings: &SagaSettings) -> Self {
Self {
store,
config: SystemCatalogConfig::default(),
interval: Duration::from_secs(settings.recovery_interval_secs.max(1)),
stale_threshold: Duration::from_secs(settings.stale_threshold_secs.max(1)),
worker_lease_ttl: Duration::from_secs(settings.worker_lease_ttl_secs.max(1)),
recovery_batch_size: settings.recovery_batch_size.max(1),
owner_id: Uuid::new_v4().to_string(),
poison_threshold: settings.poison_threshold.max(1),
compensators: Arc::new(
crate::runtime::saga_compensators::CompensatorRegistry::default(),
),
quarantine_policy: crate::runtime::saga_compensators::QuarantinePolicy {
max_attempts: settings.poison_threshold.max(1) as u32,
..crate::runtime::saga_compensators::QuarantinePolicy::default()
},
metrics: Arc::new(crate::metrics::NoopMetrics),
}
}
pub fn with_metrics(
mut self,
metrics: std::sync::Arc<dyn crate::metrics::MetricsRecorder>,
) -> Self {
self.metrics = metrics;
self
}
pub fn with_compensators(
mut self,
registry: Arc<crate::runtime::saga_compensators::CompensatorRegistry>,
) -> Self {
self.compensators = registry;
self
}
pub fn with_quarantine_policy(
mut self,
policy: crate::runtime::saga_compensators::QuarantinePolicy,
) -> Self {
self.quarantine_policy = policy;
self
}
pub fn is_enabled() -> bool {
let mut settings = SagaSettings::default();
settings.merge_env();
settings.recovery_enabled
}
pub fn is_enabled_with_settings(settings: &SagaSettings) -> bool {
settings.recovery_enabled
}
pub async fn run_forever(self) {
let mut ticker = tokio::time::interval(self.interval);
loop {
ticker.tick().await;
let report = self.run_once().await;
if report.scanned > 0 || !report.errors.is_empty() {
tracing::info!(
scanned = report.scanned,
compensated = report.compensated,
marked_manual_review = report.marked_manual_review,
errors = report.errors.len(),
"saga recovery pass completed"
);
}
for err in &report.errors {
tracing::warn!(error = err, "saga recovery error");
}
}
}
pub async fn run_once(&self) -> SagaRecoveryReport {
let mut report = SagaRecoveryReport {
owner_id: self.owner_id.clone(),
..SagaRecoveryReport::default()
};
if !self.try_acquire_worker_lease(&mut report).await {
return report;
}
let mut locked_report = self.run_once_locked().await;
locked_report.owner_id = self.owner_id.clone();
locked_report.lease_acquired = true;
self.release_worker_lease(&mut locked_report).await;
locked_report
}
async fn run_once_locked(&self) -> SagaRecoveryReport {
let mut report = SagaRecoveryReport {
owner_id: self.owner_id.clone(),
lease_acquired: true,
..SagaRecoveryReport::default()
};
let candidates = match SagaStore::claim_recoverable_sagas(
self.store.as_ref(),
self.stale_threshold,
self.recovery_batch_size,
)
.await
{
Ok(rows) => rows,
Err(err) => {
report.errors.push(format!("saga scan failed: {err}"));
return report;
}
};
report.scanned = candidates.len();
let mut compensated_ids = Vec::new();
let mut manual_review_ids = Vec::new();
for row in candidates {
let saga_id = row.saga_id;
let saga_id_str = saga_id.to_string();
let compensations = row.compensations;
use crate::runtime::saga_compensators::QuarantineState;
match self.quarantine_policy.evaluate(
row.recovery_attempts.max(0) as u32,
row.updated_at.timestamp(),
chrono::Utc::now().timestamp(),
) {
QuarantineState::Quarantine { .. } => {
manual_review_ids.push(saga_id);
report.marked_manual_review += 1;
continue;
}
QuarantineState::Cooldown { .. } => continue,
QuarantineState::Retry { .. } => {}
}
let compensation_result = self
.attempt_compensations(&saga_id_str, &compensations)
.await;
let new_status = match compensation_result {
Ok(_) => {
report.compensated += 1;
self.metrics.inc_saga_compensated_total();
SagaStatus::Compensated
}
Err(err) => {
self.metrics.inc_saga_failed_compensations_total();
let attempts = match SagaStore::increment_recovery_attempts(
self.store.as_ref(),
saga_id,
&format!("compensation failed: {err}"),
)
.await
{
Ok(n) => n,
Err(e) => {
report.errors.push(format!(
"saga {saga_id_str} recovery_attempts increment failed: {e}"
));
continue;
}
};
if attempts >= self.poison_threshold {
report.marked_manual_review += 1;
SagaStatus::ManualReview
} else {
report.errors.push(format!(
"saga {saga_id_str} compensation failed (attempt {attempts}): {err}"
));
continue;
}
}
};
match new_status {
SagaStatus::Compensated => compensated_ids.push(saga_id),
SagaStatus::ManualReview => manual_review_ids.push(saga_id),
_ => {}
}
}
if !compensated_ids.is_empty()
&& let Err(err) = SagaStore::update_saga_statuses_batch(
self.store.as_ref(),
&compensated_ids,
SagaStatus::Compensated,
CompensationStatus::Completed,
)
.await
{
report
.errors
.push(format!("batch compensated status update failed: {err}"));
}
if !manual_review_ids.is_empty()
&& let Err(err) = SagaStore::update_saga_statuses_batch(
self.store.as_ref(),
&manual_review_ids,
SagaStatus::ManualReview,
CompensationStatus::ManualReview,
)
.await
{
report
.errors
.push(format!("batch manual-review status update failed: {err}"));
}
report
}
async fn try_acquire_worker_lease(&self, report: &mut SagaRecoveryReport) -> bool {
if let Err(err) = CanonicalStore::ensure_advisory_lease_table(self.store.as_ref()).await {
report
.errors
.push(format!("ensure_advisory_lease_table failed: {err}"));
return false;
}
match CanonicalStore::try_acquire_advisory_lease(
self.store.as_ref(),
SAGA_WORKER_LEASE,
&self.owner_id,
self.worker_lease_ttl,
)
.await
{
Ok(true) => {
report.lease_acquired = true;
tracing::debug!(owner_id = self.owner_id, "saga worker lease acquired");
true
}
Ok(false) => {
tracing::debug!(owner_id = self.owner_id, "saga worker lease held elsewhere");
false
}
Err(err) => {
report
.errors
.push(format!("saga worker lease acquisition failed: {err}"));
false
}
}
}
async fn release_worker_lease(&self, report: &mut SagaRecoveryReport) {
if let Err(err) = CanonicalStore::release_advisory_lease(
self.store.as_ref(),
SAGA_WORKER_LEASE,
&self.owner_id,
)
.await
{
report
.errors
.push(format!("saga worker lease release failed: {err}"));
}
}
async fn attempt_compensations(
&self,
saga_id: &str,
compensations: &serde_json::Value,
) -> Result<(), String> {
use crate::runtime::saga_compensators::{
StepOutcome, all_compensated, dispatch_compensations,
};
let outcomes = dispatch_compensations(self.compensators.as_ref(), compensations).await;
for (i, outcome) in outcomes.iter().enumerate() {
match outcome {
StepOutcome::Compensated => tracing::info!(
saga_id = saga_id,
step = i,
"saga compensation step succeeded"
),
StepOutcome::NoCompensator { backend } => tracing::warn!(
saga_id = saga_id,
step = i,
backend = backend,
"no backend compensator registered; step skipped"
),
StepOutcome::Malformed { index } => tracing::warn!(
saga_id = saga_id,
step = index,
"saga compensation step is malformed; skipping"
),
StepOutcome::Failed { backend, reason } => tracing::error!(
saga_id = saga_id,
step = i,
backend = backend,
reason = reason,
"saga compensation step failed"
),
}
}
if all_compensated(&outcomes) {
Ok(())
} else {
let first_bad = outcomes
.iter()
.find(|o| !o.is_success())
.map(|o| match o {
StepOutcome::Failed { backend, reason } => {
format!("compensation failed at {backend}: {reason}")
}
StepOutcome::NoCompensator { backend } => {
format!("no compensator registered for backend '{backend}'")
}
StepOutcome::Malformed { index } => {
format!("malformed compensation step at index {index}")
}
StepOutcome::Compensated => unreachable!(),
})
.unwrap_or_else(|| "no compensation steps recorded".to_string());
Err(first_bad)
}
}
}
#[allow(clippy::too_many_arguments)]
pub async fn list_sagas(
store: &dyn SystemStores,
_config: &SystemCatalogConfig,
tenant_id_filter: &str,
status_filter: &str,
tx_id_filter: &str,
correlation_id_filter: &str,
limit: i64,
offset: i64,
) -> Result<Vec<SagaAdminRecord>, tonic::Status> {
let status_enum = if status_filter.is_empty() {
None
} else {
Some(SagaStatus::parse(status_filter).ok_or_else(|| {
tonic::Status::invalid_argument(format!(
"invalid status_filter '{status_filter}'; must be one of {:?}",
SagaStatus::all()
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
))
})?)
};
if !tx_id_filter.is_empty() {
Uuid::parse_str(tx_id_filter)
.map_err(|_| tonic::Status::invalid_argument("tx_id_filter must be a valid UUID"))?;
}
let filter = SagaListFilter {
tenant_id: (!tenant_id_filter.is_empty()).then(|| tenant_id_filter.to_string()),
status: status_enum,
tx_id: (!tx_id_filter.is_empty()).then(|| tx_id_filter.to_string()),
correlation_id: (!correlation_id_filter.is_empty())
.then(|| correlation_id_filter.to_string()),
limit,
offset,
};
let rows = SagaStore::list_sagas(store, &filter)
.await
.map_err(|err| tonic::Status::internal(format!("list_sagas query failed: {err}")))?;
Ok(rows.into_iter().map(SagaAdminRecord::from).collect())
}
pub async fn get_saga(
store: &dyn SystemStores,
_config: &SystemCatalogConfig,
saga_id: &str,
) -> Result<SagaAdminRecord, tonic::Status> {
let id: Uuid = saga_id
.parse()
.map_err(|_| tonic::Status::invalid_argument("saga_id must be a UUID"))?;
let row = SagaStore::get_saga(store, id)
.await
.map_err(|err| tonic::Status::internal(format!("get_saga failed: {err}")))?
.ok_or_else(|| tonic::Status::not_found(format!("saga {saga_id} not found")))?;
Ok(SagaAdminRecord::from(row))
}
pub async fn mark_saga_reviewed(
store: &dyn SystemStores,
_config: &SystemCatalogConfig,
saga_id: &str,
) -> Result<(), tonic::Status> {
let id: Uuid = saga_id
.parse()
.map_err(|_| tonic::Status::invalid_argument("saga_id must be a UUID"))?;
SagaStore::mark_saga_manual_review(store, id)
.await
.map_err(|err| {
let msg = err.to_string();
if msg.contains("not found") {
tonic::Status::not_found(format!("saga {saga_id} not found"))
} else {
tonic::Status::internal(format!("mark_saga_reviewed failed: {msg}"))
}
})
}
pub async fn retry_saga_compensation(
store: &dyn SystemStores,
_config: &SystemCatalogConfig,
saga_id: &str,
) -> Result<(), tonic::Status> {
let id: Uuid = saga_id
.parse()
.map_err(|_| tonic::Status::invalid_argument("saga_id must be a UUID"))?;
SagaStore::request_saga_recompensation(store, id)
.await
.map_err(|err| {
let msg = err.to_string();
if msg.contains("retryable state") {
tonic::Status::failed_precondition(format!(
"saga {saga_id} is not in a retryable state (failed_compensation or manual_review)"
))
} else {
tonic::Status::internal(format!("retry_saga_compensation failed: {msg}"))
}
})
}
#[cfg(test)]
#[allow(clippy::items_after_test_module)]
mod tests {
use super::*;
#[cfg(feature = "sqlite")]
use crate::runtime::canonical_store::sqlite::SqliteCanonicalStore;
#[cfg(feature = "sqlite")]
use crate::runtime::canonical_store::system_store::SagaInsert;
#[cfg(feature = "sqlite")]
use sqlx::sqlite::SqlitePoolOptions;
#[test]
fn recovery_worker_uses_env_interval() {
let interval = Duration::from_secs(60);
let stale = Duration::from_secs(300);
assert_eq!(interval, Duration::from_secs(60));
assert_eq!(stale, Duration::from_secs(300));
}
#[test]
fn recovery_enabled_by_default() {
assert!(matches!("false", "0" | "false" | "FALSE" | "no"));
assert!(matches!("0", "0" | "false" | "FALSE" | "no"));
assert!(!matches!("true", "0" | "false" | "FALSE" | "no"));
assert!(!matches!("1", "0" | "false" | "FALSE" | "no"));
}
#[cfg(feature = "sqlite")]
async fn fresh_store() -> Arc<dyn SystemStores> {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
let store = Arc::new(SqliteCanonicalStore::new(pool, "test", "udb_outbox_events"));
SagaStore::ensure_saga_tables(store.as_ref()).await.unwrap();
CanonicalStore::ensure_advisory_lease_table(store.as_ref())
.await
.unwrap();
store
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn admin_helpers_round_trip_against_sqlite_store() {
let store = fresh_store().await;
let cfg = SystemCatalogConfig::default();
let insert = |tenant: &str, status: SagaStatus| SagaInsert {
tx_id: Uuid::new_v4().to_string(),
tenant_id: tenant.to_string(),
correlation_id: "corr-1".to_string(),
backend_instance: "primary".to_string(),
operation: "upsert".to_string(),
status,
steps: serde_json::json!([]),
compensations: serde_json::json!([]),
};
let _alpha =
SagaStore::record_saga(store.as_ref(), &insert("alpha", SagaStatus::Indeterminate))
.await
.unwrap();
let beta = SagaStore::record_saga(
store.as_ref(),
&insert("beta", SagaStatus::FailedCompensation),
)
.await
.unwrap();
let all = list_sagas(store.as_ref(), &cfg, "", "", "", "", 100, 0)
.await
.unwrap();
assert_eq!(all.len(), 2);
let alpha_only = list_sagas(store.as_ref(), &cfg, "alpha", "", "", "", 100, 0)
.await
.unwrap();
assert_eq!(alpha_only.len(), 1);
assert_eq!(alpha_only[0].tenant_id, "alpha");
let fc = list_sagas(
store.as_ref(),
&cfg,
"",
"failed_compensation",
"",
"",
100,
0,
)
.await
.unwrap();
assert_eq!(fc.len(), 1);
assert_eq!(fc[0].tenant_id, "beta");
let err = list_sagas(store.as_ref(), &cfg, "", "bogus", "", "", 100, 0)
.await
.expect_err("must reject");
assert_eq!(err.code(), tonic::Code::InvalidArgument);
let row = get_saga(store.as_ref(), &cfg, &beta.to_string())
.await
.unwrap();
assert_eq!(row.tenant_id, "beta");
assert_eq!(row.status, "failed_compensation");
let phantom = Uuid::new_v4().to_string();
let err = get_saga(store.as_ref(), &cfg, &phantom)
.await
.expect_err("must error");
assert_eq!(err.code(), tonic::Code::NotFound);
retry_saga_compensation(store.as_ref(), &cfg, &beta.to_string())
.await
.unwrap();
let err = retry_saga_compensation(store.as_ref(), &cfg, &beta.to_string())
.await
.expect_err("now indeterminate, retry refused");
assert_eq!(err.code(), tonic::Code::FailedPrecondition);
mark_saga_reviewed(store.as_ref(), &cfg, &beta.to_string())
.await
.unwrap();
let row = get_saga(store.as_ref(), &cfg, &beta.to_string())
.await
.unwrap();
assert_eq!(row.status, "manual_review");
let err = mark_saga_reviewed(store.as_ref(), &cfg, &phantom)
.await
.expect_err("must error");
assert_eq!(err.code(), tonic::Code::NotFound);
let err = get_saga(store.as_ref(), &cfg, "not-a-uuid")
.await
.expect_err("must reject");
assert_eq!(err.code(), tonic::Code::InvalidArgument);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn recovery_worker_runs_one_pass_against_sqlite() {
let store = fresh_store().await;
let _id = SagaStore::record_saga(
store.as_ref(),
&SagaInsert {
tx_id: Uuid::new_v4().to_string(),
tenant_id: "t1".to_string(),
correlation_id: "c1".to_string(),
backend_instance: "primary".to_string(),
operation: "upsert".to_string(),
status: SagaStatus::Indeterminate,
steps: serde_json::json!([]),
compensations: serde_json::json!([]),
},
)
.await
.unwrap();
let settings = SagaSettings {
recovery_interval_secs: 1,
stale_threshold_secs: 1,
..Default::default()
};
let worker = SagaRecoveryWorker::with_settings(store.clone(), &settings);
let report = worker.run_once().await;
assert!(
report.lease_acquired,
"lease must be acquired (other workers idle)"
);
assert_eq!(report.scanned, 1, "the one indeterminate saga was claimed");
assert_eq!(report.compensated, 0);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn worker_lease_serializes_concurrent_workers() {
let store = fresh_store().await;
let settings = SagaSettings {
recovery_interval_secs: 1,
stale_threshold_secs: 1,
..Default::default()
};
let worker_a = SagaRecoveryWorker::with_settings(store.clone(), &settings);
let worker_b = SagaRecoveryWorker::with_settings(store.clone(), &settings);
let mut report_a = SagaRecoveryReport::default();
let a_acquired = worker_a.try_acquire_worker_lease(&mut report_a).await;
assert!(a_acquired);
let mut report_b = SagaRecoveryReport::default();
let b_acquired = worker_b.try_acquire_worker_lease(&mut report_b).await;
assert!(!b_acquired);
worker_a.release_worker_lease(&mut report_a).await;
assert!(report_a.errors.is_empty(), "release must not error");
let mut report_b2 = SagaRecoveryReport::default();
let b_now = worker_b.try_acquire_worker_lease(&mut report_b2).await;
assert!(b_now);
}
}