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#
| Owns | Who | What |
|---|---|---|
| One step at a time per key | Dapr | a 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 messages | this process, beside the actor | hostkit.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, durably | this process | a 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 timer | Dapr reminder | one 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 process | a held flag in the journal (daprJournal.Hold/IsHeld), in the same state store |
| Recovery after the host dies | this process | re-drive in-flight routes once the sidecar reports a placement table again |
| 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) — 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:
dapr --version— CLI1.18.0, Runtime1.18.4.go version—go1.27.1 darwin/arm64.dapr_placement,dapr_scheduler,dapr_redis,dapr_zipkinalready running (docker ps | grep dapr_) from an earlierdapr init. Use them as they are — don'tdapr init/dapr uninstall, don't restart or inspect-modify them; they're shared.- A
statestorecomponent at~/.dapr/components/statestore.yaml(dapr init's default:state.redisonlocalhost:6379,actorStateStore: "true") — the Go host needs it (MOXZI_STATE_STORE, default"statestore"). moxziandmoxzi-actor-host, built (cargo build --release -p moxzi -p moxzi-actor-hostundermoxzi/, or use the ones already atmoxzi/target/release/).
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.
- Durable state, two places: the Motoko heap (each actor's orthogonally persistent state) lives on each
moxzi-actor-hostservice's own disk, under its--statedirectory — a snapshot, a WAL tail and a content-addressedmodules/directory of deployed wasm, per actor. This host's own state — placement, held keys, the route journal — lives in Dapr's state store (dapr_redisunderdapr init's defaults), namespaced byMOXZI_HOST_NS(defaultdefault); a route key is<ns>|route|<callId>, a placement key is<ns>|placement. Neither store knows about the other; a service restart only needs the first, an app restart only needs the second, and both survived independently above. - Upgrading an actor: drain, then upgrade with the new module as base64: ``
sh curl -X POST http://127.0.0.1:39010/actors/Customer/1/admin/drain -d '{}' # {"state":"Quiescent"} curl -X POST http://127.0.0.1:39010/actors/Customer/1/admin/upgrade \ -H 'content-type: application/json' \ -d "{\"deploymentId\":\"crm-v2\",\"wasm\":\"$(base64 -i customer.wasm | tr -d '\n')\"}" # {"state":"Active"}`BothPOST … /admin/{op}routes are forwarded straight to the owningmoxzi-actor-hostservice with the key filled in;GET …/admin/{state,outstanding}is read-only and answers from the same place. An upgrade only proceeds once the actor isQuiescent` (no outstanding await); a failed upgrade leaves the previous deployment running. - What to watch: the sidecar's own health (
GET /v1.0/healthzon its HTTP port) and this app's/healthz, which itself blocks on the sidecar's — the harness waits on it before driving anything. A timer that never fires or anawaitthat never resumes shows up as a row that never clears in the state store's route/held keys (<ns>|routes,<ns>|held|<key>), inspectable throughredis-cliagainstdapr_redisdirectly. - The one gotcha this host works around: a Dapr state key goes in the URL path, and
Type/idcontains a/— the state get isurl.PathEscaped, or every lookup silently reads back "not found" (a comment inmain.go'sgetStatedates this to 2026-09-23: "holds never held").
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 | |
|---|---|
| Binding | cgo, inside the Dapr app |
| The actor's image lives in | Dapr's state store, one transaction per turn |
| Update latency vs Dapr's own durable actor (p50, macOS) | 1.70x |
| Conformance | HOST=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 scenario | Dapr |
|---|---|
| Persistence | PASS |
| Process Failure | PASS |
| Trap Rollback | PASS |
| Actor Isolation | PASS |
| Actor Parallelism | PASS 1.08× |
| 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 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