Proto.Actor
Proto.Actor (Go) was the reference host the actor-host contract, version 2, was proven on first — its orchestration now lives in a shared Go kit (moxzi/adapters/hostkit-go), and Orleans, Pekko and Cloudflare each reuse the same design in their own platform's terms. The code is moxzi/adapters/protoactor (moxzi-protoactor).
What Proto.Actor owns, what Motoko owns#
| Owns | Who | What |
|---|---|---|
| Identity | Proto.Actor cluster | one virtual actor per key (kind MoxziActor, identity Type/id); the cluster places it, and its mailbox is the one-step-at-a-time guarantee a turn needs |
| Routing | this process, beside the virtual actor | every parked await becomes a durable route row (a Postgres _routes table) before it is routed; the callee's turn runs as a turn of its own; a restart re-drives every non-held route |
| Durable timers | this process, in Postgres | a _timers table polled every 200 ms; a claimed row (DELETE … RETURNING) fires exactly once |
| Placement / held / pending calls | this process, in Postgres | _placement, _held tables behind PUT /_moxzi/placement and POST /_moxzi/hold |
| Motoko execution, the heap, upgrades | moxzi-actor-host (Rust) | everything past /v2/* — the virtual actor's Receive does exactly one JSON POST per step and nothing more |
Timers and routing run beside the virtual actor rather than inside its Receive: a mailbox that blocked on a routed call would hold the key for the whole await, and two actors awaiting each other would deadlock — the IC lets a parked actor keep taking other messages. So the actor does one HTTP step and returns; a Host object (from the shared kit) drives the resulting call chain outside any actor's mailbox.
Proto.Actor has no durable reminder and no built-in store of its own, so — like the Pekko host — both the route journal and the timer table are Postgres, in tables <namespace>_placement, <namespace>_held, <namespace>_routes, <namespace>_timers.
Prerequisites#
Verified on this machine:
- Go
go1.27.1 darwin/arm64. - Docker, for a Postgres 16 container. This walk-through names it
moxzi-tutorial-pg. moxziandmoxzi-actor-host, built (cargo build --release -p moxzi -p moxzi-actor-hostundermoxzi/, or use the ones already atmoxzi/target/release/).
Walk through it#
Write and build the two actors#
Same two actors as the contract page — Customer awaits Bank on chargeVia; both are simplified from moxzi/conformance/fixtures/{customer,bank}.mo:
// customer.mo
import Prim "mo:⛔";
persistent actor {
type Order = { id : Nat; total : Nat };
var balance : Nat = 0;
var orders : [Order] = [];
var timerFired : Nat = 0;
public func addOrder(order : Order) : async Nat {
orders := Prim.Array_tabulate<Order>(orders.size() + 1, func (i : Nat) : Order {
if (i < orders.size()) orders[i] else order
});
balance += order.total;
orders.size()
};
public query func getBalance() : async Nat { balance };
public func chargeVia(bank : Text, amount : Nat) : async Nat {
let b : actor { debit : (Nat) -> async Nat } = actor (bank);
let v = await b.debit(amount);
balance += v;
balance
};
public func armTimer(delayNs : Nat64) : async () {
ignore Prim.setTimer<system>(delayNs, false, func () : async () { timerFired += 1 });
};
public query func timerCount() : async Nat { timerFired };
};
// bank.mo
persistent actor {
var debited : Nat = 0;
public func debit(n : Nat) : async Nat { debited += n; n };
public query func total() : async Nat { debited };
};
MOXZI_COMPILER=bin/compiler-canister/compiler.wasm MOXZI_LINKER=bin/linker-canister/linker.wasm \
moxzi/target/release/moxzi build customer.mo -o customer.wasm
# customer.mo -> customer.wasm + customer.did (285491 bytes, 1 file(s), 65 steps, 0.83s)
MOXZI_COMPILER=bin/compiler-canister/compiler.wasm MOXZI_LINKER=bin/linker-canister/linker.wasm \
moxzi/target/release/moxzi build bank.mo -o bank.wasm
Start the moxzi-actor-host services#
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:3021 --state ./s1 \
--namespace demo --deployment crm --actor Customer=./customer.wasm --known Bank/1
# moxzi-actor-host: Bank/1 is k2e4o-mgnlm-is6i5-r7owd-3wgr3-b4lrw-vi2fq-vf6zl-43mw6-2jicp-kae
# moxzi-actor-host: moxzi-actor-host/2 on 127.0.0.1:3021, hosting Customer
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:3022 --state ./s2 \
--namespace demo --deployment crm --actor Bank=./bank.wasm
--known Bank/1 just prints the principal early; if you'd rather ask for it, any running service will answer POST /v2/learn {"key":"Bank/1"} with {"principal":"k2e4o-mgnlm-is6i5-r7owd-3wgr3-b4lrw-vi2fq-vf6zl-43mw6-2jicp-kae"} — the same value, because it is derived deterministically from namespace/deployment/type/key.
Start Proto.Actor#
Pick a Postgres host port nothing else on the box is using:
docker run -d --name moxzi-tutorial-pg -e POSTGRES_PASSWORD=moxzi -e POSTGRES_DB=moxzi \
-p 25433:5432 postgres:16
cd moxzi/adapters/protoactor
go build -o moxzi-protoactor .
DATABASE_URL="postgres://postgres:moxzi@127.0.0.1:25433/moxzi?sslmode=disable" \
MOXZI_HOST_NS=demo ./moxzi-protoactor --listen 127.0.0.1:3500
# 2026/09/23 20:52:17 moxzi-protoactor: contract moxzi-actor-host/2, cluster member up, listening on 127.0.0.1:3500
Point placement at the services, and call it#
curl -X PUT localhost:3500/_moxzi/placement \
-d '{"types":{"Customer":["http://127.0.0.1:3021"],"Bank":["http://127.0.0.1:3022"]}}'
curl -X POST localhost:3500/actors/Customer/1/method/addOrder \
-H 'content-type: application/json' -d '[{"id":1,"total":30}]'
# HTTP/1.1 200 OK, x-moxzi-outcome: replied
# [1]
A cross-actor await#
curl -X POST localhost:3500/actors/Customer/1/method/chargeVia \
-H 'content-type: application/json' \
-d '["k2e4o-mgnlm-is6i5-r7owd-3wgr3-b4lrw-vi2fq-vf6zl-43mw6-2jicp-kae",10]'
# [40]
curl -X POST localhost:3500/actors/Bank/1/method/total -H 'content-type: application/json' -d '[]'
# [10]
Customer parked on Bank's answer; the cluster's virtual-actor placement routed the call to whichever service owns Bank/1, and the host resumed Customer with the reply — getBalance now reads 30 + 10 = 40.
Arm a timer#
curl -X POST localhost:3500/actors/Customer/1/method/armTimer \
-H 'content-type: application/json' -d '[2000000000]'
sleep 3
curl -X POST localhost:3500/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
Kill it and bring it back#
pkill -f moxzi-protoactor
pkill -f 'moxzi-actor-host --listen 127.0.0.1:302'
# … restart moxzi-protoactor exactly as above, and both moxzi-actor-host services on the
# SAME --state dirs …
curl -X POST localhost:3500/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [40] — placement came back from Postgres, no re-PUT needed
curl -X POST localhost:3500/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
Operating notes#
These notes are for the service form. In process, the actor's image lives in Postgres and nothing is written to a service's disk — see In process below.
- Durable state: this host's placement, held keys, route journal and armed timers are in Postgres, tables
<namespace>_placement,<namespace>_held,<namespace>_routes,<namespace>_timers. The Motoko heap lives on eachmoxzi-actor-hostservice's own disk under--state: a snapshot, a WAL tail, and a content-addressedmodules/directory of every wasm you have deployed to that actor. - Upgrading an actor: drain, then upgrade with the new module as base64: ``
sh curl -X POST localhost:3500/actors/Customer/1/admin/drain -d '{}' # {"state":"Quiescent"} curl -X POST localhost:3500/actors/Customer/1/admin/upgrade \ -H 'content-type: application/json' \ -d "{\"deploymentId\":\"crm2\",\"wasm\":\"$(base64 -i customer.wasm)\"}" # {"state":"Active"}`POST …/admin/{drain,upgrade}route through the virtual actor, so they queue behind other pending turns on that key.GET …/admin/{state,outstanding}bypasses it and goes straight to the owning service. An upgrade only proceeds once the actor isQuiescent`; a failed one leaves the previous deployment running. - What to watch: the cluster member process (this walk-through uses Proto.Actor's in-memory test provider, one member —
automanaged, consul, etcd and k8s providers change placement, not the contract) and the Postgres instance behind it. The_routesand_timerstables are the ground truth for "what is still parked" and "what is still armed" if an HTTP call to/_moxzi/pendingis not convenient.
In process#
The same actor also runs inside Proto.Actor's own process through moxzi-rts, with Proto.Actor keeping its image: see /docs/hosts/in-process/ for the contract.
| In process on Proto.Actor | |
|---|---|
| Binding | cgo, inside the cluster member |
| The actor's image lives in | Postgres |
| Update latency vs Proto.Actor's own durable actor (p50, macOS) | 2.39x (1.41x on Linux) |
| Conformance | HOST=protoactor-inproc scripts/host_matrix_gate.sh |
From moxzi/adapters/protoactor/ (the exact commands and environment are in moxzi/conformance/hosts/protoactor-inproc.json):
(cd ../../ && cargo build --release -p moxzi-rts) # libmoxzi_rts first
go build -tags inproc -o out/moxzi-protoactor .
DATABASE_URL=… MOXZI_IN_PROCESS=1 out/moxzi-protoactor --listen 127.0.0.1:8090
The conformance matrix#
Proto.Actor's column of the spec §61 matrix, real host, real process kills (docs/host-adapters.md, measured 2026-09-23 against compiler b7577666):
| §61 scenario | Proto.Actor |
|---|---|
| Persistence | PASS |
| Process Failure | PASS |
| Trap Rollback | PASS |
| Actor Isolation | PASS |
| Actor Parallelism | PASS 1.04× |
| Duplicate Invocation | PASS |
| Await | PASS |
| Await Across Restart | PASS |
| Upgrade With No Awaits | PASS |
| Upgrade With Outstanding Await | PASS |
| Permanently Unresolved Await | PASS |
| Timer Across Passivation | PASS |
| Timer Across Process Failure | PASS |
| Tiny Heap Mutation (206 B on an 8 MB heap) | PASS |
| ICRC-72 | PASS |
| Upgrade Compatibility | PASS |
| total | 16/16 |
Reproduce it (builds the Go binary, starts a dedicated Postgres container, the Proto.Actor member, and two moxzi-actor-host instances per actor type, then drives all sixteen scenarios with real kills and restarts):
HOST=protoactor scripts/host_matrix_gate.sh