Lifetime issue - Axum layer/extension + DB transaction

I am trying to create an Axum layer which begins a database transaction, passes into the request handler via the request extension, and then commits or rolls the transaction back once the request is complete. This is similar to what the axum_sqlx_tx crate is doing, but I am using a wrapper around the DB connection pool which allows me to change the DB engine at runtime, rather than use sqlx directly.

I am struggling to get it to compile though. Here is my axum extension:

/*01*/    async fn db_middleware<'pool, 'tx>(
/*02*/        State(state): State<AppState<'pool>>,
/*03*/        mut request: Request,
/*04*/        next: Next,
/*05*/    ) -> Response {
/*06*/        let pool = state.pool.clone();
/*07*/        let f = pool.get_connection();
/*08*/        let conn_res = f.await;
/*09*/        let mut conn = match conn_res {
/*10*/            Ok(conn) => conn,
/*11*/            Err(err) => return AppErrorWrapper(AppError::UnhandledDbError(err)).into_response(),
/*12*/    };
/*13*/    {
/*14*/        let mut tx = match conn.begin().await {
/*15*/            Ok(tx) => tx,
/*16*/            Err(err) => return AppErrorWrapper(AppError::UnhandledDbError(err)).into_response(),
/*17*/        };
/*18*/
/*19*/        request.extensions_mut().insert(&mut tx); // <-- error goes away if I comment this line out
/*20*/
/*21*/        let response = next.run(request).await;
/*22*/
/*23*/        if response.status().is_success() {
/*24*/            if let Err(err) = tx.commit().await {
/*25*/                return AppErrorWrapper(AppError::UnhandledDbError(err)).into_response();
/*26*/            }
/*27*/        } else {
/*28*/            // ignore rollback errors
/*29*/            let _ = tx.rollback().await;
/*30*/        }
/*31*/        response
/*32*/    }
/*33*/}

The errors are related to lifetimes:

  • line 19
    • tx does not live long enough
  • on line 6:
    • borrowed data escapes outside of function - __arg0 escapes the function body here [E0521]
    • borrowed data escapes outside of function - argument requires that 'pool must outlive 'static
  • line 14:
    • *conn does not live long enough - borrowed value does not live long enough [E0597]
    • argument requires that *conn is borrowed for 'static
  • line 24:
    • cannot move out of tx because it is borrowed - move out of tx occurs here

These errors all go away if I comment out line 19.

The AppState struct & companion structs / traits are as follows:

pub struct AppState<'pool> {
    pub pool: Arc<dyn Pool<'pool> + Send + Sync>,
}
#[async_trait]
pub trait Pool<'pool> {
    fn db_pool(&self) -> &DbPool;
    async fn get_connection<'conn>(&self) -> Result<TConnection<'conn>, DatabaseError>
    where
        'pool: 'conn;
}
pub enum DbPool {
    Postgres(SqlxPool<SqlxPostgres>),
    MySql(SqlxPool<SqlxMySql>),
    Sqlite(SqlxPool<SqlxSqlite>),
}


#[async_trait]
pub trait Connection<'conn> {
    fn db_connection(&self) -> &DbConnection;
    async fn begin<'tx>(&'tx mut self) -> Result<TTransaction<'tx>, DatabaseError>
    where
        'conn: 'tx;
}
pub enum DbConnection {
    Postgres(SqlxPoolConnection<SqlxPostgres>),
    MySql(SqlxPoolConnection<SqlxMySql>),
    Sqlite(SqlxPoolConnection<SqlxSqlite>),
}
pub type TConnection<'conn> = Box<dyn Connection<'conn> + Send + Sync + 'conn>;

#[async_trait]
pub trait Transaction<'tx> {
    fn db_tx(&self) -> &DbTransaction;
    fn db_tx_mut(&mut self) -> &mut DbTransaction<'tx>;
    async fn commit(self: Box<Self>) -> Result<(), DatabaseError>;
    async fn rollback(self: Box<Self>) -> Result<(), DatabaseError>;
}
pub enum DbTransaction<'tx> {
    Postgres(SqlxTransaction<'tx, SqlxPostgres>),
    MySql(SqlxTransaction<'tx, SqlxMySql>),
    Sqlite(SqlxTransaction<'tx, SqlxSqlite>),
}
pub type TTransaction<'tx> = Box<dyn Transaction<'tx> + Send + Sync + 'tx>;


Then with implementations for the different DB types (mostly using sqlx currently), for instance:

pub struct PostgresConnection {
    conn: DbConnection,
}

#[async_trait]
impl<'conn> Connection<'conn> for PostgresConnection {
    fn db_connection(&self) -> &DbConnection {
        &self.conn
    }

    async fn begin<'tx>(&'tx mut self) -> Result<TTransaction<'tx>, DatabaseError>
    where
        'conn: 'tx,
    {
        match &mut self.conn {
            DbConnection::Postgres(c) => {
                let tx = c.begin().await?;
                Ok(Box::new(PostgresTransaction {
                    transaction: DbTransaction::Postgres(tx),
                }))
            }
            _ => Err(DatabaseError::UnexpectedDatabase),
        }
    }
}

Am I right in thinking that the error is because I am passing passing a borrowed value into a value which is used in a nested async block - but all values passed into a nested async block must be 'static, because of the possibility the parent async block may be terminated by the scheduler before the child completes?

What is the best way to work around this (ideally without making all the lifetimes in my structs/traits 'static?)

We can't see line numbers in the code you posted. If you post the full error from running cargo in the terminal, we'll be able to see the errors alongside the code they refer to.

1 Like

My apologies - I was trying to keep the code digestible.

I updated the fragment to include line numbers corresponding to the errors.

The full (relevant parts of my) code exhibiting the issue, including the error message, are in this Gist: Rust lifetime issue · GitHub

Full compiler output:

error[E0597]: `*conn` does not live long enough
  --> src/lib.rs:33:28
   |
28 |     let mut conn = match conn_res {
   |         -------- binding `conn` declared here
...
33 |         let mut tx = match conn.begin().await {
   |                            ^^^^--------
   |                            |
   |                            borrowed value does not live long enough
   |                            argument requires that `*conn` is borrowed for `'static`
...
57 | }
   | - `*conn` dropped here while still borrowed

error[E0597]: `tx` does not live long enough
  --> src/lib.rs:39:47
   |
33 |         let mut tx = match conn.begin().await {
   |             ------ binding `tx` declared here
...
39 |             let tx_m: &mut TTransaction<'_> = tx.borrow_mut();
   |                                               ^^-------------
   |                                               |
   |                                               borrowed value does not live long enough
   |                                               argument requires that `tx` is borrowed for `'static`
...
56 |     }
   |     - `tx` dropped here while still borrowed

error[E0505]: cannot move out of `tx` because it is borrowed
  --> src/lib.rs:48:31
   |
33 |         let mut tx = match conn.begin().await {
   |             ------ binding `tx` declared here
...
39 |             let tx_m: &mut TTransaction<'_> = tx.borrow_mut();
   |                                               ---------------
   |                                               |
   |                                               borrow of `tx` occurs here
   |                                               argument requires that `tx` is borrowed for `'static`
...
48 |             if let Err(err) = tx.commit().await {
   |                               ^^ move out of `tx` occurs here

error[E0505]: cannot move out of `tx` because it is borrowed
  --> src/lib.rs:53:21
   |
33 |         let mut tx = match conn.begin().await {
   |             ------ binding `tx` declared here
...
39 |             let tx_m: &mut TTransaction<'_> = tx.borrow_mut();
   |                                               ---------------
   |                                               |
   |                                               borrow of `tx` occurs here
   |                                               argument requires that `tx` is borrowed for `'static`
...
53 |             let _ = tx.rollback().await;
   |                     ^^ move out of `tx` occurs here

error[E0521]: borrowed data escapes outside of function
  --> src/lib.rs:25:16
   |
20 | async fn db_middleware<'pool, 'tx>(
   |                        ----- lifetime `'pool` defined here
21 |     State(state): State<AppState<'pool>>,
   |     ------------ `__arg0` is a reference that is only valid in the function body
...
25 |     let pool = state.pool.clone();
   |                ^^^^^^^^^^^^^^^^^^
   |                |
   |                `__arg0` escapes the function body here
   |                argument requires that `'pool` must outlive `'static`

There's a lot going on here. Let's break it down to the basics:

  1. The transaction is committed or rolled back after the next layer handler is finished running, meaning that all borrows need to be dropped at the point.
  2. The 'static constraint is coming from Extensions::insert(). This means that extensions are not allowed to contain temporary references (borrows).
  3. You can wrap the transaction in an Arc<Mutex> and then assert that all of the other middleware layers have dropped their references with Arc::into_inner() by the time you get to the commit/rollback. BUT attempting this will trip over the same 'static constraint due to the lifetime on Transaction<'_>.

No. The 'static lifetime is inferred by passing the borrow to the insert() method. The code in OP does not make this clear, and the error message is trying to point you that way. But the docs for the insert() method perfectly shows what's going on.

In short, satisfying the 'static constraint imposed by the extensions API with the Transaction<'_> type requires extending that "inner" lifetime to 'static. You probably don't want to do that. axum has other mechanisms for middleware, and they all have the same 'static constraint. You will have to find a way to erase that lifetime if you want to use this type in this specific manner.

A very simple way to erase the lifetime is providing a channel to the middleware (instead of a transaction that references a temporary connection). As in, an actor that owns the database connections, and everything communicates with it through channels.

It's interesting to note how this crate accomplishes the lifetime erasure. It uses parking_lot::ArcMutexGuard. To quote the docs:

This has several advantages, most notably that it has an 'static lifetime.


Edit:

Something else has been bugging me about this. How exactly would ArcMutexGuard erase the lifetime on Transaction<'_>, if that was all it took?

Well, I'm not very familiar with sqlx. But the docs answered my question:

  1. MySqlPool::begin()
  2. PgPool::begin()
  3. SqlitePool::begin()

The transactions returned by all of the connection pools are Transaction<'static>! They don't borrow from the connections or database. (The reason for the lifetime is unintuitive. It's because Transaction<'_> owns this enum to dynamically select between individual connections -- with a lifetime -- and connections from a pool -- without a lifetime!)

So, going back to your code:

#[async_trait]
pub trait Connection<'conn> {
    fn db_connection(&self) -> &DbConnection;
    async fn begin<'tx>(&'tx mut self) -> Result<TTransaction<'tx>, DatabaseError>
    where
        'conn: 'tx;
}

pub type TTransaction<'tx> = Box<dyn Transaction<'tx> + Send + Sync + 'tx>;

You are tying the transaction's lifetime to the point at which begin() is called. Which, at least for sqlx connection pools, is an unnecessary constraint. You can remove the 'tx lifetime on the TTransaction alias and substitute 'static:

#[async_trait]
pub trait Connection {
    fn db_connection(&self) -> &DbConnection;
    async fn begin<'tx>(&mut self) -> Result<TTransaction, DatabaseError>;
}

#[async_trait]
pub trait Transaction {
    fn db_tx(&self) -> &DbTransaction;
    fn db_tx_mut(&mut self) -> &mut DbTransaction;
    async fn commit(self: Box<Self>) -> Result<(), DatabaseError>;
    async fn rollback(self: Box<Self>) -> Result<(), DatabaseError>;
}
pub enum DbTransaction {
    Postgres(SqlxTransaction<'static, SqlxPostgres>),
    MySql(SqlxTransaction<'static, SqlxMySql>),
    Sqlite(SqlxTransaction<'static, SqlxSqlite>),
}
pub type TTransaction = Box<dyn Transaction + Send + Sync + 'static>;

Now fix up the implementations for each backend. You will still need to insert Arc<Mutex<TTransaction>> into the extension (or ArcMutexGuard<TTransaction>) for middleware to gain access to &mut TTransaction. Finally, unwrap the transaction to assert single-ownership with Arc::into_inner(tx).expect("Transaction was leaked") for the commit/rollback.

Thanks very much for such a detailed & thorough answer.

It'll probably take me a couple of days to work through it and hopefully fully understand it - will come back here with any further questions if that's OK, but wanted to make sure I responded reasonably quickly!