use super::split::{Dialect, split_statements};
use super::{Migration, MigrationRecord, MigrationRunner};
use crate::Result;
use chrono::Utc;
use sqlx::{AssertSqlSafe, Executor, MySql, MySqlConnection, MySqlPool, Row, pool::PoolConnection};
use tokio::sync::{Mutex, MutexGuard};
use tracing::{debug, info};
pub const MIGRATION_LOCK_PREFIX: &str = "hammerwork_migrations:";
pub const MIGRATION_LOCK_TIMEOUT_SECS: u32 = 600;
pub struct MySqlMigrationRunner {
pool: MySqlPool,
locked: Mutex<Option<PoolConnection<MySql>>>,
}
impl MySqlMigrationRunner {
pub fn new(pool: MySqlPool) -> 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<MySql>>>),
Pooled(PoolConnection<MySql>),
}
impl Conn<'_> {
fn get(&mut self) -> Result<&mut MySqlConnection> {
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::MySql> for MySqlMigrationRunner {
async fn acquire_migration_lock(&self) -> Result<()> {
let mut conn = self.pool.acquire().await?;
conn.close_on_drop();
debug!("Waiting for the MySQL migration lock");
let acquired: Option<i64> =
sqlx::query_scalar("SELECT GET_LOCK(CONCAT(?, SHA1(DATABASE())), ?)")
.bind(MIGRATION_LOCK_PREFIX)
.bind(MIGRATION_LOCK_TIMEOUT_SECS)
.fetch_one(&mut *conn)
.await?;
if acquired != Some(1) {
return Err(crate::HammerworkError::Queue {
message: format!(
"timed out after {MIGRATION_LOCK_TIMEOUT_SECS}s waiting for another \
migration run to finish (MySQL lock {MIGRATION_LOCK_PREFIX}<SHA-1 of the database name>)"
),
});
}
debug!("Acquired the MySQL 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 RELEASE_LOCK(CONCAT(?, SHA1(DATABASE())))")
.bind(MIGRATION_LOCK_PREFIX)
.execute(&mut *conn)
.await?;
debug!("Released the MySQL migration lock");
Ok(())
}
async fn run_migration(&self, migration: &Migration, sql: &str) -> Result<()> {
debug!("Executing MySQL migration: {}", migration.id);
let mut conn = self.connection().await?;
let mut tx = sqlx::Connection::begin(conn.get()?).await?;
let statements = split_statements(sql, Dialect::MySql);
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
);
tx.execute(AssertSqlSafe(full_statement)).await?;
}
tx.commit().await?;
info!(
"Successfully executed MySQL 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 COUNT(*) as count FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'hammerwork_migrations'",
)
.fetch_one(conn.get()?)
.await?;
Ok(row.try_get::<i64, _>("count")? > 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(255) NOT NULL PRIMARY KEY,
executed_at DATETIME(6) NOT NULL,
execution_time_ms BIGINT NOT NULL
)
"#,
)
.execute(conn.get()?)
.await?;
info!("Created MySQL 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 IGNORE INTO hammerwork_migrations (migration_id, executed_at, execution_time_ms)
VALUES (?, ?, ?)",
)
.bind(&migration.id)
.bind(Utc::now())
.bind(i64::try_from(execution_time_ms).unwrap_or(i64::MAX))
.execute(conn.get()?)
.await?;
debug!("Recorded MySQL migration: {}", migration.id);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::super::split::{Dialect, split_statements};
#[test]
fn guarded_ddl_migrations_split_into_whole_statements() {
let statements =
split_statements(include_str!("011_add_encryption.mysql.sql"), Dialect::MySql);
let count = |kw: &str| statements.iter().filter(|s| s.starts_with(kw)).count();
assert!(count("PREPARE") > 0);
assert_eq!(count("PREPARE"), count("EXECUTE"));
assert_eq!(count("PREPARE"), count("DEALLOCATE"));
assert!(statements.iter().all(|s| !s.starts_with('\'')));
}
}