moxzi

Dapr

This host runs the actor-host contract, version 2, on top of Dapr's sidecar: one Dapr actor (type MoxziActor) per key Type/id, a Dapr reminder for the durable timer, and the route journal in Dapr's state store (Redis, under dapr init's defaults). The code lives in moxzi/adapters/dapr/host (moxzi-daprhost, Go, on the shared moxzi/adapters/hostkit-go orchestration — the same kit the Proto.Actor host is extracted from).

What Dapr owns, what Motoko owns#

OwnsWhoWhat
One step at a time per keyDapra Dapr actor turn on type MoxziActor, entity id = hex of Type/id (a Dapr actor id can't hold a /); the sidecar's per-actor turn lock is the one-step-at-a-time guarantee, and placement is Dapr's
A turn that parks and still takes messagesthis process, beside the actorhostkit.Host.Drive loops over steps outside any Dapr actor call — if routing happened inside the actor's own turn, the key would be held for the whole await, and two actors awaiting each other would deadlock on their own turns
Routed awaits, durablythis processa route journal (Route rows) in Dapr's state store, written before a call is routed; a restart re-drives every non-held route (Host.Recover)
Durable timerDapr reminderone reminder (moxzi-global-timer) per key, kept by the Dapr scheduler so it survives the app and the sidecar dying
The held window (restart/upgrade tests)this processa held flag in the journal (daprJournal.Hold/IsHeld), in the same state store
Recovery after the host diesthis processre-drive in-flight routes once the sidecar reports a placement table again
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) — the Dapr actor does exactly one JSON POST per step to whichever service instance owns the key

One Dapr app process can host both actor types (Customer and Bank) at once — placement routes each key to whichever moxzi-actor-host instance owns it; you do not need two dapr run invocations.

Where calls actually land#

moxzi-daprhost is both the Dapr actor (the sidecar calls /actors/MoxziActor/{hexid}/method/ {step|remind} on it) and the host-control surface itself (PUT /_moxzi/placement, POST /actors/{t}/{id}/method/{m}, /admin/*, /healthz) — served directly on the app's own port, not proxied through the sidecar's v1.0/actors REST API. Verified on this machine: POST /actors/Customer/1/method/addOrder against the app's own port (39010 below) answers 200 with the result; the same call shape against the sidecar's v1.0/actors/MoxziActor/<hex>/method/addOrder (port 39510) answers 500 ERR_ACTOR_INVOKE_METHOD — actor method not found — the sidecar only knows the two Dapr-actor methods this app registers, step and remind, not the friendly method names. Always call the app's own port, never the sidecar's v1.0/actors path.

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-dapr/ 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. 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 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 };
};
// 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.52s)

Start the moxzi-actor-host services#

One service per type — --known Bank/1 prints Bank's principal so Customer can await it by text:

moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:39531 --state ./state-customer \
  --namespace tutorial --deployment crm --actor Customer=./customer.wasm --known Bank/1
# moxzi-actor-host: Bank/1 is 6lkaw-mxxfa-ayudl-lyjba-qiou3-hjvqo-deil4-f3r6l-apodg-jbfpw-uae
# moxzi-actor-host: moxzi-actor-host/2 on 127.0.0.1:39531, hosting Customer

moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:39532 --state ./state-bank \
  --namespace tutorial --deployment crm --actor Bank=./bank.wasm
# moxzi-actor-host: moxzi-actor-host/2 on 127.0.0.1:39532, hosting Bank

Build and start the Dapr Go host#

cd moxzi/adapters/dapr/host && go build -o /tmp/moxzi-tutorial-dapr/moxzi-daprhost .

cd /tmp/moxzi-tutorial-dapr
dapr run --app-id moxzi-tutorial-crm --app-port 39010 \
  --dapr-http-port 39510 --dapr-grpc-port 39511 --metrics-port 39512 \
  --log-level info -- ./moxzi-daprhost --listen 127.0.0.1:39010
# 2026/09/23 20:57:40 moxzi-daprhost: contract moxzi-actor-host/2, Dapr actor type MoxziActor, sidecar http://127.0.0.1:39510, listening on 127.0.0.1:39010
# ✅  You're up and running! Both Dapr and your app logs will appear here.

curl -s http://127.0.0.1:39010/healthz
# ok

Point placement at the services, and call it#

curl -X PUT http://127.0.0.1:39010/_moxzi/placement -H 'content-type: application/json' \
  -d '{"types":{"Customer":["http://127.0.0.1:39531"],"Bank":["http://127.0.0.1:39532"]}}'
# HTTP 204

curl -X POST http://127.0.0.1:39010/actors/Customer/1/method/addOrder \
  -H 'content-type: application/json' -d '[{"id":1,"total":30}]'
# [1]   HTTP 200

A cross-actor await#

curl -X POST http://127.0.0.1:39010/actors/Customer/1/method/chargeVia \
  -H 'content-type: application/json' \
  -d '["6lkaw-mxxfa-ayudl-lyjba-qiou3-hjvqo-deil4-f3r6l-apodg-jbfpw-uae", 5]'
# [35]  HTTP 200

curl -X POST http://127.0.0.1:39010/actors/Bank/1/method/total -H 'content-type: application/json' -d '[]'
# [5]

Customer's turn parked on Bank's answer; the Dapr host wrote a route row to the state store before routing the call to the service that owns Bank/1, then resumed Customer with the reply — 30 + 5 = 35.

Arm a timer, and watch it fire#

curl -X POST http://127.0.0.1:39010/actors/Customer/1/method/armTimer \
  -H 'content-type: application/json' -d '[3000000000]'
curl -X POST http://127.0.0.1:39010/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [0]
sleep 6
curl -X POST http://127.0.0.1:39010/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]

The 3-second due time became a Dapr reminder; the scheduler container fired it, the sidecar called this app's /actors/MoxziActor/{hexid}/method/remind, and remind ran the Motoko timer callback.

Kill it and bring it back#

Service-level restart — the Motoko heap is a file on disk, independent of Dapr:

kill <moxzi-actor-host Customer pid>          # 39531
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:39531 --state ./state-customer \
  --namespace tutorial --deployment crm --actor Customer=./customer.wasm --known Bank/1

curl -X POST http://127.0.0.1:39010/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [35]  — the durable heap on disk survived
curl -X POST http://127.0.0.1:39010/actors/Customer/1/method/orderCount -H 'content-type: application/json' -d '[]'
# [1]
curl -X POST http://127.0.0.1:39010/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]

App-level restart — the journal (placement, routes, held keys) is Redis, not this process:

dapr stop --app-id moxzi-tutorial-crm
# ✅  app stopped successfully: moxzi-tutorial-crm

dapr run --app-id moxzi-tutorial-crm --app-port 39010 \
  --dapr-http-port 39510 --dapr-grpc-port 39511 --metrics-port 39512 \
  --log-level info -- ./moxzi-daprhost --listen 127.0.0.1:39010

curl -X POST http://127.0.0.1:39010/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [35]  — no re-PUT of placement: this app polls Redis for a persisted placement on boot

Operating notes#

These notes are for the service form. In process, the actor's image lives in Dapr's state store, one transaction per turn and nothing is written to a service's disk — see In process below.

In process#

The same actor also runs inside Dapr's own process through moxzi-rts, with Dapr keeping its image: see /docs/hosts/in-process/ for the contract.

In process on Dapr
Bindingcgo, inside the Dapr app
The actor's image lives inDapr's state store, one transaction per turn
Update latency vs Dapr's own durable actor (p50, macOS)1.70x
ConformanceHOST=dapr-inproc scripts/host_matrix_gate.sh

From moxzi/adapters/dapr/host/ (the exact commands and environment are in moxzi/conformance/hosts/dapr-inproc.json):

(cd ../../../ && cargo build --release -p moxzi-rts)   # libmoxzi_rts first
go build -tags inproc -o out/moxzi-daprhost .
MOXZI_IN_PROCESS=1 bash run.sh out

The conformance matrix#

Dapr's column of the spec §61 matrix, real sidecar, real process kills (docs/host-adapters.md, measured 2026-09-23 against compiler b7577666):

§61 scenarioDapr
PersistencePASS
Process FailurePASS
Trap RollbackPASS
Actor IsolationPASS
Actor ParallelismPASS 1.08×
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 the Dapr host under dapr run against the placement, scheduler and Redis from dapr init, and two moxzi-actor-host instances per actor type, then drives all sixteen scenarios with real kills and restarts):

HOST=dapr scripts/host_matrix_gate.sh
On this pageWhat Dapr owns, what Motoko ownsWhere calls actually landPrerequisitesWalk through itWrite and build the two actorsStart the moxzi-actor-host servicesBuild and start the Dapr Go hostPoint placement at the services, and call itA cross-actor awaitArm a timer, and watch it fireKill it and bring it backOperating notesIn processThe conformance matrix