use super::split::{Dialect, split_statements};
use super::{Migration, MigrationRecord, MigrationRunner};
use crate::Result;
use chrono::Utc;
use sqlx::{PgConnection, PgPool, Postgres, Row, pool::PoolConnection};
use tokio::sync::{Mutex, MutexGuard};
use tracing::{debug, error, info};
pub const MIGRATION_LOCK_KEY: i64 = 0x686d_7277_6b6d_6967;
pub struct PostgresMigrationRunner {
pool: PgPool,
locked: Mutex<Option<PoolConnection<Postgres>>>,
}
impl PostgresMigrationRunner {
pub fn new(pool: PgPool) -> Self {
Self {
pool,
locked: Mutex::new(None),
}
}
async fn connection(&self) -> Result<Conn<'_>> {
let guard = self.locked.lock().await;
if guard.is_some() {
return Ok(Conn::Locked(guard));
}
drop(guard);
Ok(Conn::Pooled(self.pool.acquire().await?))
}
}
enum Conn<'a> {
Locked(MutexGuard<'a, Option<PoolConnection<Postgres>>>),
Pooled(PoolConnection<Postgres>),
}
impl Conn<'_> {
fn get(&mut self) -> Result<&mut PgConnection> {
match self {
Self::Locked(guard) => guard.as_mut().map(|conn| &mut **conn).ok_or_else(|| {
crate::HammerworkError::Queue {
message: "the migration lock connection is gone".to_string(),
}
}),
Self::Pooled(conn) => Ok(&mut **conn),
}
}
}
#[async_trait::async_trait]
impl MigrationRunner<sqlx::Postgres> for PostgresMigrationRunner {
async fn acquire_migration_lock(&self) -> Result<()> {
let mut conn = self.pool.acquire().await?;
conn.close_on_drop();
debug!("Waiting for the PostgreSQL migration lock");
sqlx::query("SELECT pg_advisory_lock($1)")
.bind(MIGRATION_LOCK_KEY)
.execute(&mut *conn)
.await?;
debug!("Acquired the PostgreSQL migration lock");
*self.locked.lock().await = Some(conn);
Ok(())
}
async fn release_migration_lock(&self) -> Result<()> {
let Some(mut conn) = self.locked.lock().await.take() else {
return Ok(());
};
sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(MIGRATION_LOCK_KEY)
.execute(&mut *conn)
.await?;
debug!("Released the PostgreSQL migration lock");
Ok(())
}
async fn run_migration(&self, migration: &Migration, sql: &str) -> Result<()> {
debug!("Executing PostgreSQL migration: {}", migration.id);
let mut conn = self.connection().await?;
let mut tx = sqlx::Connection::begin(conn.get()?).await?;
let statements = split_statements(sql, Dialect::Postgres);
debug!(
"Parsed {} statements for migration {}",
statements.len(),
migration.id
);
for (i, statement) in statements.iter().enumerate() {
debug!("Statement {}: '{}'", i + 1, statement.trim());
}
for (i, statement) in statements.iter().enumerate() {
let full_statement = if statement.ends_with(';') {
statement.to_string()
} else {
format!("{};", statement)
};
debug!(
"Executing statement {} of {} for migration {}: {}",
i + 1,
statements.len(),
migration.id,
full_statement
);
if let Err(e) = sqlx::query(&full_statement).execute(&mut *tx).await {
error!(
"Failed to execute statement {}: {} - Error: {}",
i + 1,
full_statement,
e
);
return Err(e.into());
}
}
tx.commit().await?;
info!(
"Successfully executed PostgreSQL migration: {} ({} statements)",
migration.id,
statements.len()
);
Ok(())
}
async fn migration_table_exists(&self) -> Result<bool> {
let mut conn = self.connection().await?;
let row = sqlx::query(
"SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = 'hammerwork_migrations'
)",
)
.fetch_one(conn.get()?)
.await?;
Ok(row.try_get::<bool, _>(0)?)
}
async fn create_migration_table(&self) -> Result<()> {
let mut conn = self.connection().await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS hammerwork_migrations (
migration_id VARCHAR NOT NULL PRIMARY KEY,
executed_at TIMESTAMPTZ NOT NULL,
execution_time_ms BIGINT NOT NULL
)
"#,
)
.execute(conn.get()?)
.await?;
info!("Created PostgreSQL migration tracking table");
Ok(())
}
async fn get_executed_migrations(&self) -> Result<Vec<MigrationRecord>> {
let mut conn = self.connection().await?;
let rows = sqlx::query(
"SELECT migration_id, executed_at, execution_time_ms
FROM hammerwork_migrations
ORDER BY executed_at",
)
.fetch_all(conn.get()?)
.await?;
let mut records = Vec::new();
for row in rows {
records.push(MigrationRecord {
migration_id: row.try_get("migration_id")?,
executed_at: row.try_get("executed_at")?,
execution_time_ms: crate::queue::db_int(
row.try_get::<i64, _>("execution_time_ms")?,
"execution_time_ms",
)?,
});
}
Ok(records)
}
async fn record_migration(&self, migration: &Migration, execution_time_ms: u64) -> Result<()> {
let mut conn = self.connection().await?;
sqlx::query(
"INSERT INTO hammerwork_migrations (migration_id, executed_at, execution_time_ms)
VALUES ($1, $2, $3) ON CONFLICT (migration_id) DO NOTHING",
)
.bind(&migration.id)
.bind(Utc::now())
.bind(i64::try_from(execution_time_ms).unwrap_or(i64::MAX))
.execute(conn.get()?)
.await?;
debug!("Recorded PostgreSQL migration: {}", migration.id);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::super::split::{Dialect, split_statements};
#[test]
fn migration_014_trigger_keeps_its_argument_list() {
let statements = split_statements(
include_str!("014_add_queue_pause.postgres.sql"),
Dialect::Postgres,
);
let trigger = statements
.iter()
.find(|s| s.contains("CREATE TRIGGER"))
.expect("migration 014 creates a trigger");
assert!(trigger.contains("EXECUTE FUNCTION"), "{trigger}");
assert!(trigger.trim_end().ends_with(')'), "{trigger}");
let function = statements
.iter()
.find(|s| s.contains("CREATE OR REPLACE FUNCTION"))
.expect("migration 014 creates a function");
assert!(
function.contains("RETURN NEW"),
"function body must stay whole"
);
}
#[test]
fn migration_012_do_blocks_are_single_statements() {
let statements = split_statements(
include_str!("012_optimize_dependencies.postgres.sql"),
Dialect::Postgres,
);
for statement in statements
.iter()
.filter(|s| s.trim_start().starts_with("DO"))
{
assert!(
statement.trim_end().ends_with('$'),
"DO block was split: {statement}"
);
}
}
}