Reaktor Actor Model
Graph is the runtime and the supervisor. ActorNode is an ordinary node. ActorKey is the identity, ActorRef is the mutation handle, and each actor owns an exclusive ObjectStore. BestBuds chat runs on it in production.
1. Implementation Status
| Area | Status | Important files |
|---|---|---|
| Actor primitive | Implemented | reaktor-graph-runtime/src/commonMain/.../core/node/ActorNode.kt |
| Graph supervision | Implemented | reaktor-graph-runtime/src/commonMain/.../core/Graph.kt (actorSupervisorStrategy) |
| Concurrent node collection | Implemented with Collection API preserved | reaktor-graph-port/src/commonMain/.../graph/PortGraph.kt |
| BestBuds chat migration | Implemented as ChatActor + ChatActorContract | bestbuds/modules/app/src/commonMain/.../data/actors/ChatActor.kt |
| Actor tests | Implemented | reaktor-graph/src/commonTest/.../ActorNodeIntegrationTest.kt, ChatActorTest.kt |
| Cloudflare Durable Object adapter | Not implemented | No ActorKey → DO mapping in reaktor-cloudflare |
2. Principles
The actor model follows the same standard as the route-binding improvement from dispatch(Push(edge, payload)) to edge.push(payload): maximum power behind a small, expressive API.
state has one owner
events go to the owner
the owner serializes mutation
the owner emits state
views render state
services transport data
ports expose capabilities
What actors are for
- Mutable state with concurrent event sources.
- WebSocket sessions, active rooms, retries, timers, and ordered side effects.
- Backend entities where one logical identity owns state.
- Workflow/session coordinators that need supervision.
What actors are not for
- Pure repositories.
- Stateless service clients.
- Screen-only Compose state.
- Adding another DI, graph, or service framework.
3. Core Model
Keep the public set tiny. Everything else should be an adapter, helper, or code generation target.
| Concept | Role | Design constraint |
|---|---|---|
ActorKey<M> | Logical actor identity and message type. | Key is the durable identity. Activation is just a node instance. |
ActorRef<M> | Public send, trySend, ask handle. | No public mutable actor state should bypass the mailbox. |
Reply<R> | Ask bridge. | Typed replies stay in the message, not in a global callback registry. |
ActorNode<M> | Node with mailbox, lifecycle, store, timers, and receive. | Subclass owns implementation; callers talk through messages or narrow convenience APIs that enqueue messages. |
MailboxPolicy | Capacity and overflow. | Default is bounded and suspending to avoid hidden overload. |
ActorDirective | Failure response. | Resume by default; restart, stop, and escalate are explicit. |
ActorAddress | graphId/key, exposed on every ActorRef. | Local today. The address shape is the hook a cluster layer would resolve. |
ActorStatus | Created · Starting · Running · Stopping · Stopped · Failed as a StateFlow. | Observable without touching the mailbox. |
Two conveniences sit on ActorRef itself. send { build() } takes a builder instead of a constructed message, and askResult types the reply as Result<R> so a failed ask and a failed operation stay distinguishable. Ask defaults to a 30-second timeout; the mailbox defaults to MailboxPolicy(capacity = 64, overflow = SUSPEND).
The critical contract
ActorRef.send(message)
-> mailbox accepts the message
-> actor loop receives messages FIFO
-> receive(message) mutates owned state
-> state flows, object state, services, and ports observe the result
4. API Shape
Actor code should feel like normal Reaktor code. The key is the only actor-specific identifier.
object ChatActors {
val Session = actorKey<ChatMsg>("bestbuds.chat.session")
}
sealed interface ChatMsg {
data class Open(val user: User, val chatId: String, val existing: Chat?) : ChatMsg
data class IncomingSocketText(val text: String) : ChatMsg
data class FetchChats(val reply: Reply<Result<List<Chat>>>) : ChatMsg
}
class ChatActor(graph: Graph) : ActorNode<ChatMsg>(
graph = graph,
key = ChatActors.Session,
) {
override suspend fun receive(message: ChatMsg) {
when (message) {
is ChatMsg.Open -> openChatNow(message.user, message.chatId, message.existing)
is ChatMsg.IncomingSocketText -> applyIncomingSocketText(message.text)
is ChatMsg.FetchChats -> message.reply.complete(fetchChatsNow())
}
}
}
Graph declaration
class BestBuds : Graph(...) {
init {
provides(ChatActors.Session, ::ChatActor)
autoWire()
}
}
Node consumption
class ChatSessionInteractor(graph: Graph) : BasicNode(graph) {
private val chatActor: ConsumerPort<ChatActor> by consumes(ChatActors.Session)
fun open(user: User, chatId: String, existing: Chat?) {
launch {
chatActor.impl?.send(ChatMsg.Open(user, chatId, existing))
}
}
}
5. Lifecycle and Supervision
Actor lifecycle is node lifecycle. Attach starts the mailbox loop; save pauses it; restore starts it again; destroy closes it.
| Lifecycle | Actor behavior |
|---|---|
Restoring | Start loop if needed and run preStart. |
Attaching | Loop should already be running; start is idempotent. |
Saving | Pause loop without closing the mailbox so Saving -> Restoring can resume. |
Destroying | Close mailbox, cancel loop, close ports and node scope. |
Failure rule
A failed message does not kill the actor by default. The failed ask is completed exceptionally, onFailure runs, and the default directive is Resume. This is right for app actors where one bad user event should not destroy an active session.
protected open suspend fun onFailure(
message: M,
error: Throwable,
): ActorDirective =
graph.actorSupervisorStrategy.onActorFailure(this, message, error)
6. Exclusive ObjectStore
Every actor has one exclusive object store derived from its key. Actor state should use named records inside that store instead of splitting the actor hierarchy into memory and persistent subclasses.
class RoomActor(graph: Graph) : ActorNode<RoomMsg>(graph, RoomActors.room("42")) {
private val summary = state<RoomSummary>("summary")
private val members = state<RoomMembers>("members")
override suspend fun receive(message: RoomMsg) {
when (message) {
is RoomMsg.Join -> members.update { it + message.userId }
}
}
}
7. Graph Wiring
The actor API deliberately stays close to Reaktor's port graph conventions.
provides(key, create)
Creates the actor if it does not exist, attaches it as a normal node, and returns the existing activation if it is already present.
consumes(key)
Registers a typed consumer port using the actor key name and the concrete actor type supplied by the property type.
Because the backing node store preserves Collection, existing code that iterates graph.nodes keeps working. Direct lookup is available through graph.node(id), while actor lookup is exposed through findActor.
8. BestBuds ChatActor
BestBuds chat is the first concrete product migration. It is a good actor because it receives concurrent events from UI, WebSocket, HTTP revalidation, timers, retries, pagination, reactions, and presence.
Before
- Chat interactor directly exposed mutable flows and launched side jobs that could mutate state from outside a single ordered path.
- WebSocket collection parsed and applied frames in collector context.
- Chat list fetch logic was duplicated in list logic and chat logic.
After
ChatActorowns active chat state, messages, participants, failed ids, typing state, presence, sockets, and pending reaction aliases.- WebSocket frames become
ChatMsg.IncomingSocketTextbefore state changes. - Revalidation and group member fetches send actor messages before applying results.
- Send, retry, discard, reaction, forward, load-older, typing, and close all enter through the mailbox.
Why this scales features
| Feature pressure | Actor benefit |
|---|---|
| Fast incoming messages plus optimistic local sends | One serialized state owner resolves temp ids, server echoes, failed ids, and ordering. |
| Reactions on temp messages | Pending reactions are flushed when the real message id arrives. |
| Typing and presence | Timers send messages back into the actor rather than mutating state from timer jobs. |
| Pagination and revalidation | Older pages and fresh cache snapshots merge through one code path. |
| Backend room actor later | The same message discipline maps to one chat room identity on server or Durable Object. |
9. Cloudflare Alignment
Cloudflare Durable Objects are the backend reference point. Cloudflare describes Durable Objects as stateful serverless units with compute and storage, and its Actors package wraps Durable Objects with actor-oriented helpers.
Durable Object mapping
ActorKey.name
-> Durable Object namespace id/name
-> one backend actor instance
-> actor-owned storage
-> HTTP/RPC/WebSocket/alarm messages
Reaktor superset path
- Local app actor: graph node plus mailbox.
- Worker actor: Durable Object wrapper plus same receive discipline.
- Storage: ObjectStore abstraction over local DB or Durable Object storage.
- Timers: local coroutine timers now, Durable Object alarms in the backend adapter.
None of the four rows below the first are built. reaktor-cloudflare ships the raw DO bindings (DurableObjects.kt, PartyServer, blockConcurrencyWhile, WebSocket hibernation), and BestBuds drives them directly for chat rooms and notification dispatch. The missing piece is the adapter that turns those bindings into an ActorRef — see Distributed Actors §16 for the design and Tidemark §6, which classifies a DO as one consistency profile among several rather than the universal authority.
10. Invariants
- There is no
ActorSystem,ActorGraph,ActorRepository, orDurableActorNode. - Actor identity is the key. Activation is a graph node instance.
- The raw mailbox channel is private.
- Every external mutation enters through
send,trySend, orask, including socket and timer events. - Actor state helpers use the actor-owned ObjectStore.
- Services remain transport endpoints. Repositories remain data access. Actors own serialized mutable behavior.
- Convenience methods on concrete actors are acceptable only when they enqueue messages and do not mutate state directly.
- Graph node collection remains the source of truth for actors and non-actors.
11. Next Work
Hardening — short term
- Stable node ids and store names now come from
stableActorUuid/stableActorStoreNameonActorKey. Keep them stable across renames, because a changed key silently orphans an actor's store. - Keep fatal failure status from being overwritten by normal stop status.
- Add tests for lifecycle restart, actor lookup, timeout behavior, and duplicate key rejection.
- Continue moving chat-adjacent state through
ChatActorwhen it owns serialization semantics. - Actor keys carry no tenant. Multitenancy §6 argues for
ActorKey(tenantId, name, messageType), which makes a grain a per-tenant bulkhead by construction. That change is cheap now and expensive after the cluster layer exists.
Backend adapterNext Reaktor Cloudflare pass
- Create a Durable Object wrapper that deserializes requests into actor messages.
- Back actor
state()with Durable Object storage or SQLite-backed ObjectStore. - Map actor timers to Durable Object alarms.
- Generate handler surfaces for typed ask/send endpoints.
12. Sources
- Cloudflare Actors GitHub repository
- Cloudflare Durable Objects overview
- Cloudflare Durable Objects actor model explanation
Related docs
- Graph Kernel - Use to keep the core language small while modules add vocabulary.
- Distributed Actors - Use for cluster membership, sharding, supervision, and durable transport planning.
- Work - Use before changing background scheduling, worker contracts, WorkRequest/WorkConstraints, BestBuds workers, edge/web scheduling, work telemetry, or graph projection for work.
- Reaktor Bus - Use before designing durable async events, jobs, fan-out, queues, Pub/Sub, outbox, DLQ, replay, or graph-visible delivery.
- Service Layer - Use before adding service APIs, splitting reaktor-service out of reaktor-graph, wiring handlers to Spring/Workers, or making modules graph-projectable without hard graph dependencies.
- Graph Visitors - Use before designing a new graph pass or selector.