aster_forge_db_migration/
coordination.rs1use 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
14pub type MigrationFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, DbErr>> + Send + 'a>>;
16
17#[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 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 #[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 #[must_use]
52 pub const fn with_postgres_transaction(mut self, enabled: bool) -> Self {
53 self.postgres_transaction = enabled;
54 self
55 }
56
57 #[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 #[must_use]
66 pub fn namespace(&self) -> &str {
67 &self.namespace
68 }
69
70 #[must_use]
72 pub const fn postgres_advisory_key(&self) -> i64 {
73 self.postgres_advisory_key
74 }
75
76 #[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
100pub 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 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 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
253pub 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}