Building Apps
Parent-Child Actors
This page covers practical patterns for working with parent-child actors. For foundational concepts, see Supervision Basics.
Quick Recap: The Supervision Pattern
Parent-child relationships are created using supervise():
let mut runtime = ActonApp::launch_async().await;
// Create and start the parent
let parent = runtime.new_actor::<Supervisor>();
let parent_handle = parent.start().await;
// Create and configure the child
let mut child = runtime.new_actor::<Worker>();
child.mutate_on::<Task>(|actor, ctx| {
actor.model.task_count += 1;
Reply::ready()
});
// Parent supervises child (starts it and registers the relationship)
let child_handle = parent_handle.supervise(child).await?;
Key points:
- Create children with
runtime.new_actor() - Configure handlers before supervision
supervise()starts the child and returns its handle- When the parent stops, all children stop automatically
Worker Pool Pattern
A supervisor managing multiple workers is one of the most common patterns:
use acton_reactive::prelude::*;
#[acton_actor]
struct Supervisor {
workers: Vec<ActorHandle>,
}
#[acton_actor]
struct Worker {
task_count: u32,
}
#[acton_message]
struct Task { id: u32 }
#[acton_main]
async fn main() -> anyhow::Result<()> {
let mut runtime = ActonApp::launch_async().await;
// Create supervisor
let supervisor = runtime.new_actor::<Supervisor>();
let supervisor_handle = supervisor.start().await;
// Create worker pool
let mut worker_handles = Vec::new();
for i in 0..3 {
let config = ActorConfig::new(
Ern::with_root(format!("worker-{i}")).unwrap(),
None,
None
)?;
let mut worker = runtime.new_actor_with_config::<Worker>(config);
worker.mutate_on::<Task>(|actor, ctx| {
let task = ctx.message();
actor.model.task_count += 1;
println!("Worker processing task {}", task.id);
Reply::ready()
});
let handle = supervisor_handle.supervise(worker).await?;
worker_handles.push(handle);
}
// Distribute work round-robin
for i in 0..9 {
let worker = &worker_handles[i % 3];
worker.send(Task { id: i as u32 }).await;
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
runtime.shutdown_all().await?;
Ok(())
}
Each worker processes its assigned tasks. The supervisor has three children that stop automatically when the supervisor stops.
Communication Patterns
Parent to Child
After supervising a child, use the returned handle to send messages:
// Store handles for later use
let mut workers: Vec<ActorHandle> = Vec::new();
for i in 0..3 {
let mut worker = runtime.new_actor::<Worker>();
// ... configure handlers ...
let handle = supervisor_handle.supervise(worker).await?;
workers.push(handle);
}
// Send work to children
for worker in &workers {
worker.send(Task { id: 1 }).await;
}
Child to Parent
Children can report back to their parent using stored handles or reply envelopes.
Option 1: Store parent handle in child state
#[acton_actor]
struct Child {
parent_handle: Option<ActorHandle>,
}
#[acton_message]
struct SetParent(ActorHandle);
#[acton_message]
struct TaskComplete { id: u32 }
// Give child the parent's handle after creation
child_handle.send(SetParent(parent_handle.clone())).await;
// Child can now report back in any handler
child.mutate_on::<DoWork>(|actor, ctx| {
let parent = actor.model.parent_handle.clone();
let task_id = ctx.message().id;
Reply::pending(async move {
// Do work...
if let Some(parent) = parent {
parent.send(TaskComplete { id: task_id }).await;
}
})
});
Option 2: Use reply envelopes for request-response
child.act_on::<DoWork>(|_actor, ctx| {
let reply = ctx.reply_envelope();
Reply::pending(async move {
// Do work...
reply.send(WorkComplete).await;
})
});
For more on request-response patterns, see Request-Response.
Finding Children
Parents can look up their children programmatically:
// Get all children
let children = supervisor_handle.children();
println!("Supervisor has {} children", children.len());
// Find a specific child by ID
if let Some(child) = supervisor_handle.find_child(&child_id) {
child.send(Task { id: 1 }).await;
}
// Iterate over children
for entry in supervisor_handle.children().iter() {
let child_id = entry.key();
let child_handle = entry.value();
println!("Child: {}", child_id);
}
Lifecycle Hooks for Children
Children have their own lifecycle hooks that work exactly like root actors:
let mut child = runtime.new_actor::<ChildState>();
child
.after_start(|actor| {
println!("Child {} started", actor.id());
Reply::ready()
})
.after_stop(|actor| {
println!("Child {} stopped", actor.id());
Reply::ready()
});
let child_handle = parent_handle.supervise(child).await?;
This is useful for:
- Initializing child-specific resources
- Logging child lifecycle events
- Test assertions about child behavior
Cascading Shutdown
When a parent stops, all children stop first (depth-first):
This means:
- Children's
before_stopandafter_stophooks run before the parent completes - Resources are cleaned up in reverse order of creation
- No manual cleanup tracking needed
Best Practices
Keep Hierarchies Shallow
# Prefer flat structures
supervisor/
├── worker-1
├── worker-2
└── worker-3
# Avoid deep nesting unless necessary
supervisor/
└── manager/
└── sub-manager/
└── worker
Use Meaningful Names
let config = ActorConfig::new(
Ern::with_root("order-processor").unwrap(),
None,
None
)?;
let processor = runtime.new_actor_with_config::<Processor>(config);
This creates clear ERN paths like order-processor/validator.
Store Handles When Needed
If you need to communicate with children later, store their handles:
#[acton_actor]
struct Supervisor {
workers: Vec<ActorHandle>,
}
If you only need parent-to-child communication at creation time, you can discard the handles.
Next
Request-Response — Getting responses from actors