Skip to main content

modkit_db/
deadlock.rs

1//! Deadlock and serialization failure detection utilities.
2//!
3//! > "Always be prepared to re-issue a transaction if it fails due to deadlock.
4//! > Deadlocks are not dangerous. Just try again."
5//! > — [`MySQL` 8.0 Reference Manual, `InnoDB` Deadlocks](https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlocks.html)
6//!
7//! `InnoDB` detects deadlocks instantly and rolls back one transaction (the victim).
8//! SQLSTATE `40001` signals a serialization failure that is always safe to retry.
9//! This module provides detection helpers for use by callers that manage their
10//! own transaction lifecycle (e.g., the outbox sequencer, hierarchy mutations).
11
12use sea_orm::DbErr;
13
14/// SQLSTATE `40001` — serialization failure / deadlock.
15///
16/// This code is used by both `PostgreSQL` (for serialization failures under
17/// `SERIALIZABLE` isolation) and `MySQL`/`MariaDB` (for `InnoDB` deadlocks).
18const SERIALIZATION_FAILURE_SQLSTATE: &str = "40001";
19
20/// `PostgreSQL` error message substring for serialization failures.
21///
22/// `PostgreSQL` reports `could not serialize access` when a `SERIALIZABLE`
23/// transaction detects a read/write dependency conflict.
24const PG_SERIALIZATION_MSG: &str = "could not serialize access";
25
26/// Returns `true` if the error contains SQLSTATE `40001`.
27///
28/// This matches both `MySQL`/`MariaDB` deadlocks and `PostgreSQL` serialization
29/// failures. It does **not** distinguish between the two — both are retryable.
30/// [`is_serialization_failure`] broadens detection by also matching the
31/// `PostgreSQL` error message text for cases where the SQLSTATE is absent.
32///
33/// Always returns `false` for non-runtime errors (`Custom`, `RecordNotFound`, etc.)
34/// and for `SQLite` errors (single-writer model, no SQLSTATE `40001`).
35///
36/// Detection is based on the error's string representation containing the
37/// SQLSTATE code, which avoids a direct dependency on `sqlx` types.
38#[must_use]
39pub fn is_deadlock(err: &DbErr) -> bool {
40    match err {
41        DbErr::Exec(runtime_err) | DbErr::Query(runtime_err) => {
42            let msg = runtime_err.to_string();
43            msg.contains(SERIALIZATION_FAILURE_SQLSTATE)
44        }
45        _ => false,
46    }
47}
48
49/// Returns `true` if the error is a retryable serialization failure.
50///
51/// This is a superset of [`is_deadlock`] — it matches SQLSTATE `40001` **and**
52/// the `PostgreSQL` `could not serialize access` message text.  Both deadlocks
53/// and serialization conflicts are retryable, and this function does not
54/// distinguish between them.
55///
56/// Coverage:
57/// - **`PostgreSQL`**: `SERIALIZABLE` isolation conflicts
58///   (`could not serialize access`, SQLSTATE `40001`)
59/// - **`MySQL`/`MariaDB`**: `InnoDB` deadlocks (SQLSTATE `40001`)
60/// - **`SQLite`**: Always `false` (single-writer model, no serialization failures)
61///
62/// Use this to implement bounded retry around `SERIALIZABLE` transactions.
63///
64/// Detection is based on the error's string representation to avoid a direct
65/// dependency on `sqlx` types.
66#[must_use]
67pub fn is_serialization_failure(err: &DbErr) -> bool {
68    match err {
69        DbErr::Exec(runtime_err) | DbErr::Query(runtime_err) => {
70            let msg = runtime_err.to_string();
71            msg.contains(SERIALIZATION_FAILURE_SQLSTATE) || msg.contains(PG_SERIALIZATION_MSG)
72        }
73        _ => false,
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use sea_orm::RuntimeErr;
81
82    fn exec_err(msg: &str) -> DbErr {
83        DbErr::Exec(RuntimeErr::Internal(msg.to_owned()))
84    }
85
86    // -- is_deadlock positive cases --
87
88    #[test]
89    fn deadlock_sqlstate_40001_detected() {
90        assert!(is_deadlock(&exec_err(
91            "error returned from database: 40001: Deadlock found"
92        )));
93    }
94
95    #[test]
96    fn deadlock_pg_serialization_failure_detected() {
97        assert!(is_deadlock(&exec_err(
98            "ERROR: 40001: could not serialize access"
99        )));
100    }
101
102    // -- is_deadlock negative cases --
103
104    #[test]
105    fn non_deadlock_errors_return_false() {
106        assert!(!is_deadlock(&DbErr::Custom("something".into())));
107        assert!(!is_deadlock(&DbErr::RecordNotFound("x".into())));
108        assert!(!is_deadlock(&exec_err("duplicate key value")));
109    }
110
111    // -- is_serialization_failure positive cases --
112
113    #[test]
114    fn serialization_failure_sqlstate_detected() {
115        assert!(is_serialization_failure(&exec_err(
116            "error returned from database: 40001"
117        )));
118    }
119
120    #[test]
121    fn serialization_failure_pg_message_detected() {
122        assert!(is_serialization_failure(&exec_err(
123            "ERROR: could not serialize access due to concurrent update"
124        )));
125    }
126
127    // -- is_serialization_failure negative cases --
128
129    #[test]
130    fn non_serialization_errors_return_false() {
131        assert!(!is_serialization_failure(&DbErr::Custom(
132            "something".into()
133        )));
134        assert!(!is_serialization_failure(&exec_err("unique constraint")));
135    }
136}