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 mysql_timeout_seconds: u64,
23}
24
25impl MigrationLockOptions {
26 pub fn new(namespace: impl Into<String>) -> Self {
31 let namespace = namespace.into();
32 Self {
33 postgres_advisory_key: stable_advisory_key(&namespace),
34 namespace,
35 mysql_timeout_seconds: DEFAULT_MYSQL_LOCK_TIMEOUT_SECONDS,
36 }
37 }
38
39 #[must_use]
41 pub const fn with_postgres_advisory_key(mut self, key: i64) -> Self {
42 self.postgres_advisory_key = key;
43 self
44 }
45
46 #[must_use]
48 pub const fn with_mysql_timeout_seconds(mut self, seconds: u64) -> Self {
49 self.mysql_timeout_seconds = seconds;
50 self
51 }
52
53 #[must_use]
55 pub fn namespace(&self) -> &str {
56 &self.namespace
57 }
58
59 #[must_use]
61 pub const fn postgres_advisory_key(&self) -> i64 {
62 self.postgres_advisory_key
63 }
64
65 #[must_use]
67 pub const fn mysql_timeout_seconds(&self) -> u64 {
68 self.mysql_timeout_seconds
69 }
70
71 fn validate(&self) -> Result<(), DbErr> {
72 if self.namespace.is_empty() || self.namespace.len() > MYSQL_LOCK_NAME_MAX_BYTES {
73 return Err(DbErr::Custom(format!(
74 "migration lock namespace must contain 1-{MYSQL_LOCK_NAME_MAX_BYTES} bytes"
75 )));
76 }
77 if self.namespace.contains('\0') {
78 return Err(DbErr::Custom(
79 "migration lock namespace must not contain NUL bytes".to_string(),
80 ));
81 }
82 i64::try_from(self.mysql_timeout_seconds).map_err(|_| {
83 DbErr::Custom("MySQL migration lock timeout exceeds signed 64-bit range".to_string())
84 })?;
85 Ok(())
86 }
87}
88
89pub async fn with_migration_lock<T, F>(
100 database: &DatabaseConnection,
101 options: &MigrationLockOptions,
102 operation: F,
103) -> Result<T, DbErr>
104where
105 F: for<'a> FnOnce(DatabaseExecutor<'a>) -> MigrationFuture<'a, T>,
106{
107 options.validate()?;
108 match database.get_database_backend() {
109 DbBackend::Postgres => {
110 run_transactional_migration(database, options, operation, true).await
111 }
112 DbBackend::Sqlite => run_transactional_migration(database, options, operation, false).await,
113 DbBackend::MySql => run_mysql_migration(database, options, operation).await,
114 _ => Err(DbErr::Custom(
115 "unsupported database backend for migration coordination".to_string(),
116 )),
117 }
118}
119
120async fn run_transactional_migration<T, F>(
121 database: &DatabaseConnection,
122 options: &MigrationLockOptions,
123 operation: F,
124 acquire_postgres_advisory_lock: bool,
125) -> Result<T, DbErr>
126where
127 F: for<'a> FnOnce(DatabaseExecutor<'a>) -> MigrationFuture<'a, T>,
128{
129 let transaction = database.begin().await?;
130 if acquire_postgres_advisory_lock {
131 acquire_postgres_lock(&transaction, options).await?;
132 }
133 let operation_result = operation((&transaction).into()).await;
134
135 match operation_result {
136 Ok(value) => {
137 transaction.commit().await?;
138 Ok(value)
139 }
140 Err(error) => rollback_preserving_error(transaction, error).await,
141 }
142}
143
144async fn run_mysql_migration<T, F>(
145 database: &DatabaseConnection,
146 options: &MigrationLockOptions,
147 operation: F,
148) -> Result<T, DbErr>
149where
150 F: for<'a> FnOnce(DatabaseExecutor<'a>) -> MigrationFuture<'a, T>,
151{
152 let dedicated_database = create_mysql_migration_connection(database).await?;
153 acquire_mysql_lock(&dedicated_database, options).await?;
154 let operation_result = operation((&dedicated_database).into()).await;
155 let release_result = release_mysql_lock(&dedicated_database, options).await;
156
157 let result = match (operation_result, release_result) {
158 (Ok(value), Ok(())) => Ok(value),
159 (Err(error), Ok(())) => Err(error),
160 (Ok(_), Err(release_error)) => Err(release_error),
161 (Err(operation_error), Err(release_error)) => Err(DbErr::Custom(format!(
162 "migration operation failed: {operation_error}; additionally failed to release \
163 the MySQL migration lock: {release_error}"
164 ))),
165 };
166
167 if let Err(close_error) = dedicated_database.close().await {
168 tracing::warn!(%close_error, "failed to close dedicated MySQL migration connection");
169 }
170 result
171}
172
173async fn create_mysql_migration_connection(
174 database: &DatabaseConnection,
175) -> Result<DatabaseConnection, DbErr> {
176 let source_pool = database.get_mysql_connection_pool();
177 let connect_options = source_pool.connect_options();
178 let dedicated_pool = source_pool
179 .options()
180 .clone()
181 .max_connections(1)
182 .min_connections(1)
183 .idle_timeout(None)
184 .max_lifetime(None)
185 .test_before_acquire(false)
186 .before_acquire(|_, _| Box::pin(async { Ok(true) }))
187 .after_release(|_, _| Box::pin(async { Ok(true) }))
188 .connect_with((*connect_options).clone())
189 .await
190 .map_err(|error| DbErr::Conn(RuntimeErr::SqlxError(Arc::new(error))))?;
191 Ok(dedicated_pool.into())
192}
193
194pub async fn run_migrator_with_lock<M>(
200 database: &DatabaseConnection,
201 options: &MigrationLockOptions,
202 steps: Option<u32>,
203) -> Result<(), DbErr>
204where
205 M: MigratorTrait + 'static,
206{
207 with_migration_lock(database, options, |connection| {
208 Box::pin(M::up(connection, steps))
209 })
210 .await
211}
212
213async fn acquire_postgres_lock(
214 transaction: &DatabaseTransaction,
215 options: &MigrationLockOptions,
216) -> Result<(), DbErr> {
217 transaction
218 .query_one_raw(Statement::from_sql_and_values(
219 DbBackend::Postgres,
220 "SELECT pg_advisory_xact_lock($1)",
221 [options.postgres_advisory_key.into()],
222 ))
223 .await?;
224 Ok(())
225}
226
227async fn acquire_mysql_lock(
228 connection: &DatabaseConnection,
229 options: &MigrationLockOptions,
230) -> Result<(), DbErr> {
231 let timeout = i64::try_from(options.mysql_timeout_seconds).map_err(|_| {
232 DbErr::Custom("MySQL migration lock timeout exceeds signed 64-bit range".to_string())
233 })?;
234 let acquired = mysql_lock_query_result(
235 connection,
236 "SELECT GET_LOCK(?, ?)",
237 [options.namespace.clone().into(), timeout.into()],
238 "acquire",
239 )
240 .await?;
241 if acquired {
242 Ok(())
243 } else {
244 Err(DbErr::Custom(format!(
245 "timed out after {} seconds waiting for MySQL migration lock '{}'",
246 options.mysql_timeout_seconds, options.namespace
247 )))
248 }
249}
250
251async fn release_mysql_lock(
252 connection: &DatabaseConnection,
253 options: &MigrationLockOptions,
254) -> Result<(), DbErr> {
255 let released = mysql_lock_query_result(
256 connection,
257 "SELECT RELEASE_LOCK(?)",
258 [options.namespace.clone().into()],
259 "release",
260 )
261 .await?;
262 if released {
263 Ok(())
264 } else {
265 Err(DbErr::Custom(format!(
266 "MySQL migration lock '{}' was not owned by the migration connection",
267 options.namespace
268 )))
269 }
270}
271
272async fn mysql_lock_query_result<C, const N: usize>(
273 connection: &C,
274 sql: &str,
275 values: [sea_orm_migration::sea_orm::Value; N],
276 operation: &str,
277) -> Result<bool, DbErr>
278where
279 C: ConnectionTrait,
280{
281 let row = connection
282 .query_one_raw(Statement::from_sql_and_values(
283 DbBackend::MySql,
284 sql,
285 values,
286 ))
287 .await?
288 .ok_or_else(|| {
289 DbErr::Custom(format!(
290 "MySQL migration lock {operation} query returned no rows"
291 ))
292 })?;
293
294 if let Ok(value) = row.try_get_by_index::<Option<i64>>(0) {
295 return value.map(|value| value == 1).ok_or_else(|| {
296 DbErr::Custom(format!(
297 "MySQL migration lock {operation} query returned NULL"
298 ))
299 });
300 }
301 if let Ok(value) = row.try_get_by_index::<Option<i32>>(0) {
302 return value.map(|value| value == 1).ok_or_else(|| {
303 DbErr::Custom(format!(
304 "MySQL migration lock {operation} query returned NULL"
305 ))
306 });
307 }
308
309 Err(DbErr::Custom(format!(
310 "failed to decode MySQL migration lock {operation} result"
311 )))
312}
313
314async fn rollback_preserving_error<T>(
315 transaction: DatabaseTransaction,
316 error: DbErr,
317) -> Result<T, DbErr> {
318 if let Err(rollback_error) = transaction.rollback().await {
319 tracing::warn!(%rollback_error, "failed to rollback migration transaction after callback error");
320 }
321 Err(error)
322}
323
324fn stable_advisory_key(namespace: &str) -> i64 {
325 const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
326 const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
327
328 let hash = namespace.bytes().fold(FNV_OFFSET_BASIS, |hash, byte| {
329 (hash ^ u64::from(byte)).wrapping_mul(FNV_PRIME)
330 });
331 i64::from_be_bytes(hash.to_be_bytes())
332}
333
334#[cfg(test)]
335mod tests {
336 use sea_orm_migration::sea_orm::{ConnectionTrait, Database, DbErr};
337
338 use super::{MigrationLockOptions, stable_advisory_key, with_migration_lock};
339
340 #[test]
341 fn advisory_key_is_stable_and_namespace_sensitive() {
342 assert_eq!(
343 stable_advisory_key("aster_drive:database_migrations"),
344 stable_advisory_key("aster_drive:database_migrations")
345 );
346 assert_ne!(
347 stable_advisory_key("aster_drive:database_migrations"),
348 stable_advisory_key("aster_yggdrasil:database_migrations")
349 );
350 }
351
352 #[test]
353 fn migration_lock_options_validate_namespace_boundaries() {
354 assert!(MigrationLockOptions::new("a").validate().is_ok());
355 assert!(MigrationLockOptions::new("a".repeat(64)).validate().is_ok());
356 assert!(MigrationLockOptions::new("").validate().is_err());
357 assert!(
358 MigrationLockOptions::new("a".repeat(65))
359 .validate()
360 .is_err()
361 );
362 assert!(MigrationLockOptions::new("bad\0name").validate().is_err());
363 }
364
365 #[tokio::test]
366 async fn sqlite_callback_commits_on_success() {
367 let database = Database::connect("sqlite::memory:").await.unwrap();
368 with_migration_lock(
369 &database,
370 &MigrationLockOptions::new("sqlite-success"),
371 |transaction| {
372 Box::pin(async move {
373 transaction
374 .execute_unprepared("CREATE TABLE example (id INTEGER PRIMARY KEY)")
375 .await?;
376 Ok(())
377 })
378 },
379 )
380 .await
381 .unwrap();
382
383 database
384 .execute_unprepared("INSERT INTO example (id) VALUES (1)")
385 .await
386 .unwrap();
387 }
388
389 #[tokio::test]
390 async fn sqlite_callback_error_rolls_back_and_is_preserved() {
391 let database = Database::connect("sqlite::memory:").await.unwrap();
392 let error = with_migration_lock(
393 &database,
394 &MigrationLockOptions::new("sqlite-error"),
395 |transaction| {
396 Box::pin(async move {
397 transaction
398 .execute_unprepared("CREATE TABLE rolled_back (id INTEGER PRIMARY KEY)")
399 .await?;
400 Err::<(), _>(DbErr::Custom("product migration failed".to_string()))
401 })
402 },
403 )
404 .await
405 .unwrap_err();
406
407 assert_eq!(error.to_string(), "Custom Error: product migration failed");
408 assert!(
409 database
410 .execute_unprepared("INSERT INTO rolled_back (id) VALUES (1)")
411 .await
412 .is_err()
413 );
414 }
415}