Temporal
This host runs the actor-host contract, version 2, on top of a Temporal entity workflow per key (moxzi/Type/id) — Temporal's own per-workflow-id execution guarantee and its history replay are the per-key serialisation and the recovery story. A turn is a workflow.Go coroutine inside that one long-lived execution, not a separate workflow: the IC lets a parked actor keep taking other messages, so routing and the durable timer run beside the turn rather than blocking it. The code is moxzi/adapters/temporal (moxzi-temporal-host, Go, Temporal SDK v1.49.0), built by moxzi/conformance/hosts/temporal.json and driven by HOST=temporal scripts/host_matrix_gate.sh.
What Temporal owns, what Motoko owns#
| Owns | Who | What |
|---|---|---|
| Identity, one actor per key | the entity workflow | id moxzi/Type/id — Temporal's own per-workflow-id execution guarantee |
| One step at a time per key | the workflow's own step-mutex coroutine | a capacity-1 buffered channel (stepMutex) held only around each /v2/begin/resume/remind activity call, never across a routed call's answer |
| A turn that parks and still takes messages | a workflow.Go coroutine per turn, inside the one entity execution | costs nothing extra in history beyond the signal that eventually unblocks it — no second workflow id to route to |
| Routed awaits, durably | this process, as ordinary workflow history | a signal-with-start to the callee's own entity workflow, replied by a signal back; both replayed like any other event |
| Durable timer | workflow.NewTimer | cancelled and re-armed from each step's timer field; replay resumes it at its original fire time, not a fresh countdown |
| Placement, held keys, pending calls | the singleton moxzi/control workflow | signals to mutate, queries to read |
| Recovery after the host dies | nothing to re-drive | Temporal replays every open workflow's history against the reconnecting worker |
| Motoko execution, the heap, upgrades | moxzi-actor-host (Rust) | everything past /v2/* — the workflow's activity does exactly one JSON POST per step |
Two processes matter here: temporal server start-dev (the durable store — a restart of this host never restarts it, exactly like Restate's server for moxzi-restate-host) and one or more moxzi-actor-host services, one per actor type instance, same as every other host in this matrix.
Prerequisites#
Verified on this machine:
temporal --version—temporal version 1.9.1 (Server 1.32.0, UI 2.54.1).go version—go1.27.1 darwin/arm64; the adapter pinsgo.temporal.io/sdk v1.49.0(moxzi/adapters/temporal/go.mod).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-temporal/ is the scratch directory for this walk-through. Give the dev server its own directory — putting its SQLite file under a directory a harness (or your own script) wipes on start is exactly the "context deadline exceeded" bug the matrix gate hit and fixed on 2026-09-23 (docs/host-adapters.md).
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 (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
# bank.mo -> bank.wasm + bank.did (249927 bytes, 1 file(s), 58 steps, 0.52s)
Start the Temporal dev server, in its own directory#
mkdir -p /tmp/moxzi-tutorial-temporal/server && cd /tmp/moxzi-tutorial-temporal/server
temporal server start-dev --headless --ip 127.0.0.1 --port 27233 --ui-port 28233 \
--db-filename ./temporal-dev.db &
temporal operator cluster health --address 127.0.0.1:27233
# SERVING
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:29531 --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:29531, hosting Customer
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:29532 --state ./state-bank \
--namespace tutorial --deployment crm --actor Bank=./bank.wasm
# moxzi-actor-host: moxzi-actor-host/2 on 127.0.0.1:29532, hosting Bank
Build and start the Temporal Go host#
cd moxzi/adapters/temporal && go build -o /tmp/moxzi-tutorial-temporal/moxzi-temporal-host .
cd /tmp/moxzi-tutorial-temporal
PORT=29080 MOXZI_TEMPORAL_ADDRESS=127.0.0.1:27233 ./moxzi-temporal-host &
# 2026/09/23 22:32:49 INFO Started Worker Namespace default TaskQueue moxzi-temporal-host WorkerID 38376@...
# moxzi-temporal-host: contract moxzi-actor-host/2; Temporal 127.0.0.1:27233 (namespace default); host-control on :29080
curl -s http://127.0.0.1:29080/healthz
# ok
Point placement at the services, and call it#
curl -X PUT http://127.0.0.1:29080/_moxzi/placement -H 'content-type: application/json' \
-d '{"types":{"Customer":["http://127.0.0.1:29531"],"Bank":["http://127.0.0.1:29532"]}}'
# HTTP 204
curl -X POST http://127.0.0.1:29080/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:29080/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:29080/actors/Bank/1/method/total -H 'content-type: application/json' -d '[]'
# [5]
Customer's turn — a workflow.Go coroutine — parked on a blocking channel receive while the signal-with-start to Bank's own entity workflow was in flight; getBalance now reads 30 + 5 = 35.
Arm a timer, and watch it fire#
curl -X POST http://127.0.0.1:29080/actors/Customer/1/method/armTimer \
-H 'content-type: application/json' -d '[3000000000]'
curl -X POST http://127.0.0.1:29080/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [0]
sleep 6
curl -X POST http://127.0.0.1:29080/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
The 3-second due time became a workflow.NewTimer; on fire, the entity workflow's timer loop drove /v2/remind and the Motoko timer callback ran.
Kill it and bring it back#
Service-level restart — the Motoko heap is a file on disk, independent of Temporal:
kill <moxzi-actor-host Customer pid> # 29531
moxzi/target/release/moxzi-actor-host --listen 127.0.0.1:29531 --state ./state-customer \
--namespace tutorial --deployment crm --actor Customer=./customer.wasm --known Bank/1
curl -X POST http://127.0.0.1:29080/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:29080/actors/Customer/1/method/orderCount -H 'content-type: application/json' -d '[]'
# [1]
curl -X POST http://127.0.0.1:29080/actors/Customer/1/method/timerCount -H 'content-type: application/json' -d '[]'
# [1]
Host-level restart — kill only this host's worker+front process; the dev server (like Restate's server) is not part of kill_and_restart and keeps running:
kill <moxzi-temporal-host pid>
cd /tmp/moxzi-tutorial-temporal
PORT=29080 MOXZI_TEMPORAL_ADDRESS=127.0.0.1:27233 ./moxzi-temporal-host &
curl -s http://127.0.0.1:29080/healthz
# ok
curl -X POST http://127.0.0.1:29080/actors/Customer/1/method/getBalance -H 'content-type: application/json' -d '[]'
# [35] — no re-PUT of placement: Temporal replayed the control and entity workflows' history
Drain, then upgrade#
curl -X POST http://127.0.0.1:29080/actors/Customer/1/admin/drain -d '{}'
# {"state":"Quiescent"}
curl -X POST http://127.0.0.1:29080/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:29080/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 lives on each
moxzi-actor-hostservice's own disk under--state— a snapshot, a WAL tail, a content-addressedmodules/directory. Everything this host itself owns — placement, held keys, pending calls, every turn's routing and timer state — lives in the Temporal server's own history and workflow state (SQLite forstart-dev, a real database in production), namespaced byMOXZI_TEMPORAL_NAMESPACE(defaultdefault). Neither store knows about the other. - The SDK's 10-second RPC cap:
go.temporal.io/sdk's client caps every individual gRPC call at 10 seconds, not overridable by a longer context deadline. Under load, a single call can occasionally lose that race for reasons unrelated to correctness; every client call the front or an activity makes is wrapped in a small retry loop with its own 30–45s budget (retryUntilinmain.go) —pollTurn's own loop gets this resilience for free since each poll iteration already is a retry. - Upgrading an actor: drain, then upgrade with the new module as base64 (shown above). Both
POST …/admin/{op}routes are forwarded straight to the owningmoxzi-actor-hostservice;GET …/admin/{state,outstanding}is read-only and answers the same way. An upgrade only proceeds once the actor isQuiescent; a failed upgrade leaves the previous deployment running. - What to watch:
temporal operator cluster health --address <addr>for the server itself, this host's/healthz, and — since there is no per-turn workflow id to look up — the entity workflowmoxzi/Type/id's own history in the Temporal Web UI (--ui-port, above) for what a turn actually did. Continue-As-New for a long-lived, high-turn-volume entity is not implemented in this adapter (the seam is documented at the top ofEntityWorkflow'sturnSignalloop inworkflows.go); nothing in the conformance matrix's turn counts needs it yet.
In process#
The same actor also runs inside Temporal's own process through moxzi-rts, with Temporal keeping its image: see /docs/hosts/in-process/ for the contract.
| In process on Temporal | |
|---|---|
| Binding | cgo, inside the worker; each turn is a local activity |
| The actor's image lives in | Postgres (the workflow history stays small) |
| Update latency vs Temporal's own durable actor (p50, macOS) | 1.68x |
| Conformance | HOST=temporal-inproc scripts/host_matrix_gate.sh |
From moxzi/adapters/temporal/ (the exact commands and environment are in moxzi/conformance/hosts/temporal-inproc.json):
(cd ../../ && cargo build --release -p moxzi-rts) # libmoxzi_rts first
go build -tags inproc -o out/moxzi-temporal-host .
MOXZI_TEMPORAL_ADDRESS=… DATABASE_URL=… MOXZI_IN_PROCESS=1 out/moxzi-temporal-host
The conformance matrix#
Temporal's column of the spec §61 matrix, real dev server, real process kills (moxzi/adapters/temporal/README.md, measured 2026-09-23 against compiler b7577666):
| §61 scenario | Temporal |
|---|---|
| Persistence | PASS |
| Process Failure | PASS |
| Trap Rollback | PASS |
| Actor Isolation | PASS |
| Actor Parallelism | PASS 1.16× |
| 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 its own temporal server start-dev on a scratch port with a private --db-filename — never a server this run did not start — plus two moxzi-actor-host instances per actor type, then drives all sixteen scenarios with real kills and restarts):
HOST=temporal scripts/host_matrix_gate.sh