A stupendously simple actor framework for the functionally inclined.
Lupin is a lightweight actor framework for building composable and modular asynchronous systems. It is built around the concept of Actors: asynchronous functions that process input messages and produce output messages. Actors can be composed into pipelines, enabling the creation of complex workflows.
For example, the simplest actor possible, the identity actor, can be defined as an asynchronous function that returns its input:
async fn identity(input: &usize) -> usize {
*input
}More complex actors can be built in a similar fashion:
async fn collatz(input: &usize) -> usize {
if *input % 2 == 0 {
*input / 2
} else {
3 * *input + 1
}
}An actor runs indefinitely as long as input is available.
Lupin distinguishes between two primary kinds of actors:
-
Source Actors: These actors do not require external input to produce output. They are defined as asynchronous functions with no input argument. Source actors are useful for generating data streams, timers, or periodic events.
async fn source() -> usize { 42 } let (task, actor) = source.build(); tokio::spawn(task); let value = actor.recv().await.unwrap();
-
Pipeline Actors: These actors process input messages and produce output messages. They are defined as asynchronous functions that take an input (and optionally mutable state) and return an output.
async fn add1(input: &usize) -> usize { *input + 1 } let (task, actor) = add1.build(); tokio::spawn(task); actor.send(41).await.unwrap(); let value = actor.recv().await.unwrap(); // 42
When you build an actor, Lupin returns an ActorRef, which is an enum representing a handle to the actor’s communication channels. ActorRef allows you to send input messages to pipeline actors and receive output messages from both source and pipeline actors.
- For Source actors, you use
.recv().awaitto pull output values. - For Pipeline actors, you use
.send(input).awaitto provide input, and.recv().awaitto get the output.
ActorRef abstracts away the underlying channels and provides a unified API for interacting with actors, making it easy to compose and connect actors in your system.
Lupin provides a rich set of combinators to compose and transform actors:
pipe: Connects the output of one actor to the input of another.chunk: Groups outputs into fixed-size chunks. (Requiresallocfeature.)parallel: Runs multiple instances of an actor in parallel. (Requiresallocfeature.)filter: Filters outputs based on a predicate.map: Transforms outputs using a mapping function.filter_map: Combines filtering and mapping in a single step.
For example, you can compose two actors together using pipe to perform two sequential operations on an input:
async fn mul3(input: &usize) -> usize {
*input * 3
}
async fn add1(input: &usize) -> usize {
*input + 1
}
let (task, actor) = mul3.pipe(add1).build();
tokio::spawn(task);
actor.send(2).await.unwrap();
let result = actor.recv().await.unwrap();
assert_eq!(result, 7);Since lupin uses futures_lite, it supports no_std environments out of the box, optionally without allocation. Features requiring allocation can be disabled in Cargo.toml:
[dependencies.lupin]
default-features = falseSome combinators or utilities that require OS-level async runtimes (like Tokio) or allocation may not be available, but core actor composition and message processing remain fully supported.
In addition to immutable actors, Lupin supports mutable actors for scenarios where state needs to be maintained across messages. Mutable actors are defined as asynchronous functions that operate on a mutable state and process input messages.
For example, a counter actor that increments its state with each input can be defined as:
async fn counter(state: &mut usize, input: &usize) -> usize {
*state += *input;
*state
}Mutable actors are particularly useful for tasks like aggregation, caching, or any operation that requires stateful computation.
Compose two actors into a pipeline where the output of one actor becomes the input of the next:
async fn add1(input: &usize) -> usize { *input + 1 }
async fn mul2(input: &usize) -> usize { *input * 2 }
let (task, actor) = add1.pipe(mul2).build();
tokio::spawn(task);
actor.send(1).await.unwrap();
let result = actor.recv().await.unwrap();
assert_eq!(result, 4);Group outputs into fixed-size chunks (requires alloc feature):
let (task, actor) = identity.chunk(3).build();
tokio::spawn(task);
actor.send(1).await.unwrap();
actor.send(2).await.unwrap();
actor.send(3).await.unwrap();
let chunk = actor.recv().await.unwrap();
assert_eq!(chunk, vec![1, 2, 3]);Contributions to Lupin are welcome and encouraged! Please read CONTRIBUTING.md for more information.
This project is licensed under a dual MIT and Apache 2.0 license, at your discretion. See LICENSE-MIT and LICENSE-APACHE-2.0 for more information.