Creating a Virtual Actor Framework

I've recently had a minor obsession with actor systems. I like how actors are completely independent and can be integrated into a system with minimal (or no) changes to existing code.

I was thinking that actors would become more relevant with the rise of LLM coding. An LLM/Agent could be the "owner" of any given actor and manage the code. Since an actor is fully independent, the entire codebase would easily fit into the context of a LLM. This reduces hallucinations and increases the chances that the model would be able to generate working code.

In addition to fitting the entire program into the context, we would also need to provide a relevant listing of messages for the actor. This could be queried using vector search or provided ahead of time by a programmer.

Existing Actor Frameworks

The main issue with most actor implementations (not just in Rust, but other languages as well) is that they are the minimal implementation needed to make actors work. This is fine if you only want to use actors for something specific, like web request routing. But I want to see what an entire application built using only actors would look like.

A complete actor framework includes things such as:

  • Supervisors
  • Pub/sub
  • Automatic actor discovery/spawning

And the only frameworks I was able to find that are truly feature-complete are:

Actors tend to be used for large, scalable systems, and both Orleans and Akka are geared towards this use-case. I didn't want to set up a large infrastructure just to try out an actor system, especially for my idea. And also, I want to use actors as a program architecture instead of scaling things. So you know what that means: time to put in the work and write it from scratch!

Inspiration

I looked mostly towards Microsoft Orleans and the "grains" concept. It's a virtual actor model that treats actors as things that are always available. Sending a message to an actor that doesn't exist will cause that actor to be spawned or loaded. The framework manages this automatically so it gives code a more sequential style.

Edacious

I wrote a primitive implementation of a virtual actor system in Rust called edacious. I hadn't heard of the word before doing a search, but here's the definition:

edacious
(1) characterized by voracity; devouring
(2) very eager for something, especially a lot of food

My usage of the term refers to devouring of messages, but also EDA is short for "event-driven architecture". So it seemed like a fitting name.

The implementation is pretty simple and works like this:

  1. edacious system starts up
  2. Actor configuration/supervisors are added. I use the term "supervisor" loosely here. They are more like fancy configuration objects in the current state, but supervision capabilities could be added later. This is just a proof-of-concept, after all.
  3. Send messages!

Whenever messages are sent, edacious checks to see if the target actor exists. If not, it spawns it using the configured supervisor and then sends the message to the actor. That's it!

Messages are sent using Zenoh on a pub/sub bus. I specifically avoided an actor implementation that required a reference to an actor (which many frameworks require). Instead, all actors must publish on the bus and then other actors can respond if they want. This allows fully-decoupled fault-tolerant communication between actors. Remember: the inspirational idea behind this is context reduction for an LLM. If we make it impossible to know about other actors and are forced to use only messages, we essentially create mini-programs in the form of an actor.

Example

Here is an example implementation of a simple actor.

Messages

Let's start with messages. Since I want actors to not make point-to-point communication, they won't need to know which topics to send a message on. However, those topics still need to exist to route correctly on Zenoh. So I opted to encode this information on each message type:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FooCmd {
    pub foo: String,
}

impl MsgSubscription for FooCmd {
    fn subscription_pattern() -> KeyExpr<'static> {
        "my_topic/foo".try_into().expect("invalid keyexpr")
    }
}

impl MsgRouting for FooCmd {
    const MSG_TYPE: &'static str = "foo";

    fn publish_to(&self) -> KeyExpr<'_> {
        "my_topic/foo".try_into().expect("invalid keyexpr")
    }
}

edacious will use this information internally when sending it via Zenoh, so it arrives to the correct actor. Additionally, the MSG_TYPE is used by actors to deserialize the message into the correct type. When I initially wrote edacious, I didn't know how to properly use the type system to avoid this, but now I know how to do it.

Supervisors and Actors

Since every actor type needs a supervisor, we need two implementations:

  1. The supervisor
  2. The actor

The edacious top-level system is instantiated with customized dependencies (a struct) containing whatever you need for your application. Supervisors create a projection (it's owned, so not a true projection) into the system dependencies, and then passes that on to the actor. This keeps the actor code focused just on the things it needs.

Note

The design of the system assumes that the system dependencies would almost always be behind pointers, so cloning is expected.

Here is an implementation of a supervisor:

// Available to all supervisors. Database
// connections, config, etc can be placed here.
#[derive(Debug, Clone)]
pub struct SystemDependencies {
    pub foo_deps: String,
}

#[derive(Debug, Clone)]
pub struct FooSupervisor {
    // Options for this particular supervisor.
    config: SingletonSupervisorConfig,
}

impl SingletonSupervisor<SystemDependencies> for FooSupervisor {
    // The actor receives this as it's dependencies.
    type DependencyProjection = String;
    // Supervise this actor type.
    type Actor = Foo;

    // The actor listens to these messages.
    fn subscriptions(&self) -> Vec<KeyExpr<'_>> {
        vec![FooCmd::subscription_pattern()]
    }

    // Create the "projection" of the dependencies.
    fn projection_of(&self, deps: &SystemDependencies) -> Self::DependencyProjection {
        // Pull out the relevant dependencies
        deps.foo_deps.clone()
    }

    // So `edacious` can get this supervisor configuration.
    fn config(&self) -> &SingletonSupervisorConfig {
        &self.config
    }
}

Once we have a supervisor, we can implement the actor:

// Actor is a regular struct. No special types.
#[derive(Debug, Clone)]
pub struct Foo {
    pub state: String,
    pub n_calls: u64,
}

impl Foo {
    // More regular code. No interface with the actor system here.
    async fn foo_handler(&mut self, msg: FooCmd) {
        self.state = msg.foo;
        self.n_calls += 1;
    }
}

// Actor implementation is decoupled  from the actor.
#[async_trait]
impl Actor for Foo {
    // This is the "projection" type from the system dependencies
    type Args = String;

    // Create a new actor.
    async fn new(args: Self::Args) -> Result<Self, ActorError> {
        Ok(Foo {
            state: args,
            n_calls: 0,
        })
    }

    // Message processing method.
    async fn on_receive<D>(&mut self, _: &ActorContext<D>, msg: Envelope) {
        match msg.metadata.msg_type.as_ref() {
            // Forward message to handler.
            FooCmd::MSG_TYPE => handle(msg.payload).with(|msg| self.foo_handler(msg)).await,
            _ => {
                unimplemented!()
            }
        }
    }

    // Name of this actor.
    fn name() -> ActorName {
        "foo".to_string()
    }
}

There are also lifecycle hooks available (pre_start and on_stop), but they are optional and I've omitted them to keep the code shorter.

The nice thing about this implementation is that the actor itself is completely decoupled from the actor system integration. It can be fully tested without any actor system at all.

However, it is quite a lot of code to set all this up. Macros would probably help a lot here, but I didn't feel like taking the experiment much further than this.

Running

Finally we can actually run the system:

#[tokio::main]
async fn main() {
    let eda = Edacious::new(SystemDependencies {
        foo_deps: "foo".to_string(),
    })
    .await
    .unwrap();

    let supervisor = SingletonSupervisorAdapter::new(FooSupervisor {
        config: SingletonSupervisorConfig::default(),
    });
    eda.add_singleton_supervisor(supervisor).await.unwrap();

    // Wait a moment to give zenoh time to listen for messages.
    tokio::time::sleep(Duration::from_millis(100)).await;
    eda.publish(FooCmd {
        foo: "bar".to_string(),
    })
    .await
    .unwrap();

    // Wait a moment for the actor to receive the message.
    tokio::time::sleep(Duration::from_millis(100)).await;
}

Supervisor Types

Unfortunately, it's not possible for third-party implementation of new supervisor types. However, I did create three basic supervisor types which cover many use-cases:

Singleton
Manages a single actor. This is good for situations where you only ever want one actor to process messages. Think like a logger actor or something that manages the state of a door lock.
Pool
Distributes messages between a configured number of actors. This is a standard "worker pool".
Keyed
Allows messages to be routed to a specific actor. This is the typical mode of operation for actor systems. Examples include spawning an actor per connection, or actor per account, etc. The message payload needs to include the actor ID and edacious will send the message to the actor, spawning it if necessary. The "key" is user-defined, so it can be whatever you want.

Final Thoughts

I mean, it works, but there is a lot of boilerplate required in order to get a system up and running. This can be reduced with heavy use of macros, but I don't think spamming macros would be very Rusty. I still think actors are going to be important when it comes to maintaining LLM-generated code. However, my implementation probably isn't the right way forward. If I think of a better implementation I'll definitely give it a go and write about it!



Comments