1use crate::{DbError, Result, database_error_kind};
20use sea_orm::TransactionSession;
21use std::panic::Location;
22use std::{future::Future, ops::AsyncFnOnce, pin::Pin, time::Duration};
23
24struct RollbackGuard {
25 file: &'static str,
26 line: u32,
27 armed: bool,
28}
29
30impl RollbackGuard {
31 fn new(location: &'static Location<'static>) -> Self {
32 Self {
33 file: location.file(),
34 line: location.line(),
35 armed: true,
36 }
37 }
38
39 fn disarm(&mut self) {
40 self.armed = false;
41 }
42}
43
44impl Drop for RollbackGuard {
45 fn drop(&mut self) {
46 if self.armed {
47 tracing::warn!(
48 file = self.file,
49 line = self.line,
50 "transaction dropped before explicit commit/rollback; relying on rollback-on-drop"
51 );
52 }
53 }
54}
55
56pub async fn begin<C: sea_orm::TransactionTrait>(db: &C) -> Result<C::Transaction> {
64 db.begin()
65 .await
66 .map_err(|error| database_operation_with_context(&error, "begin transaction"))
67}
68
69pub async fn commit<T: sea_orm::TransactionSession>(txn: T) -> Result<()> {
75 txn.commit()
76 .await
77 .map_err(|error| database_operation_with_context(&error, "commit transaction"))
78}
79
80pub async fn rollback<T: sea_orm::TransactionSession>(txn: T) -> Result<()> {
86 txn.rollback()
87 .await
88 .map_err(|error| database_operation_with_context(&error, "rollback transaction"))
89}
90
91fn database_operation_with_context(error: &sea_orm::DbErr, context: &str) -> DbError {
92 let kind = database_error_kind(error);
93 let message = format!("{context}: {error}");
94 match kind {
95 Some(kind) => DbError::database_operation_classified(message, kind),
96 None => DbError::database_operation(message),
97 }
98}
99
100fn transaction_delay(config: &crate::retry::RetryConfig, attempt: u32) -> Duration {
101 aster_forge_utils::backoff::cap_delay(
102 aster_forge_utils::backoff::exponential_delay(
103 Duration::from_millis(config.base_delay_ms),
104 attempt,
105 ),
106 Duration::from_millis(config.max_delay_ms),
107 )
108}
109
110#[derive(Clone, Copy, Debug, Eq, PartialEq)]
111enum CommitFailureAction {
112 Retry,
113 ReturnKnownFailure,
114 ReturnOutcomeUnknown,
115}
116
117fn commit_failure_action(
118 retryable: bool,
119 outcome_known_rolled_back: bool,
120 attempt: u32,
121 max_retries: u32,
122) -> CommitFailureAction {
123 if !outcome_known_rolled_back {
124 CommitFailureAction::ReturnOutcomeUnknown
125 } else if retryable {
126 if attempt < max_retries {
127 CommitFailureAction::Retry
128 } else {
129 CommitFailureAction::ReturnKnownFailure
130 }
131 } else {
132 CommitFailureAction::ReturnKnownFailure
133 }
134}
135
136fn commit_outcome_known_rolled_back(kind: Option<crate::DatabaseErrorKind>) -> bool {
137 matches!(
138 kind,
139 Some(crate::DatabaseErrorKind::Deadlock | crate::DatabaseErrorKind::SerializationFailure)
140 )
141}
142
143pub async fn with_transaction_retry<C, F, T, E, P>(
157 db: &C,
158 config: &crate::retry::RetryConfig,
159 mut operation: F,
160 should_retry: P,
161) -> std::result::Result<T, E>
162where
163 C: sea_orm::TransactionTrait,
164 F: for<'txn> FnMut(
165 &'txn C::Transaction,
166 )
167 -> Pin<Box<dyn Future<Output = std::result::Result<T, E>> + Send + 'txn>>,
168 E: From<DbError> + std::fmt::Display,
169 P: Fn(&E) -> bool,
170{
171 let mut attempt = 0;
172 loop {
173 let txn = match db.begin().await {
174 Ok(txn) => txn,
175 Err(error) => {
176 let error = E::from(database_operation_with_context(&error, "begin transaction"));
177 if attempt < config.max_retries && should_retry(&error) {
178 tokio::time::sleep(transaction_delay(config, attempt)).await;
179 attempt += 1;
180 continue;
181 }
182 return Err(error);
183 }
184 };
185
186 match operation(&txn).await {
187 Ok(value) => match txn.commit().await {
188 Ok(()) => return Ok(value),
189 Err(error) => {
190 let kind = database_error_kind(&error);
191 let classified_error = E::from(match kind {
192 Some(kind) => DbError::database_operation_classified(
193 format!("commit transaction: {error}"),
194 kind,
195 ),
196 None => DbError::database_operation(format!("commit transaction: {error}")),
197 });
198 match commit_failure_action(
199 should_retry(&classified_error),
200 commit_outcome_known_rolled_back(kind),
201 attempt,
202 config.max_retries,
203 ) {
204 CommitFailureAction::Retry => {
205 tokio::time::sleep(transaction_delay(config, attempt)).await;
206 attempt += 1;
207 }
208 CommitFailureAction::ReturnKnownFailure => return Err(classified_error),
209 CommitFailureAction::ReturnOutcomeUnknown => {
210 return Err(E::from(DbError::commit_outcome_unknown(
211 format!("commit transaction: {error}"),
212 kind,
213 )));
214 }
215 }
216 }
217 },
218 Err(error) => {
219 if let Err(rollback_error) = txn.rollback().await {
220 tracing::warn!(
221 callback_error = %error,
222 rollback_error = %rollback_error,
223 "transaction rollback failed after callback error"
224 );
225 }
226 if attempt < config.max_retries && should_retry(&error) {
227 tokio::time::sleep(transaction_delay(config, attempt)).await;
228 attempt += 1;
229 continue;
230 }
231 return Err(error);
232 }
233 }
234 }
235}
236
237pub async fn with_transaction<C, F, T, E>(db: &C, operation: F) -> std::result::Result<T, E>
246where
247 C: sea_orm::TransactionTrait,
248 F: for<'txn> AsyncFnOnce(&'txn C::Transaction) -> std::result::Result<T, E>,
249 E: From<DbError> + std::fmt::Display,
250{
251 let location = Location::caller();
252 tracing::debug!(
253 file = location.file(),
254 line = location.line(),
255 "beginning transaction"
256 );
257 let txn = begin(db).await.map_err(E::from)?;
258 let mut rollback_guard = RollbackGuard::new(location);
259
260 match operation(&txn).await {
261 Ok(value) => {
262 rollback_guard.disarm();
263 commit(txn).await.map_err(E::from)?;
264 tracing::debug!(
265 file = location.file(),
266 line = location.line(),
267 "committed transaction"
268 );
269 Ok(value)
270 }
271 Err(error) => {
272 tracing::debug!(
273 file = location.file(),
274 line = location.line(),
275 error = %error,
276 "rolling back transaction after callback error"
277 );
278 rollback_guard.disarm();
279 if let Err(rollback_error) = rollback(txn).await {
280 tracing::error!(
281 file = location.file(),
282 line = location.line(),
283 callback_error = %error,
284 rollback_error = %rollback_error,
285 "transaction rollback failed after callback error"
286 );
287 }
288 Err(error)
289 }
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::{
296 CommitFailureAction, commit_failure_action, commit_outcome_known_rolled_back, rollback,
297 transaction_delay, with_transaction,
298 };
299 use crate::{DbError, connection::DatabaseConfig};
300 use sea_orm::{ConnectionTrait, DatabaseConnection, Statement, TransactionTrait};
301 use std::fmt;
302
303 async fn sqlite_db() -> DatabaseConnection {
304 crate::connection::connect(&DatabaseConfig {
305 url: "sqlite::memory:".into(),
306 pool_size: 1,
307 retry_count: 0,
308 })
309 .await
310 .expect("sqlite memory database should connect")
311 }
312
313 async fn count_rows(db: &DatabaseConnection) -> i64 {
314 let statement = Statement::from_string(
315 sea_orm::DbBackend::Sqlite,
316 "SELECT COUNT(*) FROM transaction_items",
317 );
318 let row = db
319 .query_one_raw(statement)
320 .await
321 .expect("count query should succeed")
322 .expect("count query should return one row");
323 row.try_get_by_index(0).expect("count should decode")
324 }
325
326 #[derive(Debug, PartialEq, Eq)]
327 enum ProductError {
328 Db(String),
329 Validation(&'static str),
330 }
331
332 impl From<DbError> for ProductError {
333 fn from(value: DbError) -> Self {
334 Self::Db(value.to_string())
335 }
336 }
337
338 impl fmt::Display for ProductError {
339 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
340 match self {
341 Self::Db(message) => formatter.write_str(message),
342 Self::Validation(message) => formatter.write_str(message),
343 }
344 }
345 }
346
347 #[tokio::test]
348 async fn with_transaction_commits_successful_callback() {
349 let db = sqlite_db().await;
350 db.execute_unprepared("CREATE TABLE transaction_items (id INTEGER PRIMARY KEY);")
351 .await
352 .expect("table should be created");
353
354 let value = with_transaction(&db, async |txn| {
355 txn.execute_unprepared("INSERT INTO transaction_items (id) VALUES (1);")
356 .await
357 .map_err(DbError::from)?;
358 Ok::<_, DbError>("committed")
359 })
360 .await
361 .expect("transaction should commit");
362
363 assert_eq!(value, "committed");
364 assert_eq!(count_rows(&db).await, 1);
365 }
366
367 #[tokio::test]
368 async fn with_transaction_rolls_back_callback_error() {
369 let db = sqlite_db().await;
370 db.execute_unprepared("CREATE TABLE transaction_items (id INTEGER PRIMARY KEY);")
371 .await
372 .expect("table should be created");
373
374 let error = with_transaction(&db, async |txn| {
375 txn.execute_unprepared("INSERT INTO transaction_items (id) VALUES (1);")
376 .await
377 .map_err(DbError::from)?;
378 Err::<(), _>(DbError::database_operation("forced failure"))
379 })
380 .await
381 .expect_err("callback error should propagate");
382
383 assert!(matches!(error, DbError::DatabaseOperation(_)));
384 assert_eq!(count_rows(&db).await, 0);
385 }
386
387 #[tokio::test]
388 async fn with_transaction_preserves_product_callback_errors() {
389 let db = sqlite_db().await;
390 db.execute_unprepared("CREATE TABLE transaction_items (id INTEGER PRIMARY KEY);")
391 .await
392 .expect("table should be created");
393
394 let error = with_transaction(&db, async |txn| {
395 txn.execute_unprepared("INSERT INTO transaction_items (id) VALUES (1);")
396 .await
397 .map_err(DbError::from)
398 .map_err(ProductError::from)?;
399 Err::<(), _>(ProductError::Validation("business validation failed"))
400 })
401 .await
402 .expect_err("callback error should propagate");
403
404 assert_eq!(
405 error,
406 ProductError::Validation("business validation failed")
407 );
408 assert_eq!(count_rows(&db).await, 0);
409 }
410
411 #[tokio::test]
412 async fn with_transaction_retry_restarts_after_callback_failure() {
413 use std::sync::{
414 Arc,
415 atomic::{AtomicUsize, Ordering},
416 };
417
418 let db = sqlite_db().await;
419 let attempts = Arc::new(AtomicUsize::new(0));
420 let config = crate::retry::RetryConfig {
421 max_retries: 2,
422 base_delay_ms: 0,
423 max_delay_ms: 0,
424 };
425 let result = {
426 let attempts = Arc::clone(&attempts);
427 super::with_transaction_retry(
428 &db,
429 &config,
430 move |_txn| {
431 let attempts = Arc::clone(&attempts);
432 Box::pin(async move {
433 let attempt = attempts.fetch_add(1, Ordering::SeqCst);
434 if attempt < 2 {
435 Err(DbError::database_operation("temporary failure"))
436 } else {
437 Ok::<_, DbError>("ok")
438 }
439 })
440 },
441 |error| matches!(error, DbError::DatabaseOperation(_)),
442 )
443 .await
444 };
445
446 assert_eq!(result.unwrap(), "ok");
447 assert_eq!(attempts.load(Ordering::SeqCst), 3);
448 }
449
450 #[test]
451 fn transaction_delay_doubles_caps_and_handles_boundaries() {
452 let config = crate::retry::RetryConfig {
453 max_retries: 3,
454 base_delay_ms: 100,
455 max_delay_ms: 250,
456 };
457 assert_eq!(
458 transaction_delay(&config, 0),
459 std::time::Duration::from_millis(100)
460 );
461 assert_eq!(
462 transaction_delay(&config, 1),
463 std::time::Duration::from_millis(200)
464 );
465 assert_eq!(
466 transaction_delay(&config, 2),
467 std::time::Duration::from_millis(250)
468 );
469 assert_eq!(
470 transaction_delay(&config, u32::MAX),
471 std::time::Duration::from_millis(250)
472 );
473
474 let zero_max = crate::retry::RetryConfig {
475 max_retries: 1,
476 base_delay_ms: 100,
477 max_delay_ms: 0,
478 };
479 assert_eq!(transaction_delay(&zero_max, 0), std::time::Duration::ZERO);
480
481 let initial_above_max = crate::retry::RetryConfig {
482 max_retries: 1,
483 base_delay_ms: 1_000,
484 max_delay_ms: 250,
485 };
486 assert_eq!(
487 transaction_delay(&initial_above_max, 0),
488 std::time::Duration::from_millis(250)
489 );
490 }
491
492 #[tokio::test]
493 async fn with_transaction_retry_zero_budget_runs_callback_once() {
494 use std::sync::{
495 Arc,
496 atomic::{AtomicUsize, Ordering},
497 };
498
499 let db = sqlite_db().await;
500 let attempts = Arc::new(AtomicUsize::new(0));
501 let config = crate::retry::RetryConfig {
502 max_retries: 0,
503 base_delay_ms: 0,
504 max_delay_ms: 0,
505 };
506 let error = {
507 let attempts = attempts.clone();
508 super::with_transaction_retry(
509 &db,
510 &config,
511 move |_txn| {
512 let attempts = attempts.clone();
513 Box::pin(async move {
514 attempts.fetch_add(1, Ordering::SeqCst);
515 Err::<(), _>(DbError::database_operation("temporary failure"))
516 })
517 },
518 |error| matches!(error, DbError::DatabaseOperation(_)),
519 )
520 .await
521 .unwrap_err()
522 };
523
524 assert!(matches!(error, DbError::DatabaseOperation(_)));
525 assert_eq!(attempts.load(Ordering::SeqCst), 1);
526 }
527
528 #[test]
529 fn commit_failure_action_preserves_known_exhausted_deadlocks() {
530 assert_eq!(
531 commit_failure_action(true, true, 0, 3),
532 CommitFailureAction::Retry
533 );
534 assert_eq!(
535 commit_failure_action(true, true, 3, 3),
536 CommitFailureAction::ReturnKnownFailure
537 );
538 assert_eq!(
539 commit_failure_action(false, false, 0, 3),
540 CommitFailureAction::ReturnOutcomeUnknown
541 );
542 assert_eq!(
543 commit_failure_action(true, false, 0, 3),
544 CommitFailureAction::ReturnOutcomeUnknown
545 );
546 assert_eq!(
547 commit_failure_action(false, true, 0, 3),
548 CommitFailureAction::ReturnKnownFailure
549 );
550 assert!(commit_outcome_known_rolled_back(Some(
551 crate::DatabaseErrorKind::Deadlock
552 )));
553 assert!(commit_outcome_known_rolled_back(Some(
554 crate::DatabaseErrorKind::SerializationFailure
555 )));
556 assert!(!commit_outcome_known_rolled_back(Some(
557 crate::DatabaseErrorKind::LockTimeout
558 )));
559 }
560
561 #[tokio::test]
562 async fn rollback_helper_discards_pending_changes() {
563 let db = sqlite_db().await;
564 db.execute_unprepared("CREATE TABLE transaction_items (id INTEGER PRIMARY KEY);")
565 .await
566 .expect("table should be created");
567 let txn = db.begin().await.expect("transaction should begin");
568 txn.execute_unprepared("INSERT INTO transaction_items (id) VALUES (1);")
569 .await
570 .expect("insert should succeed");
571
572 rollback(txn).await.expect("rollback should succeed");
573
574 assert_eq!(count_rows(&db).await, 0);
575 }
576}