How Celld Implements the Actor Model in Its Event Processing Loop
Celld runs every Durable Object (DO) inside a single, serial "actor" that owns the entire state of the object, processing all events sequentially through an asynchronous channel to guarantee consistency.
The actor model implementation in celld centers on a compact, well-isolated runtime where each Durable Object gets its own async task. This architecture eliminates race conditions by design, ensuring that all state mutations—from SQLite writes to KV store updates—happen one at a time. The core implementation lives in crates/celld/runtime.rs, with lifecycle management in crates/celld/main.rs and event bridging in crates/celld/js.rs.
The Event Channel Architecture
All events that affect a Durable Object flow through an asynchronous unbounded channel (mpsc::UnboundedSender<Event>). This includes HTTP fetches, alarm triggers, WebSocket frames, and internal messages. When the runtime spawns an actor, it passes the receiver end (rx) of this channel to the actor's processing loop.
The sender handle (tx) gets cloned and distributed to code that needs to inject events into the actor. For example, in crates/celld/js.rs, incoming JavaScript-level requests forward their payloads into this channel:
// crates/celld/js.rs – forwarding a request to the actor
cell_rx.send(Event::Fetch(request)).expect("actor alive");
This decouples event producers from the actor's execution context, allowing the runtime to queue work without blocking external I/O.
The Main Processing Loop
The heart of the actor model implementation is the Actor::run method in crates/celld/runtime.rs. This loop continuously awaits the next event using Tokio's select! macro, listening either for incoming events or a shutdown signal.
// crates/celld/runtime.rs – simplified view of the event loop
async fn run(mut self, mut rx: UnboundedReceiver<Event>) {
loop {
tokio::select! {
Some(event) = rx.recv() => {
match event {
Event::Fetch(req) => self.handle_fetch(req).await,
Event::Alarm(al) => self.handle_alarm(al).await,
Event::WsMessage(m) => self.handle_ws(m).await,
// … other event types …
}
}
_ = self.shutdown.notified() => break,
}
}
}
Because the loop runs on a single async task, the actor processes events one-by-one. This guarantees sequential consistency: state mutations never overlap, and the order of events matches the order of execution. The match statement delegates each event variant to its specific handler in the logic crate, such as logic::sqlite::handle_fetch or logic::alarm::handle_alarm.
State Isolation and Safety
Each actor holds a CellState instance containing the SQLite connection, KV store, and in-memory caches. The single-task design ensures no concurrent access to these mutable resources. When Actor::run calls a handler method like handle_fetch, it passes &mut self, giving exclusive mutable access to the entire state for the duration of that event's processing.
This isolation extends to error handling. If a SQLite operation fails, the error handling code in crates/logic/sqlite.rs "poisons" the actor, preventing further corrupt operations by marking the state as invalid. This mechanism ensures that database consistency errors cannot propagate through subsequent events.
Actor Lifecycle and Spawning
The actor lifecycle begins in crates/celld/main.rs. After restoring Durable Object metadata, the runtime creates the communication channel, constructs the Actor instance, and spawns it onto the Tokio executor:
// crates/celld/main.rs – actor creation and spawning
let (tx, rx) = mpsc::unbounded_channel();
let actor = Actor::from_environment(env, tx.clone());
tokio::spawn(actor.run(rx));
The tx handle gets stored and used throughout the codebase to send events into the actor. When the Durable Object needs to shut down (for example, during hibernation or deletion), the runtime signals the shutdown notifier, causing the select! block to break the loop and clean up resources.
Practical Event Injection
Higher-level code interacts with the actor exclusively through the event channel. To send a fetch event to an actor from external code:
use celld::runtime::{Event, CellRuntime};
let (tx, _rx) = mpsc::unbounded_channel();
let request = /* build a FetchRequest */ ;
tx.send(Event::Fetch(request)).unwrap(); // enqueues the request for the actor
Custom event handlers follow the same pattern. You implement an async method on the Actor struct that mutates state, then add the corresponding variant to the Event enum and match arm in Actor::run:
impl Actor {
async fn handle_my_event(&mut self, data: MyData) -> Result<()> {
// safe mutable access to the cell’s state
self.state.kv.insert(data.key, data.value).await?;
Ok(())
}
}
// In the run loop:
Event::MyEvent(e) => self.handle_my_event(e).await,
Summary
- Single-threaded execution: The
Actor::runloop incrates/celld/runtime.rsprocesses all events on one async task, eliminating data races. - Channel-based messaging: All communication uses
mpsc::UnboundedSender<Event>to queue work without blocking producers. - State isolation: Each actor owns its
CellState(SQLite, KV, caches) exclusively, with mutable access guaranteed sequential. - Poisoning on error: Database errors in
crates/logic/sqlite.rsmark the actor as poisoned, preventing corrupted state from persisting. - Clean lifecycle: Spawning happens in
crates/celld/main.rswith proper shutdown signaling through Tokio's notification system.
Frequently Asked Questions
How does celld guarantee sequential consistency in the actor model?
Celld guarantees sequential consistency by running each Durable Object's actor as a single async task that processes events from an mpsc channel one at a time. The Actor::run loop in crates/celld/runtime.rs uses await to handle each event to completion before accepting the next, ensuring that state mutations never overlap and that event processing order matches the queue order.
What happens when a SQLite operation fails inside an actor?
When a SQLite operation fails, the error handling code in crates/logic/sqlite.rs triggers actor poisoning. This mechanism marks the actor's state as corrupted, preventing subsequent events from executing further database operations that could compound the corruption. The actor effectively shuts down safe operations while preserving the error state for diagnostics.
How are external HTTP requests routed to the correct actor?
External HTTP requests enter through JavaScript bindings in crates/celld/js.rs, which construct FetchRequest objects and send them as Event::Fetch variants through the actor's mpsc::UnboundedSender. This bridges the external JavaScript runtime with the internal Rust actor, queuing the request for sequential processing without blocking the I/O thread.
Is the celld actor model multi-threaded?
No. While Tokio may schedule the actor task on different OS threads over its lifetime, only one thread accesses the actor's state at any given time because it runs as a single async task. There is no internal parallelism within a Durable Object; concurrency happens only between separate Durable Objects, each with their own isolated actor instance.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →