Ascolt - async runtime-agnostic actor framework

let first_actor = FirstActor {};
let (first_actor_tx, first_actor_rx) = create_channel(100);

let second_actor = SecondActor { first_actor_tx };
let (second_actor_tx, second_actor_rx) = create_channel(100);

start_actor(first_actor, first_actor_rx);
start_actor(second_actor, second_actor_rx);

first_actor_tx.tell(SomeRequest { number: 3 })
    .await?; // fire and forget

let result = second_actor_tx
    .call(SecondActorCalcRequest(10))
    .await?; // request-response

println!("Result: {}", result.0);

I wasn't satisfied with existing actor frameworks implementation of request-response patterns and other issues, such as:

  • Some handle panics instead of relying on error results
  • Some don't support async in handlers

So I built my own minimal library for my own projects and for educational purposes.

I would appreciate feedback. Thanks

1 Like

Sounds great. You have to find a better name though, "ctrfrmwrk" is just awful. Reminds of a demented old time C programmer intent on shortening identities as much as possible :slight_smile:

3 Likes

Now its okay or still awful?

A lot less awful :slight_smile:

I'm no great shakes at naming things either. Perhaps others are inspired to offer suggestions based on what you have.

Sorry to be niggley about it.

Thanks, this is better than no feedback at all

It's great to look at the question of "what's the simplest layer I can add to gain the most benefit". I think this is a good building block!

  • For BaseHandler and BaseHandlerTrait, it wasn't immediately clear that these are implementation details for the match_messages! macro. It may be helpful to add #[doc(hidden)] so they're not in the public facing docs, and a comment linking to that macro to help guide new contributors.

  • If BaseHandlerTrait is only meant to be implemented by BaseHandler, it may be simpler to just define 2 methods on BaseHandler (handle_call and handle_tell), using the generics only for the parts that are not known from the library's point of view (error E, input I, and maybe some others can stay)

  • Small style opinions:

    • CallMessage could have field names (request, tx) to avoid the (msg.0, msg.1) rebinding dance. Similar for TellMessage.
    • On the other hand, Sender<M> is a candidate to change to a tuple struct, but OK to leave it open in case more fields are needed later.
  • There a great blog post about Tree Structured Concurrency. Concretely, all the tokio::spawn calls in the supervision module could instead return a future, to let the caller manage their future execution. This shifts some complexity to the caller, but hopefully a simple futures_util::join! invocation is all they need.

  • The tokio::sync docs that all the tokio types you use will work on any async runtime. There's nothing wrong with being tied to tokio specifically, but it's neat that really your library is async runtime agnostic! (assuming the tokio::spawn calls are adjusted/removed, see previous item)

  • It's slightly unfortunate that the error types use strings internally. Imagine a caller who knows how to handle a specific error without presenting it to the user. They might have to pay the overhead of the to_string() conversion for each error occurrence. If you remove pub from all the error fields (and wrap DefaultActorError's Fatal variant in a struct, to add privacy) then you're free to use the underlying source error types without exposing the exact version of tokio you're using in your public API. The idea is to keep the errors as lean as possible (enum with a handful of variants) and only materialize the user-facing message when the error is Displayed.

Let me know if any of these items can use more clarification. These are just my initial thoughts, and others may be able to pitch in to help on specific examples, too (especially if you quote the specific code/usages inline in the posts).

1 Like

Thanks for spending your time, this is really useful.

  1. Added #[doc(hidden)] for private code
  2. Maybe this is a better approach, but in this situation function overloading seems more flexible cause new message types can be added (Like tell and call) without touching macro code. Macros aren't very readable also, I prefer more generics over more macros logic.
  3. Fixed style issues
  4. Fixed that, thanks for the link
  5. Renamed DefaultActorError -> DefaultHandleError and did this:
#[derive(Error, Debug)]
#[error("Fatal error {0}")]
pub struct FatalError(#[source] Box<dyn std::error::Error + Send + Sync>);

#[derive(Debug)]
pub enum DefaultHandleError {
    Fatal(FatalError),
}

impl fmt::Display for DefaultHandleError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            DefaultHandleError::Fatal(inner) => write!(f, "{inner}"),
        }
    }
}

impl<E> From<E> for DefaultHandleError
where
    E: std::error::Error + Send + Sync + 'static,
{
    fn from(value: E) -> Self {
        let error = FatalError(Box::new(value));

        DefaultHandleError::Fatal(error)
    }
}

Same for ActorInitError and ActorStopError, not sure its the right way, but looks ok.

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.