use sqlx::{PgPool, Postgres, Transaction};
use crate::runtime::xa::{PrepareVote, XaParticipant, XaParticipantHandle};
pub struct PostgresXaParticipant {
handle: XaParticipantHandle,
pool: PgPool,
mutations_sql: Vec<String>,
}
pub struct ActivePostgresXaParticipant {
handle: XaParticipantHandle,
pool: PgPool,
tx: tokio::sync::Mutex<Option<Transaction<'static, Postgres>>>,
}
impl ActivePostgresXaParticipant {
pub fn new(
instance: impl Into<String>,
pool: PgPool,
tx: Transaction<'static, Postgres>,
) -> Self {
Self {
handle: XaParticipantHandle::new("postgres", instance),
pool,
tx: tokio::sync::Mutex::new(Some(tx)),
}
}
}
impl PostgresXaParticipant {
pub fn new(handle: XaParticipantHandle, pool: PgPool, mutations_sql: Vec<String>) -> Self {
Self {
handle,
pool,
mutations_sql,
}
}
pub fn validate_xid(xid: &str) -> Result<(), String> {
if xid.is_empty() || xid.len() > 200 {
return Err(format!(
"xid '{xid}' is invalid: must be 1–200 chars (PostgreSQL limit)"
));
}
if !xid
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
return Err(format!(
"xid '{xid}' contains invalid characters; only ASCII alphanumeric, '-', '_' are allowed"
));
}
Ok(())
}
pub(crate) fn quote_xid(xid: &str) -> String {
format!("'{}'", xid.replace('\'', "''"))
}
}
#[async_trait::async_trait]
impl XaParticipant for PostgresXaParticipant {
fn handle(&self) -> &XaParticipantHandle {
&self.handle
}
async fn prepare(&self, xid: &str) -> PrepareVote {
if let Err(reason) = Self::validate_xid(xid) {
return PrepareVote::Aborted { reason };
}
let mut tx = match self.pool.begin().await {
Ok(tx) => tx,
Err(err) => {
return PrepareVote::Aborted {
reason: format!("BEGIN failed: {err}"),
};
}
};
for sql in &self.mutations_sql {
if let Err(err) = sqlx::query(sql).execute(&mut *tx).await {
return PrepareVote::Aborted {
reason: format!("mutation failed: {err}"),
};
}
}
let prepare_sql = format!("PREPARE TRANSACTION {}", Self::quote_xid(xid));
if let Err(err) = sqlx::query(&prepare_sql).execute(&mut *tx).await {
return PrepareVote::Aborted {
reason: format!("PREPARE TRANSACTION failed: {err}"),
};
}
let _ = tx;
PrepareVote::Prepared
}
async fn commit_prepared(&self, xid: &str) -> Result<(), String> {
Self::validate_xid(xid)?;
let sql = format!("COMMIT PREPARED {}", Self::quote_xid(xid));
sqlx::query(&sql)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(|err| format!("COMMIT PREPARED failed: {err}"))
}
async fn rollback_prepared(&self, xid: &str) -> Result<(), String> {
Self::validate_xid(xid)?;
let sql = format!("ROLLBACK PREPARED {}", Self::quote_xid(xid));
sqlx::query(&sql)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(|err| format!("ROLLBACK PREPARED failed: {err}"))
}
}
#[async_trait::async_trait]
impl XaParticipant for ActivePostgresXaParticipant {
fn handle(&self) -> &XaParticipantHandle {
&self.handle
}
async fn prepare(&self, xid: &str) -> PrepareVote {
if let Err(reason) = PostgresXaParticipant::validate_xid(xid) {
return PrepareVote::Aborted { reason };
}
let mut guard = self.tx.lock().await;
let Some(mut tx) = guard.take() else {
return PrepareVote::Aborted {
reason: "active Postgres transaction was already consumed".to_string(),
};
};
let prepare_sql = format!(
"PREPARE TRANSACTION {}",
PostgresXaParticipant::quote_xid(xid)
);
if let Err(err) = sqlx::query(&prepare_sql).execute(&mut *tx).await {
let _ = tx.rollback().await;
return PrepareVote::Aborted {
reason: format!("PREPARE TRANSACTION failed: {err}"),
};
}
drop(tx);
PrepareVote::Prepared
}
async fn commit_prepared(&self, xid: &str) -> Result<(), String> {
PostgresXaParticipant::validate_xid(xid)?;
let sql = format!("COMMIT PREPARED {}", PostgresXaParticipant::quote_xid(xid));
sqlx::query(&sql)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(|err| format!("COMMIT PREPARED failed: {err}"))
}
async fn rollback_prepared(&self, xid: &str) -> Result<(), String> {
PostgresXaParticipant::validate_xid(xid)?;
let sql = format!(
"ROLLBACK PREPARED {}",
PostgresXaParticipant::quote_xid(xid)
);
sqlx::query(&sql)
.execute(&self.pool)
.await
.map(|_| ())
.map_err(|err| format!("ROLLBACK PREPARED failed: {err}"))
}
}
pub async fn list_udb_prepared_xacts(pool: &PgPool) -> Result<Vec<String>, String> {
use sqlx::Row;
let rows = sqlx::query("SELECT gid FROM pg_prepared_xacts WHERE gid LIKE 'udb-%'")
.fetch_all(pool)
.await
.map_err(|err| format!("pg_prepared_xacts scan failed: {err}"))?;
let mut xids = Vec::with_capacity(rows.len());
for row in rows {
let gid: String = row.try_get("gid").map_err(|err| err.to_string())?;
xids.push(gid);
}
Ok(xids)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_xid_accepts_canonical_form() {
assert!(PostgresXaParticipant::validate_xid("udb-abc-123").is_ok());
assert!(PostgresXaParticipant::validate_xid("udb-0123_456").is_ok());
let almost_max = format!("udb-{}", "a".repeat(195));
assert_eq!(almost_max.len(), 199);
assert!(PostgresXaParticipant::validate_xid(&almost_max).is_ok());
}
#[test]
fn validate_xid_rejects_invalid_forms() {
assert!(PostgresXaParticipant::validate_xid("").is_err());
assert!(PostgresXaParticipant::validate_xid("with spaces").is_err());
assert!(PostgresXaParticipant::validate_xid("with;semi").is_err());
assert!(PostgresXaParticipant::validate_xid("with'quote").is_err());
let too_long = format!("udb-{}", "a".repeat(200));
assert!(too_long.len() > 200);
assert!(PostgresXaParticipant::validate_xid(&too_long).is_err());
}
#[test]
fn quote_xid_doubles_single_quotes() {
assert_eq!(PostgresXaParticipant::quote_xid("udb-test"), "'udb-test'");
assert_eq!(
PostgresXaParticipant::quote_xid("udb-it's"),
"'udb-it''s'",
"single quotes must be doubled per SQL string literal escaping"
);
}
}