Incompatible lifetimes - mutable references in an async loop

Hi,

I'm struggling against lifetimes for almost the first time, trying to write a function that will retry a Future-returning function until it succeeds, or we run out of retries. The challenging part is I would like the retry state to be passed in to each call of the Future-returning function, so that the function itself can do internal retrying using the same 'retry context'.

For context, in the real code I'm building on top of the backoff crate, and trying to write a retry function that can work with an instance of backoff::backoff::Backoff. This leads to some constraints, such as the inner &mut inner state, which may be complicating things, but I'm hoping someone can tell me if what I'm trying to do is possible before I give up.

Here's a stand-alone code sample (playground):

use std::{future::Future, time::Duration};

// This is the function I'm trying to write.
// The function itself compiles, but the call-site in `main` does not.
async fn retry<Op, Fut, T, E>(mut state: RetryState<'_>, mut op: Op) -> Result<T, E>
where
    Op: FnMut(RetryState<'_>) -> Fut,
    Fut: Future<Output = Result<T, Error<E>>>,
{
    loop {
        match op(RetryState(&mut state.0)).await {
            Ok(value) => return Ok(value),
            Err(Error::Permanent(error)) => return Err(error),
            Err(Error::Transient(error)) => {
                if let Some(duration) = state.next_attempt() {
                    tokio::time::sleep(duration).await;
                    continue;
                } else {
                    return Err(error);
                }
            }
        }
    }
}

#[tokio::main]
async fn main() {
    // Some inner state, `()` for this example
    let mut inner = ();

    // Initial retry state
    let state = RetryState(&mut inner);

    // Start retrying with the given state
    retry(state, |state| async move {
        retryable(state).await?;
        Result::<_, Error<()>>::Ok(())
    })
    .await;
}

// Retry state. In my real code `()` is replaced by `B: backoff::backoff::Backoff`,
// but that's not in the playground!
struct RetryState<'s>(&'s mut ());

impl RetryState<'_> {
    // The time to wait until the next retry, if we've not hit the limit.
    // In my real code, this is provided by `backoff::backoff::Backoff`.
    fn next_attempt(&mut self) -> Option<Duration> {
        todo!()
    }
}

// Some operation that might perform retries using `RetryState`.
async fn retryable<'s>(_: RetryState<'s>) -> Result<(), ()> {
    todo!()
}

// Transient errors are retried automatically, permanent errors are propagated.
// This whole endeavour is to allow permanent errors to be retried by callers
// while maintaining the same 'retry state'.
enum Error<E> {
    Transient(E),
    Permanent(E),
}

impl<E> From<E> for Error<E> {
    fn from(error: E) -> Self {
        // Retry all errors by default
        Self::Transient(error)
    }
}

Although the function itself compiles fine, the call of it fails to compile with the following error:

error: lifetime may not live long enough
  --> src/lib.rs:35:26
   |
35 |       retry(state, |state| async move {
   |  ___________________------_^
   | |                   |    |
   | |                   |    return type of closure `impl Future` contains a lifetime `'2`
   | |                   has type `RetryState<'1>`
36 | |         retryable(state).await?;
37 | |         Result::<_, Error<()>>::Ok(())
38 | |     })
   | |_____^ returning this value requires that `'1` must outlive `'2`

I understand this to mean that the returned impl Future has a lifetime that outlives the lifetime of its RetryState argument. So, my next step was to try and tweak the signature of retry to try and say "the Future returns a lifetime bounded by the RetryState", which got me to:

async fn retry<'s1, 's2, Op, Fut, T, E>(mut state: RetryState<'s1>, mut op: Op) -> Result<T, E>
where
    Op: FnMut(RetryState<'s2>) -> Fut,
    Fut: Future<Output = Result<T, Error<E>>> + 's2,
    's1: 's2
{
    ...
}

https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=fcb52ad3a1a54e9272561ee9652663bb

The compiler is not happy with this though:

error[E0499]: cannot borrow `state.0` as mutable more than once at a time
  --> src/lib.rs:12:29
   |
5  | async fn retry<'s1, 's2, Op, Fut, T, E>(mut state: RetryState<'s1>, mut op: Op) -> Result<T, E>
   |                     --- lifetime `'s2` defined here
...
12 |         match op(RetryState(&mut state.0)).await {
   |               --------------^^^^^^^^^^^^--
   |               |             |
   |               |             `state.0` was mutably borrowed here in the previous iteration of the loop
   |               argument requires that `state.0` is borrowed for `'s2`

error[E0597]: `state.0` does not live long enough
  --> src/lib.rs:12:29
   |
5  | async fn retry<'s1, 's2, Op, Fut, T, E>(mut state: RetryState<'s1>, mut op: Op) -> Result<T, E>
   |                     --- lifetime `'s2` defined here
...
12 |         match op(RetryState(&mut state.0)).await {
   |               --------------^^^^^^^^^^^^--
   |               |             |
   |               |             borrowed value does not live long enough
   |               argument requires that `state.0` is borrowed for `'s2`
...
25 | }
   | - `state.0` dropped here while still borrowed

error[E0499]: cannot borrow `state` as mutable more than once at a time
  --> src/lib.rs:16:41
   |
5  | async fn retry<'s1, 's2, Op, Fut, T, E>(mut state: RetryState<'s1>, mut op: Op) -> Result<T, E>
   |                     --- lifetime `'s2` defined here
...
12 |         match op(RetryState(&mut state.0)).await {
   |               ----------------------------
   |               |             |
   |               |             first mutable borrow occurs here
   |               argument requires that `state.0` is borrowed for `'s2`
...
16 |                 if let Some(duration) = state.next_attempt() {
   |                                         ^^^^^ second mutable borrow occurs here

Now it seems that the mutable borrow lives too long. I'd thought that the mutable borrow for &mut state.0 would borrow for 's2, but that does not seem to be the case. I feel like what I'm really trying to say is:

async fn retry<'s1, 's2, Op, Fut, T, E>(mut state: RetryState<'s1>, mut op: Op) -> Result<T, E>
where
    Op: for<'s1: 's2> FnMut(RetryState<'s2>) -> impl Future<Output = Result<T, Error<E>>> + 's2,
{
    ...
}

E.g., Op is a function taking RetryState<'s2>, for any 's2 less than 's1, and returning a Future that lives no longer than 's2. Of course, that syntax doesn't work at all (neither bounds inside for<...> not impl Future in that position).

What I'm not sure is if I'm trying to do something silly. It seems like it should be possible (e.g. there are no overlapping mutable borrows), but I'm not sure how to convince the compiler.

Unfortunately there is no way to specify the trait bound directly. One thing you can do is define a helper trait:

trait OpFn<'a, T, E> {
    type Fut: Future<Output = Result<T, Error<E>>> + 'a;
    fn call(&mut self, state: RetryState<'a>) -> Self::Fut;
}
impl<'a, T, E, F, Fut> OpFn<'a, T, E> for F
where
    F: FnMut(RetryState<'a>) -> Fut,
    Fut: Future<Output = Result<T, Error<E>>> + 'a,
{
    type Fut = Fut;
    fn call(&mut self, state: RetryState<'a>) -> Fut {
        (*self)(state)
    }
}
async fn retry<Op, T, E>(mut state: RetryState<'_>, mut op: Op) -> Result<T, E>
where
    Op: for<'a> OpFn<'a, T, E>,
{
    ...
}

However this does not work with closures - type inference isn't really able to handle a trait like this when you are using a closure. But it does work with actual functions:

async fn my_fn(state: RetryState<'_>) -> Result<(), Error<()>> {
    retryable(state).await?;
    Ok(())
}

// Start retrying with the given state
retry(state, my_fn).await;

The way to avoid all of this is to use a boxed future, e.g.:

Op: for<'a> FnMut(RetryState<'a>) -> BoxFuture<'a, Result<T, Error<E>>>

That is the futures::future::BoxFuture alias.

2 Likes

Aha, thank you for clarifying that for me. Indeed, the following compiles just fine:

I also attempted a solution using type_alias_impl_trait, but bumped into Unification fails when type alias impl trait is used in non-return position · Issue #64445 · rust-lang/rust · GitHub, I think (playground).

This is the second time you've pulled me out of a fight with the compiler, so thanks again!

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.