Akka (Apache Pekko)
This host runs as Apache Pekko, not Akka itself: Pekko 1.1 is the Apache-licensed fork of Akka 2.6, and EntityTypeKey / entity ids are the same API, so an Akka Cluster Sharding deployment is the same design with a different artifact coordinate. The code lives in moxzi/adapters/pekko (moxzi-pekko, Scala).
What Pekko owns, what Motoko owns#
| Owns | Who | What |
|---|---|---|
| Identity | Pekko Cluster Sharding | one sharded entity per key (EntityTypeKey("MoxziStep"), entity id Type/id); one mailbox is what gives a turn its one-step-at-a-time guarantee |
| Routing | this process, beside the entity | every parked await becomes a durable route row before it is routed (a _routes table); the callee's turn runs as a turn of its own through its own entity; the answer resumes the caller; a restart re-drives every non-held route |
| Durable timers | this process, in Postgres | a _timers table polled every 200 ms by a system.scheduler loop; DELETE … RETURNING claims a due row so a timer fires exactly once under concurrent polls |
| Placement / held / pending calls | this process, in Postgres | _placement, _held tables; PUT /_moxzi/placement persists and updates the in-memory copy each step consults |
| Motoko execution, the heap, upgrades | moxzi-actor-host (Rust) | everything past /v2/* — the entity does exactly one JSON POST per step to whichever service instance owns the key, and nothing more |
The routing and timer loop run beside the entity, not inside its Receive, on purpose: if the entity's mailbox blocked on a routed call, the key would be held for the whole await, and two actors awaiting each other would deadlock — the IC lets a parked actor keep taking other messages; a blocking Pekko mailbox would not. So the entity does one HTTP step and returns; a separate Host object drives the resulting call chain in Futures outside any entity's mailbox.
Pekko has no durable timer or K/V outside Akka Persistence, and the entity here is deliberately not an EventSourcedBehavior — the Motoko heap is not event-sourced, so there is nothing to replay. Everything this host owns (placement, held keys, the route journal, armed timers) lives in Postgres instead, over plain JDBC.
Prerequisites#
Verified on this machine:
coursier(cs) at~/.local/bin/cs—build.shuses it to resolve the classpath, nosbtneeded.- Java:
openjdk version "11.0.27"(Homebrew). - Scala 2.13.16 and the Pekko/Postgres jars — resolved automatically by
build.shon first run (org.apache.pekko:pekko-cluster-sharding-typed_2.13:1.1.3,pekko-http_2.13:1.1.0,pekko-serialization-jackson_2.13:1.1.3,org.postgresql:postgresql:42.7.4). - Docker, for a Postgres 16 container. Give it a name you'll recognise — this walk-through uses
moxzi-tutorial-pg. moxziandmoxzi-actor-host, built (cargo build --release -p moxzi -p moxzi-actor-hostundermoxzi/, or use the ones already atmoxzi/target/release/).
Walk through it#
Write and build the two actors#
Customer takes orders and, on chargeVia, awaits a Bank actor by principal text. Both are 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 };
// 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.83s)
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 the moxzi-actor-host services#
One service per type is enough for a first run — --known Bank/1 prints the Bank principal so Customer can await it by text:
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:3021 --state ./s1 \
--namespace demo --deployment crm --actor Customer=./customer.wasm --known Bank/1
# moxzi-actor-host: Bank/1 is k2e4o-mgnlm-is6i5-r7owd-3wgr3-b4lrw-vi2fq-vf6zl-43mw6-2jicp-kae
# moxzi-actor-host: moxzi-actor-host/2 on 127.0.0.1:3021, hosting Customer
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:3022 --state ./s2 \
--namespace demo --deployment crm --actor Bank=./bank.wasm
Bank/1's principal is deterministic — sha224 over the namespace, deployment, type and key — so it is the same every time you use demo/crm/Bank/1; you do not need to reread the log once you know it.
Start Pekko#
Pick a Postgres host port nothing else on the box is using (docker ps first — this is a shared machine); this walk-through uses 25432:
docker run -d --name moxzi-tutorial-pg -e POSTGRES_PASSWORD=moxzi -e POSTGRES_DB=moxzi \
-p 25432:5432 postgres:16
cd moxzi/adapters/pekko
bash build.sh /tmp/pekko-out # ~4s with a warm coursier cache
DATABASE_URL="postgres://postgres:moxzi@127.0.0.1:25432/moxzi?sslmode=disable" \
MOXZI_HOST_NS=demo bash run.sh /tmp/pekko-out --listen 127.0.0.1:3500
# moxzi-pekko: contract moxzi-actor-host/2, cluster member up (artery :3600), listening on 127.0.0.1:3500
The artery (cluster) port is the HTTP port +100 by default — --remote-port overrides it, so concurrent runs on one box never collide. sbt run also works for local development (build.sbt carries the same dependencies); it is optional — the conformance gate and CI use build.sh/run.sh.
Point placement at the services, and call it#
curl -X PUT localhost:3500/_moxzi/placement \
-d '{"types":{"Customer":["http://127.0.0.1:3021"],"Bank":["http://127.0.0.1:3022"]}}'
# HTTP/1.1 204 No Content
curl -X POST localhost:3500/actors/Customer/1/method/addOrder \
-H 'content-type: application/json' -d '[{"id":1,"total":30}]'
# HTTP/1.1 200 OK, x-moxzi-outcome: replied
# [1]
A cross-actor await#
curl -X POST localhost:3500/actors/Customer/1/method/chargeVia \
-H 'content-type: application/json' \
-d '["k2e4o-mgnlm-is6i5-r7owd-3wgr3-b4lrw-vi2fq-vf6zl-43mw6-2jicp-kae",10]'
# [40]
curl -X POST localhost:3500/actors/Bank/1/method/total -H 'content-type: application/json' -d '[]'
# [10]
Customer's turn parked on Bank's answer; the Pekko host routed the call to the service that owns Bank/1 (a durable route row written before it was routed), then resumed Customer with the reply — getBalance reads 30 + 10 = 40.
Arm a timer#
curl -X POST localhost:3500/actors/Customer/1/method/armTimer \
-H 'content-type: application/json' -d '[2000000000]'
curl -X POST localhost:3500/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [0]
sleep 3
curl -X POST localhost:3500/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
Kill it and bring it back#
pkill -f 'Main --listen 127.0.0.1:3500' # the JVM process
pkill -f 'moxzi-actor-host --listen 127.0.0.1:302' # both services
# … restart Pekko exactly as above, and both moxzi-actor-host services on the SAME --state dirs …
curl -X POST localhost:3500/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [40] — no re-PUT of placement needed: Postgres held it
curl -X POST localhost:3500/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1] — the fired timer's count survived too
Placement, in-flight routes and armed timers are all in Postgres, so the restarted process picks them straight back up — you never re-run the PUT /_moxzi/placement after a crash.
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: this host's own state (placement, held keys, the route journal, armed timers) is Postgres, in tables
<namespace>_placement,<namespace>_held,<namespace>_routes,<namespace>_timers(the--namespace/MOXZI_HOST_NSvalue prefixes them). The Motoko heap itself lives on eachmoxzi-actor-hostservice's own disk under its--statedirectory — a snapshot, a WAL tail and a content-addressedmodules/directory of deployed wasm, per actor. - Upgrading an actor: drain, then upgrade with the new module as base64: ``
sh curl -X POST localhost:3500/actors/Customer/1/admin/drain -d '{}' # {"state":"Quiescent"} curl -X POST localhost:3500/actors/Customer/1/admin/upgrade \ -H 'content-type: application/json' \ -d "{\"deploymentId\":\"crm2\",\"wasm\":\"$(base64 -i customer.wasm)\"}" # {"state":"Active"}`Both routes arePOST … /admin/{op}— routed **through** the entity, so they queue behind other pending turns on that key, matching the spec's "serialized step". Read-onlyGET …/admin/{state,outstanding}bypasses the entity and goes straight to the owning service. Upgrade only proceeds once the actor isQuiescent` (no outstanding await); a failed upgrade leaves the previous deployment running. - What to watch: the JVM process (one Pekko cluster node here — a real deployment adds seed nodes) and the Postgres instance backing it. A timer that never fires and an
awaitthat never resumes are both visible in the_timersand_routestables directly, without going through the HTTP surface.
In process#
The same actor also runs inside Pekko's own process through moxzi-rts, with Pekko keeping its image: see /docs/hosts/in-process/ for the contract.
| In process on Pekko | |
|---|---|
| Binding | JNA, inside each sharded entity |
| The actor's image lives in | Postgres (<namespace>_images, _pages, _modules) |
| Update latency vs Pekko's own durable actor (p50, macOS) | 3.05x |
| Conformance | HOST=pekko-inproc scripts/host_matrix_gate.sh |
From moxzi/adapters/pekko/ (the exact commands and environment are in moxzi/conformance/hosts/pekko-inproc.json):
bash build.sh out
DATABASE_URL=… MOXZI_IN_PROCESS=1 MOXZI_RTS_LIB_DIR=../../target/release \
bash run.sh out --listen 127.0.0.1:8090
The conformance matrix#
Akka's column of the spec §61 matrix, real host, real process kills (docs/host-adapters.md, measured 2026-09-23 against compiler b7577666):
| §61 scenario | Akka (Pekko) |
|---|---|
| Persistence | PASS |
| Process Failure | PASS |
| Trap Rollback | PASS |
| Actor Isolation | PASS |
| Actor Parallelism | PASS 1.07–1.17× |
| 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, the Pekko host, and two moxzi-actor-host instances per actor type, and drives all sixteen scenarios with real kills and restarts):
HOST=pekko scripts/host_matrix_gate.sh