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-basics.sx
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:

spawning.sx
// 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.sx
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:

ask.sx
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:

checkpointing.sx
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.sx
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:

hierarchy.sx
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

worker-pool.sx
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

aggregator.sx
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

Next Steps