Skip to content

Latest commit

 

History

History
677 lines (539 loc) · 20.2 KB

File metadata and controls

677 lines (539 loc) · 20.2 KB

Actor System Architecture Guide

Zero-cost, platform-dual, supervision-aware actors for Rust statecharts


🚀 Introduction

The lit-bit actor system brings mature actor model patterns to Rust statecharts with a unique focus on:

  • Zero-cost StateMachine integration - Your statecharts become actors with no runtime overhead
  • Platform-dual design - Same code runs on embedded (no_std + Embassy) and cloud (std + Tokio)
  • Supervision-aware lifecycle - OTP-inspired restart strategies prevent cascading failures
  • Research-validated performance - <200ns message latency, >100k messages/sec/core targets

Unlike traditional actor frameworks that require boxing and dynamic dispatch, our system leverages Rust's type system for compile-time guarantees and zero-cost abstractions.


🧱 Core Concepts

Actor Trait: Minimal & Powerful

pub trait Actor {
    type Message: Send + 'static;
    
    // Core message handler - your business logic lives here
    async fn on_event(&mut self, msg: Self::Message);
    
    // Lifecycle hooks for supervision
    fn on_start(&mut self) -> Result<(), ActorError> { Ok(()) }
    fn on_stop(self) -> Result<(), ActorError> { Ok(()) }
    fn on_panic(&self, _info: &PanicInfo) -> RestartStrategy { 
        RestartStrategy::OneForOne 
    }
}

StateMachine Integration: Zero-Cost Magic

The real power comes from blanket implementation - every StateMachine automatically becomes an Actor:

// Your existing statechart
statechart! {
    name: TrafficLight,
    event: TrafficEvent,
    initial: Red,
    // ... states and transitions
}

// Automatically becomes an actor - no boilerplate!
impl Actor for TrafficLight {
    type Message = TrafficEvent;
    
    async fn on_event(&mut self, event: TrafficEvent) {
        // Direct forwarding - zero overhead
        let _ = self.send(&event);
    }
}

// Spawn it like any actor
let addr = spawn_actor_tokio::<TrafficLight, 32>(traffic_light);
addr.send(TrafficEvent::TimerExpired).await?;

RestartStrategy: Supervision That Makes Sense

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RestartStrategy {
    OneForOne,    // Restart only this actor (default - most common)
    OneForAll,    // Restart all sibling actors (shared state scenarios)
    RestForOne,   // Restart this + all actors started after it (dependency chains)
}

When to use each:

  • OneForOne: Independent actors (sensors, API handlers, state machines)
  • OneForAll: Actors sharing critical state (database connections, caches)
  • RestForOne: Dependency chains (auth → session → user-data actors)

📡 Messaging System

Type-Safe Addresses

// Compile-time type safety - can only send valid events
let traffic_addr: Address<TrafficEvent, 32> = spawn_actor_tokio(traffic_light);
let sensor_addr: Address<SensorReading, 16> = spawn_actor_tokio(sensor);

// ✅ This compiles
traffic_addr.send(TrafficEvent::TimerExpired).await?;

// ❌ This fails at compile time
traffic_addr.send(SensorReading::Temperature(25.0)).await?; // Type error!

Platform-Specific Mailboxes

The same Address<Event, N> type provides different semantics based on your target:

Embedded (no_std + Embassy): Fail-Fast

// Bounded queue with immediate feedback
let addr = spawn_actor_embassy::<SensorActor, 8>(spawner, sensor);

// Fails immediately if queue is full - no blocking
match addr.try_send(SensorReading::Temperature(25.0)) {
    Ok(()) => { /* Message queued */ },
    Err(SendError::Full(msg)) => { /* Handle overflow */ },
}

Cloud (std + Tokio): Async Back-pressure

// Bounded channel with natural flow control
let addr = spawn_actor_tokio::<ApiHandler, 1000>(handler);

// Awaits if queue is full - natural back-pressure
addr.send(HttpRequest::new("/api/users")).await?;

Mailbox Capacity Guidelines

Use Case Recommended N Reasoning
State machines 4-16 Low latency, predictable load
Sensors (embedded) 8-32 Burst tolerance, memory constraints
API handlers 100-1000 High throughput, variable load
Background workers 32-128 Batch processing, moderate latency

🔄 Actor Lifecycle Diagram

Understanding the actor lifecycle is crucial for effective supervision and debugging. Here's the complete flow from spawn to restart:

flowchart TD
    A[🟢 Spawn Actor] --> B[🔧 on_start]
    B --> |Success| C[🔄 Event Loop]
    B --> |Error| D[💀 Actor Dies]
    
    C --> E{📨 Receive Message}
    E --> F[⚡ on_event]
    F --> |Success| C
    F --> |Panic!| G[⚠️ Panic Detected]
    
    G --> H[🛡️ on_panic]
    H --> I{🎯 Restart Strategy?}
    
    I --> |OneForOne| J[♻️ Restart This Actor]
    I --> |OneForAll| K[♻️ Restart All Siblings]
    I --> |RestForOne| L[♻️ Restart This + Newer]
    
    J --> M[🧹 on_stop]
    K --> N[🧹 Stop All Siblings]
    L --> O[🧹 Stop Dependency Chain]
    
    M --> B
    N --> P[🔧 Restart All Siblings]
    O --> Q[🔧 Restart Chain]
    
    P --> B
    Q --> B
    
    C --> |Shutdown Signal| R[🛑 Graceful Shutdown]
    R --> S[🧹 on_stop]
    S --> D
    
    style A fill:#90EE90
    style D fill:#FFB6C1
    style G fill:#FFD700
    style C fill:#87CEEB
Loading

📋 Lifecycle Phase Breakdown

Phase Purpose Key Actions Failure Handling
🟢 Spawn Create actor instance Allocate mailbox, setup supervision Actor creation fails
🔧 on_start Initialize state, resources Load config, connect to services Return Err → actor dies
🔄 Event Loop Process messages continuously Dequeue → process → repeat Cooperative yielding
⚡ on_event Handle business logic State transitions, side effects Panic → supervision
⚠️ Panic Capture failure information Log error, determine strategy Supervisor decides fate
🛡️ on_panic Choose restart scope OneForOne/OneForAll/RestForOne Strategy guides recovery
♻️ Restart Clean slate recovery Stop → start cycle Fresh state, clean mailbox
🧹 on_stop Cleanup resources Close connections, save state Best-effort cleanup
🛑 Shutdown Graceful termination Drain mailbox, notify dependents Orderly system shutdown

⏱️ Timing & Guarantees

  • Single-threaded processing: Each on_event() completes before the next message is dequeued
  • Panic isolation: Actor panics don't crash siblings (unless supervision strategy dictates restart)
  • Mailbox preservation: Messages sent during restart are queued and processed after on_start() succeeds
  • Supervision timing: Restart decisions happen immediately after panic detection
  • Resource cleanup: on_stop() is always called before actor destruction (best-effort)

🧠 Supervision Model

Supervision Tree Structure

                    ┌─────────────────┐
                    │   Supervisor    │
                    │  (OneForAll)    │
                    └─────────┬───────┘
                              │
                 ┌────────────┼────────────┐
                 │            │            │
         ┌───────▼──────┐ ┌───▼────┐ ┌─────▼─────┐
         │   Database   │ │  Cache │ │  Session  │
         │   Manager    │ │Manager │ │  Manager  │
         │ (OneForOne)  │ │        │ │           │
         └──────────────┘ └────────┘ └─────┬─────┘
                                           │
                                    ┌──────▼──────┐
                                    │   User      │
                                    │   Actors    │
                                    │ (RestForOne)│
                                    └─────────────┘

Panic Handling Example

struct SensorActor {
    readings: Vec<f32>,
    calibration: Option<CalibrationData>,
}

impl Actor for SensorActor {
    type Message = SensorCommand;
    
    async fn on_event(&mut self, cmd: SensorCommand) {
        match cmd {
            SensorCommand::Read => {
                // This might panic if calibration is None
                let calibrated = self.calibration.unwrap().apply(raw_reading);
                self.readings.push(calibrated);
            },
            SensorCommand::Calibrate(data) => {
                self.calibration = Some(data);
            }
        }
    }
    
    fn on_panic(&self, info: &PanicInfo) -> RestartStrategy {
        // Log the panic for debugging
        log::error!("Sensor actor panicked: {}", info);
        
        // Restart just this sensor - don't affect siblings
        RestartStrategy::OneForOne
    }
    
    fn on_start(&mut self) -> Result<(), ActorError> {
        // Reset state on restart
        self.readings.clear();
        self.calibration = None;
        log::info!("Sensor actor restarted");
        Ok(())
    }
}

Real-World Supervision Example

// Supervisor manages a fleet of sensor actors
struct SensorSupervisor {
    sensor_addresses: HashMap<SensorId, Address<SensorCommand, 16>>,
}

impl Actor for SensorSupervisor {
    type Message = SupervisorCommand;
    
    async fn on_event(&mut self, cmd: SupervisorCommand) {
        match cmd {
            SupervisorCommand::SensorPanicked { id, strategy } => {
                match strategy {
                    RestartStrategy::OneForOne => {
                        // Restart just the failed sensor
                        self.restart_sensor(id).await;
                    },
                    RestartStrategy::OneForAll => {
                        // Restart all sensors (shared calibration corrupted)
                        self.restart_all_sensors().await;
                    },
                    RestartStrategy::RestForOne => {
                        // Restart this sensor and all newer ones
                        self.restart_sensor_chain(id).await;
                    }
                }
            },
            SupervisorCommand::AddSensor(config) => {
                let addr = spawn_actor_embassy(spawner, SensorActor::new(config));
                self.sensor_addresses.insert(config.id, addr);
            }
        }
    }
}

⚡ Performance & Platform Tuning

Tokio vs Embassy: Choose Your Fighter

Aspect Tokio (std) Embassy (no_std)
Throughput >1M msg/sec >500k msg/sec
Latency <100ns <200ns
Memory/Actor ~1KB ~512B
Spawn Cost ~50ns ~100ns
Back-pressure Async await Fail-fast
Use Cases APIs, services, high-throughput IoT, real-time, resource-constrained

Performance Best Practices

Embedded Optimization

// ✅ Good: Small, bounded mailboxes
spawn_actor_embassy::<SensorActor, 8>(spawner, sensor);

// ✅ Good: Value types for messages
#[derive(Clone)]
enum SensorMessage {
    Reading(f32),
    Calibrate { offset: f32, scale: f32 },
}

// ❌ Avoid: Large mailboxes on embedded
spawn_actor_embassy::<SensorActor, 1000>(spawner, sensor); // Too big!

// ❌ Avoid: Heap allocations in messages
enum BadMessage {
    Data(Vec<u8>), // Heap allocation!
    Text(String),  // Heap allocation!
}

Cloud Optimization

// ✅ Good: Larger mailboxes for burst handling
spawn_actor_tokio::<ApiHandler, 1000>(handler);

// ✅ Good: Batch processing for throughput
impl Actor for BatchProcessor {
    async fn on_event(&mut self, item: WorkItem) {
        self.batch.push(item);
        
        if self.batch.len() >= BATCH_SIZE {
            self.process_batch().await;
            self.batch.clear();
        }
    }
}

Memory Usage Patterns

// Actual measurements from benchmarks:

// Minimal actor (no_std)
struct MinimalActor;
// Memory: ~64B (just the actor task overhead)

// StateMachine actor (no_std)
struct TrafficLight { state: TrafficState, context: TrafficContext }
// Memory: ~128B (state + context + task overhead)

// Complex actor with mailbox (std)
struct ApiHandler { 
    db_pool: DbPool, 
    cache: LruCache<String, Response>,
    metrics: Metrics 
}
// Memory: ~2KB (includes Tokio task + mailbox + actor state)

🧨 Anti-patterns & Pitfalls

❌ Shared Mutable State

// DON'T: Shared Arc<Mutex<T>> between actors
struct BadActor {
    shared_state: Arc<Mutex<HashMap<String, Value>>>, // Defeats actor isolation!
}

// DO: Message passing for coordination
enum CoordinatorMessage {
    Get { key: String, reply_to: Address<GetResponse, 8> },
    Set { key: String, value: Value },
}

❌ Spawning Without Lifecycle Guards

// DON'T: Spawn and forget
let addr = spawn_actor_tokio(risky_actor);
// If this actor panics, no one knows or cares

// DO: Proper supervision
struct Supervisor {
    children: Vec<Address<ChildMessage, 16>>,
}

impl Actor for Supervisor {
    async fn on_event(&mut self, msg: SupervisorMessage) {
        match msg {
            SupervisorMessage::ChildPanicked { id } => {
                // Restart the child, update monitoring, etc.
                self.restart_child(id).await;
            }
        }
    }
}

❌ Over-sized Mailboxes

// DON'T: Massive mailboxes hide back-pressure
spawn_actor_tokio::<SlowProcessor, 100_000>(processor); // Memory bomb!

// DO: Right-sized for your use case
spawn_actor_tokio::<SlowProcessor, 32>(processor); // Natural back-pressure

❌ Blocking in Actor Handlers

// DON'T: Block the executor
impl Actor for BadActor {
    async fn on_event(&mut self, msg: Message) {
        std::thread::sleep(Duration::from_secs(1)); // Blocks executor!
        
        let result = reqwest::blocking::get("http://api.example.com"); // Blocking!
    }
}

// DO: Use async APIs
impl Actor for GoodActor {
    async fn on_event(&mut self, msg: Message) {
        tokio::time::sleep(Duration::from_secs(1)).await; // Yields properly
        
        let result = reqwest::get("http://api.example.com").await; // Async!
    }
}

✅ Quick Start Examples

Minimal Tokio Actor

use lit_bit_core::actor::{Actor, spawn_actor_tokio};

#[derive(Debug)]
struct Counter {
    value: u32,
}

#[derive(Debug, Clone)]
enum CounterMessage {
    Increment,
    Decrement,
    GetValue { reply_to: tokio::sync::oneshot::Sender<u32> },
}

impl Actor for Counter {
    type Message = CounterMessage;
    
    async fn on_event(&mut self, msg: CounterMessage) {
        match msg {
            CounterMessage::Increment => self.value += 1,
            CounterMessage::Decrement => self.value = self.value.saturating_sub(1),
            CounterMessage::GetValue { reply_to } => {
                let _ = reply_to.send(self.value);
            }
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let counter = Counter { value: 0 };
    let addr = spawn_actor_tokio::<Counter, 16>(counter);
    
    // Send some messages
    addr.send(CounterMessage::Increment).await?;
    addr.send(CounterMessage::Increment).await?;
    
    // Get the value
    let (tx, rx) = tokio::sync::oneshot::channel();
    addr.send(CounterMessage::GetValue { reply_to: tx }).await?;
    let value = rx.await?;
    
    println!("Counter value: {}", value); // Prints: Counter value: 2
    Ok(())
}

Minimal Embassy Actor

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use lit_bit_core::actor::{Actor, spawn_actor_embassy};

#[derive(Debug)]
struct LedController {
    is_on: bool,
}

#[derive(Debug, Clone)]
enum LedMessage {
    TurnOn,
    TurnOff,
    Toggle,
}

impl Actor for LedController {
    type Message = LedMessage;
    
    async fn on_event(&mut self, msg: LedMessage) {
        match msg {
            LedMessage::TurnOn => {
                self.is_on = true;
                // gpio.set_high(); // Actual GPIO control
            },
            LedMessage::TurnOff => {
                self.is_on = false;
                // gpio.set_low();
            },
            LedMessage::Toggle => {
                self.is_on = !self.is_on;
                // if self.is_on { gpio.set_high() } else { gpio.set_low() }
            }
        }
    }
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    let led = LedController { is_on: false };
    let addr = spawn_actor_embassy::<LedController, 8>(spawner, led);
    
    // Blink pattern
    loop {
        addr.try_send(LedMessage::Toggle).ok();
        embassy_time::Timer::after(embassy_time::Duration::from_millis(500)).await;
    }
}

Actor with Supervised Children

use lit_bit_core::actor::{Actor, RestartStrategy, spawn_actor_tokio};
use std::collections::HashMap;

struct WorkerSupervisor {
    workers: HashMap<u32, Address<WorkerMessage, 16>>,
    next_worker_id: u32,
}

#[derive(Debug, Clone)]
enum SupervisorMessage {
    SpawnWorker,
    WorkerPanicked { id: u32 },
    DistributeWork { task: WorkTask },
}

impl Actor for WorkerSupervisor {
    type Message = SupervisorMessage;
    
    async fn on_event(&mut self, msg: SupervisorMessage) {
        match msg {
            SupervisorMessage::SpawnWorker => {
                let worker = Worker::new(self.next_worker_id);
                let addr = spawn_actor_tokio::<Worker, 16>(worker);
                self.workers.insert(self.next_worker_id, addr);
                self.next_worker_id += 1;
            },
            SupervisorMessage::WorkerPanicked { id } => {
                // Remove the failed worker
                self.workers.remove(&id);
                
                // Spawn a replacement
                let worker = Worker::new(id);
                let addr = spawn_actor_tokio::<Worker, 16>(worker);
                self.workers.insert(id, addr);
                
                log::info!("Restarted worker {}", id);
            },
            SupervisorMessage::DistributeWork { task } => {
                // Round-robin work distribution
                if let Some(addr) = self.workers.values().next() {
                    let _ = addr.send(WorkerMessage::ProcessTask(task)).await;
                }
            }
        }
    }
}

struct Worker {
    id: u32,
    processed_count: u32,
}

impl Worker {
    fn new(id: u32) -> Self {
        Self { id, processed_count: 0 }
    }
}

impl Actor for Worker {
    type Message = WorkerMessage;
    
    async fn on_event(&mut self, msg: WorkerMessage) {
        match msg {
            WorkerMessage::ProcessTask(task) => {
                // Simulate work that might panic
                if task.is_poison() {
                    panic!("Worker {} encountered poison task!", self.id);
                }
                
                // Normal processing
                self.processed_count += 1;
                log::info!("Worker {} processed task (total: {})", self.id, self.processed_count);
            }
        }
    }
    
    fn on_panic(&self, _info: &std::panic::PanicInfo) -> RestartStrategy {
        // Notify supervisor of panic
        // (In real implementation, supervisor would be notified automatically)
        RestartStrategy::OneForOne
    }
}

🔧 Next Steps

📚 Continue Learning

🛠️ Development Tools

  • TestKit utilities - Mock actors, message capturing, deterministic execution
  • Actor visualization - Generate supervision tree diagrams
  • Performance profiling - Message latency and throughput analysis

🚀 Production Deployment

  • Monitoring integration - Metrics, tracing, and health checks
  • Graceful shutdown - Clean actor termination patterns
  • Error recovery - Advanced supervision strategies and circuit breakers

Built with ❤️🦀 for the Rust community. Questions? Open an issue or start a discussion!