moxzi
Docs / Actor platforms / Restate

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#

OwnsWhoWhat
One step at a time per keyRestatethe 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 messagesRestatethe 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, durablyRestatea 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 timerRestatea delayed send, send_after(remind) — Restate's own scheduler, not a reminder service or a polled table
The held window (restart/upgrade tests)Restatean 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 diesRestatereplays the invocation journal into whichever process re-registers the deployment — no re-drive loop in this code at all
Placement, held keys, pending callsRestatestate of the virtual object MoxziControl/control (set_placement, hold, park/pending/take)
Motoko execution, the heap, upgradesmoxzi-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:

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:

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.

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
BindingRust, linked into the Restate service
The actor's image lives inthe virtual object's Restate state
Update latency vs Restate's own durable actor (p50, macOS)1.49x
ConformanceHOST=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 scenarioRestate
PersistencePASS
Process FailurePASS
Trap RollbackPASS
Actor IsolationPASS
Actor ParallelismPASS 1.07×
Duplicate InvocationPASS
AwaitPASS
Await Across RestartPASS
Upgrade With No AwaitsPASS
Upgrade With Outstanding AwaitPASS
Permanently Unresolved AwaitPASS
Timer Across PassivationPASS
Timer Across Process FailurePASS
Tiny Heap Mutation (206 B on an 8 MB heap)PASS
ICRC-72PASS
Upgrade CompatibilityPASS
total16/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
On this pageWhat Restate owns, what Motoko ownsTwo HTTP servers, one processPrerequisitesWalk through itWrite and build the two actorsStart the moxzi-actor-host servicesStart Restate, and moxzi-restate-hostPoint placement at the services, and call itA cross-actor awaitArm a timer, and watch it fireKill it and bring it backUpgrade it, without losing state or the outstanding contractOperating notesIn processThe conformance matrix