actor*: many addressable actors inside one canister
Experimental, in the Moxzi compiler. I’d like reactions to the shape of it before it settles.
The gap
You’re building something with many independent entities — accounts, rooms, agents, ledgers,
inventories. Today Motoko gives you two choices, and neither fits:
- A
class. Cheap and simple, but an instance is invisible from outside the canister. It has no
address, nobody can call it, and it’s just heap state you index into. - A canister per entity. Addressable and independently upgradable, but you pay a canister’s cost
and operational weight for every single one.
actor* sits between them: an instance that lives inside one canister’s heap, but has its own
principal, its own Candid methods, and its own upgrade story.
The whole thing
import Actor "mo:local-actors/Actor";
import Principal "mo:core/Principal";
import Array "mo:core/Array";
import Error "mo:core/Error";
persistent actor World {
// `creator` is an ordinary constructor argument the application chooses to keep -- nothing
// the language provides. (The language-level `Actor.container(me)` is the container, World.)
public persistent actor* class Counter(creator : Principal, initial : Nat) = me {
var n = initial;
// Internal: a computation. Callable from anywhere in World with `await*`; never a message.
public func internal_add(amount : Nat) : async* Nat { n += amount; n };
// External: the actor pattern. `msg.caller` is the real caller of the container message.
public shared(msg) func add(amount : Nat) : async Nat {
if (msg.caller != creator) throw Error.reject("only the creator may add");
await* internal_add(amount)
};
public query func read() : async Nat { n };
};
var counters : [Counter] = []; // held directly, and they outlive an upgrade
public shared(msg) func create(initial : Nat) : async Principal {
let c = Counter(msg.caller, initial); // the creator is the message caller
counters := Array.concat(counters, [c]);
Actor.id(c) // the outward spelling of a reference
};
public func total() : async Nat { // local composition: inline, one segment
var t = 0;
for (c in counters.vals()) { t += await* c.internal_add(0) };
t
};
};
Three things to notice:
-
It’s declared and used like a class.
Counter(msg.caller, initial)constructs one; you hold
instances in ordinary values likecounters. -
It has an address.
Actor.id(c)is a principal you can hand out, store, and later turn back
into a reference. A stale or retired one fails closed rather than silently hitting something else. -
Methods come in two kinds, and this is the core of the design:
internal_addreturnsasync*. It’s internal: callable from anywhere in the container with
await*, never a message, no commit point, and it doesn’t appear in the canister’s interface.addreturnsasync. It’s external: a real method with a realmsg.caller, and it does
appear in the interface.
In one line:
async*is inside,asyncis outside.
Callable from outside
The container’s Candid interface is generated for you:
service : {
Counter_add: (id: principal, amount: nat) -> (nat);
Counter_read: (id: principal) -> (nat) query;
create: (initial: nat) -> (principal);
total: () -> (nat);
}
A subactor’s external methods show up as Class_method(id, args…). Anything that can call a
canister can call a subactor — dfx, an agent, another canister — by passing the principal it was
handed. msg.caller inside add is the real caller of that message, which is why the
authorisation check in the example does what it looks like it does. The compiler also emits a small
client module so a calling canister writes Counter(service, id).add(5) rather than spelling the
façade by hand.
It survives upgrades
The example above is already the durable form: Counter is a persistent actor* class and
counters is an ordinary stable variable. After an upgrade the instances are still there with
their state, and the principals you handed out still name the same ones. Callers outside the
canister do not have to be told anything, and you do not rebuild the population from a side table
of identifiers.
Adding a method to the class does not disturb that. If the class’s fields change you give it a
migration function, exactly as you would for an actor, and every live instance is migrated.
Instances can carry system func preupgrade and postupgrade of their own.
What it costs
A call into a subactor is about 2,900 instructions against about 280 for a plain class
method — roughly 11x — and it allocates nothing per call. Each live instance costs about 744
bytes on top of its own fields.
So the honest advice: if all you want is encapsulated state that rolls back with the message, use
a class. It’s an order of magnitude cheaper and it’s the right tool. Reach for actor* when you
need at least one of:
- an address you can hand out and validate later,
- entities that outlive an upgrade with their own migration,
- entities callable from outside the canister.
It suits a large, mostly-idle population well: a hundred thousand instances is roughly 75 MB and
costs nothing until called. It does not suit a hot loop that touches every entity every tick — for
that, a class and a flat array will beat it comfortably.
Status
Experimental and behind a flag, in Moxzi — a Motoko compiler that self-hosts. Not in upstream moc.
The example above is compiled and run as a test fixture, and so is every claim this post makes about
it; the post is generated from that fixture, so the code here is code that ran.