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

AreaStatusImportant files
Actor primitiveImplementedreaktor-graph-runtime/src/commonMain/.../core/node/ActorNode.kt
Graph supervisionImplementedreaktor-graph-runtime/src/commonMain/.../core/Graph.kt (actorSupervisorStrategy)
Concurrent node collectionImplemented with Collection API preservedreaktor-graph-port/src/commonMain/.../graph/PortGraph.kt
BestBuds chat migrationImplemented as ChatActor + ChatActorContractbestbuds/modules/app/src/commonMain/.../data/actors/ChatActor.kt
Actor testsImplementedreaktor-graph/src/commonTest/.../ActorNodeIntegrationTest.kt, ChatActorTest.kt
Cloudflare Durable Object adapterNot implementedNo 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.

ConceptRoleDesign 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.
MailboxPolicyCapacity and overflow.Default is bounded and suspending to avoid hidden overload.
ActorDirectiveFailure response.Resume by default; restart, stop, and escalate are explicit.
ActorAddressgraphId/key, exposed on every ActorRef.Local today. The address shape is the hook a cluster layer would resolve.
ActorStatusCreated · 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.

LifecycleActor behavior
RestoringStart loop if needed and run preStart.
AttachingLoop should already be running; start is idempotent.
SavingPause loop without closing the mailbox so Saving -> Restoring can resume.
DestroyingClose 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

  • ChatActor owns active chat state, messages, participants, failed ids, typing state, presence, sockets, and pending reaction aliases.
  • WebSocket frames become ChatMsg.IncomingSocketText before 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 pressureActor benefit
Fast incoming messages plus optimistic local sendsOne serialized state owner resolves temp ids, server echoes, failed ids, and ordering.
Reactions on temp messagesPending reactions are flushed when the real message id arrives.
Typing and presenceTimers send messages back into the actor rather than mutating state from timer jobs.
Pagination and revalidationOlder pages and fresh cache snapshots merge through one code path.
Backend room actor laterThe 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, or DurableActorNode.
  • Actor identity is the key. Activation is a graph node instance.
  • The raw mailbox channel is private.
  • Every external mutation enters through send, trySend, or ask, 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 / stableActorStoreName on ActorKey. 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 ChatActor when 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

  • 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.