Restate
This host runs the actor-host contract, version 2, on top of Restate: one virtual object MoxziActor per key Type/id, its EXCLUSIVE handler step doing the one-step-at-a-time work, its SHARED handler turn orchestrating a step loop that can park on another actor's answer without holding the key, and a second virtual object MoxziControl holding placement and the held/pending calls. The code is moxzi/adapters/restate/src/bin/host.rs (moxzi-restate-host, Rust, on the official restate-sdk crate).
What Restate owns, what Motoko owns#
| Owns | Who | What |
|---|---|---|
| One step at a time per key | Restate | the EXCLUSIVE handler MoxziActor/{key}/step — Restate's own per-key concurrency limit, not a lock this process manages |
| A turn that parks and still takes messages | Restate | the SHARED handler MoxziActor/{key}/turn (no key lock): it loops step/resume until the actor replies, and a HELD callee suspends on an awakeable instead of blocking the key |
| Routed awaits, durably | Restate | a durable object call to the callee's turn — Restate's own journal, not a hand-rolled route table; a crash mid-await replays from the journal, not from anything this process wrote down |
| Durable timer | Restate | a delayed send, send_after(remind) — Restate's own scheduler, not a reminder service or a polled table |
| The held window (restart/upgrade tests) | Restate | an awakeable: deliver resolves it, cancel rejects it, and Restate keeps the parked turn invocation suspended across any restart of this process or the actor-host service |
| Recovery after the host dies | Restate | replays the invocation journal into whichever process re-registers the deployment — no re-drive loop in this code at all |
| Placement, held keys, pending calls | Restate | state of the virtual object MoxziControl/control (set_placement, hold, park/pending/take) |
| Motoko execution, the heap, upgrades | moxzi-actor-host (Rust) | everything past /v2/* (docs/host-abi.md, "The actor-host contract, version 2"; routes in moxzi/actor-host/src/main.rs's header) — moxzi-restate-host does exactly one JSON POST per step to whichever service instance owns the key |
Restate is the one host in this chapter where the "recovery after the host dies" row is not a re-drive loop written for this project: an in-flight turn invocation — including one parked on an awakeable — is a row in Restate's own journal, and a new process for the same deployment just gets it replayed at it. Every other host adapter keeps its own route journal for the same purpose; this one has none.
Two HTTP servers, one process#
moxzi-restate-host binds two ports:
PORT— the host-control surface:PUT /_moxzi/placement,POST /actors/{t}/{id}/method/{m},/admin/{drain,upgrade,passivate,state,outstanding},/healthz(docs/host-abi.md, "The host-control surface") — the same routes every other host in this chapter serves, and the only ones this tutorial calls.PORT+300— the Restate SDK endpoint: the two virtual objects (MoxziActor,MoxziControl), which only Restate itself calls (ctx.object_clientunder the hood, or Restate's ingress if you go around this process — don't; the host-control surface is the contract).
On boot the process self-registers the SDK endpoint as a Restate deployment (POST {admin}/deployments {"uri": advertised, "force": true}), where advertised defaults to http://host.docker.internal:{PORT+300} — this only resolves from inside a container, which is exactly where the Restate server runs, so docker run needs --add-host=host.docker.internal:host-gateway on Linux (macOS Docker Desktop, used below, already maps that name to the host).
A method call's JSON body is the raw arguments array, not wrapped in an envelope — POST /actors/Customer/1/method/addOrder -d '[{"id":1,"total":30}]', per docs/candid-json.md, not {"args": [...]}. Sending the wrapped form answers 500 from the underlying moxzi-actor-host service ("args: expected a JSON array of 1 argument(s), got an object") — worth knowing before you go looking for a routing bug that isn't one.
Prerequisites#
Verified on this machine:
docker --version—Docker version 29.2.1, build a5c7197.restatedev/restate:latestalready pulled (docker images | grep restate—371MB, no download needed).moxzi,moxzi-actor-hostandmoxzi-restate-host, prebuilt atmoxzi/target/release/— no cargo build needed for this walkthrough.
Walk through it#
Ports below are picked to avoid colliding with anything else on the box; use any free ports of your own. /tmp/moxzi-tutorial-restate/ is the scratch directory for this walk-through.
Write and build the two actors#
Customer takes orders and, on chargeVia, awaits a Bank actor by principal text; it also arms a durable timer. This walkthrough builds the spec's reference fixtures unmodified (moxzi/conformance/fixtures/{customer,bank}.mo) — the parts this tutorial exercises:
// 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 {
let n = orders.size();
orders := Prim.Array_tabulate<Order>(n + 1, func (i : Nat) : Order { if (i < n) orders[i] else order });
balance += order.total;
orders.size()
};
public query func getBalance() : async Nat { balance };
public query func orderCount() : async Nat { orders.size() };
// A cross-actor await: `bank` is the Bank actor's principal, as text.
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 };
// … plus trapAfterWrites, grow/heapBytes, tiny and spin — the rest of §61's reference actor.
};
// 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.81s)
MOXZI_COMPILER=bin/compiler-canister/compiler.wasm MOXZI_LINKER=bin/linker-canister/linker.wasm \
moxzi/target/release/moxzi build bank.mo -o bank.wasm
# bank.mo -> bank.wasm + bank.did (249927 bytes, 1 file(s), 58 steps, 0.51s)
Start the moxzi-actor-host services#
One service per type — --known Bank/1 prints Bank's principal so Customer can await it by text, before Bank's own service is even started:
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:48521 --state ./state-customer \
--namespace tutorial --deployment restate-tutorial --actor Customer=./customer.wasm --known Bank/1
# moxzi-actor-host: Bank/1 is avixb-o2cav-u5s36-indxj-fgy6g-g5pfw-ojeyz-if5uu-cbcph-6hatz-xqe
# moxzi-actor-host: moxzi-actor-host/2 on 127.0.0.1:48521, hosting Customer
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:48522 --state ./state-bank \
--namespace tutorial --deployment restate-tutorial --actor Bank=./bank.wasm
# moxzi-actor-host: moxzi-actor-host/2 on 127.0.0.1:48522, hosting Bank
Start Restate, and moxzi-restate-host#
docker run -d --name moxzi-tutorial-restate -p 48180:8080 -p 48190:9070 \
--add-host=host.docker.internal:host-gateway restatedev/restate:latest
--add-host is load-bearing here: without it, the container can't resolve host.docker.internal to reach moxzi-restate-host's SDK endpoint on the Mac host, and registration hangs.
PORT=48200 MOXZI_RESTATE_INGRESS=http://127.0.0.1:48180 MOXZI_RESTATE_ADMIN=http://127.0.0.1:48190 \
moxzi/target/release/moxzi-restate-host
# moxzi-restate-host: contract moxzi-actor-host/2; SDK endpoint :48500 registered as
# http://host.docker.internal:48500; host-control on :48200
Confirm the deployment really registered (curl localhost:48190/deployments):
{"deployments": [{"id": "dp_17kJOwsbkeh8O7HGV3xOkKJ",
"uri": "http://host.docker.internal:48500/",
"services": [{"name": "MoxziControl", "revision": 1}, {"name": "MoxziActor", "revision": 1}]}]}
Point placement at the services, and call it#
curl -X PUT http://127.0.0.1:48200/_moxzi/placement -H 'content-type: application/json' \
-d '{"types":{"Customer":["http://127.0.0.1:48521"],"Bank":["http://127.0.0.1:48522"]}}'
# HTTP 204
curl -X POST http://127.0.0.1:48200/actors/Customer/1/method/addOrder \
-H 'content-type: application/json' -d '[{"id":1,"total":30}]'
# [1] HTTP 200, x-moxzi-outcome: replied
curl -X POST http://127.0.0.1:48200/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [30]
A cross-actor await#
curl -X POST http://127.0.0.1:48200/actors/Customer/1/method/chargeVia \
-H 'content-type: application/json' \
-d '["avixb-o2cav-u5s36-indxj-fgy6g-g5pfw-ojeyz-if5uu-cbcph-6hatz-xqe", 10]'
# [40] HTTP 200
curl -X POST http://127.0.0.1:48200/actors/Bank/1/method/total -H 'content-type: application/json' -d '[]'
# [10]
Customer's turn handler made a durable object call to Bank's turn (not a hand-rolled HTTP route) and resumed with the answer — 30 + 10 = 40, and Bank really was debited.
This really went through Restate's own journal, not a passthrough — Restate's SQL introspection endpoint shows it as rows in sys_invocation:
curl -s http://127.0.0.1:48190/query -H 'content-type: application/json' -H 'accept: application/json' \
-d '{"query":"SELECT target, status FROM sys_invocation ORDER BY created_at DESC LIMIT 6"}'
{"rows": [
{"target": "MoxziActor/Customer/1/step", "status": "completed"},
{"target": "MoxziControl/control/pending", "status": "completed"},
{"target": "MoxziActor/Customer/1/turn", "status": "completed"},
{"target": "MoxziActor/Bank/1/step", "status": "completed"},
{"target": "MoxziControl/control/pending", "status": "completed"},
{"target": "MoxziActor/Bank/1/turn", "status": "completed"}
]}
Every step (the EXCLUSIVE handler's ctx.run) and every turn (the SHARED handler orchestrating the await) is its own completed invocation in Restate's journal.
Arm a timer, and watch it fire#
curl -X POST http://127.0.0.1:48200/actors/Customer/1/method/armTimer \
-H 'content-type: application/json' -d '[3000000000]'
curl -X POST http://127.0.0.1:48200/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [0]
sleep 5
curl -X POST http://127.0.0.1:48200/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
The 3-second due time became a Restate delayed send (send_after, on MoxziActor's remind handler for this key); Restate's own scheduler fired it, remind called this service's /v2/remind, and the Motoko timer callback ran.
Kill it and bring it back#
Service-level restart — the Motoko heap is a file on disk, independent of Restate:
kill <moxzi-actor-host Customer pid> # the one on :48521
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:48521 --state ./state-customer \
--namespace tutorial --deployment restate-tutorial --actor Customer=./customer.wasm --known Bank/1
curl -X POST http://127.0.0.1:48200/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [40] — the durable heap on disk survived
curl -X POST http://127.0.0.1:48200/actors/Customer/1/method/orderCount -H 'content-type: application/json' -d '[]'
# [1]
curl -X POST http://127.0.0.1:48200/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
Nothing about placement or the Restate deployment needed to be redone — that state lives in Restate's own store (the MoxziControl object and the deployment registration), in the moxzi-tutorial-restate container, not in the service process that was restarted.
Upgrade it, without losing state or the outstanding contract#
curl -X POST http://127.0.0.1:48200/actors/Customer/1/admin/drain -d '{}'
# {"state":"Quiescent"}
curl -X POST http://127.0.0.1:48200/actors/Customer/1/admin/upgrade \
-H 'content-type: application/json' \
-d "{\"deploymentId\":\"restate-tutorial-v2\",\"wasm\":\"$(base64 -i customer.wasm | tr -d '\n')\"}"
# {"state":"Active"}
curl -X POST http://127.0.0.1:48200/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [40] — state survived the upgrade
curl -X POST http://127.0.0.1:48200/actors/Customer/1/method/orderCount -H 'content-type: application/json' -d '[]'
# [1]
drain and upgrade are forwarded straight to the owning moxzi-actor-host service's /v2/drain and /v2/upgrade with the key filled in — Restate is not involved in the upgrade itself, only in the fact that new turns for that key queue behind the EXCLUSIVE handler while it happens.
Operating notes#
These notes are for the service form. In process, the actor's image lives in the virtual object's Restate state and nothing is written to a service's disk — see In process below.
- Durable state, two places, two owners. The Motoko heap (each actor's orthogonally persistent state — a snapshot, a WAL tail and a content-addressed
modules/directory of deployed wasm) lives on eachmoxzi-actor-hostservice's own disk, under its--statedirectory (state-customer/,state-bank/above; ~64 KB for this tutorial's actors). Restate's own state — the deployment registration, the invocation journal, and this adapter's placement/held/pending rows in theMoxziControlobject — lives entirely inside themoxzi-tutorial-restatecontainer, invisible from the host filesystem. A service restart only needs the first; amoxzi-restate-hostrestart only needs the second (it reloads placement fromMoxziControlon boot); both survived independently above. - Upgrading:
POST /actors/{t}/{id}/admin/drain(waits forQuiescent— no outstandingawait), thenPOST /actors/{t}/{id}/admin/upgrade {deploymentId, wasm}(wasm as base64). A failed upgrade leaves the previous deployment running;GET /actors/{t}/{id}/admin/statereadsActive/Draining/Quiescent/Upgradingwithout side effects. - What to watch:
GET /healthzonmoxzi-restate-host'sPORT, andGET {admin}/deploymentsto confirm the SDK endpoint is still registered (a container restart or a network change under it does not re-register automatically — this process only registers once, at its own startup). Restate's ownsys_invocationtable (queryable atPOST {admin}/query,Accept: application/json) shows every step and turn and their status, including ones stuck mid-await — useful for telling "parked on a HELD key" apart from "actually wedged."
In process#
The same actor also runs inside Restate's own process through moxzi-rts, with Restate keeping its image: see /docs/hosts/in-process/ for the contract.
| In process on Restate | |
|---|---|
| Binding | Rust, linked into the Restate service |
| The actor's image lives in | the virtual object's Restate state |
| Update latency vs Restate's own durable actor (p50, macOS) | 1.49x |
| Conformance | HOST=restate-inproc scripts/host_matrix_gate.sh |
From moxzi/ (the exact commands and environment are in moxzi/conformance/hosts/restate-inproc.json):
cargo build --release -p moxzi-restate --bin moxzi-restate-host
MOXZI_IN_PROCESS=1 MOXZI_RESTATE_INGRESS=… MOXZI_RESTATE_ADMIN=… \
target/release/moxzi-restate-host
The conformance matrix#
Restate's column of the spec §61 matrix, real server (docker), real process kills (docs/host-adapters.md, measured 2026-09-23 against compiler b7577666):
| §61 scenario | Restate |
|---|---|
| Persistence | PASS |
| Process Failure | PASS |
| Trap Rollback | PASS |
| Actor Isolation | PASS |
| Actor Parallelism | PASS 1.07× |
| 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 (starts a real restatedev/restate server in docker, two moxzi-restate-host instances registered against it, and two moxzi-actor-host instances per actor type, then drives all sixteen scenarios with real kills and restarts):
HOST=restate scripts/host_matrix_gate.sh