use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::backend::BackendCapabilityMatrixEntry;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TransactionStrategy {
Saga,
BestEffort,
TwoPhase,
}
impl Default for TransactionStrategy {
fn default() -> Self {
Self::Saga
}
}
impl TransactionStrategy {
pub fn as_str(self) -> &'static str {
match self {
Self::Saga => "saga",
Self::BestEffort => "best_effort",
Self::TwoPhase => "two_phase",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token.trim().to_ascii_lowercase().as_str() {
"saga" => Some(Self::Saga),
"best_effort" | "best-effort" => Some(Self::BestEffort),
"two_phase" | "two-phase" | "2pc" | "xa" => Some(Self::TwoPhase),
_ => None,
}
}
pub fn parse_or_default(token: &str) -> Self {
Self::parse(token).unwrap_or_default()
}
pub fn requires_two_phase(self) -> bool {
matches!(self, Self::TwoPhase)
}
}
#[derive(Debug, Clone)]
pub struct XaParticipantHandle {
pub backend: String,
pub instance: String,
pub label: String,
}
impl XaParticipantHandle {
pub fn new(backend: impl Into<String>, instance: impl Into<String>) -> Self {
let backend = backend.into();
let instance = instance.into();
let label = format!("{backend}:{instance}");
Self {
backend,
instance,
label,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PrepareVote {
Prepared,
Aborted { reason: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum XaDecision {
Committed,
RolledBack,
InDoubt,
}
impl XaDecision {
pub fn as_str(self) -> &'static str {
match self {
Self::Committed => "committed",
Self::RolledBack => "rolled_back",
Self::InDoubt => "in_doubt",
}
}
pub fn is_terminal(self) -> bool {
matches!(self, Self::Committed | Self::RolledBack)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct XaLedgerEntry {
pub xid: String,
pub tenant_id: String,
pub project_id: String,
pub origin_rpc: String,
pub correlation_id: String,
pub participants: Vec<String>,
pub decision: XaDecision,
pub decided_at_unix_ms: i64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub reason: String,
}
impl XaLedgerEntry {
pub fn new(
xid: impl Into<String>,
tenant_id: impl Into<String>,
project_id: impl Into<String>,
origin_rpc: impl Into<String>,
correlation_id: impl Into<String>,
participants: Vec<String>,
decision: XaDecision,
) -> Self {
Self {
xid: xid.into(),
tenant_id: tenant_id.into(),
project_id: project_id.into(),
origin_rpc: origin_rpc.into(),
correlation_id: correlation_id.into(),
participants,
decision,
decided_at_unix_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0),
reason: String::new(),
}
}
pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
self.reason = reason.into();
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct XaOutcome {
pub ledger: XaLedgerEntry,
pub participants: Vec<ParticipantOutcome>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ParticipantOutcome {
pub label: String,
pub vote: PrepareVote,
pub committed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum XaError {
CapabilityRefused { unsupported: Vec<String> },
PrepareFailed {
ledger: XaLedgerEntry,
failures: Vec<ParticipantOutcome>,
},
InDoubt { ledger: XaLedgerEntry },
}
impl std::fmt::Display for XaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CapabilityRefused { unsupported } => write!(
f,
"2PC refused: {} participant(s) lack supports_two_phase_commit: {}",
unsupported.len(),
unsupported.join(", ")
),
Self::PrepareFailed { ledger, .. } => write!(
f,
"2PC PREPARE phase failed for xid {}; rolled back: {}",
ledger.xid, ledger.reason
),
Self::InDoubt { ledger } => write!(
f,
"2PC xid {} is in-doubt; recovery worker will drive to terminal state",
ledger.xid
),
}
}
}
impl std::error::Error for XaError {}
#[async_trait::async_trait]
pub trait XaParticipant: Send + Sync {
fn handle(&self) -> &XaParticipantHandle;
async fn prepare(&self, xid: &str) -> PrepareVote;
async fn commit_prepared(&self, xid: &str) -> Result<(), String>;
async fn rollback_prepared(&self, xid: &str) -> Result<(), String>;
}
#[derive(Debug, Clone)]
pub struct XaRequest {
pub tenant_id: String,
pub project_id: String,
pub origin_rpc: String,
pub correlation_id: String,
}
pub struct XaCoordinator;
impl XaCoordinator {
pub fn new_xid() -> String {
format!("udb-{}", Uuid::new_v4())
}
pub fn validate_participants(
participants: &[XaParticipantHandle],
capability_matrix: &[BackendCapabilityMatrixEntry],
) -> Result<(), XaError> {
let mut unsupported: Vec<String> = Vec::new();
for p in participants {
let entry = capability_matrix
.iter()
.find(|e| e.backend.eq_ignore_ascii_case(&p.backend));
match entry {
Some(e) if e.supports_two_phase_commit => {}
Some(_) => unsupported.push(p.label.clone()),
None => unsupported.push(format!("{}:unknown", p.label)),
}
}
if !unsupported.is_empty() {
return Err(XaError::CapabilityRefused { unsupported });
}
Ok(())
}
pub async fn execute(
request: XaRequest,
participants: Vec<Box<dyn XaParticipant>>,
capability_matrix: &[BackendCapabilityMatrixEntry],
) -> Result<XaOutcome, XaError> {
let handles: Vec<XaParticipantHandle> =
participants.iter().map(|p| p.handle().clone()).collect();
let labels: Vec<String> = handles.iter().map(|h| h.label.clone()).collect();
Self::validate_participants(&handles, capability_matrix)?;
let xid = Self::new_xid();
let mut outcomes: Vec<ParticipantOutcome> = Vec::with_capacity(participants.len());
let mut had_failure = false;
let mut first_failure_reason = String::new();
for p in &participants {
let vote = p.prepare(&xid).await;
if let PrepareVote::Aborted { reason } = &vote {
had_failure = true;
if first_failure_reason.is_empty() {
first_failure_reason = reason.clone();
}
}
outcomes.push(ParticipantOutcome {
label: p.handle().label.clone(),
vote,
committed: false,
});
}
if had_failure {
for (i, p) in participants.iter().enumerate() {
if matches!(outcomes[i].vote, PrepareVote::Prepared)
&& let Err(err) = p.rollback_prepared(&xid).await
{
tracing::warn!(
xid = %xid,
participant = %outcomes[i].label,
error = %err,
"ROLLBACK PREPARED failed during prepare-phase abort",
);
}
}
let ledger = XaLedgerEntry::new(
xid,
request.tenant_id,
request.project_id,
request.origin_rpc,
request.correlation_id,
labels,
XaDecision::RolledBack,
)
.with_reason(first_failure_reason);
return Err(XaError::PrepareFailed {
ledger,
failures: outcomes,
});
}
let mut commit_error: Option<String> = None;
for (i, p) in participants.iter().enumerate() {
match p.commit_prepared(&xid).await {
Ok(()) => outcomes[i].committed = true,
Err(err) => {
if commit_error.is_none() {
commit_error = Some(format!("{}: {err}", outcomes[i].label));
}
}
}
}
if let Some(reason) = commit_error {
let ledger = XaLedgerEntry::new(
xid,
request.tenant_id,
request.project_id,
request.origin_rpc,
request.correlation_id,
labels,
XaDecision::InDoubt,
)
.with_reason(reason);
return Err(XaError::InDoubt { ledger });
}
Ok(XaOutcome {
ledger: XaLedgerEntry::new(
xid,
request.tenant_id,
request.project_id,
request.origin_rpc,
request.correlation_id,
labels,
XaDecision::Committed,
),
participants: outcomes,
})
}
}
#[cfg(feature = "mysql")]
pub struct XaMysqlParticipant {
handle: XaParticipantHandle,
pool: sqlx::MySqlPool,
prepared_statements: Vec<(String, Vec<serde_json::Value>)>,
connection: tokio::sync::Mutex<Option<sqlx::pool::PoolConnection<sqlx::MySql>>>,
}
#[cfg(feature = "mysql")]
impl XaMysqlParticipant {
pub fn new(
instance: impl Into<String>,
pool: sqlx::MySqlPool,
prepared_statements: Vec<(String, Vec<serde_json::Value>)>,
) -> Self {
Self {
handle: XaParticipantHandle::new("mysql", instance),
pool,
prepared_statements,
connection: tokio::sync::Mutex::new(None),
}
}
fn bind_xa_value<'a>(
q: sqlx::query::Query<'a, sqlx::MySql, sqlx::mysql::MySqlArguments>,
v: &'a serde_json::Value,
) -> sqlx::query::Query<'a, sqlx::MySql, sqlx::mysql::MySqlArguments> {
match v {
serde_json::Value::Null => q.bind(Option::<&str>::None),
serde_json::Value::Bool(b) => q.bind(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
q.bind(i)
} else if let Some(f) = n.as_f64() {
q.bind(f)
} else {
q.bind(n.to_string())
}
}
serde_json::Value::String(s) => q.bind(s.as_str()),
other => q.bind(other.to_string()),
}
}
}
#[cfg(feature = "mysql")]
#[async_trait::async_trait]
impl XaParticipant for XaMysqlParticipant {
fn handle(&self) -> &XaParticipantHandle {
&self.handle
}
async fn prepare(&self, xid: &str) -> PrepareVote {
let mut conn = match self.pool.acquire().await {
Ok(c) => c,
Err(err) => {
return PrepareVote::Aborted {
reason: format!("XA mysql acquire connection: {err}"),
};
}
};
use sqlx::Executor;
if let Err(err) = conn.execute(format!("XA START '{xid}'").as_str()).await {
return PrepareVote::Aborted {
reason: format!("XA START failed: {err}"),
};
}
for (sql, params) in &self.prepared_statements {
let mut q = sqlx::query(sql);
for v in params {
q = Self::bind_xa_value(q, v);
}
if let Err(err) = q.execute(&mut *conn).await {
let _ = conn.execute(format!("XA END '{xid}'").as_str()).await;
let _ = conn.execute(format!("XA ROLLBACK '{xid}'").as_str()).await;
return PrepareVote::Aborted {
reason: format!("XA statement failed: {err}"),
};
}
}
if let Err(err) = conn.execute(format!("XA END '{xid}'").as_str()).await {
let _ = conn.execute(format!("XA ROLLBACK '{xid}'").as_str()).await;
return PrepareVote::Aborted {
reason: format!("XA END failed: {err}"),
};
}
if let Err(err) = conn.execute(format!("XA PREPARE '{xid}'").as_str()).await {
return PrepareVote::Aborted {
reason: format!("XA PREPARE failed: {err}"),
};
}
*self.connection.lock().await = Some(conn);
PrepareVote::Prepared
}
async fn commit_prepared(&self, xid: &str) -> Result<(), String> {
let mut guard = self.connection.lock().await;
let mut conn = guard
.take()
.ok_or_else(|| "XA commit called without prepared connection".to_string())?;
use sqlx::Executor;
conn.execute(format!("XA COMMIT '{xid}'").as_str())
.await
.map(|_| ())
.map_err(|err| format!("XA COMMIT failed: {err}"))
}
async fn rollback_prepared(&self, xid: &str) -> Result<(), String> {
let mut guard = self.connection.lock().await;
let mut conn = match guard.take() {
Some(c) => c,
None => self
.pool
.acquire()
.await
.map_err(|err| format!("XA mysql rollback acquire: {err}"))?,
};
use sqlx::Executor;
conn.execute(format!("XA ROLLBACK '{xid}'").as_str())
.await
.map(|_| ())
.map_err(|err| format!("XA ROLLBACK failed: {err}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct StubParticipant {
handle: XaParticipantHandle,
prepare_outcome: PrepareVote,
commit_outcome: Result<(), String>,
log: Mutex<Vec<String>>,
}
impl StubParticipant {
fn new(label: &str, prepare: PrepareVote, commit: Result<(), String>) -> Self {
Self {
handle: XaParticipantHandle::new("postgres", label),
prepare_outcome: prepare,
commit_outcome: commit,
log: Mutex::new(Vec::new()),
}
}
}
#[async_trait::async_trait]
impl XaParticipant for StubParticipant {
fn handle(&self) -> &XaParticipantHandle {
&self.handle
}
async fn prepare(&self, xid: &str) -> PrepareVote {
self.log.lock().unwrap().push(format!("prepare({xid})"));
self.prepare_outcome.clone()
}
async fn commit_prepared(&self, xid: &str) -> Result<(), String> {
self.log
.lock()
.unwrap()
.push(format!("commit_prepared({xid})"));
self.commit_outcome.clone()
}
async fn rollback_prepared(&self, xid: &str) -> Result<(), String> {
self.log
.lock()
.unwrap()
.push(format!("rollback_prepared({xid})"));
Ok(())
}
}
fn matrix_with_two_phase(backend: &str, supports: bool) -> BackendCapabilityMatrixEntry {
BackendCapabilityMatrixEntry {
backend: backend.to_string(),
tier: "sql".to_string(),
operations: vec!["query".to_string(), "mutate".to_string()],
unsupported_error_code: "failed_precondition".to_string(),
consistency_model: "strong".to_string(),
max_payload_bytes: 0,
supports_xa: supports,
supports_two_phase_commit: supports,
role: crate::backend::BackendRole::Canonical,
canonical_candidate: crate::backend::CanonicalCandidateProfile::ExplicitlyNotSupported,
canonical_goal: String::new(),
canonical_feasibility: None,
}
}
fn request() -> XaRequest {
XaRequest {
tenant_id: "tenant-a".to_string(),
project_id: "billing".to_string(),
origin_rpc: "BeginTransaction".to_string(),
correlation_id: "corr-1".to_string(),
}
}
#[test]
fn strategy_tokens_are_pinned() {
assert_eq!(TransactionStrategy::Saga.as_str(), "saga");
assert_eq!(TransactionStrategy::BestEffort.as_str(), "best_effort");
assert_eq!(TransactionStrategy::TwoPhase.as_str(), "two_phase");
}
#[test]
fn strategy_parses_aliases_back_to_canonical() {
assert_eq!(
TransactionStrategy::parse("2pc"),
Some(TransactionStrategy::TwoPhase)
);
assert_eq!(
TransactionStrategy::parse("xa"),
Some(TransactionStrategy::TwoPhase)
);
assert_eq!(
TransactionStrategy::parse("best-effort"),
Some(TransactionStrategy::BestEffort)
);
assert_eq!(TransactionStrategy::parse("unknown"), None);
assert_eq!(
TransactionStrategy::parse_or_default(""),
TransactionStrategy::Saga
);
}
#[tokio::test]
async fn capability_refused_emits_no_side_effects() {
let pg = StubParticipant::new("primary", PrepareVote::Prepared, Ok(()));
let mongo_handle = XaParticipantHandle::new("mongodb", "default");
let mongo = StubParticipant {
handle: mongo_handle,
prepare_outcome: PrepareVote::Prepared,
commit_outcome: Ok(()),
log: Mutex::new(Vec::new()),
};
let matrix = vec![
matrix_with_two_phase("postgres", true),
matrix_with_two_phase("mongodb", false),
];
let err = XaCoordinator::execute(request(), vec![Box::new(pg), Box::new(mongo)], &matrix)
.await
.unwrap_err();
assert!(matches!(err, XaError::CapabilityRefused { .. }));
}
#[tokio::test]
async fn prepare_abort_triggers_rollback_on_prepared_participants() {
let p1 = StubParticipant::new("p1", PrepareVote::Prepared, Ok(()));
let p2 = StubParticipant::new(
"p2",
PrepareVote::Aborted {
reason: "constraint violation".to_string(),
},
Ok(()),
);
let p3 = StubParticipant::new("p3", PrepareVote::Prepared, Ok(()));
let matrix = vec![matrix_with_two_phase("postgres", true)];
let err = XaCoordinator::execute(
request(),
vec![Box::new(p1), Box::new(p2), Box::new(p3)],
&matrix,
)
.await
.unwrap_err();
match err {
XaError::PrepareFailed { ledger, failures } => {
assert_eq!(ledger.decision, XaDecision::RolledBack);
assert!(ledger.reason.contains("constraint violation"));
assert_eq!(failures.len(), 3);
assert!(matches!(failures[0].vote, PrepareVote::Prepared));
assert!(matches!(failures[1].vote, PrepareVote::Aborted { .. }));
assert!(matches!(failures[2].vote, PrepareVote::Prepared));
assert!(failures.iter().all(|f| !f.committed));
}
other => panic!("expected PrepareFailed, got {other:?}"),
}
}
#[tokio::test]
async fn happy_path_commits_every_participant() {
let p1 = StubParticipant::new("p1", PrepareVote::Prepared, Ok(()));
let p2 = StubParticipant::new("p2", PrepareVote::Prepared, Ok(()));
let matrix = vec![matrix_with_two_phase("postgres", true)];
let outcome = XaCoordinator::execute(request(), vec![Box::new(p1), Box::new(p2)], &matrix)
.await
.expect("happy path");
assert_eq!(outcome.ledger.decision, XaDecision::Committed);
assert!(outcome.ledger.decision.is_terminal());
assert_eq!(outcome.participants.len(), 2);
assert!(outcome.participants.iter().all(|p| p.committed));
assert_eq!(
outcome.ledger.participants,
vec!["postgres:p1", "postgres:p2"]
);
}
#[tokio::test]
async fn commit_failure_lands_in_doubt() {
let p1 = StubParticipant::new("p1", PrepareVote::Prepared, Ok(()));
let p2 = StubParticipant::new(
"p2",
PrepareVote::Prepared,
Err("network partition".to_string()),
);
let matrix = vec![matrix_with_two_phase("postgres", true)];
let err = XaCoordinator::execute(request(), vec![Box::new(p1), Box::new(p2)], &matrix)
.await
.unwrap_err();
match err {
XaError::InDoubt { ledger } => {
assert_eq!(ledger.decision, XaDecision::InDoubt);
assert!(!ledger.decision.is_terminal());
assert!(ledger.reason.contains("network partition"));
assert!(!ledger.xid.is_empty());
}
other => panic!("expected InDoubt, got {other:?}"),
}
}
#[test]
fn decision_tokens_and_terminality_are_pinned() {
assert_eq!(XaDecision::Committed.as_str(), "committed");
assert_eq!(XaDecision::RolledBack.as_str(), "rolled_back");
assert_eq!(XaDecision::InDoubt.as_str(), "in_doubt");
assert!(XaDecision::Committed.is_terminal());
assert!(XaDecision::RolledBack.is_terminal());
assert!(!XaDecision::InDoubt.is_terminal());
}
#[test]
fn xid_is_valid_postgres_identifier() {
let xid = XaCoordinator::new_xid();
assert!(xid.starts_with("udb-"));
assert!(
xid.len() < 200,
"must fit Postgres prepared-xact name limit"
);
assert!(
xid.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
"xid must be ASCII: {xid}"
);
}
#[test]
fn ledger_round_trips_through_serde() {
let entry = XaLedgerEntry::new(
"udb-test",
"t1",
"p1",
"BeginTransaction",
"corr",
vec!["postgres:primary".into(), "postgres:replica".into()],
XaDecision::InDoubt,
)
.with_reason("kafka timeout");
let json = serde_json::to_string(&entry).unwrap();
let back: XaLedgerEntry = serde_json::from_str(&json).unwrap();
assert_eq!(back, entry);
}
}