# How Celld Implements the Actor Model in Its Event Processing Loop

> Discover how Celld uses the actor model to process events sequentially within Durable Objects, ensuring consistent state management and robust event handling.

- Repository: [Deno/celld](https://github.com/denoland/celld)
- Tags: deep-dive
- Published: 2026-08-10

---

**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`](https://github.com/denoland/celld/blob/main/crates/celld/runtime.rs), with lifecycle management in [`crates/celld/main.rs`](https://github.com/denoland/celld/blob/main/crates/celld/main.rs) and event bridging in [`crates/celld/js.rs`](https://github.com/denoland/celld/blob/main/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`](https://github.com/denoland/celld/blob/main/crates/celld/js.rs), incoming JavaScript-level requests forward their payloads into this channel:

```rust
// 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`](https://github.com/denoland/celld/blob/main/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.

```rust
// 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`](https://github.com/denoland/celld/blob/main/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`](https://github.com/denoland/celld/blob/main/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:

```rust
// 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:

```rust
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`:

```rust
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::run` loop in [`crates/celld/runtime.rs`](https://github.com/denoland/celld/blob/main/crates/celld/runtime.rs) processes 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.rs`](https://github.com/denoland/celld/blob/main/crates/logic/sqlite.rs) mark the actor as poisoned, preventing corrupted state from persisting.
- **Clean lifecycle**: Spawning happens in [`crates/celld/main.rs`](https://github.com/denoland/celld/blob/main/crates/celld/main.rs) with 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`](https://github.com/denoland/celld/blob/main/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`](https://github.com/denoland/celld/blob/main/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`](https://github.com/denoland/celld/blob/main/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.