Skip to main content

aster_forge_db_migration/
coordination.rs

1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4
5use sea_orm_migration::MigratorTrait;
6use sea_orm_migration::sea_orm::{
7    ConnectionTrait, DatabaseConnection, DatabaseExecutor, DatabaseTransaction, DbBackend, DbErr,
8    RuntimeErr, Statement, TransactionTrait,
9};
10
11const DEFAULT_MYSQL_LOCK_TIMEOUT_SECONDS: u64 = 300;
12const MYSQL_LOCK_NAME_MAX_BYTES: usize = 64;
13
14/// Boxed migration callback future tied to the coordinated database connection.
15pub type MigrationFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, DbErr>> + Send + 'a>>;
16
17/// Stable cross-process migration lock configuration.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct MigrationLockOptions {
20    namespace: String,
21    postgres_advisory_key: i64,
22    postgres_transaction: bool,
23    mysql_timeout_seconds: u64,
24}
25
26impl MigrationLockOptions {
27    /// Creates options using a deterministic `PostgreSQL` advisory key derived from `namespace`.
28    ///
29    /// Products migrating from an existing lock implementation should call
30    /// [`Self::with_postgres_advisory_key`] to preserve the old key during rolling upgrades.
31    pub fn new(namespace: impl Into<String>) -> Self {
32        let namespace = namespace.into();
33        Self {
34            postgres_advisory_key: stable_advisory_key(&namespace),
35            namespace,
36            postgres_transaction: true,
37            mysql_timeout_seconds: DEFAULT_MYSQL_LOCK_TIMEOUT_SECONDS,
38        }
39    }
40
41    /// Overrides the `PostgreSQL` advisory key while preserving the shared namespace for `MySQL`.
42    #[must_use]
43    pub const fn with_postgres_advisory_key(mut self, key: i64) -> Self {
44        self.postgres_advisory_key = key;
45        self
46    }
47
48    /// Controls the `PostgreSQL` callback transaction (default: true).
49    /// Disable for migrators containing `CREATE INDEX CONCURRENTLY` or independently
50    /// committed backfills. The dedicated session still owns the migration lock.
51    #[must_use]
52    pub const fn with_postgres_transaction(mut self, enabled: bool) -> Self {
53        self.postgres_transaction = enabled;
54        self
55    }
56
57    /// Overrides the `MySQL` named-lock wait timeout in whole seconds.
58    #[must_use]
59    pub const fn with_mysql_timeout_seconds(mut self, seconds: u64) -> Self {
60        self.mysql_timeout_seconds = seconds;
61        self
62    }
63
64    /// Returns the stable product namespace used for `MySQL` named locks.
65    #[must_use]
66    pub fn namespace(&self) -> &str {
67        &self.namespace
68    }
69
70    /// Returns the `PostgreSQL` advisory-lock key.
71    #[must_use]
72    pub const fn postgres_advisory_key(&self) -> i64 {
73        self.postgres_advisory_key
74    }
75
76    /// Returns the `MySQL` named-lock wait timeout in seconds.
77    #[must_use]
78    pub const fn mysql_timeout_seconds(&self) -> u64 {
79        self.mysql_timeout_seconds
80    }
81
82    fn validate(&self) -> Result<(), DbErr> {
83        if self.namespace.is_empty() || self.namespace.len() > MYSQL_LOCK_NAME_MAX_BYTES {
84            return Err(DbErr::Custom(format!(
85                "migration lock namespace must contain 1-{MYSQL_LOCK_NAME_MAX_BYTES} bytes"
86            )));
87        }
88        if self.namespace.contains('\0') {
89            return Err(DbErr::Custom(
90                "migration lock namespace must not contain NUL bytes".to_string(),
91            ));
92        }
93        i64::try_from(self.mysql_timeout_seconds).map_err(|_| {
94            DbErr::Custom("MySQL migration lock timeout exceeds signed 64-bit range".to_string())
95        })?;
96        Ok(())
97    }
98}
99
100/// Runs a product migration callback while holding the backend's process-wide migration lock.
101///
102/// `PostgreSQL` acquires a session advisory lock without opening a waiting transaction;
103/// the callback is transactional unless explicitly disabled in the options. `MySQL`
104/// uses a dedicated session and named lock without a DDL transaction. `SQLite` runs
105/// the callback in a transaction without an additional external lock.
106///
107/// # Errors
108///
109/// Returns an error when lock options are invalid, the backend is unsupported, connection,
110/// transaction, or lock operations fail, the callback fails, or transactional finalization fails.
111pub async fn with_migration_lock<T, F>(
112    database: &DatabaseConnection,
113    options: &MigrationLockOptions,
114    operation: F,
115) -> Result<T, DbErr>
116where
117    F: for<'a> FnOnce(DatabaseExecutor<'a>) -> MigrationFuture<'a, T>,
118{
119    options.validate()?;
120    match database.get_database_backend() {
121        DbBackend::Postgres => run_postgres_migration(database, options, operation).await,
122        DbBackend::Sqlite => run_transactional_migration(database, operation).await,
123        DbBackend::MySql => run_mysql_migration(database, options, operation).await,
124        _ => Err(DbErr::Custom(
125            "unsupported database backend for migration coordination".to_string(),
126        )),
127    }
128}
129
130async fn run_transactional_migration<T, F>(
131    database: &DatabaseConnection,
132    operation: F,
133) -> Result<T, DbErr>
134where
135    F: for<'a> FnOnce(DatabaseExecutor<'a>) -> MigrationFuture<'a, T>,
136{
137    let transaction = database.begin().await?;
138    let operation_result = operation((&transaction).into()).await;
139
140    match operation_result {
141        Ok(value) => {
142            transaction.commit().await?;
143            Ok(value)
144        }
145        Err(error) => rollback_preserving_error(transaction, error).await,
146    }
147}
148
149async fn run_postgres_migration<T, F>(
150    database: &DatabaseConnection,
151    options: &MigrationLockOptions,
152    operation: F,
153) -> Result<T, DbErr>
154where
155    F: for<'a> FnOnce(DatabaseExecutor<'a>) -> MigrationFuture<'a, T>,
156{
157    let source_pool = database.get_postgres_connection_pool();
158    let connect_options = source_pool.connect_options();
159    let pool = source_pool
160        .options()
161        .clone()
162        .max_connections(1)
163        .min_connections(1)
164        .idle_timeout(None)
165        .max_lifetime(None)
166        .test_before_acquire(false)
167        .before_acquire(|_, _| Box::pin(async { Ok(true) }))
168        .after_release(|_, _| Box::pin(async { Ok(true) }))
169        .connect_with((*connect_options).clone())
170        .await
171        .map_err(|error| DbErr::Conn(RuntimeErr::SqlxError(Arc::new(error))))?;
172    let dedicated: DatabaseConnection = pool.into();
173    // Blocking pg_advisory_lock, even in autocommit, retains a statement
174    // snapshot while waiting. CONCURRENTLY may wait for that snapshot, forming
175    // a cycle with the lock holder. Each try statement ends its transaction.
176    loop {
177        let row = dedicated
178            .query_one_raw(Statement::from_sql_and_values(
179                DbBackend::Postgres,
180                "SELECT pg_try_advisory_lock($1)",
181                [options.postgres_advisory_key.into()],
182            ))
183            .await?
184            .ok_or_else(|| DbErr::Custom("migration lock query returned no row".into()))?;
185        if row.try_get_by_index::<bool>(0)? {
186            break;
187        }
188        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
189    }
190    let result = if options.postgres_transaction {
191        run_transactional_migration(&dedicated, operation).await
192    } else {
193        operation((&dedicated).into()).await
194    };
195    // Explicit close releases the session lock on success/error. Cancellation
196    // drops the private pool as well; this session never returns to the writer pool.
197    if let Err(close_error) = dedicated.close().await {
198        tracing::warn!(%close_error, "failed to close dedicated PostgreSQL migration connection");
199    }
200    result
201}
202
203async fn run_mysql_migration<T, F>(
204    database: &DatabaseConnection,
205    options: &MigrationLockOptions,
206    operation: F,
207) -> Result<T, DbErr>
208where
209    F: for<'a> FnOnce(DatabaseExecutor<'a>) -> MigrationFuture<'a, T>,
210{
211    let dedicated_database = create_mysql_migration_connection(database).await?;
212    acquire_mysql_lock(&dedicated_database, options).await?;
213    let operation_result = operation((&dedicated_database).into()).await;
214    let release_result = release_mysql_lock(&dedicated_database, options).await;
215
216    let result = match (operation_result, release_result) {
217        (Ok(value), Ok(())) => Ok(value),
218        (Err(error), Ok(())) => Err(error),
219        (Ok(_), Err(release_error)) => Err(release_error),
220        (Err(operation_error), Err(release_error)) => Err(DbErr::Custom(format!(
221            "migration operation failed: {operation_error}; additionally failed to release \
222             the MySQL migration lock: {release_error}"
223        ))),
224    };
225
226    if let Err(close_error) = dedicated_database.close().await {
227        tracing::warn!(%close_error, "failed to close dedicated MySQL migration connection");
228    }
229    result
230}
231
232async fn create_mysql_migration_connection(
233    database: &DatabaseConnection,
234) -> Result<DatabaseConnection, DbErr> {
235    let source_pool = database.get_mysql_connection_pool();
236    let connect_options = source_pool.connect_options();
237    let dedicated_pool = source_pool
238        .options()
239        .clone()
240        .max_connections(1)
241        .min_connections(1)
242        .idle_timeout(None)
243        .max_lifetime(None)
244        .test_before_acquire(false)
245        .before_acquire(|_, _| Box::pin(async { Ok(true) }))
246        .after_release(|_, _| Box::pin(async { Ok(true) }))
247        .connect_with((*connect_options).clone())
248        .await
249        .map_err(|error| DbErr::Conn(RuntimeErr::SqlxError(Arc::new(error))))?;
250    Ok(dedicated_pool.into())
251}
252
253/// Runs a standard `SeaORM` migrator while holding the backend migration lock.
254///
255/// # Errors
256///
257/// Returns any migration coordination, transaction, lock, or [`MigratorTrait::up`] failure.
258pub async fn run_migrator_with_lock<M>(
259    database: &DatabaseConnection,
260    options: &MigrationLockOptions,
261    steps: Option<u32>,
262) -> Result<(), DbErr>
263where
264    M: MigratorTrait + 'static,
265{
266    with_migration_lock(database, options, |connection| {
267        Box::pin(M::up(connection, steps))
268    })
269    .await
270}
271
272async fn acquire_mysql_lock(
273    connection: &DatabaseConnection,
274    options: &MigrationLockOptions,
275) -> Result<(), DbErr> {
276    let timeout = i64::try_from(options.mysql_timeout_seconds).map_err(|_| {
277        DbErr::Custom("MySQL migration lock timeout exceeds signed 64-bit range".to_string())
278    })?;
279    let acquired = mysql_lock_query_result(
280        connection,
281        "SELECT GET_LOCK(?, ?)",
282        [options.namespace.clone().into(), timeout.into()],
283        "acquire",
284    )
285    .await?;
286    if acquired {
287        Ok(())
288    } else {
289        Err(DbErr::Custom(format!(
290            "timed out after {} seconds waiting for MySQL migration lock '{}'",
291            options.mysql_timeout_seconds, options.namespace
292        )))
293    }
294}
295
296async fn release_mysql_lock(
297    connection: &DatabaseConnection,
298    options: &MigrationLockOptions,
299) -> Result<(), DbErr> {
300    let released = mysql_lock_query_result(
301        connection,
302        "SELECT RELEASE_LOCK(?)",
303        [options.namespace.clone().into()],
304        "release",
305    )
306    .await?;
307    if released {
308        Ok(())
309    } else {
310        Err(DbErr::Custom(format!(
311            "MySQL migration lock '{}' was not owned by the migration connection",
312            options.namespace
313        )))
314    }
315}
316
317async fn mysql_lock_query_result<C, const N: usize>(
318    connection: &C,
319    sql: &str,
320    values: [sea_orm_migration::sea_orm::Value; N],
321    operation: &str,
322) -> Result<bool, DbErr>
323where
324    C: ConnectionTrait,
325{
326    let row = connection
327        .query_one_raw(Statement::from_sql_and_values(
328            DbBackend::MySql,
329            sql,
330            values,
331        ))
332        .await?
333        .ok_or_else(|| {
334            DbErr::Custom(format!(
335                "MySQL migration lock {operation} query returned no rows"
336            ))
337        })?;
338
339    if let Ok(value) = row.try_get_by_index::<Option<i64>>(0) {
340        return value.map(|value| value == 1).ok_or_else(|| {
341            DbErr::Custom(format!(
342                "MySQL migration lock {operation} query returned NULL"
343            ))
344        });
345    }
346    if let Ok(value) = row.try_get_by_index::<Option<i32>>(0) {
347        return value.map(|value| value == 1).ok_or_else(|| {
348            DbErr::Custom(format!(
349                "MySQL migration lock {operation} query returned NULL"
350            ))
351        });
352    }
353
354    Err(DbErr::Custom(format!(
355        "failed to decode MySQL migration lock {operation} result"
356    )))
357}
358
359async fn rollback_preserving_error<T>(
360    transaction: DatabaseTransaction,
361    error: DbErr,
362) -> Result<T, DbErr> {
363    if let Err(rollback_error) = transaction.rollback().await {
364        tracing::warn!(%rollback_error, "failed to rollback migration transaction after callback error");
365    }
366    Err(error)
367}
368
369fn stable_advisory_key(namespace: &str) -> i64 {
370    const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
371    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
372
373    let hash = namespace.bytes().fold(FNV_OFFSET_BASIS, |hash, byte| {
374        (hash ^ u64::from(byte)).wrapping_mul(FNV_PRIME)
375    });
376    i64::from_be_bytes(hash.to_be_bytes())
377}
378
379#[cfg(test)]
380mod tests {
381    use sea_orm_migration::sea_orm::{ConnectionTrait, Database, DbErr};
382
383    use super::{MigrationLockOptions, stable_advisory_key, with_migration_lock};
384
385    #[test]
386    fn advisory_key_is_stable_and_namespace_sensitive() {
387        assert_eq!(
388            stable_advisory_key("aster_drive:database_migrations"),
389            stable_advisory_key("aster_drive:database_migrations")
390        );
391        assert_ne!(
392            stable_advisory_key("aster_drive:database_migrations"),
393            stable_advisory_key("aster_yggdrasil:database_migrations")
394        );
395    }
396
397    #[test]
398    fn migration_lock_options_validate_namespace_boundaries() {
399        assert!(MigrationLockOptions::new("a").validate().is_ok());
400        assert!(MigrationLockOptions::new("a".repeat(64)).validate().is_ok());
401        assert!(MigrationLockOptions::new("").validate().is_err());
402        assert!(
403            MigrationLockOptions::new("a".repeat(65))
404                .validate()
405                .is_err()
406        );
407        assert!(MigrationLockOptions::new("bad\0name").validate().is_err());
408    }
409
410    #[tokio::test]
411    async fn sqlite_callback_commits_on_success() {
412        let database = Database::connect("sqlite::memory:").await.unwrap();
413        with_migration_lock(
414            &database,
415            &MigrationLockOptions::new("sqlite-success"),
416            |transaction| {
417                Box::pin(async move {
418                    transaction
419                        .execute_unprepared("CREATE TABLE example (id INTEGER PRIMARY KEY)")
420                        .await?;
421                    Ok(())
422                })
423            },
424        )
425        .await
426        .unwrap();
427
428        database
429            .execute_unprepared("INSERT INTO example (id) VALUES (1)")
430            .await
431            .unwrap();
432    }
433
434    #[tokio::test]
435    async fn sqlite_callback_error_rolls_back_and_is_preserved() {
436        let database = Database::connect("sqlite::memory:").await.unwrap();
437        let error = with_migration_lock(
438            &database,
439            &MigrationLockOptions::new("sqlite-error"),
440            |transaction| {
441                Box::pin(async move {
442                    transaction
443                        .execute_unprepared("CREATE TABLE rolled_back (id INTEGER PRIMARY KEY)")
444                        .await?;
445                    Err::<(), _>(DbErr::Custom("product migration failed".to_string()))
446                })
447            },
448        )
449        .await
450        .unwrap_err();
451
452        assert_eq!(error.to_string(), "Custom Error: product migration failed");
453        assert!(
454            database
455                .execute_unprepared("INSERT INTO rolled_back (id) VALUES (1)")
456                .await
457                .is_err()
458        );
459    }
460}