The Actor Model
Actors are the fundamental unit of computation in Simplex. An actor is an independent entity that:
- Has private state - no other actor can access it directly
- Communicates via messages - the only way to interact with an actor
- Processes one message at a time - no internal concurrency issues
- Can spawn child actors - creating hierarchies
- Can be supervised - automatic recovery from failures
actor Counter {
var count: i64 = 0
// Fire-and-forget message (no response)
receive Increment {
count += 1
}
// Request-response message (returns a value)
receive GetCount -> i64 {
count
}
// Message with parameters
receive Add(n: i64) {
count += n
}
// Lifecycle hooks
on_start() {
print("Counter started")
}
on_stop() {
print("Counter stopped")
}
}
Spawning Actors
Use the spawn keyword to create actor instances:
// Basic spawn
let counter = spawn Counter
// Spawn with initialization
let worker = spawn Worker::new(config)
// Spawn with placement hints
let processor = spawn DataProcessor with {
placement: Locality(database_actor),
resources: Resources(cpu: 2, memory: "1GB")
}
Message Passing
Actors communicate through two primary mechanisms:
send (Fire-and-Forget)
Sends a message and continues immediately without waiting for a response:
send(counter, Increment) // Returns immediately
send(counter, Add(10)) // Message with parameter
send(logger, Log("Event")) // Fire and forget
ask (Request-Response)
Sends a message and waits for a response:
let count = ask(counter, GetCount) // Waits for response
// With timeout
let result = ask(processor, Process(data), timeout: 5.seconds)?
// Async ask (doesn't block)
let future = ask_async(processor, Process(data))
// ... do other work ...
let result = await future
Checkpointing
Checkpointing persists actor state for fault tolerance:
actor OrderProcessor {
var current_order: Option<Order> = None
var processed_ids: Set<String> = {}
receive ProcessOrder(order: Order) -> Result<Receipt, Error> {
// Skip if already processed (idempotency)
if processed_ids.contains(order.id) {
return Ok(get_receipt(order.id))
}
// Track current order for recovery
current_order = Some(order)
checkpoint()
// Process the order (may fail)
let receipt = process_payment(order)?
fulfill_order(order)?
// Mark as processed
processed_ids.insert(order.id)
current_order = None
checkpoint()
Ok(receipt)
}
on_resume() {
// If we crashed mid-processing, retry
if let Some(order) = current_order {
print("Resuming order {order.id}")
// Will be reprocessed on next message
}
}
}
Supervision
Supervisors monitor child actors and handle failures automatically:
supervisor ApplicationSupervisor {
// Restart strategy
strategy: OneForOne, // Only restart failed child
// Restart limits
max_restarts: 5,
within: Duration::seconds(60),
// Child specifications
children: [
child(DatabaseConnection, restart: Always),
child(WebServer, restart: Always),
child(CacheManager, restart: Transient),
child(BackgroundWorker, restart: Temporary)
]
}
Restart Strategies
| Strategy | Behavior |
|---|---|
OneForOne |
Only restart the failed child |
OneForAll |
Restart all children when one fails |
RestForOne |
Restart failed child and all started after it |
Restart Types
| Type | Behavior |
|---|---|
Always |
Always restart when terminated |
Transient |
Only restart on abnormal termination |
Temporary |
Never restart |
Supervision Hierarchies
Build complex systems with nested supervision:
supervisor RootSupervisor {
strategy: OneForOne,
children: [
child(DatabaseSupervisor),
child(WebSupervisor),
child(WorkerSupervisor)
]
}
supervisor DatabaseSupervisor {
strategy: OneForAll,
children: [
child(ConnectionPool),
child(QueryCache)
]
}
supervisor WorkerSupervisor {
strategy: OneForOne,
children: [
child(Worker, replicas: 10) // Pool of workers
]
}
Common Patterns
Worker Pool
actor WorkerPool {
var workers: List<ActorRef<Worker>> = []
var next_worker: i64 = 0
fn new(size: i64) -> Self {
let workers = (0..size).map(|_| spawn Worker)
WorkerPool { workers, next_worker: 0 }
}
receive Submit(task: Task) {
// Round-robin distribution
let worker = workers[next_worker % workers.len()]
next_worker += 1
send(worker, DoWork(task))
}
}
Request Aggregator
actor Aggregator {
receive Aggregate(sources: List<ActorRef>, query: Query) -> Results {
// Query all sources in parallel
let futures = sources.map(s => ask_async(s, query))
let results = await parallel(futures)
// Combine results
merge_results(results)
}
}
Best Practices
- Keep actors focused: Each actor should have a single responsibility
- Checkpoint at boundaries: Save state before and after risky operations
- Use supervision: Every actor should be supervised
- Prefer messages over shared state: Communicate, don't share
- Design for failure: Assume any operation can fail
- Make operations idempotent: Allow safe retries