Looking for architecture advice: Iterating over futures and execute them in serial

My question is mostly about design, architecture and best practices.
My code actually compiles and does what I want but what was meant as an easy way for my users to plug in to existing functionality turns out to be not so easy to use anymore.

I've tried to reproduce a smaller example below but my code is fully open-source on Github.

My background is Java (only a few months on Rust) so what I've tried to replicate is the Abstract class pattern from Java. I'd usually create an abstract "base" class with common functionality and subclasses can extend this base class and just implement the abstract methods with their business logic.

This does not work in Rust so I've tried the Strategy pattern instead.

  1. I have a Controller (struct) that processes events and it takes a Strategy (trait)
  2. My Controller calls a method (init_event) on the Strategy for each event that comes in, this method returns a struct implementing the PerEvent trait
  3. The Controller calls the method operations on the PerEvent object which returns a Vec of Futures.
  4. The Controller now iterates over these Futures and awaits each, the result of one of these Futures determines whether the next Futures should be run at all or not

My goals for this design are:

  • A bunch of independent functions which all have access to the same shared state per event (the PerEvent object), parts of which might be mutable, so these functions can build on each others work
  • These functions are run in order and they can influence whether the next ones are run or not
  • No need to pass around the state manually and return it every time

My problems are:

  • I need to treat all state etc. as if it were run in parallel (Arc / Mutex) while in reality they are always run serially (I understand that the compiler can't know this)
  • "Unpacking" an Arc<Mutex<>> isn't easy (unless I'm doing it wrong), it's certainly overhead
  • Even when using Arc<Mutex<>> I run into issues: Uncomment the //self.nothing().await; line and it'll fail because the MutexGuard is not Send and would be held over an await, I can work around that but again it's not super trivial to use
  • Function signatures are not trivial to read

I tried to make it easier for my users but I have a feeling that my architecture doesn't achieve that goal.

I admit that this is not trivial and I'm probably not doing the best job of explaining my issue but I'm still wondering if anyone might have an idea on how this whole architecture could be improved?

Thank you!

use core::future::Future;
use core::pin::Pin;
use std::sync::{Arc, Mutex};

pub enum ReturnAction {
    Continue,
    Done,
}

trait PerEvent {
    fn operations(&self) -> Vec<Pin<Box<dyn Future<Output = ReturnAction> + Send + '_>>>;
}

struct TestPerEvent {
    event: String,
    some_state: Option<String>,
    more_state: Arc<Mutex<Option<String>>>,
}
impl TestPerEvent {
    async fn test1(&self) -> ReturnAction {
        let t1 = Arc::clone(&self.more_state);
        let mut t2 = t1.lock().unwrap();
        *t2 = Some("foobar".to_string());

        println!("{:?}", t2);

        ReturnAction::Continue
    }
    async fn test2(&self) -> ReturnAction {
        let t1 = Arc::clone(&self.more_state);
        let t2 = t1.lock().unwrap();

        println!("{:?}", t2);

        //self.nothing().await;

        ReturnAction::Continue
    }

    async fn nothing(&self) {}
}
impl PerEvent for TestPerEvent {
    fn operations(&self) -> Vec<Pin<Box<dyn Future<Output = ReturnAction> + Send + '_>>> {
        let vec: Vec<Pin<Box<dyn Future<Output = ReturnAction> + Send + '_>>> =
            vec![Box::pin(self.test1()), Box::pin(self.test2())];
        vec
    }
}

trait Strategy<T: PerEvent> {
    fn init_event(&self, event: String) -> T;
}

struct TestStrategy {}
impl Strategy<TestPerEvent> for TestStrategy {
    fn init_event(&self, event: String) -> TestPerEvent {
        TestPerEvent {
            event,
            some_state: None,
            more_state: Arc::new(Mutex::new(None)),
        }
    }
}

struct Controller {}

impl Controller {
    pub async fn process_event<T: PerEvent>(&self, strategy: impl Strategy<T>, event: String) {
        let event_processor = strategy.init_event(event);
        let operations = event_processor.operations();

        for operation in operations {
            let result = operation.await;

            if let ReturnAction::Done = result {
                return;
            }
        }
    }
}

#[tokio::main]
async fn main() {
    let strategy = TestStrategy {};
    let controller = Controller {};

    println!("Before");

    tokio::spawn(async move {
        controller
            .process_event(strategy, "foobar".to_string())
            .await
    });

    println!("After");
}

(Playground)

Output:

Before
After
Some("foobar")
Some("foobar")

Errors if that one line is uncommented

   Compiling playground v0.0.1 (/playground)
error: future cannot be sent between threads safely
  --> src/main.rs:45:42
   |
45 |             vec![Box::pin(self.test1()), Box::pin(self.test2())];
   |                                          ^^^^^^^^^^^^^^^^^^^^^^ future returned by `test2` is not `Send`
   |
   = help: within `impl Future`, the trait `Send` is not implemented for `std::sync::MutexGuard<'_, Option<String>>`
note: future is not `Send` as this value is used across an await
  --> src/main.rs:35:9
   |
31 |         let t2 = t1.lock().unwrap();
   |             -- has type `std::sync::MutexGuard<'_, Option<String>>` which is not `Send`
...
35 |         self.nothing().await;
   |         ^^^^^^^^^^^^^^^^^^^^ await occurs here, with `t2` maybe used later
...
38 |     }
   |     - `t2` is later dropped here
   = note: required for the cast to the object type `dyn Future<Output = ReturnAction> + Send`

error: aborting due to previous error

error: could not compile `playground`

To learn more, run the command again with --verbose.

There’s AtomicRefCell as a faster alternative to Mutex. It’s guards are also Send and Sync.

Sorry for the late reply and thank you for your response.
With the help of someone from Discord I actually did find a different solution where I move logic into the ReturnAction. That sounds promising and I'll try to implement that.

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.