moxzi
Docs / Actor platforms / Orleans

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#

OwnsWhoWhat
Actor identity, Type/idOrleansthe grain key on IStepGrain/IGrainWithStringKey
One step at a time per keyOrleansa grain's single-threaded mailbox (StepGrain.Step)
A turn that parks and still takes messagesthis silo's ASP.NET handlers, not a grainOrchestrator.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, durablyJournalGrainone 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 timeran Orleans reminderRegisterOrUpdateReminder 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)JournalGraina held-key set in the same ADO.NET-persisted grain
Recovery after the silo diesthis siloHostOrchestrator.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, upgradesmoxzi-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:

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.

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
BindingP/Invoke, inside the silo
The actor's image lives inPostgres
Update latency vs Orleans's own durable actor (p50, macOS)1.73x
ConformanceHOST=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 scenarioOrleans
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 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
On this pageWhat Orleans owns, what Motoko ownsPrerequisitesWalk through itWrite and build the two actorsStart PostgresPublish and start the siloStart the moxzi-actor-host servicesPoint 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