Skip to content

7. Actor and Spot

Guide Home | Previous: 6. Spot | Next: 8. Session and Actor Binding

View in another language — C#/.NET · C++ · Java · Kotlin · Node/TypeScript

The documents that own this chapter's contractActor Model and Spot And Actor Membership own the behavior, and the per-language Actor/Spot public contract owns the exact signatures.

An Actor is a stateful object found by a global string ActorId. Right after creation, it exists in the Object Server's Entry Spot. Once an application handler schedules a join, it moves to a User Spot.

An Actor's location and its client session binding are separate pieces of state. Actor–Spot membership persists even when no client is connected. Session binding is covered in the next document.

1. Registration

Register an Entry Spot and an Actor factory together on the Object Server. Any Serving node that registers actorType becomes a creation candidate.

Below is the Play server registration from the Bingo sample.

mesh.objects().server()
  .addEntrySpot(BingoEntrySpot)
  .addActorFactory(
    SampleNames.playerActorType,
    PlayerActorFactory,
    (factory) => factory.preserveStateWith(PlayerActorRelocationAdapter));

The relocation policy is fixed once, at factory registration, and doesn't change while running. This policy applies both when an Actor joins another node's Spot and when it moves via host relocate.

Policy How it's recreated on another node
DisableRelocation() Refuses before a cross-node move even starts. If this target remains, host relocation can't complete.
RecreateOnRelocation() Creates a new instance with the same logical identity. Pending messages and timers are preserved, but application state isn't restored.
PreserveStateWith<TAdapter>() Restores the byte[] the adapter saved onto the new instance. The Framework queue and timers are preserved as well.

2. Creating an Actor

create fails if the same ActorId already exists. getOrCreate returns Existing if a Ready Actor of the same type already exists. The caller never specifies the target node.

const result = await actors
  .getOrCreate(playerId, 'player')
  .inMesh('play')
  .request(createPlayer(displayName))
  .timeout(10_000)
  .submit();

if (result.status === 'rejected') throw new Error('Player creation was rejected.');
const actor = result.actor;

ActorRef carries the exact incarnation and the owner route as of the lookup. It's used for session binding or exact destroy. Ordinary Actor messaging uses only the ActorId.

const current = await actors.find(playerId);
const currentSpot = await actors.findSpot(playerId);

if (current !== undefined) {
  // Doesn't terminate an Actor whose generation differs.
  await actors.destroy(current);
}

An Actor can only be terminated from the Entry Spot. If it's in a User Spot, finish an Entry Spot join first.

3. Entry Spot

The Entry Spot accepts or rejects an Actor creation request, and handles the lifecycle of an Actor joining and leaving.

A membership callback is a lifecycle callback the Framework calls when an Actor becomes or stops being a member of this Spot. The Entry Spot has four.

Callback When it's called
onCreateActor When a new Actor takes this Entry Spot as its first membership. Decides accept/reject
onJoinedActor Once the commit finishes for an Actor that was in another Spot coming into this Entry Spot
onLeaveActor Once the commit finishes for an Actor that was in this Entry Spot leaving to another Spot
onDisconnectActor When the client connection for an Actor belonging to this Entry Spot drops

These callbacks aren't called when an Actor is restored into another node's Entry Spot via relocation. Relocation keeps membership exactly as it is and only moves the execution location, so from the application's point of view it's not an event of "coming in" or "going out."

See it in a sample — TicTacToe. This is the Entry Spot a player first enters. Actual code from the repository.

class PlayEntrySpot implements ZLinkEntrySpot<PlayActor> {
  readonly context!: ZLinkEntrySpotContext<PlayActor>;

  constructor(
    private readonly milestoneObservers: MilestoneObserverRegistry
  ) {}

  configure(): void {
    // send: JoinGameMsg starts the deferred Room Spot join.
    this.context.handlers.addHandler(PlayActorJoinGameHandler);
    // request: ObserveMilestoneReq returns ObserveMilestoneRes after registration.
    this.context.handlers.addHandler(PlayActorObserveMilestoneHandler);
    // send: the internal notification message is relayed to the current session.
    this.context.handlers.addHandler(DeliverPlayNotificationEntryHandler);
    // subscribe: the published milestone event is delivered to this Entry Spot.
    this.context.handlers.addSubscribe(
      PlayerWinMilestoneEventHandler,
      SampleNames.playerMilestoneChannel,
      SampleNames.playerMilestoneTopic
    );
  }

  async onActorJoin(_actorId: string, _request: ZLinkMessage): Promise<{ accepted: boolean }> {
    return { accepted: true };
  }

  async notifyMilestone(event: PlayerWinMilestoneEvent): Promise<void> {
    await this.milestoneObservers.notify(event);
  }

  async onDisconnectActor(actor: PlayActor): Promise<void> {
    this.milestoneObservers.remove(actor.actorId);
  }

  async onCreateActor(actor: PlayActor, createRequest: ZLinkMessage): Promise<ZLinkActorCreateResponse> {
    const { player } = createRequest.decode(PlayerActorCreateReq);
    actor.displayName = player.displayName;
    actor.level = player.level;
    actor.wins = player.wins;
    this.milestoneObservers.track(actor);
    return { accepted: true };
  }

  async onJoinedActor(actor: PlayActor): Promise<void> {
    this.milestoneObservers.track(actor);
    if (actor.destroyAfterEntrySpotJoin) {
      this.scheduleDestroy(actor);
    }
  }

  async onLeaveActor(actor: PlayActor): Promise<void> {
    console.log(`entry spot: actor left. actor=${actor.actorId}`);
    this.milestoneObservers.remove(actor.actorId);
  }

  scheduleDestroy(actor: PlayActor): void {
    void this.context.runIoWorker(async () => true).submit().then(async () => {
      console.log(`entry spot: actor destroy started. actor=${actor.actorId}`);
      await this.context.destroyActor(actor);
      console.log(`tictactoe-lifecycle actor-destroy-complete actor=${actor.actorId}`);
    });
  }
}

In its minimal shape, it looks like this.

// <PlayerActor> — the Actor type this Entry Spot manages membership for.
export class PlayEntrySpot implements ZLinkEntrySpot<PlayerActor> {
  readonly context!: ZLinkEntrySpotContext<PlayerActor>;

  // Called once when the Spot instance is prepared.
  configure(): void {
    // JoinGameHandler receives a JoinGame packet addressed to a PlayerActor.
    this.context.handlers.addActorPacket(JoinGameHandler);
  }

  // Called when a new Actor takes this Entry Spot as its first membership.
  // The return value decides whether to create this Actor — this Spot is the admission gate.
  async onCreateActor(
    actor: PlayerActor, createRequest: ZLinkMessage): Promise<ZLinkActorCreateResponse> {
    actor.setDisplayName(createRequest.decode<CreatePlayer>(Object as never).displayName);
    return ZLinkActorCreateResponse.accept();
  }

  // Called once the commit finishes for an Actor that was in a User Spot returning.
  async onJoinedActor(actor: PlayerActor): Promise<void> {}

  // Called after the commit for an Actor leaving to a User Spot.
  async onLeaveActor(actor: PlayerActor): Promise<void> {}
}

It's safer for an Entry Spot not to keep per-Actor application state of its own. The Actor owns its state; the Entry Spot only provides handlers and the membership lifecycle.

To terminate an Actor, first return it to the Entry Spot, then pass the current Actor instance to the Entry Spot context's actor-destroy call.

// The Entry Spot requests termination of the current Actor.
await this.context.destroyActor(actor);

This call doesn't call the membership lifecycle callbacks again — it cleans up the native actor ref, the Framework registry, and the bound session mapping. An Actor in a User Spot can't be terminated directly. It has to finish leaving and return to the Entry Spot first.

4. User Spot Membership

A User Spot accepts or rejects a join request first. Once accepted and membership commits, onJoinedActor is called.

export class GameRoom implements ZLinkSpot<PlayerActor> {
  readonly context!: ZLinkSpotContext<PlayerActor>;

  async onActorJoin(actorId: string, request: ZLinkMessage): Promise<ZLinkSpotActorJoinResult> {
    const join = request.decode<JoinGame>(Object as never);
    return this.hasSeat(join.seat)
      ? ZLinkSpotActorJoinResult.accept(joined(join.seat))
      : ZLinkSpotActorJoinResult.reject(roomFull());
  }

  async onJoinedActor(actor: PlayerActor): Promise<void> {}
  async onLeaveActor(actor: PlayerActor): Promise<void> {}
}

5. When a Join Actually Runs

What defer() Does

defer() schedules a join on the current handler instead of running it now. When it is called, the Framework fixes three things — an immutable snapshot of the join request, the absolute deadline computed from timeout(...), and the barrier to run once this handler finishes.

What happens to the scheduled barrier depends on how the handler ends.

How the handler ends The scheduled join
Ends normally Activates and starts running
Exception, cancellation, or reply-encoding failure Discarded. The join never starts

defer() can only be called while the current handler's registration scope is open. Calling it after the handler finishes, or from a background task detached from the handler, is InvalidOperation.

It can be called only from specific contexts.

Can call it Can't call it
An Actor send/request handler The factory and configuration phase
A packet/request/subscription/timer handler on a User/Entry Spot A lifecycle callback
A relocation adapter
An Instance Spot handler
A background task detached from a handler

Calling it from the right column is InvalidOperation. The Framework doesn't guarantee catching a detached task in every language — it might not be discovered before the handler finishes, so simply don't call it from there in the first place.

Calling defer() twice in the same call is InvalidOperation, and if that Actor already has a different membership transition in flight, it's Unavailable. If an Actor already belonging to that Spot joins the same Spot again, it ends in success without changing location — it touches neither the Store nor membership, and doesn't run the join/joined/leave callbacks either.

Why joinSpot Only Ever Runs Through defer()

The join call has no Async. The reason it doesn't provide a form that waits for the result right there is what a join actually does.

  • A join changes this Actor's location and membership. If the target Spot's owner is a different node, it performs Actor relocation within the same operation — location lookup, the target admission callback, and the Store commit are all included.
  • Waiting for its completion within the current turn blocks itself. An Actor executes its queue's jobs one at a time. If the currently executing handler waits for the join to complete, this Actor's follow-up work needed for that join to finish (the lifecycle callback after the membership commit) ends up waiting in the same queue.
  • The Actor executing at completion time can change. If a cross-node join succeeds, the one that receives the Accepted callback is the target node's Actor. The source Actor, where the current handler is, is already being cleaned up by that point, so receiving the result inside this handler isn't possible.

So the contract separates registration from execution. The handler schedules the join and ends normally, and the Framework starts location lookup and Store work after that. The result arrives through the completion callback below. This separation is what keeps the current Actor job's execution order from getting tangled with the join-completion callback's.

Once the barrier is activated, an ordinary message that arrives after it never runs ahead of the completion callback. That Actor's ordinary processing waits until the join finishes.

Registration and Receiving the Result

Schedule the join from an Actor handler. The handler is a separate class that receives a one-way packet addressed to a member Actor (06-spot §4.1), registered as an actor packet during the configuration phase. After defer(), there's nothing left to do except let this handler end normally.

See it in a sample — TicTacToe. This is the handler where a player schedules entering a room. Actual code from the repository.

class PlayActorJoinGameHandler
  implements ZLinkEntrySpotActorSendHandler<PlayEntrySpot, PlayActor, JoinGameMsg> {
  @ZLinkSpotActorSend(PacketNames.joinGameMsg)
  async handle(
    _spot: PlayEntrySpot,
    actor: PlayActor,
    context: ZLinkMessageContext,
    message: JoinGameMsg
  ): Promise<void> {
    void context;
    const joinRequest: TicTacToeGameJoinReq = {
      roomId: message.roomId,
      player: {
        actorId: actor.actorId,
        displayName: actor.displayName,
        level: actor.level,
        wins: actor.wins
      }
    };
    actor.pendingJoinRoomId = message.roomId;
    try {
      actor.context
        .joinSpot(message.roomId, joinRequest)
        .defer();
    } catch (error) {
      actor.pendingJoinRoomId = undefined;
      await actor.push(new JoinGameFailedNotify(
        message.roomId,
        error instanceof Error ? error.message : String(error)
      ));
    }
  }
}

In its minimal shape, it looks like this.

export class JoinGameHandler
  implements ZLinkSpotActorSendHandler<PlayEntrySpot, PlayerActor, JoinGame> {

  async handle(
    entrySpot: PlayEntrySpot,  // The Spot this Actor currently belongs to.
    actor: PlayerActor,        // The Actor requesting the join.
    messageContext: ZLinkMessageContext,
    command: JoinGame
  ): Promise<void> {
    actor.context
      .joinSpot(command.spotId, joinGameRequest(command.seat))
      .timeout(5_000)
      .defer(); // Starts the join once the current handler succeeds.
  }
}

The result arrives through the Actor's onJoinCompleted. Which Actor runs this callback depends on the result — Accepted goes to the target Actor that committed the location change, while Rejected and a pre-commit Failed go to the original source Actor.

async onJoinCompleted(completion: ZLinkActorJoinCompletion): Promise<void> {
  switch (completion.status) {
    // The location and membership change committed. completion.actor is the current ActorRef.
    case 'accepted':
      this.rememberCurrentLocation(completion.actor);
      break;
    // The target's admission callback rejected the join. The location is unchanged.
    case 'rejected':
      this.clearPendingJoin();
      break;
    // Only the error kind is received. Decide whether to retry by checking business state and idempotency.
    case 'failed':
      this.handleJoinFailure(completion.kind);
      break;
  }
}

Going back from a User Spot to the Entry Spot works the same way.

actor.context
  .joinEntrySpot(leaveGame(reason))
  .timeout(5_000)
  .defer();

operationId is an idempotency ID that distinguishes whether this completion is the result of a retry. Handle a callback for the same operationId running again safely.

Registration Limits

There's a ceiling on how much one handler can schedule.

What Ceiling
Number of joins one handler can schedule 64
Encoded size of one join request 1 MiB
Sum of request sizes one handler has scheduled 8 MiB
A cross-node join's application reply 1 MiB
Default timeout 5 seconds. If specified, must be a finite positive value

Exceeding the ceiling ends immediately in an error. It never leaves a state where only part of it registered and the rest was dropped. The request and reply ceilings are independent and aren't computed as a combined total.

Don't Send a Request to a Scheduled Actor

Sending a request from the same handler to an Actor that already has a defer() barrier attached, and waiting for the reply, creates a circular wait. The request waits behind the barrier, the barrier only opens once this handler finishes, and the handler can't finish because it's waiting for the reply.

The Framework rejects this request with InvalidOperation before it's ever submitted. It ends in an error instead of hanging, so if you see this error, check whether the scheduled target and the request's target are the same Actor.

When a Scheduled Join Doesn't Survive

The schedule and its barrier exist only in the current process's memory. If the process goes down before the join runs or is reflected in the Store, that schedule isn't replayed. The Actor's location and membership stay exactly as they were — it never ends up half-moved.

If it overlaps with relocate or shutdown, whichever settled first wins. If the join started first, maintenance waits until the join finishes; if the relocation seal came first, the join ends in Unavailable; if the shutdown seal came first, it ends in ShuttingDown.

6. Actor Messaging

You can send a message by ActorId without knowing which Spot or node the Actor is on.

await actorClient.sendToActor(playerId, awardExperience(10)).submit();

const profile = await actorClient
  .requestToActor(playerId, getPlayerProfile())
  .timeout(3_000)
  .submit<PlayerProfile>();

Even while an Actor is moving to another node, the caller specifies only the ActorId. The Framework re-queries the current owner recorded in the Location Store on every call and sends to that node.

A message a caller sends to the previous owner, because it had cached the location right before the move, isn't dropped either. The previous owner node that received that message forwards it on the caller's behalf to the new owner. This is called Message Follow — not a redirect that tells the sender the new address and makes it resend, but a scheme where the node that received it hands it off. This forwarding is valid only within the Message Follow duration; a message that arrives after that is treated as an ordinary stale-route failure. The application never tracks NodeRid.

A request sent during the move also completes back at the original caller. The reply the target produced is correlated back to the original caller, the timeout follows the caller's existing path as-is, and a reply that arrives late is dropped (spot-actor spec §10.5). The number of requests waiting on a reply during a move is observed through the surface=actor value of zlink.mesh_node.requests.inflight (12-operations).

7. Relocation State Adapter

The adapter saves and restores only the Actor instance's application state, as a byte array. Location authority, queue, timer, the accepted journal, and the session route are all handled by the Framework.

See it in a sample — TicTacToe. This is the adapter that packs and unpacks a player Actor's state. Actual code from the repository.

class PlayActorRelocationAdapter implements ZLinkActorRelocationAdapter<PlayActor> {
  async capture(actor: PlayActor): Promise<Uint8Array> {
    return new TextEncoder().encode(JSON.stringify({
      displayName: actor.displayName,
      level: actor.level,
      wins: actor.wins,
      roomId: actor.roomId,
      pendingJoinRoomId: actor.pendingJoinRoomId,
      destroyAfterEntrySpotJoin: actor.destroyAfterEntrySpotJoin
    } satisfies PlayActorTransferState));
  }

  async restore(actor: PlayActor, payload: Uint8Array): Promise<void> {
    const restored = JSON.parse(new TextDecoder().decode(payload)) as PlayActorTransferState;
    actor.displayName = restored.displayName;
    actor.level = restored.level;
    actor.wins = restored.wins;
    actor.roomId = restored.roomId;
    actor.pendingJoinRoomId = restored.pendingJoinRoomId;
    actor.destroyAfterEntrySpotJoin = restored.destroyAfterEntrySpotJoin === true;
  }
}

In its minimal shape, it looks like this.

export class PlayerActorRelocationAdapter
  implements ZLinkActorRelocationAdapter<PlayerActor> {

  async capture(actor: PlayerActor): Promise<Uint8Array> {
    return actor.exportState();
  }

  async restore(actor: PlayerActor, payload: Uint8Array): Promise<void> {
    actor.importState(payload);
  }
}

Capture and restore can be called again within the same relocation. The adapter must be retry-safe, and must copy the payload memory if it's kept around outside the callback.

  • Runnable verification examples for this chapter's contract: 13. Interface Catalog chapter §4 — the verification class ActorContracts
  • Session and Actor binding: Session Actor Dispatch
  • The STREAM server and client: STREAM
  • The Actor/Spot address resolution rule: Object routing