moxzi
Docs / Actor platforms / Akka (Apache Pekko)

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#

OwnsWhoWhat
IdentityPekko Cluster Shardingone sharded entity per key (EntityTypeKey("MoxziStep"), entity id Type/id); one mailbox is what gives a turn its one-step-at-a-time guarantee
Routingthis process, beside the entityevery 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 timersthis process, in Postgresa _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 callsthis process, in Postgres_placement, _held tables; PUT /_moxzi/placement persists and updates the in-memory copy each step consults
Motoko execution, the heap, upgradesmoxzi-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:

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.

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
BindingJNA, inside each sharded entity
The actor's image lives inPostgres (<namespace>_images, _pages, _modules)
Update latency vs Pekko's own durable actor (p50, macOS)3.05x
ConformanceHOST=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 scenarioAkka (Pekko)
PersistencePASS
Process FailurePASS
Trap RollbackPASS
Actor IsolationPASS
Actor ParallelismPASS 1.07–1.17×
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, 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
On this pageWhat Pekko owns, what Motoko ownsPrerequisitesWalk through itWrite and build the two actorsStart the moxzi-actor-host servicesStart PekkoPoint placement at the services, and call itA cross-actor awaitArm a timerKill it and bring it backOperating notesIn processThe conformance matrix