Ray
This host runs the actor-host contract, version 2, on top of a named Ray actor per key (@ray.remote(max_concurrency=1), name moxzi/Type/id): Ray's own actor mailbox semantics — one method call in flight at a time on a synchronous actor — are the whole one-step-at-a- time guarantee, no lock of this host's own. It is a faithful Python port of the shared Go host kit (moxzi/adapters/hostkit-go/hostkit.go) that the Proto.Actor reference host was extracted from, for Ray + Postgres. The code is moxzi/adapters/ray (moxzi-ray-host, Python, Ray 2.58), built by moxzi/conformance/hosts/ray.json and driven by HOST=ray scripts/host_matrix_gate.sh.
What Ray owns, what Motoko owns#
| Owns | Who | What |
|---|---|---|
| Identity / one-step-at-a-time per key | Ray, a named actor per key | KeyActor, max_concurrency=1, name moxzi/{key}; Ray's own mailbox is the serialisation mechanism |
| Routing | this process, outside any Ray actor | every parked await becomes a durable route row before it is routed (a _routes table in Postgres); a restart re-drives every non-held route |
| Timers | this process | a _timers table polled every 200ms; a claimed row (DELETE … RETURNING) fires exactly once |
| Placement / held / pending calls | this process, in Postgres | _placement, _held tables behind PUT /_moxzi/placement and POST /_moxzi/hold |
| The HTTP surface | this process | server.py on ThreadingHTTPServer — different keys' turns run concurrently in different request threads; a single key's steps still serialise one layer down, inside its Ray actor |
| Motoko execution, the heap, upgrades | moxzi-actor-host (Rust) | everything past /v2/* — the Ray actor only proxies one JSON POST per step |
Routing runs outside the Ray actor, not inside KeyActor.do_post: if a key's actor blocked on a routed call until the callee answered, the key would be held for the whole await, and two actors awaiting each other would deadlock. So the actor does one HTTP POST and returns; the shared hostkit.Host drives the resulting call chain in the HTTP front's own request thread. Ray has neither durable actor state (a KeyActor's Python object lives in RAM only) nor a durable timer of its own, so placement, held keys, the route journal and armed timers all live in Postgres, tables <ns>_placement, <ns>_held, <ns>_routes, <ns>_timers — the same schema shape as Proto.Actor's and Pekko's.
Prerequisites#
Verified on this machine:
python3 --version—Python 3.11.3.python3 -c "import ray; print(ray.__version__)"—2.58.0.python3 -c "import psycopg; print(psycopg.__version__)"—3.3.6(psycopg[binary],moxzi/adapters/ray/requirements.txt).- Docker, for a Postgres 16 container. This walk-through names it
moxzi-tutorial-pg;docker psfirst to confirm the name and port you pick are free. moxziandmoxzi-actor-host, built (cargo build --release -p moxzi -p moxzi-actor-hostundermoxzi/, or use the ones already atmoxzi/target/release/).
Walk through it#
/tmp/moxzi-tutorial-ray/ is the scratch directory for this walk-through. ray_host.py starts its own private local Ray cluster (ray.init(address="local", ...)) — it never attaches to a cluster already running on the machine.
Write and build the two actors#
Same two actors as every other host page — Customer awaits Bank on chargeVia; both 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() };
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 (272261 bytes, 1 file(s), 65 steps, 0.79s)
MOXZI_COMPILER=bin/compiler-canister/compiler.wasm MOXZI_LINKER=bin/linker-canister/linker.wasm \
moxzi/target/release/moxzi build bank.mo -o bank.wasm
Start Postgres and the Ray host#
Pick a Postgres host port nothing else on the box is using (docker ps first):
docker run -d --name moxzi-tutorial-pg -e POSTGRES_PASSWORD=moxzi -e POSTGRES_DB=moxzi \
-p 25901:5432 postgres:16
cd /tmp/moxzi-tutorial-ray
DATABASE_URL="postgres://postgres:moxzi@127.0.0.1:25901/moxzi?sslmode=disable" \
MOXZI_HOST_NS=tutorial python3 moxzi/adapters/ray/ray_host.py --listen 127.0.0.1:29500 &
# ray: private local cluster up, namespace=tutorial
# moxzi-ray-host: contract moxzi-actor-host/2, listening on 127.0.0.1:29500
curl -s http://127.0.0.1:29500/healthz
# ok
Start the moxzi-actor-host services#
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:29521 --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/target/release/moxzi-actor-host --listen 127.0.0.1:29522 --state ./state-bank \
--namespace tutorial --deployment crm --actor Bank=./bank.wasm
Point placement at the services, and call it#
curl -X PUT http://127.0.0.1:29500/_moxzi/placement -H 'content-type: application/json' \
-d '{"types":{"Customer":["http://127.0.0.1:29521"],"Bank":["http://127.0.0.1:29522"]}}'
# HTTP 204
curl -X POST http://127.0.0.1:29500/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:29500/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:29500/actors/Bank/1/method/total -H 'content-type: application/json' -d '[]'
# [5]
Customer's KeyActor did one HTTP POST and returned; the HTTP front's own thread drove the routed call to whichever service owns Bank/1 and resumed Customer with the answer — getBalance now reads 30 + 5 = 35.
Arm a timer, and watch it fire#
curl -X POST http://127.0.0.1:29500/actors/Customer/1/method/armTimer \
-H 'content-type: application/json' -d '[3000000000]'
sleep 6
curl -X POST http://127.0.0.1:29500/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
Kill it and bring it back#
A restart of ray_host.py is a genuine restart of Ray, not a resume. ray.init(address= "local", ...) spawns raylet, gcs_server, the plasma store, and idle workers as children of the driver process — confirmed on this machine:
ps aux | grep -E "ray::|raylet|gcs_server"
# ray::DashboardAgent, ray::IDLE (x6), raylet, ray::KeyActor (x2: Customer, Bank), gcs_server
Killing only the driver brings every one of those down:
kill -9 <ray_host.py pid>
sleep 4
ps aux | grep -E "ray::|raylet|gcs_server" # nothing
nc -z 127.0.0.1 29500 || echo "port free" # port free
So a restart here has no durable Ray state to lose — every KeyActor and the whole local cluster are gone — and everything that must survive already lives in Postgres and the moxzi-actor-host services' own disks:
cd /tmp/moxzi-tutorial-ray
DATABASE_URL="postgres://postgres:moxzi@127.0.0.1:25901/moxzi?sslmode=disable" \
MOXZI_HOST_NS=tutorial python3 moxzi/adapters/ray/ray_host.py --listen 127.0.0.1:29500 &
curl -X POST http://127.0.0.1:29500/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [35] — placement and the route journal came back from Postgres, no re-PUT needed
curl -X POST http://127.0.0.1:29500/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
Drain, then upgrade#
curl -X POST http://127.0.0.1:29500/actors/Customer/1/admin/drain -d '{}'
# {"state": "Quiescent"}
curl -X POST http://127.0.0.1:29500/actors/Customer/1/admin/upgrade \
-H 'content-type: application/json' \
-d "{\"deploymentId\":\"crm-v2\",\"wasm\":\"$(base64 -i customer.wasm | tr -d '\n')\"}"
# {"state": "Active"}
curl -X POST http://127.0.0.1:29500/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: this host's placement, held keys, route journal and armed timers live in Postgres, tables
<namespace>_placement,<namespace>_held,<namespace>_routes,<namespace>_timers. The Motoko heap lives on eachmoxzi-actor-hostservice's own disk under--state. Neither knows about the other; a service restart only needs the first, a Ray/driver restart only needs the second. - A
@ray.remoteactor is the only Ray primitive with its own identity and a default of one method call at a time;name=f"moxzi/{key}"withget_if_exists=Truemakes first-touch idempotent under concurrent requests for a key never seen before — Ray's GCS resolves that race, not this host. Lifetime is left at Ray's default (tied to the driver process) rather than"detached", since nothing needs aKeyActorto outlive the process whose lifetime already spans the whole run. - Upgrading an actor: drain, then upgrade with the new module as base64 (shown above).
POST …/admin/{drain,upgrade}forward straight to the owningmoxzi-actor-hostservice;GET …/admin/{state,outstanding}bypass it and answer from the same place. An upgrade only proceeds once the actor isQuiescent. - What to watch: the Ray driver process and the Postgres container behind it. The
_routesand_timerstables are ground truth for "what is still parked" and "what is still armed" if/_moxzi/pendingis not convenient. Two calls to different keys run concurrently (independent Ray actors, independent HTTP threads); two calls to the same key serialise through that key's own actor mailbox — the contract working as designed.
In process#
The same actor also runs inside Ray's own process through moxzi-rts, with Ray keeping its image: see /docs/hosts/in-process/ for the contract.
| In process on Ray | |
|---|---|
| Binding | ctypes, inside each key's Ray actor |
| The actor's image lives in | Postgres |
| Update latency vs Ray's own durable actor (p50, macOS) | 1.86x |
| Conformance | HOST=ray-inproc scripts/host_matrix_gate.sh |
From moxzi/adapters/ray/ (the exact commands and environment are in moxzi/conformance/hosts/ray-inproc.json):
(cd ../../ && cargo build --release -p moxzi-rts) # nothing else to build
DATABASE_URL=… MOXZI_IN_PROCESS=1 MOXZI_RTS_LIB=../../target/release/libmoxzi_rts.dylib \
python3 ray_host.py --listen 127.0.0.1:8090 # libmoxzi_rts.so on Linux
The conformance matrix#
Ray's column of the spec §61 matrix, real Ray cluster, real process kills (moxzi/adapters/ray/README.md, measured 2026-09-23 against compiler b7577666):
| §61 scenario | Ray |
|---|---|
| Persistence | PASS |
| Process Failure | PASS |
| Trap Rollback | PASS |
| Actor Isolation | PASS |
| Actor Parallelism | PASS 1.09–1.10× |
| 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 |
Run twice by the lead (both 16/16, no scenario needed a SKIP): after each run, no moxzi-host-matrix-* Docker container and no ray::*/raylet/gcs_server/ray_host.py/ moxzi-actor-host process survived.
Reproduce it (needs moxzi/target/release/{moxzi,moxzi-actor-host} built, and Docker running — the gate starts its own dedicated, disposable Postgres container for this run and removes it on exit):
HOST=ray scripts/host_matrix_gate.sh