Weekend discussion: actor* for motoko

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:

  1. It’s declared and used like a class. Counter(msg.caller, initial) constructs one; you hold
    instances in ordinary values like counters.

  2. 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.

  3. Methods come in two kinds, and this is the core of the design:

    • internal_add returns async*. 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.
    • add returns async. It’s external: a real method with a real msg.caller, and it does
      appear in the interface.

    In one line: async* is inside, async is 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.

1 Like