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 contract — Actor 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
Acceptedcallback 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.
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.
8. Related Documents¶
- Runnable verification examples for this chapter's contract:
13. Interface Catalogchapter §4 — the verification classActorContracts - Session and Actor binding: Session Actor Dispatch
- The STREAM server and client: STREAM
- The Actor/Spot address resolution rule: Object routing