use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[async_trait]
pub trait XaInDoubtParticipant: Send + Sync {
fn backend_label(&self) -> &str;
async fn list_prepared_xids(&self) -> Result<Vec<String>, String>;
async fn commit_prepared(&self, xid: &str) -> Result<(), String>;
async fn rollback_prepared(&self, xid: &str) -> Result<(), String>;
}
#[cfg(feature = "mysql")]
pub struct MysqlInDoubtParticipant {
pub label: String,
pub pool: sqlx::MySqlPool,
}
#[cfg(feature = "mysql")]
#[async_trait]
impl XaInDoubtParticipant for MysqlInDoubtParticipant {
fn backend_label(&self) -> &str {
&self.label
}
async fn list_prepared_xids(&self) -> Result<Vec<String>, String> {
use sqlx::Row;
let rows = sqlx::query("XA RECOVER")
.fetch_all(&self.pool)
.await
.map_err(|e| format!("XA RECOVER failed: {e}"))?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let data: Vec<u8> = row
.try_get::<Vec<u8>, _>("data")
.or_else(|_| row.try_get::<String, _>("data").map(|s| s.into_bytes()))
.map_err(|e| format!("XA RECOVER row decode: {e}"))?;
let xid = String::from_utf8_lossy(&data).to_string();
out.push(xid);
}
Ok(out)
}
async fn commit_prepared(&self, xid: &str) -> Result<(), String> {
use sqlx::Executor;
let mut conn = self
.pool
.acquire()
.await
.map_err(|e| format!("mysql xa commit acquire: {e}"))?;
conn.execute(format!("XA COMMIT '{xid}'").as_str())
.await
.map(|_| ())
.map_err(|e| format!("XA COMMIT failed: {e}"))
}
async fn rollback_prepared(&self, xid: &str) -> Result<(), String> {
use sqlx::Executor;
let mut conn = self
.pool
.acquire()
.await
.map_err(|e| format!("mysql xa rollback acquire: {e}"))?;
conn.execute(format!("XA ROLLBACK '{xid}'").as_str())
.await
.map(|_| ())
.map_err(|e| format!("XA ROLLBACK failed: {e}"))
}
}
pub struct PostgresInDoubtParticipant {
pub label: String,
pub pool: sqlx::PgPool,
}
#[async_trait]
impl XaInDoubtParticipant for PostgresInDoubtParticipant {
fn backend_label(&self) -> &str {
&self.label
}
async fn list_prepared_xids(&self) -> Result<Vec<String>, String> {
let rows: Vec<(String,)> = sqlx::query_as("SELECT gid FROM pg_prepared_xacts")
.fetch_all(&self.pool)
.await
.map_err(|e| format!("pg_prepared_xacts query failed: {e}"))?;
Ok(rows.into_iter().map(|(g,)| g).collect())
}
async fn commit_prepared(&self, xid: &str) -> Result<(), String> {
validate_xid(xid)?;
sqlx::query(&format!("COMMIT PREPARED '{xid}'"))
.execute(&self.pool)
.await
.map(|_| ())
.map_err(|e| format!("COMMIT PREPARED failed: {e}"))
}
async fn rollback_prepared(&self, xid: &str) -> Result<(), String> {
validate_xid(xid)?;
sqlx::query(&format!("ROLLBACK PREPARED '{xid}'"))
.execute(&self.pool)
.await
.map(|_| ())
.map_err(|e| format!("ROLLBACK PREPARED failed: {e}"))
}
}
fn validate_xid(xid: &str) -> Result<(), String> {
if xid.is_empty() {
return Err("xa xid is empty".into());
}
if !xid
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(format!("xa xid '{xid}' contains disallowed characters"));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RecoveryOutcome {
Committed { xid: String, backend: String },
RolledBack { xid: String, backend: String },
AlreadyTerminal { xid: String, backend: String },
NoParticipant { xid: String, backend: String },
Failed {
xid: String,
backend: String,
reason: String,
},
}
impl RecoveryOutcome {
pub fn is_terminal(&self) -> bool {
matches!(
self,
Self::Committed { .. } | Self::RolledBack { .. } | Self::AlreadyTerminal { .. }
)
}
}
#[derive(Default, Clone)]
pub struct InDoubtRegistry {
by_label: HashMap<String, Arc<dyn XaInDoubtParticipant>>,
}
impl InDoubtRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, p: Arc<dyn XaInDoubtParticipant>) {
self.by_label
.insert(p.backend_label().to_ascii_lowercase(), p);
}
pub fn get(&self, label: &str) -> Option<Arc<dyn XaInDoubtParticipant>> {
let key = label
.split_once(':')
.map(|(b, _)| b)
.unwrap_or(label)
.to_ascii_lowercase();
self.by_label.get(&key).cloned()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InDoubtLedgerRow {
pub xid: String,
pub participants: Vec<String>,
pub reason: String,
}
impl InDoubtLedgerRow {
pub fn target_intent(&self) -> RecoveryIntent {
let lc = self.reason.to_ascii_lowercase();
if lc.contains("prepare failed")
|| lc.contains("xa prepare")
|| lc.contains("aborted vote")
|| lc.contains("aborted: ")
{
RecoveryIntent::Rollback
} else {
RecoveryIntent::Commit
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecoveryIntent {
Commit,
Rollback,
}
pub async fn drive_indoubt_row(
row: &InDoubtLedgerRow,
registry: &InDoubtRegistry,
) -> Vec<RecoveryOutcome> {
let intent = row.target_intent();
let xid = &row.xid;
let mut outcomes = Vec::with_capacity(row.participants.len());
for label in &row.participants {
let backend = label
.split_once(':')
.map(|(b, _)| b.to_string())
.unwrap_or_else(|| label.clone());
let Some(participant) = registry.get(label) else {
outcomes.push(RecoveryOutcome::NoParticipant {
xid: xid.clone(),
backend,
});
continue;
};
let prepared = match participant.list_prepared_xids().await {
Ok(list) => list,
Err(err) => {
outcomes.push(RecoveryOutcome::Failed {
xid: xid.clone(),
backend,
reason: format!("list_prepared_xids: {err}"),
});
continue;
}
};
if !prepared.iter().any(|p| p == xid) {
outcomes.push(RecoveryOutcome::AlreadyTerminal {
xid: xid.clone(),
backend,
});
continue;
}
let result = match intent {
RecoveryIntent::Commit => participant.commit_prepared(xid).await,
RecoveryIntent::Rollback => participant.rollback_prepared(xid).await,
};
match result {
Ok(()) => outcomes.push(match intent {
RecoveryIntent::Commit => RecoveryOutcome::Committed {
xid: xid.clone(),
backend,
},
RecoveryIntent::Rollback => RecoveryOutcome::RolledBack {
xid: xid.clone(),
backend,
},
}),
Err(reason) => outcomes.push(RecoveryOutcome::Failed {
xid: xid.clone(),
backend,
reason,
}),
}
}
outcomes
}
#[derive(Debug, Clone)]
pub struct RecoveryConfig {
pub interval: Duration,
pub max_attempts: u32,
}
impl Default for RecoveryConfig {
fn default() -> Self {
let interval_secs = std::env::var("UDB_XA_RECOVERY_INTERVAL_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(30);
let max_attempts = std::env::var("UDB_XA_RECOVERY_MAX_ATTEMPTS")
.ok()
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(10);
Self {
interval: Duration::from_secs(interval_secs),
max_attempts,
}
}
}
pub fn now_unix_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct StubParticipant {
label: String,
prepared: Mutex<Vec<String>>,
commit_outcome: Mutex<Result<(), String>>,
rollback_outcome: Mutex<Result<(), String>>,
events: Mutex<Vec<String>>,
}
impl StubParticipant {
fn new(label: &str, prepared: Vec<String>) -> Self {
Self {
label: label.to_string(),
prepared: Mutex::new(prepared),
commit_outcome: Mutex::new(Ok(())),
rollback_outcome: Mutex::new(Ok(())),
events: Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl XaInDoubtParticipant for StubParticipant {
fn backend_label(&self) -> &str {
&self.label
}
async fn list_prepared_xids(&self) -> Result<Vec<String>, String> {
self.events
.lock()
.unwrap()
.push("list_prepared_xids".into());
Ok(self.prepared.lock().unwrap().clone())
}
async fn commit_prepared(&self, xid: &str) -> Result<(), String> {
self.events
.lock()
.unwrap()
.push(format!("commit_prepared({xid})"));
self.prepared.lock().unwrap().retain(|p| p != xid);
self.commit_outcome.lock().unwrap().clone()
}
async fn rollback_prepared(&self, xid: &str) -> Result<(), String> {
self.events
.lock()
.unwrap()
.push(format!("rollback_prepared({xid})"));
self.prepared.lock().unwrap().retain(|p| p != xid);
self.rollback_outcome.lock().unwrap().clone()
}
}
#[test]
fn target_intent_defaults_to_commit() {
let row = InDoubtLedgerRow {
xid: "udb-x".into(),
participants: vec![],
reason: "PHASE 2 COMMIT PREPARED timed out on mysql:primary".into(),
};
assert_eq!(row.target_intent(), RecoveryIntent::Commit);
}
#[test]
fn target_intent_prepare_reason_rolls_back() {
let row = InDoubtLedgerRow {
xid: "udb-x".into(),
participants: vec![],
reason: "XA PREPARE failed on mysql:primary".into(),
};
assert_eq!(row.target_intent(), RecoveryIntent::Rollback);
}
#[test]
fn target_intent_commit_prepared_phrase_does_not_false_match() {
let row = InDoubtLedgerRow {
xid: "udb-x".into(),
participants: vec![],
reason: "COMMIT PREPARED transport error".into(),
};
assert_eq!(row.target_intent(), RecoveryIntent::Commit);
}
#[test]
fn validate_xid_accepts_canonical_format() {
assert!(validate_xid("udb-abc-123").is_ok());
assert!(validate_xid("udb_xid_42").is_ok());
}
#[test]
fn validate_xid_rejects_injection_shapes() {
assert!(validate_xid("").is_err());
assert!(validate_xid("xid'; DROP TABLE x").is_err());
assert!(validate_xid("xid\"").is_err());
assert!(validate_xid("xid OR 1=1").is_err());
}
#[tokio::test]
async fn drive_indoubt_row_commits_when_xid_still_prepared() {
let participant = Arc::new(StubParticipant::new("mysql", vec!["udb-abc".into()]));
let mut registry = InDoubtRegistry::new();
registry.register(participant.clone());
let row = InDoubtLedgerRow {
xid: "udb-abc".into(),
participants: vec!["mysql:primary".into()],
reason: "COMMIT PREPARED transport error".into(),
};
let outcomes = drive_indoubt_row(&row, ®istry).await;
assert_eq!(outcomes.len(), 1);
assert!(matches!(outcomes[0], RecoveryOutcome::Committed { .. }));
assert!(participant.prepared.lock().unwrap().is_empty());
}
#[tokio::test]
async fn drive_indoubt_row_skips_when_xid_already_terminal() {
let participant = Arc::new(StubParticipant::new("mysql", vec![]));
let mut registry = InDoubtRegistry::new();
registry.register(participant.clone());
let row = InDoubtLedgerRow {
xid: "udb-already-done".into(),
participants: vec!["mysql:primary".into()],
reason: String::new(),
};
let outcomes = drive_indoubt_row(&row, ®istry).await;
assert!(matches!(
outcomes[0],
RecoveryOutcome::AlreadyTerminal { .. }
));
let events = participant.events.lock().unwrap().clone();
assert!(events.iter().all(|e| !e.starts_with("commit_prepared")));
}
#[tokio::test]
async fn drive_indoubt_row_reports_no_participant_for_unknown_backend() {
let registry = InDoubtRegistry::new();
let row = InDoubtLedgerRow {
xid: "udb-x".into(),
participants: vec!["weaviate:primary".into()],
reason: String::new(),
};
let outcomes = drive_indoubt_row(&row, ®istry).await;
assert!(matches!(outcomes[0], RecoveryOutcome::NoParticipant { .. }));
}
#[tokio::test]
async fn drive_indoubt_row_rolls_back_when_intent_is_rollback() {
let participant = Arc::new(StubParticipant::new("mysql", vec!["udb-x".into()]));
let mut registry = InDoubtRegistry::new();
registry.register(participant.clone());
let row = InDoubtLedgerRow {
xid: "udb-x".into(),
participants: vec!["mysql:primary".into()],
reason: "XA PREPARE failed".into(),
};
let outcomes = drive_indoubt_row(&row, ®istry).await;
assert!(matches!(outcomes[0], RecoveryOutcome::RolledBack { .. }));
}
#[tokio::test]
async fn drive_indoubt_row_propagates_participant_failure() {
let participant = Arc::new(StubParticipant::new("mysql", vec!["udb-x".into()]));
*participant.commit_outcome.lock().unwrap() = Err("network down".into());
let mut registry = InDoubtRegistry::new();
registry.register(participant.clone());
let row = InDoubtLedgerRow {
xid: "udb-x".into(),
participants: vec!["mysql:primary".into()],
reason: String::new(),
};
let outcomes = drive_indoubt_row(&row, ®istry).await;
match &outcomes[0] {
RecoveryOutcome::Failed { reason, .. } => {
assert!(reason.contains("network down"));
}
other => panic!("expected Failed, got {other:?}"),
}
}
#[test]
fn registry_lookup_strips_instance_suffix() {
let p = Arc::new(StubParticipant::new("postgres", vec![]));
let mut reg = InDoubtRegistry::new();
reg.register(p);
assert!(reg.get("postgres:primary").is_some());
assert!(reg.get("postgres:replica").is_some());
assert!(reg.get("mysql:primary").is_none());
}
}