Orleans
This host runs the actor-host contract, version 2, on top of an Orleans silo: one grain (StepGrain, IGrainWithStringKey) per key Type/id, an Orleans reminder for the durable timer, and the route journal in a second grain (JournalGrain) persisted through Microsoft.Orleans.Persistence.AdoNet on Postgres. The code lives in moxzi/adapters/orleans (.NET 8, Orleans 9.1) — a from-scratch port of the reference Go host's orchestration (moxzi/adapters/protoactor/main.go) onto Orleans' own primitives, not a Dapr-protocol impersonation.
What Orleans owns, what Motoko owns#
| Owns | Who | What |
|---|---|---|
Actor identity, Type/id | Orleans | the grain key on IStepGrain/IGrainWithStringKey |
| One step at a time per key | Orleans | a grain's single-threaded mailbox (StepGrain.Step) |
| A turn that parks and still takes messages | this silo's ASP.NET handlers, not a grain | Orchestrator.cs's HostOrchestrator loops over steps beside the grain — if routing a parked await happened inside the grain call, the key would be unavailable for the whole await (the IC lets other turns interleave during an await; Orleans would not), and two actors awaiting each other would deadlock on their own grain calls |
| Routed awaits, durably | JournalGrain | one grain, key "journal", persisted via Microsoft.Orleans.Persistence.AdoNet on Postgres; a single grain gets the no-lost-update guarantee for free from Orleans' own call serialization, instead of hand-rolled SQL transactions |
| Durable actor timer | an Orleans reminder | RegisterOrUpdateReminder on StepGrain; ReceiveReminder unregisters first, then calls POST /v2/remind and re-arms from nextDueInNs if it isn't null |
| The held window (restart/upgrade tests) | JournalGrain | a held-key set in the same ADO.NET-persisted grain |
| Recovery after the silo dies | this silo | HostOrchestrator.Recover(), kicked off in the background once startup loads the journal's persisted placement into the in-process PlacementCache — every non-held route is re-driven |
| Motoko execution, the heap, upgrades | moxzi-actor-host (Rust, one process per actor type/instance) | everything past /v2/* (docs/host-abi.md, "The actor-host contract, version 2"; routes in moxzi/actor-host/src/main.rs's header) — the silo only calls its routes, never the other way |
StepGrain deliberately does not run the orchestration — a grain call is non-reentrant and holds the key for its whole duration. See moxzi/adapters/orleans/README.md's file map for exactly which .cs file owns which piece.
Prerequisites#
Verified on this machine:
~/.dotnet/dotnet --version—8.0.425..NETlives under~/.dotnet, not a system-wide install, so every command below puts~/.dotnetonPATHexplicitly.docker --version—Docker version 29.2.1, build a5c7197.postgres:16already pulled (docker images | grep postgreslists it) — nothing to download.moxzi,moxzi-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 (475xx/47432) are picked to avoid colliding with anything else on the box; use any free ports of your own. /tmp/moxzi-tutorial-orleans/ 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.55s)
Start Postgres#
docker run -d --name moxzi-tutorial-orleans-pg \
-e POSTGRES_PASSWORD=moxzi -e POSTGRES_DB=moxzi -p 47432:5432 postgres:16
Publish and start the silo#
~/.dotnet/dotnet run -c Release -- does work fine interactively (verified: schema provisioned, silo up, no libhostfxr error) — the dotnet publish + dotnet MoxziOrleans.dll two-step below is what the conformance harness and its orleans.json launch file actually use, because the harness's child process doesn't inherit a PATH with ~/.dotnet on it and the published apphost run directly (./MoxziOrleans) can't autolocate the runtime from there (Failed to resolve libhostfxr.dylib). In your own shell, with ~/.dotnet on PATH, either works; this walk-through uses the harness's own proven pattern so the commands match what's actually gated.
export PATH="$HOME/.dotnet:$PATH" DOTNET_CLI_TELEMETRY_OPTOUT=1
cd moxzi/adapters/orleans
dotnet publish -c Release -o /tmp/moxzi-tutorial-orleans/publish/orleans
# MoxziOrleans -> .../moxzi/adapters/orleans/bin/Release/net8.0/MoxziOrleans.dll
# MoxziOrleans -> /tmp/moxzi-tutorial-orleans/publish/orleans/
DATABASE_URL="postgres://postgres:moxzi@127.0.0.1:47432/moxzi?sslmode=disable" \
MOXZI_HOST_NS=tutorial \
dotnet /tmp/moxzi-tutorial-orleans/publish/orleans/MoxziOrleans.dll --urls http://127.0.0.1:47500 &
First boot against a fresh Postgres provisions the ADO.NET schema itself — no separate migration step:
moxzi-orleans: created Orleans ADO.NET schema (orleansquery)
moxzi-orleans: created Orleans ADO.NET schema (orleansstorage)
moxzi-orleans: created Orleans ADO.NET schema (orleansreminderstable)
moxzi-orleans: contract moxzi-actor-host/2, silo up (silo:47600 gateway:47700), listening on :47500
curl -s http://127.0.0.1:47500/healthz
# ok
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:47521 --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:47521, hosting Customer
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:47522 --state ./state-bank \
--namespace tutorial --deployment crm --actor Bank=./bank.wasm
# moxzi-actor-host: moxzi-actor-host/2 on 127.0.0.1:47522, hosting Bank
Point placement at the services, and call it#
curl -X PUT http://127.0.0.1:47500/_moxzi/placement -H 'content-type: application/json' \
-d '{"types":{"Customer":["http://127.0.0.1:47521"],"Bank":["http://127.0.0.1:47522"]}}'
# HTTP 204
curl -X POST http://127.0.0.1:47500/actors/Customer/1/method/addOrder \
-H 'content-type: application/json' -d '[{"id":1,"total":30}]'
# [1] HTTP 200, x-moxzi-outcome: replied
A cross-actor await#
curl -X POST http://127.0.0.1:47500/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:47500/actors/Bank/1/method/total -H 'content-type: application/json' -d '[]'
# [5]
Customer's turn parked on Bank's answer; the silo journaled the route in JournalGrain 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#
Orleans enforces a minimum reminder period, not a minimum due time, so a short due time is fine — armTimer below uses 5 seconds:
curl -X POST http://127.0.0.1:47500/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [0]
curl -X POST http://127.0.0.1:47500/actors/Customer/1/method/armTimer \
-H 'content-type: application/json' -d '[5000000000]'
# []
sleep 8
curl -X POST http://127.0.0.1:47500/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
The due time became StepGrain's reminder; the Orleans reminder table on Postgres fired it, ReceiveReminder unregistered it and called POST /v2/remind on the Customer service, which ran the Motoko timer callback.
Kill it and bring it back#
Service-level restart — the Motoko heap is a file on disk, independent of Orleans:
kill <moxzi-actor-host Customer pid> # 47521
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:47521 --state ./state-customer \
--namespace tutorial --deployment crm --actor Customer=./customer.wasm --known Bank/1
curl -X POST http://127.0.0.1:47500/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:47500/actors/Customer/1/method/orderCount -H 'content-type: application/json' -d '[]'
# [1]
curl -X POST http://127.0.0.1:47500/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
Silo-level restart — the routing journal and placement are on Postgres, not this process:
kill <silo pid> # 47500 — graceful shutdown, ~5s
DATABASE_URL="postgres://postgres:moxzi@127.0.0.1:47432/moxzi?sslmode=disable" \
MOXZI_HOST_NS=tutorial \
dotnet /tmp/moxzi-tutorial-orleans/publish/orleans/MoxziOrleans.dll --urls http://127.0.0.1:47500 &
curl -X POST http://127.0.0.1:47500/actors/Customer/1/method/orderCount -H 'content-type: application/json' -d '[]'
# [1] — no re-PUT of placement: startup reloaded it from JournalGrain on Postgres
curl -X POST http://127.0.0.1:47500/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [35]
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, two places: the Motoko heap (each actor's orthogonally persistent state — snapshot, WAL tail, a content-addressed
modules/directory of deployed wasm) lives on eachmoxzi-actor-hostservice's own disk, under its--statedirectory. The silo's own state — placement, held keys, the route journal — is one row in Postgres'orleansstoragetable (graintypestring = 'journal', ~900 bytes for this walk-through); the durable timer is a row inorleansreminderstablewhile armed, gone once it has fired and not re-armed. Neither store knows about the other; a service restart only needs the first, a silo 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:47500/actors/Customer/1/admin/drain -d '{}' # {"state":"Quiescent"} curl -X POST http://127.0.0.1:47500/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. Verified above with the same wasm redeployed under a newdeploymentId:orderCount/getBalance` read back unchanged after the round trip. - What to watch: the silo's
/healthz, and each service's own/healthzandGET /v2/state?key=Type/id(Active / Draining / Quiescent / Upgrading). A timer that never fires or anawaitthat never resumes shows up as a row that never clears inorleansreminderstableor inJournalGrain's route set — inspect the latter withGET /_moxzi/pendingon the silo, or the former directly withpsqlagainst the Postgres container.
In process#
The same actor also runs inside Orleans's own process through moxzi-rts, with Orleans keeping its image: see /docs/hosts/in-process/ for the contract.
| In process on Orleans | |
|---|---|
| Binding | P/Invoke, inside the silo |
| The actor's image lives in | Postgres |
| Update latency vs Orleans's own durable actor (p50, macOS) | 1.73x |
| Conformance | HOST=orleans-inproc scripts/host_matrix_gate.sh |
From moxzi/adapters/orleans/ (the exact commands and environment are in moxzi/conformance/hosts/orleans-inproc.json):
dotnet publish -c Release -o out/orleans
DATABASE_URL=… MOXZI_IN_PROCESS=1 MOXZI_RTS_LIB_DIR=../../target/release \
dotnet out/orleans/MoxziOrleans.dll --urls http://127.0.0.1:8090
The conformance matrix#
Orleans' column of the spec §61 matrix, real silo, real process kills (docs/host-adapters.md, measured 2026-09-23 against compiler b7577666; the README's own narrated run shows the full transcript, "16 PASS, 0 FAIL, 0 SKIP — nothing in orleans.json's skip block"):
| §61 scenario | Orleans |
|---|---|
| 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 dedicated Postgres container, publishes and runs the silo, and two moxzi-actor-host instances per actor type, then drives all sixteen scenarios with real kills and restarts):
HOST=orleans scripts/host_matrix_gate.sh