Advanced
Integration
Acton Reactive runs on Tokio. Handler futures require Send + 'static, so database and HTTP futures can be awaited directly even when they are not Sync.
Await I/O in the Handler
Use act_on for read-only work that may run concurrently. Use mutate_on when each message must finish before the next begins. Both support Send futures without a Sync requirement.
#[acton_message]
struct FetchData { url: String }
#[acton_message]
struct FetchDone { url: String, body: String }
builder.act_on::<FetchData>(|actor, ctx| {
let handle = actor.handle().clone();
let url = ctx.message().url.clone();
Reply::pending(async move {
match reqwest::get(&url).await {
Ok(resp) => match resp.text().await {
Ok(body) => handle.send(FetchDone { url, body }).await,
Err(e) => tracing::error!("read failed for {url}: {e}"),
},
Err(e) => tracing::error!("fetch failed for {url}: {e}"),
}
})
});
builder.mutate_on::<FetchDone>(|actor, ctx| {
let msg = ctx.message();
actor.model.cache.insert(msg.url.clone(), msg.body.clone());
Reply::ready()
});
The runtime tracks the returned future for concurrency limits and shutdown. Clone owned inputs before the async block because handler futures are 'static. Actor state, messages, and the reusable callback closure still require Sync.
When migrating a named callback with an explicit Pin<Box<dyn Future<Output = ()> + Send + Sync>> return type, remove Sync from that signature. Inline closures normally infer the v10 signature automatically.
Database Connections
Store the connection pool in actor state. The pool is cheap to clone (it's an Arc internally), so handlers clone it out and await queries in a returned future.
Connecting is async, and you can't assign to actor.model from inside a 'static async block — so return a read-only handler future that connects and sends the pool back to be stored.
use sqlx::PgPool;
#[acton_actor]
struct DatabaseActor {
pool: Option<PgPool>,
}
#[acton_message]
struct Connect { database_url: String }
#[acton_message]
struct Connected { pool: PgPool }
#[acton_message]
struct Query { sql: String }
#[acton_message]
struct QueryResult { rows: Vec<Row> }
// 1. Connect, then hand the pool back
builder.act_on::<Connect>(|actor, ctx| {
let handle = actor.handle().clone();
let url = ctx.message().database_url.clone();
Reply::pending(async move {
match PgPool::connect(&url).await {
Ok(pool) => handle.send(Connected { pool }).await,
Err(e) => tracing::error!("DB connect failed: {e}"),
}
})
});
// 2. Store it — a plain sync handler, no future needed
builder.mutate_on_sync::<Connected>(|actor, ctx| {
actor.model.pool = Some(ctx.message().pool.clone());
tracing::info!("Database connected");
});
// 3. Query using the stored pool
builder.act_on::<Query>(|actor, ctx| {
let Some(pool) = actor.model.pool.clone() else {
tracing::warn!("Query before pool was ready");
return Reply::ready();
};
let sql = ctx.message().sql.clone();
let reply = ctx.reply_envelope();
Reply::pending(async move {
match sqlx::query(&sql).fetch_all(&pool).await {
Ok(rows) => reply.send(QueryResult { rows }).await,
Err(e) => tracing::error!("query failed: {e}"),
}
})
});
Choose concurrency explicitly
Read-only act_on queries run concurrently within the actor's limit. A mutate_on future is awaited before processing the next message, which preserves ordering when a write or transaction must complete first.
`#[acton_message]` requires Clone + Debug
Connected { pool: PgPool } works because PgPool is both. A type that isn't (a raw connection, say) can't ride in a message — put it behind an Arc, or hand the actor a factory instead.
HTTP Servers
Actors can handle HTTP requests. Pass actor handles to your HTTP framework:
use axum::{Router, routing::get, extract::State};
async fn get_count(
State(counter): State<ActorHandle>,
) -> String {
// Send increment and let client poll for result
counter.send(Increment).await;
"Incremented".to_string()
}
#[acton_main]
async fn main() {
let mut runtime = ActonApp::launch_async().await;
// Set up actor
let counter = setup_counter(&mut runtime).await;
// Set up HTTP server with actor handle
let router = Router::new()
.route("/increment", get(get_count))
.with_state(counter);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, router).await.unwrap();
}
For request-response, use a probe pattern or channels to collect responses.
Message Queues
Actors can consume from external queues:
#[acton_actor]
struct QueueConsumer;
#[acton_message]
struct StartConsuming;
#[acton_message]
struct ProcessMessage { data: String }
builder.mutate_on::<StartConsuming>(|actor, _envelope| {
let handle = actor.handle().clone();
tokio::spawn(async move {
loop {
// Fetch from external queue
if let Some(msg) = fetch_from_queue().await {
handle.send(ProcessMessage { data: msg }).await;
}
}
});
Reply::ready()
});
Background Tasks
Spawn background work from actors:
#[acton_message]
struct StartBackgroundTask { id: u32 }
#[acton_message]
struct TaskComplete { id: u32, result: String }
builder.mutate_on::<StartBackgroundTask>(|actor, envelope| {
let handle = actor.handle().clone();
let task_id = envelope.message().id;
tokio::spawn(async move {
// Long-running work
let result = perform_work().await;
// Report completion back to actor
handle.send(TaskComplete { id: task_id, result }).await;
});
Reply::ready()
});
Talking to Actors From Other Processes
Everything above keeps the integration inside one process. When the other side is a separate process — a Python script, a Node service, a CLI, another Rust binary — use Acton's IPC layer instead of building your own socket protocol.
Enable the feature:
[dependencies]
Server side — register the message types that can arrive over the wire, mark actors as reachable, and start the listener:
let mut runtime = ActonApp::launch_async().await;
runtime.ipc_registry().register::<GetPrice>("GetPrice");
let mut service = runtime.new_actor_with_name::<PriceService>("prices".to_string());
service
.act_on::<GetPrice>(|actor, ctx| { /* ... */ })
.expose_for_ipc(); // reachable as "prices"
service.start().await;
let listener = runtime.start_ipc_listener().await?;
Client side — IpcClient connects over a Unix domain socket and gives you fire-and-forget, request-response, and subscriptions:
use acton_reactive::ipc::{IpcClient, IpcConfig, IpcEnvelope};
let client = IpcClient::connect(IpcConfig::load().socket_path()).await?;
let response = client.request(IpcEnvelope::new_request(
"prices",
"GetPrice",
serde_json::json!({ "symbol": "ACTON" }),
)).await?;
client.disconnect().await?;
Messages that cross the boundary need serde derives — #[acton_message(ipc)] adds them for you.
See IPC Setup to get a socket running, IPC Patterns for request-response, streaming, and push subscriptions, and Advanced IPC for the wire protocol.
Graceful Shutdown
Coordinate shutdown with external resources:
#[acton_main]
async fn main() {
let mut runtime = ActonApp::launch_async().await;
let database_pool = setup_database().await;
let actors = setup_actors(&mut runtime, &database_pool).await;
// Wait for shutdown signal
tokio::signal::ctrl_c().await.ok();
// Shutdown actors first
runtime.shutdown_all().await.ok();
// Then cleanup external resources
database_pool.close().await;
}
Best Practices
- Choose ordering: Use
mutate_onfor sequential work andact_onfor concurrent reads - Return I/O futures: Await
Sendfutures directly inReply::pendingorReply::try_pending - Share pools — Use connection pools, not per-actor connections
- Handle timeouts — External calls can fail or hang
- Propagate shutdowns — Shut actors down before the resources they depend on
- Log errors — External integrations often fail, and a spawned task has nowhere else to report
Reference
Continue to Reference for API documentation and cheatsheets.