use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::backend::BackendCapabilityMatrixEntry;
pub const XA_COMMIT_INTENT_REASON: &str = "commit decided; phase 2 in flight";
#[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,
ManualReview,
}
impl XaDecision {
pub fn as_str(self) -> &'static str {
match self {
Self::Committed => "committed",
Self::RolledBack => "rolled_back",
Self::InDoubt => "in_doubt",
Self::ManualReview => "manual_review",
}
}
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> {
Self::execute_with_write_ahead(request, participants, capability_matrix, |_| async {
Ok(())
})
.await
}
pub async fn execute_with_write_ahead<F, Fut>(
request: XaRequest,
participants: Vec<Box<dyn XaParticipant>>,
capability_matrix: &[BackendCapabilityMatrixEntry],
write_ahead: F,
) -> Result<XaOutcome, XaError>
where
F: FnOnce(XaLedgerEntry) -> Fut + Send,
Fut: std::future::Future<Output = Result<(), String>> + Send,
{
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 write_ahead_entry = XaLedgerEntry::new(
xid.clone(),
request.tenant_id.clone(),
request.project_id.clone(),
request.origin_rpc.clone(),
request.correlation_id.clone(),
labels.clone(),
XaDecision::InDoubt,
)
.with_reason(XA_COMMIT_INTENT_REASON);
if let Err(err) = write_ahead(write_ahead_entry).await {
for (i, p) in participants.iter().enumerate() {
if let Err(rb_err) = p.rollback_prepared(&xid).await {
tracing::warn!(
xid = %xid,
participant = %outcomes[i].label,
error = %rb_err,
"ROLLBACK PREPARED failed during write-ahead-ledger abort",
);
}
}
let ledger = XaLedgerEntry::new(
xid,
request.tenant_id,
request.project_id,
request.origin_rpc,
request.correlation_id,
labels,
XaDecision::RolledBack,
)
.with_reason(format!("write-ahead XA ledger insert failed: {err}"));
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,
})
}
}
pub fn translate_pg_plan_sql_to_mysql(sql: &str) -> Result<String, String> {
if sql.to_ascii_uppercase().contains("RETURNING") {
return Err("RETURNING is not supported by the MySQL XA participant".to_string());
}
if sql.contains("\"\"") {
return Err(
"identifiers containing embedded double quotes cannot be translated to MySQL"
.to_string(),
);
}
if sql.contains('`') {
return Err("plan SQL already contains backticks; refusing ambiguous translation".into());
}
let quoted = sql.replace('"', "`").replace(" ILIKE ", " LIKE ");
let mut out = String::with_capacity(quoted.len());
let mut expected = 1usize;
let mut chars = quoted.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
out.push(c);
continue;
}
let mut digits = String::new();
while let Some(d) = chars.peek().copied() {
if d.is_ascii_digit() {
digits.push(d);
chars.next();
} else {
break;
}
}
if digits.is_empty() {
return Err("unexpected '$' without parameter number in plan SQL".to_string());
}
let n: usize = digits
.parse()
.map_err(|e| format!("invalid placeholder ${digits}: {e}"))?;
if n != expected {
return Err(format!(
"placeholder ${n} out of order (expected ${expected}); MySQL binds positionally"
));
}
expected += 1;
out.push('?');
}
rewrite_on_conflict_for_mysql(&out)
}
fn rewrite_on_conflict_for_mysql(sql: &str) -> Result<String, String> {
const MARKER: &str = " ON CONFLICT (";
let Some(pos) = sql.find(MARKER) else {
return Ok(sql.to_string());
};
let head = &sql[..pos];
let rest = &sql[pos + MARKER.len()..];
let close = rest
.find(')')
.ok_or_else(|| "malformed ON CONFLICT clause".to_string())?;
let conflict_columns = &rest[..close];
let tail = rest[close + 1..].trim();
if tail == "DO NOTHING" {
let first = conflict_columns
.split(',')
.next()
.map(str::trim)
.filter(|c| !c.is_empty())
.ok_or_else(|| "ON CONFLICT clause without columns".to_string())?;
return Ok(format!("{head} ON DUPLICATE KEY UPDATE {first} = {first}"));
}
let Some(assignments) = tail.strip_prefix("DO UPDATE SET ") else {
return Err(format!("unsupported ON CONFLICT action '{tail}'"));
};
let mut rewritten = Vec::new();
for assignment in assignments.split(", ") {
let (lhs, rhs) = assignment
.split_once(" = ")
.ok_or_else(|| format!("unsupported ON CONFLICT assignment '{assignment}'"))?;
let column = rhs
.strip_prefix("EXCLUDED.")
.ok_or_else(|| format!("unsupported ON CONFLICT assignment source '{rhs}'"))?;
if column != lhs {
return Err(format!(
"unsupported cross-column ON CONFLICT assignment '{assignment}'"
));
}
rewritten.push(format!("{lhs} = VALUES({column})"));
}
Ok(format!(
"{head} ON DUPLICATE KEY UPDATE {}",
rewritten.join(", ")
))
}
#[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,
transport_label: "test".to_string(),
live_probe: false,
configured: false,
role: crate::backend::BackendRole::Canonical,
canonical_candidate: crate::backend::CanonicalCandidateProfile::ExplicitlyNotSupported,
canonical_goal: String::new(),
canonical_feasibility: None,
control_plane_ha_level: crate::backend::ControlPlaneHaLevel::ProjectionOnly,
}
}
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_eq!(XaDecision::ManualReview.as_str(), "manual_review");
assert!(XaDecision::Committed.is_terminal());
assert!(XaDecision::RolledBack.is_terminal());
assert!(!XaDecision::InDoubt.is_terminal());
assert!(!XaDecision::ManualReview.is_terminal());
}
struct SharedLogParticipant {
handle: XaParticipantHandle,
log: std::sync::Arc<Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl XaParticipant for SharedLogParticipant {
fn handle(&self) -> &XaParticipantHandle {
&self.handle
}
async fn prepare(&self, _xid: &str) -> PrepareVote {
self.log
.lock()
.unwrap()
.push(format!("prepare:{}", self.handle.label));
PrepareVote::Prepared
}
async fn commit_prepared(&self, _xid: &str) -> Result<(), String> {
self.log
.lock()
.unwrap()
.push(format!("commit:{}", self.handle.label));
Ok(())
}
async fn rollback_prepared(&self, _xid: &str) -> Result<(), String> {
self.log
.lock()
.unwrap()
.push(format!("rollback:{}", self.handle.label));
Ok(())
}
}
#[tokio::test]
async fn write_ahead_record_precedes_every_phase_2_commit() {
let log = std::sync::Arc::new(Mutex::new(Vec::<String>::new()));
let p1 = SharedLogParticipant {
handle: XaParticipantHandle::new("postgres", "p1"),
log: log.clone(),
};
let p2 = SharedLogParticipant {
handle: XaParticipantHandle::new("postgres", "p2"),
log: log.clone(),
};
let matrix = vec![matrix_with_two_phase("postgres", true)];
let hook_log = log.clone();
let outcome = XaCoordinator::execute_with_write_ahead(
request(),
vec![Box::new(p1), Box::new(p2)],
&matrix,
|entry| async move {
assert_eq!(entry.decision, XaDecision::InDoubt);
assert_eq!(entry.reason, XA_COMMIT_INTENT_REASON);
hook_log.lock().unwrap().push("write_ahead".to_string());
Ok(())
},
)
.await
.expect("happy path");
assert_eq!(outcome.ledger.decision, XaDecision::Committed);
let events = log.lock().unwrap().clone();
let write_ahead_idx = events
.iter()
.position(|e| e == "write_ahead")
.expect("write-ahead hook ran");
for (idx, event) in events.iter().enumerate() {
if event.starts_with("commit:") {
assert!(
write_ahead_idx < idx,
"write-ahead must precede phase-2 commit {event}; got {events:?}"
);
}
if event.starts_with("prepare:") {
assert!(
idx < write_ahead_idx,
"write-ahead must follow all prepares; got {events:?}"
);
}
}
}
#[tokio::test]
async fn write_ahead_failure_rolls_back_and_never_commits() {
let log = std::sync::Arc::new(Mutex::new(Vec::<String>::new()));
let p1 = SharedLogParticipant {
handle: XaParticipantHandle::new("postgres", "p1"),
log: log.clone(),
};
let matrix = vec![matrix_with_two_phase("postgres", true)];
let err = XaCoordinator::execute_with_write_ahead(
request(),
vec![Box::new(p1)],
&matrix,
|_entry| async move { Err("ledger down".to_string()) },
)
.await
.unwrap_err();
match err {
XaError::PrepareFailed { ledger, .. } => {
assert_eq!(ledger.decision, XaDecision::RolledBack);
assert!(ledger.reason.contains("ledger down"));
}
other => panic!("expected PrepareFailed, got {other:?}"),
}
let events = log.lock().unwrap().clone();
assert!(events.iter().any(|e| e.starts_with("rollback:")));
assert!(events.iter().all(|e| !e.starts_with("commit:")));
}
#[test]
fn commit_intent_reason_is_not_misread_as_prepare_failure() {
let lc = XA_COMMIT_INTENT_REASON.to_ascii_lowercase();
for phrase in ["prepare failed", "xa prepare", "aborted vote", "aborted: "] {
assert!(
!lc.contains(phrase),
"commit-intent reason must not contain '{phrase}'"
);
}
}
#[test]
fn translate_upsert_plan_to_mysql_dialect() {
let pg = "INSERT INTO \"public\".\"users\" (\"id\", \"name\") VALUES ($1, $2) \
ON CONFLICT (\"id\") DO UPDATE SET \"name\" = EXCLUDED.\"name\"";
let mysql = translate_pg_plan_sql_to_mysql(pg).expect("translates");
assert_eq!(
mysql,
"INSERT INTO `public`.`users` (`id`, `name`) VALUES (?, ?) \
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`)"
);
}
#[test]
fn translate_do_nothing_upsert_to_mysql_noop_update() {
let pg = "INSERT INTO \"s\".\"t\" (\"id\") VALUES ($1) ON CONFLICT (\"id\") DO NOTHING";
let mysql = translate_pg_plan_sql_to_mysql(pg).expect("translates");
assert_eq!(
mysql,
"INSERT INTO `s`.`t` (`id`) VALUES (?) ON DUPLICATE KEY UPDATE `id` = `id`"
);
}
#[test]
fn translate_delete_plan_to_mysql_dialect() {
let pg = "DELETE FROM \"s\".\"t\" WHERE \"tenant_id\" = $1 AND \"id\" IN ($2, $3)";
let mysql = translate_pg_plan_sql_to_mysql(pg).expect("translates");
assert_eq!(
mysql,
"DELETE FROM `s`.`t` WHERE `tenant_id` = ? AND `id` IN (?, ?)"
);
}
#[test]
fn translate_fails_closed_on_untranslatable_shapes() {
assert!(
translate_pg_plan_sql_to_mysql(
"INSERT INTO \"s\".\"t\" (\"id\") VALUES ($1) ON CONFLICT (\"id\") DO NOTHING RETURNING *"
)
.is_err()
);
assert!(
translate_pg_plan_sql_to_mysql("DELETE FROM \"s\".\"t\" WHERE \"a\" = $2").is_err()
);
assert!(
translate_pg_plan_sql_to_mysql("DELETE FROM \"s\".\"t\"\"x\" WHERE \"a\" = $1")
.is_err()
);
assert!(translate_pg_plan_sql_to_mysql("DELETE FROM `t` WHERE \"a\" = $1").is_err());
}
#[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);
}
}