Actors are the unit of long-running services in Lambda Prelude. They are implemented on OCaml 5 effect handlers — no Eio, no Miou, no POSIX-only primitives. The same actor code runs on Windows and Linux, under the interpreter and under AOT.
Three Communication Primitives
The actor surface starts with three communication primitives — spawn, send, receive — plus Actor self, which returns the current fiber's actor ref.
Actor self "actor ref of the current fiber"
Actor spawn: [:me | body] "spawn a new fiber, body sees its own ref"
actor ! message "fire-and-forget send"
actor receive "block until a message arrives"
A first ping-pong:
parent := Actor self.
child := Actor spawn: [:me |
msg := me receive.
msg printNl.
parent ! #pong
].
child ! #ping.
parent receive printNl.State Lives in Loop Arguments
There is no shared mutable state. An actor's state is the argument of its recursive loop. To "update" state, the actor calls itself with a new value:
Object subclass: #Counter.
Counter class >> run: me count: n =
me receive: {
Inc -> [:msg | Counter run: me count: n + 1].
GetCount -> [:msg | msg reply ! n. Counter run: me count: n]
}.
counter := Actor spawn: [:me | Counter run: me count: 0].
n is closed inside the call frame; no other fiber can read or write it.
Typed Selective Receive
me receive: { Class -> [:msg | ...] } is a class-tag dispatch table. The scheduler scans the mailbox head-to-tail and runs the first matching handler. Non-matching messages stay in their original positions — selective receive.
Object subclass: #Inc.
Object subclass: #Get fields: (reply).
Counter class >> run: me state: n =
me receive: {
Inc -> [:msg | Counter run: me state: n + 1].
Get -> [:msg | msg reply ! n. Counter run: me state: n]
}.
The dispatch table threads each message class through Hindley-Milner inference, so the handler body sees concrete types — no Obj.magic, no untyped escape.
Receive Timeouts
receive:after:do: returns either a matched message or the timeout branch.
me receive: { Tick -> [:t | handle: t] }
after: 1000
do: [Log warn: 'no tick for 1 s'].Futures and ask:
ask: is request / response sugar on top of send + receive. It returns a Future that resolves when the reply arrives.
fut := worker ask: (Compute fields: { x: 7 }).
fut await printNl.
Manual Future construction is also available; ask: is the common path.
Link, Monitor, Trap Exits
Failure semantics follow the Erlang vocabulary, applied to the local model:
- Link — symmetric. If either actor dies abnormally, the other dies too. Used to tie two actors' lifetimes together.
- Monitor — asymmetric. The observer receives a
Downmessage when the target dies, but keeps running. - Trap exits — convert a link's death cascade into an
Exitmessage in the mailbox, so the actor can handle it instead of dying.
Object subclass: #Boom.
Object subclass: #Worker.
Worker class >> loop: me =
me receive: {
Boom -> [:msg | Error raise: 'kaboom!']
}.
main := Actor self.
worker := Actor spawn: [:me | Worker loop: me].
main monitor: worker.
worker ! (Boom fields: {}).
main receive: {
Down -> [:d |
'Down received' printNl.
d reason printNl
]
}.
'main still alive' printNl.
Down carries the dead actor's ref and the failure reason. The observer keeps running — there is no cascade unless the actor is also linked.
Supervision
Supervision trees are written as plain classes, not as dedicated syntax. A supervisor monitors its children and restarts them on Down. When the restart budget is exhausted, the supervisor stops; if it is itself monitored by a higher supervisor, that level takes over from there.
The Supervision module in stdlib provides reusable strategies (one-for-one, one-for-all, rest-for-one) and restart policies, but the underlying mechanism is just monitor + spawn.
Multi-Domain Scheduling
The runtime is multi-domain: actors can spawn across OS threads with cross-domain inbox handoff and work-stealing. The same runtime is compiled into the AOT binary, so it gets the identical multi-domain scheduler — CPU-bound workloads spread across cores in both modes, with no separate single-domain runtime.
Bounded Socket I/O
TCP connect:port:timeout:, TCPSocket >> readLine:timeout: / readBytes:timeout:, TLS wrapClient:host:timeout:, and the TLS read / write helpers all integrate with the actor scheduler. A slow upstream parks the calling fiber on wait_readable / wait_writable instead of blocking the OS thread, so other actors keep running.
The TLS bridge drives Tls.Engine directly — handshake, application read, and write each park on the runtime's I/O wait primitives.
Not on the Roadmap
Erlang / Akka Cluster style distributed actors — cross-node spawn, link, monitor, supervision over the wire — are not on the roadmap. The local actor model stays as-is.
Multi-node approach — virtual actors (grains)
The multi-node path ships as a virtual-actor (grain) model. A grain is an actor addressed by a stable (class, id) identity rather than a fiber reference, with its live state in an external store instead of pinned process memory. Because it is reached by identity, an activation can be dropped when idle and rehydrated on demand — there is no cross-node reference graph to garbage-collect. The application writes ordinary methods on the grain class and sends messages to the identity; the distributed lifecycle lives in the Grain module (stdlib/grain.lp).
The runtime upholds one-live-object-per-identity with three roles:
- Directory (one per server) — a single-threaded actor holding the
id -> ownermap, so get-or-create cannot race in a process. - Owner (one per active id) — holds state in memory and runs every call for that id through its mailbox in order (per-id serialization), deactivating after an idle timeout so memory tracks the active working set.
- Lease (cross-node) — activating an id takes a Redis lease that pins it to one node for a TTL. State writes carry a monotonic fence token, so a node whose lease lapsed cannot overwrite a newer owner. If a node dies, its lease expires and another node activates from the stored state.
Single-node mode (for:initial:redis:) uses the directory and owner only; cross-node mode (for:initial:redis:url:) adds the lease. A node that does not own an id replies with an error naming the owner and the client routes the retry (transparent server-side forwarding is not yet in). Redis is the volatile activation store, not a durable record — persisting to a system-of-record database is the application's responsibility.
Storage is pluggable. GrainServer talks to a GrainStore protocol (stateFor:, persist:json:token:, acquireFor:, renew:token:, release:token:). RedisGrainStore is the built-in backend; an application can inject its own (for example an actor-held in-memory map) with for:initial:store:.
Selective receive / link / monitor remain local-only by design — there is no cross-node link cascade. The remote ref Remote at: url for: #Class id: is typed when given a literal class: an unknown selector is a compile-time error (a non-literal for: stays dynamically typed as before).