use sqlx::{AssertSqlSafe, PgPool, Row};
use super::{
Dialect, Migration, RenderError, SHARED_MIGRATIONS, build_migrations, quote_identifier,
};
const MAX_ATTEMPTS: u32 = 3;
const PEER_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
const PEER_POLL: std::time::Duration = std::time::Duration::from_millis(100);
const UNKNOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(1);
const ALREADY_EXISTS: &[&str] = &[
"23505", "42701", "42710", "42723", "42P04", "42P06", "42P07", ];
const NOT_MIGRATED: &[&str] = &[
"42P01", "3F000", ];
const TRANSIENT: &[&str] = &[
"40P01", "40001", "55P03", ];
fn has_code(error: &sqlx::Error, codes: &[&str]) -> bool {
error
.as_database_error()
.and_then(|e| e.code())
.is_some_and(|c| codes.contains(&c.as_ref()))
}
fn is_possibly_concurrent(error: &sqlx::Error) -> bool {
if has_code(error, ALREADY_EXISTS) || has_code(error, TRANSIENT) {
return true;
}
error
.as_database_error()
.is_some_and(|e| e.message().contains("concurrently updated"))
}
fn is_already_exists(error: &sqlx::Error) -> bool {
has_code(error, ALREADY_EXISTS)
}
#[derive(Debug)]
pub enum MigrateError {
Database(sqlx::Error),
Render(RenderError),
Migration {
version: u32,
source: sqlx::Error,
},
Outdated {
recorded: i64,
required: i64,
},
}
impl std::fmt::Display for MigrateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MigrateError::Database(e) => write!(f, "system database error: {e}"),
MigrateError::Render(e) => write!(f, "could not render a migration: {e}"),
MigrateError::Migration { version, source } => {
write!(f, "migration {version} failed: {source}")
}
MigrateError::Outdated { recorded, required } => write!(
f,
"the system database is not up to date for this version of DBOS Rust: it is at \
migration {recorded}, and this build needs {required}. Bring it up to date by \
launching a DBOS instance with migration enabled, or by migrating out of band."
),
}
}
}
impl std::error::Error for MigrateError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
MigrateError::Database(e) | MigrateError::Migration { source: e, .. } => Some(e),
MigrateError::Render(e) => Some(e),
MigrateError::Outdated { .. } => None,
}
}
}
impl From<sqlx::Error> for MigrateError {
fn from(e: sqlx::Error) -> Self {
MigrateError::Database(e)
}
}
impl From<RenderError> for MigrateError {
fn from(e: RenderError) -> Self {
MigrateError::Render(e)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub from_version: i64,
pub to_version: i64,
pub applied: Vec<u32>,
pub was_current: bool,
}
pub async fn detect_dialect(pool: &PgPool) -> Result<Dialect, MigrateError> {
let version: String = sqlx::query_scalar("SELECT version()")
.fetch_one(pool)
.await?;
Ok(if version.to_lowercase().contains("cockroachdb") {
Dialect::Cockroach
} else {
Dialect::Postgres
})
}
async fn ensure_schema(pool: &PgPool, schema: &str) -> Result<(), MigrateError> {
let quoted = quote_identifier(schema);
for sql in [
format!("CREATE SCHEMA IF NOT EXISTS {quoted}"),
format!(
"CREATE TABLE IF NOT EXISTS {quoted}.dbos_migrations \
(version BIGINT NOT NULL PRIMARY KEY)"
),
] {
match sqlx::raw_sql(AssertSqlSafe(sql)).execute(pool).await {
Ok(_) => {}
Err(e) if is_already_exists(&e) => {}
Err(e) => return Err(e.into()),
}
}
Ok(())
}
async fn recorded_version(pool: &PgPool, schema: &str) -> Result<i64, MigrateError> {
let quoted = quote_identifier(schema);
let row = sqlx::query(AssertSqlSafe(format!(
"SELECT version FROM {quoted}.dbos_migrations"
)))
.fetch_optional(pool)
.await?;
Ok(row.map(|r| r.get::<i64, _>(0)).unwrap_or(0))
}
async fn record_version(
pool: &PgPool,
schema: &str,
version: i64,
had_row: bool,
) -> Result<(), MigrateError> {
let quoted = quote_identifier(schema);
let sql = if had_row {
format!("UPDATE {quoted}.dbos_migrations SET version = {version}")
} else {
format!("INSERT INTO {quoted}.dbos_migrations (version) VALUES ({version})")
};
sqlx::raw_sql(AssertSqlSafe(sql)).execute(pool).await?;
Ok(())
}
async fn drop_invalid_indexes(pool: &PgPool, schema: &str) -> Result<(), sqlx::Error> {
let names: Vec<String> = sqlx::query_scalar(
"SELECT i.relname FROM pg_index ix \
JOIN pg_class i ON i.oid = ix.indexrelid \
JOIN pg_class t ON t.oid = ix.indrelid \
JOIN pg_namespace n ON n.oid = t.relnamespace \
WHERE NOT ix.indisvalid AND n.nspname = $1",
)
.bind(schema)
.fetch_all(pool)
.await?;
let quoted = quote_identifier(schema);
for name in names {
tracing::warn!(
index = %name,
"dropping invalid index left by an interrupted migration"
);
let index = quote_identifier(&name);
sqlx::raw_sql(AssertSqlSafe(format!(
"DROP INDEX CONCURRENTLY IF EXISTS {quoted}.{index}"
)))
.execute(pool)
.await?;
}
Ok(())
}
pub async fn run(
pool: &PgPool,
schema: &str,
use_listen_notify: bool,
) -> Result<Outcome, MigrateError> {
let dialect = detect_dialect(pool).await?;
let migrations = build_migrations(schema, dialect, use_listen_notify);
apply(pool, schema, &migrations).await
}
pub async fn verify(pool: &PgPool, schema: &str) -> Result<(), MigrateError> {
let required = i64::from(SHARED_MIGRATIONS);
let recorded = match recorded_version(pool, schema).await {
Ok(version) => version,
Err(MigrateError::Database(e)) if has_code(&e, NOT_MIGRATED) => 0,
Err(e) => return Err(e),
};
if recorded < required {
return Err(MigrateError::Outdated { recorded, required });
}
if recorded > required {
tracing::info!(
recorded,
known = required,
"system database is ahead of this build; another implementation has migrated it \
further"
);
}
Ok(())
}
pub async fn apply(
pool: &PgPool,
schema: &str,
migrations: &[Migration],
) -> Result<Outcome, MigrateError> {
ensure_schema(pool, schema).await?;
let latest = migrations.len() as i64;
let from_version = recorded_version(pool, schema).await?;
let mut had_row = from_version > 0;
if from_version > latest {
tracing::info!(
recorded = from_version,
known = latest,
"system database is ahead of this build; another implementation has migrated it \
further. Proceeding without changes."
);
return Ok(Outcome {
from_version,
to_version: from_version,
applied: Vec::new(),
was_current: true,
});
}
if from_version == latest {
return Ok(Outcome {
from_version,
to_version: from_version,
applied: Vec::new(),
was_current: true,
});
}
let mut last_applied = from_version;
let mut applied = Vec::new();
for migration in migrations {
let version = i64::from(migration.version);
if version <= last_applied {
continue;
}
if migration.sql.trim().is_empty() {
continue;
}
apply_one(pool, schema, migration, &mut last_applied, &mut had_row).await?;
applied.push(migration.version);
}
if latest > last_applied {
record_version(pool, schema, latest, had_row).await?;
last_applied = latest;
}
Ok(Outcome {
from_version,
to_version: last_applied,
applied,
was_current: false,
})
}
async fn apply_one(
pool: &PgPool,
schema: &str,
migration: &Migration,
last_applied: &mut i64,
had_row: &mut bool,
) -> Result<(), MigrateError> {
let version = i64::from(migration.version);
for attempt in 1..=MAX_ATTEMPTS {
match try_apply(pool, schema, migration, *had_row).await {
Ok(()) => {
*had_row = true;
*last_applied = version;
return Ok(());
}
Err(source) => {
let wait = if is_possibly_concurrent(&source) {
PEER_WAIT
} else {
UNKNOWN_WAIT
};
if let Some(now) = await_peer(pool, schema, version, wait).await {
tracing::debug!(
version = migration.version,
"another process applied this migration concurrently"
);
*had_row = true;
*last_applied = now;
return Ok(());
}
if attempt == MAX_ATTEMPTS {
return Err(MigrateError::Migration {
version: migration.version,
source,
});
}
tracing::warn!(
version = migration.version,
attempt,
error = %source,
"migration failed; retrying"
);
}
}
}
unreachable!("the loop returns or errors on the final attempt")
}
async fn await_peer(
pool: &PgPool,
schema: &str,
version: i64,
wait: std::time::Duration,
) -> Option<i64> {
let deadline = std::time::Instant::now() + wait;
loop {
if let Ok(now) = recorded_version(pool, schema).await
&& now >= version
{
return Some(now);
}
if std::time::Instant::now() >= deadline {
return None;
}
tokio::time::sleep(PEER_POLL).await;
}
}
async fn try_apply(
pool: &PgPool,
schema: &str,
migration: &Migration,
had_row: bool,
) -> Result<(), sqlx::Error> {
let version = i64::from(migration.version);
let quoted = quote_identifier(schema);
let version_sql = if had_row {
format!("UPDATE {quoted}.dbos_migrations SET version = {version}")
} else {
format!("INSERT INTO {quoted}.dbos_migrations (version) VALUES ({version})")
};
if let Some(guard) = migration.guard
&& sqlx::query(guard)
.bind(schema)
.fetch_optional(pool)
.await?
.is_some()
{
tracing::debug!(version = migration.version, "migration already satisfied");
sqlx::raw_sql(AssertSqlSafe(version_sql))
.execute(pool)
.await?;
return Ok(());
}
if migration.online {
drop_invalid_indexes(pool, schema).await?;
sqlx::raw_sql(AssertSqlSafe(migration.sql.clone()))
.execute(pool)
.await?;
sqlx::raw_sql(AssertSqlSafe(version_sql))
.execute(pool)
.await?;
} else {
let mut tx = pool.begin().await?;
sqlx::raw_sql(AssertSqlSafe(migration.sql.clone()))
.execute(&mut *tx)
.await?;
sqlx::raw_sql(AssertSqlSafe(version_sql))
.execute(&mut *tx)
.await?;
tx.commit().await?;
}
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
fn dialect_detection_reads_the_server_banner() {
let crdb = "CockroachDB CCL v26.2.0 (x86_64-pc-linux-gnu)";
let pg = "PostgreSQL 16.3 on x86_64-pc-linux-gnu";
assert!(crdb.to_lowercase().contains("cockroachdb"));
assert!(!pg.to_lowercase().contains("cockroachdb"));
}
}