Skip to main content

aster_forge_db/
retry.rs

1//! Retry helpers for transient database operations.
2//!
3//! Three levels exist, from preferred to most specific:
4//!
5//! - Multi-statement transactions: [`crate::transaction::with_transaction_retry`] retries the
6//!   whole transaction callback and classifies commit outcomes. This is the only retry level
7//!   that is safe for transactions.
8//! - Idempotent single statements (reads, upserts, deletes by key): [`with_sea_orm_retry`] and
9//!   [`with_sea_orm_retry_timeout`].
10//! - Crate-internal [`DbError`] workflows such as connection setup: [`with_retry`].
11//!
12//! Every retryability decision derives from [`crate::database_error_kind`] and driver-native
13//! error codes; error message text is never inspected. Non-retryable errors return
14//! immediately so application bugs are not hidden behind sleep loops.
15
16use std::time::Duration;
17use tokio::time::{sleep, timeout};
18
19use crate::{DbError, Result};
20
21/// Retry configuration for async database operations.
22///
23/// One config type serves every retry level; construct it through a profile
24/// ([`RetryConfig::connection`], [`RetryConfig::deadlock`]) and override individual fields
25/// when the product needs a different budget.
26#[derive(Clone, Copy, Debug)]
27pub struct RetryConfig {
28    /// Maximum number of retry attempts after the initial attempt.
29    pub max_retries: u32,
30    /// Base exponential-backoff delay in milliseconds.
31    pub base_delay_ms: u64,
32    /// Maximum backoff delay in milliseconds.
33    pub max_delay_ms: u64,
34}
35
36impl RetryConfig {
37    /// Profile for connection setup and acquisition: the peer may need time to recover,
38    /// so back off slowly (3 retries, 100ms base, 5s cap).
39    pub fn connection() -> Self {
40        Self {
41            max_retries: 3,
42            base_delay_ms: 100,
43            max_delay_ms: 5000,
44        }
45    }
46
47    /// Profile for deadlock/serialization-failure retries: lock-wait windows are short,
48    /// so retry quickly (3 retries, 5ms base, 50ms cap).
49    pub fn deadlock() -> Self {
50        Self {
51            max_retries: 3,
52            base_delay_ms: 5,
53            max_delay_ms: 50,
54        }
55    }
56}
57
58impl Default for RetryConfig {
59    /// Defaults to the [`RetryConfig::connection`] profile.
60    fn default() -> Self {
61        Self::connection()
62    }
63}
64
65/// Returns whether a SeaORM error represents a transient database failure.
66///
67/// Connection acquisition and connection failures are always retryable: they happen before
68/// the statement ran, so retrying cannot duplicate work. Query and execution failures are
69/// retried only when [`crate::database_error_kind`] proves a transient locking conflict
70/// (deadlock, serialization failure, lock timeout) from driver-native error codes. Error
71/// message text is never inspected.
72pub fn is_retryable_sea_orm_error(error: &sea_orm::DbErr) -> bool {
73    use sea_orm::DbErr;
74
75    match error {
76        DbErr::ConnectionAcquire(_) | DbErr::Conn(_) => true,
77        _ => crate::database_error_kind(error)
78            .is_some_and(crate::DatabaseErrorKind::is_transient_locking),
79    }
80}
81
82/// Executes a SeaORM operation with shared transient-error classification and backoff.
83///
84/// This is a statement-level escape hatch for idempotent single statements only (reads,
85/// upserts, deletes by key). **Never use it around a multi-statement transaction**: a
86/// deadlock rolls the whole transaction back, and re-running individual statements
87/// afterwards executes them in autocommit mode, producing partial writes. Transactions
88/// belong in [`crate::transaction::with_transaction_retry`], which retries the entire
89/// callback and classifies commit outcomes.
90pub async fn with_sea_orm_retry<F, Fut, T>(
91    operation_name: &str,
92    config: RetryConfig,
93    mut operation: F,
94) -> std::result::Result<T, sea_orm::DbErr>
95where
96    F: FnMut() -> Fut,
97    Fut: std::future::Future<Output = std::result::Result<T, sea_orm::DbErr>>,
98{
99    let mut attempt = 0_u32;
100    loop {
101        match operation().await {
102            Ok(value) => return Ok(value),
103            Err(error) if attempt < config.max_retries && is_retryable_sea_orm_error(&error) => {
104                let delay = calculate_delay(&config, attempt);
105                tracing::warn!(
106                    operation = operation_name,
107                    attempt = attempt + 1,
108                    max_attempts = config.max_retries + 1,
109                    delay_ms = duration_millis_u64(delay),
110                    error = %error,
111                    "retrying SeaORM operation"
112                );
113                sleep(delay).await;
114                attempt += 1;
115            }
116            Err(error) => return Err(error),
117        }
118    }
119}
120
121/// Executes a SeaORM operation with retry and a timeout applied to every attempt.
122///
123/// Same classification and boundary rules as [`with_sea_orm_retry`]; a timed-out attempt
124/// is retried regardless of error classification because the operation produced no outcome.
125/// Only wrap work that stays safe when a timed-out attempt keeps running in the background.
126pub async fn with_sea_orm_retry_timeout<F, Fut, T>(
127    operation_name: &str,
128    config: RetryConfig,
129    attempt_timeout: Duration,
130    mut operation: F,
131) -> std::result::Result<T, sea_orm::DbErr>
132where
133    F: FnMut() -> Fut,
134    Fut: std::future::Future<Output = std::result::Result<T, sea_orm::DbErr>>,
135{
136    let mut attempt = 0_u32;
137    loop {
138        match timeout(attempt_timeout, operation()).await {
139            Ok(Ok(value)) => return Ok(value),
140            Ok(Err(error))
141                if attempt < config.max_retries && is_retryable_sea_orm_error(&error) =>
142            {
143                let delay = calculate_delay(&config, attempt);
144                tracing::warn!(
145                    operation = operation_name,
146                    attempt = attempt + 1,
147                    max_attempts = config.max_retries + 1,
148                    delay_ms = duration_millis_u64(delay),
149                    error = %error,
150                    "retrying SeaORM operation"
151                );
152                sleep(delay).await;
153                attempt += 1;
154            }
155            Ok(Err(error)) => return Err(error),
156            Err(_) if attempt < config.max_retries => {
157                let delay = calculate_delay(&config, attempt);
158                tracing::warn!(
159                    operation = operation_name,
160                    attempt = attempt + 1,
161                    max_attempts = config.max_retries + 1,
162                    timeout_ms = duration_millis_u64(attempt_timeout),
163                    delay_ms = duration_millis_u64(delay),
164                    "SeaORM operation attempt timed out; retrying"
165                );
166                sleep(delay).await;
167                attempt += 1;
168            }
169            Err(_) => {
170                return Err(sea_orm::DbErr::Custom(format!(
171                    "operation '{operation_name}' timed out after {}ms",
172                    duration_millis_u64(attempt_timeout)
173                )));
174            }
175        }
176    }
177}
178
179/// Execute an async operation with exponential backoff retry
180pub async fn with_retry<F, Fut, T>(config: &RetryConfig, operation: F) -> Result<T>
181where
182    F: Fn() -> Fut,
183    Fut: std::future::Future<Output = Result<T>>,
184{
185    let mut last_err = None;
186    for attempt in 0..=config.max_retries {
187        match operation().await {
188            Ok(val) => return Ok(val),
189            Err(e) => {
190                if attempt == config.max_retries || !is_retryable(&e) {
191                    return Err(e);
192                }
193                let delay = calculate_delay(config, attempt);
194                tracing::warn!(
195                    attempt = attempt + 1,
196                    max = config.max_retries,
197                    delay_ms = duration_millis_u64(delay),
198                    error = %e,
199                    "retrying operation"
200                );
201                last_err = Some(e);
202                sleep(delay).await;
203            }
204        }
205    }
206    Err(last_err.unwrap_or(DbError::RetryExhausted))
207}
208
209fn is_retryable(err: &DbError) -> bool {
210    err.is_retryable()
211}
212
213fn calculate_delay(config: &RetryConfig, attempt: u32) -> Duration {
214    use aster_forge_utils::backoff::{cap_delay, exponential_delay, randomized_jitter};
215
216    let raw = exponential_delay(Duration::from_millis(config.base_delay_ms), attempt);
217    cap_delay(
218        randomized_jitter(raw, 50, 150),
219        Duration::from_millis(config.max_delay_ms),
220    )
221}
222
223fn duration_millis_u64(duration: Duration) -> u64 {
224    u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use std::sync::{
231        Arc,
232        atomic::{AtomicUsize, Ordering},
233    };
234
235    fn zero_delay_config(max_retries: u32) -> RetryConfig {
236        RetryConfig {
237            max_retries,
238            base_delay_ms: 0,
239            max_delay_ms: 0,
240        }
241    }
242
243    #[test]
244    fn retryable_error_classification_only_allows_database_errors() {
245        assert!(is_retryable(&DbError::database_connection(
246            "connection lost"
247        )));
248        assert!(is_retryable(&DbError::database_operation_classified(
249            "deadlock",
250            crate::DatabaseErrorKind::Deadlock
251        )));
252        // Unclassified operation errors carry no driver evidence of retry safety.
253        assert!(!is_retryable(&DbError::database_operation("deadlock")));
254        assert!(!is_retryable(&DbError::non_retryable("invalid input")));
255        assert!(!is_retryable(&DbError::non_retryable("forbidden")));
256    }
257
258    #[test]
259    fn sea_orm_retryability_allows_connection_failures_before_any_statement() {
260        let conn_error = sea_orm::DbErr::Conn(sea_orm::error::RuntimeErr::Internal(
261            "connection reset".to_string(),
262        ));
263
264        assert!(is_retryable_sea_orm_error(&conn_error));
265    }
266
267    #[test]
268    fn sea_orm_retryability_rejects_non_driver_errors_without_reading_messages() {
269        // Message text must not influence classification: these mention deadlock but are
270        // not driver database errors, so they are not retryable.
271        assert!(!is_retryable_sea_orm_error(&sea_orm::DbErr::Custom(
272            "deadlock detected".to_string()
273        )));
274        assert!(!is_retryable_sea_orm_error(
275            &sea_orm::DbErr::RecordNotFound("deadlock".to_string())
276        ));
277    }
278
279    #[tokio::test]
280    async fn sea_orm_retryability_classifies_real_sqlite_busy_as_retryable() {
281        use aster_forge_test::temp::SqliteTestDatabase;
282        use sea_orm::{ConnectOptions, ConnectionTrait, SqlxSqliteConnector};
283
284        let database = SqliteTestDatabase::new("retry-busy");
285        let locker = SqlxSqliteConnector::connect(ConnectOptions::new(database.url()))
286            .await
287            .unwrap();
288        let contender = SqlxSqliteConnector::connect(ConnectOptions::new(database.url()))
289            .await
290            .unwrap();
291        contender
292            .execute_unprepared("PRAGMA busy_timeout=0;")
293            .await
294            .unwrap();
295        locker
296            .execute_unprepared("CREATE TABLE items (id INTEGER PRIMARY KEY);")
297            .await
298            .unwrap();
299        locker.execute_unprepared("BEGIN IMMEDIATE;").await.unwrap();
300        locker
301            .execute_unprepared("INSERT INTO items (id) VALUES (1);")
302            .await
303            .unwrap();
304
305        let error = contender
306            .execute_unprepared("INSERT INTO items (id) VALUES (2);")
307            .await
308            .unwrap_err();
309
310        locker.execute_unprepared("ROLLBACK;").await.unwrap();
311        contender.close().await.unwrap();
312        locker.close().await.unwrap();
313
314        assert!(
315            is_retryable_sea_orm_error(&error),
316            "SQLITE_BUSY from a locked database should be retryable, got: {error}"
317        );
318        assert_eq!(
319            crate::database_error_kind(&error),
320            Some(crate::DatabaseErrorKind::LockTimeout)
321        );
322    }
323
324    #[tokio::test]
325    async fn sea_orm_retryability_rejects_sqlite_unique_violation() {
326        use sea_orm::{ConnectionTrait, Database};
327
328        let db = Database::connect("sqlite::memory:").await.unwrap();
329        db.execute_unprepared("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT UNIQUE);")
330            .await
331            .unwrap();
332        db.execute_unprepared("INSERT INTO items (name) VALUES ('a');")
333            .await
334            .unwrap();
335
336        let error = db
337            .execute_unprepared("INSERT INTO items (name) VALUES ('a');")
338            .await
339            .unwrap_err();
340
341        assert!(!is_retryable_sea_orm_error(&error));
342        assert_eq!(
343            crate::database_error_kind(&error),
344            Some(crate::DatabaseErrorKind::UniqueConstraint)
345        );
346    }
347
348    #[test]
349    fn calculate_delay_applies_jitter_and_hard_caps_max_delay() {
350        let config = RetryConfig {
351            max_retries: 3,
352            base_delay_ms: 100,
353            max_delay_ms: 250,
354        };
355
356        let expected_bounds = [(0, 50, 150), (1, 100, 250), (2, 200, 250), (8, 250, 250)];
357
358        for (attempt, min_ms, max_ms) in expected_bounds {
359            for _ in 0..64 {
360                let delay_ms = duration_millis_u64(calculate_delay(&config, attempt));
361                assert!(
362                    (min_ms..=max_ms).contains(&delay_ms),
363                    "attempt {attempt} produced {delay_ms}ms outside [{min_ms}, {max_ms}]"
364                );
365            }
366        }
367    }
368
369    #[test]
370    fn calculate_delay_handles_zero_and_initial_above_max_boundaries() {
371        let zero_base = RetryConfig {
372            max_retries: 1,
373            base_delay_ms: 0,
374            max_delay_ms: 100,
375        };
376        let zero_max = RetryConfig {
377            max_retries: 1,
378            base_delay_ms: 100,
379            max_delay_ms: 0,
380        };
381        let initial_above_max = RetryConfig {
382            max_retries: 1,
383            base_delay_ms: 1_000,
384            max_delay_ms: 250,
385        };
386
387        for attempt in [0, 1, u32::MAX] {
388            assert_eq!(calculate_delay(&zero_base, attempt), Duration::ZERO);
389            assert_eq!(calculate_delay(&zero_max, attempt), Duration::ZERO);
390            assert_eq!(
391                calculate_delay(&initial_above_max, attempt),
392                Duration::from_millis(250)
393            );
394        }
395    }
396
397    #[tokio::test]
398    async fn with_retry_retries_retryable_errors_until_success() {
399        let attempts = Arc::new(AtomicUsize::new(0));
400        let result = {
401            let attempts = Arc::clone(&attempts);
402            with_retry(&zero_delay_config(3), move || {
403                let attempts = Arc::clone(&attempts);
404                async move {
405                    let attempt = attempts.fetch_add(1, Ordering::SeqCst);
406                    if attempt < 2 {
407                        Err(DbError::database_operation_classified(
408                            "deadlock",
409                            crate::DatabaseErrorKind::Deadlock,
410                        ))
411                    } else {
412                        Ok("ok")
413                    }
414                }
415            })
416            .await
417        };
418
419        assert_eq!(result.unwrap(), "ok");
420        assert_eq!(attempts.load(Ordering::SeqCst), 3);
421    }
422
423    #[tokio::test]
424    async fn with_retry_stops_immediately_for_non_retryable_errors() {
425        let attempts = Arc::new(AtomicUsize::new(0));
426        let result = {
427            let attempts = Arc::clone(&attempts);
428            with_retry(&zero_delay_config(3), move || {
429                let attempts = Arc::clone(&attempts);
430                async move {
431                    attempts.fetch_add(1, Ordering::SeqCst);
432                    Err::<(), _>(DbError::non_retryable("bad request"))
433                }
434            })
435            .await
436        };
437
438        assert!(matches!(result.unwrap_err(), DbError::NonRetryable(_)));
439        assert_eq!(attempts.load(Ordering::SeqCst), 1);
440    }
441
442    #[tokio::test]
443    async fn with_retry_stops_after_exhausting_retry_budget() {
444        let attempts = Arc::new(AtomicUsize::new(0));
445        let result = {
446            let attempts = Arc::clone(&attempts);
447            with_retry(&zero_delay_config(2), move || {
448                let attempts = Arc::clone(&attempts);
449                async move {
450                    attempts.fetch_add(1, Ordering::SeqCst);
451                    Err::<(), _>(DbError::database_connection("still failing"))
452                }
453            })
454            .await
455        };
456
457        assert!(matches!(
458            result.unwrap_err(),
459            DbError::DatabaseConnection(_)
460        ));
461        assert_eq!(attempts.load(Ordering::SeqCst), 3);
462    }
463
464    #[tokio::test]
465    async fn with_retry_zero_budget_runs_exactly_one_attempt() {
466        let attempts = Arc::new(AtomicUsize::new(0));
467        let result = {
468            let attempts = Arc::clone(&attempts);
469            with_retry(&zero_delay_config(0), move || {
470                let attempts = Arc::clone(&attempts);
471                async move {
472                    attempts.fetch_add(1, Ordering::SeqCst);
473                    Err::<(), _>(DbError::database_connection("still failing"))
474                }
475            })
476            .await
477        };
478
479        assert!(matches!(
480            result.unwrap_err(),
481            DbError::DatabaseConnection(_)
482        ));
483        assert_eq!(attempts.load(Ordering::SeqCst), 1);
484    }
485}