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.class)
.addActorFactory(
SampleNames.PlayerActorType,
PlayerActor.class,
PlayerActorFactory.class,
factory -> factory.preserveStateWith(PlayerActorRelocationAdapter.class));
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.
ZLinkActorCreateResult result = actors
.getOrCreate(playerId, "player")
.inMesh("play")
.request(new CreatePlayer(displayName))
.timeout(Duration.ofSeconds(10))
.submit()
.toCompletableFuture().join();
ActorRef actor;
if (result instanceof ZLinkActorCreateResult.Existing existing) actor = existing.actor();
else if (result instanceof ZLinkActorCreateResult.Created created) actor = created.actor();
else throw new IllegalStateException("Player creation was rejected.");
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.
Optional<ActorRef> current = actors.find(playerId).toCompletableFuture().join();
Optional<SpotRef> currentSpot = actors.findSpot(playerId).toCompletableFuture().join();
current.ifPresent(actor ->
// Doesn't terminate an Actor whose generation differs.
actors.destroy(actor).toCompletableFuture().join());
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.
public final class PlayEntrySpot implements ZLinkEntrySpot<PlayActor> {
private static final Logger LOGGER = LoggerFactory.getLogger(PlayEntrySpot.class);
private final ZLinkEntrySpotContext context;
private final PlaySettings settings;
private final List<PlayActor> milestoneObservers = new ArrayList<>();
public PlayEntrySpot(
ZLinkEntrySpotContext context,
PlaySettings settings) {
this.context = context;
this.settings = settings;
// send: JoinGameMsg를 받고 join 완료 뒤 current session으로 결과를 push한다.
context.handlers().addHandler(PlayActorJoinGameHandler.class);
// request: ObserveMilestoneReq에 ObserveMilestoneRes로 응답한다.
context.handlers().addHandler(PlayActorObserveMilestoneHandler.class);
// subscribe: PlayerWinMilestoneEvent를 받아 observer session에 알린다.
context.handlers().addHandler(PlayerWinMilestoneEventHandler.class);
}
@Override
public ZLinkEntrySpotContext context() {
return context;
}
@Override
public CompletionStage<ZLinkActorCreateResponse> onCreateActor(
PlayActor actor,
ZLinkMessage createRequest) {
if (createRequest.isEmpty()) {
return CompletableFuture.completedFuture(
ZLinkActorCreateResponse.accept());
}
PlayerActorCreateReq request = createRequest.decode(PlayerActorCreateReq.class);
actor.applyPlayer(request.player());
return CompletableFuture.completedFuture(
ZLinkActorCreateResponse.accept());
}
@Override
public CompletionStage<Void> onJoinedActor(PlayActor actor) {
if (actor.destroyAfterEntrySpotJoin()) {
return context.destroyActor(actor)
.thenRun(() -> LOGGER.info(
"tictactoe-lifecycle actor-destroy-complete actor={}", actor.actorId()));
}
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<Void> onLeaveActor(PlayActor actor) {
milestoneObservers.removeIf(existing -> existing.actorId().equals(actor.actorId()));
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<Void> onDisconnectActor(PlayActor actor) {
actor.markDisconnected();
milestoneObservers.removeIf(existing -> existing.actorId().equals(actor.actorId()));
return CompletableFuture.completedFuture(null);
}
public ObserveMilestoneRes observeMilestone(PlayActor actor) {
rememberObserver(actor);
return new ObserveMilestoneRes(true);
}
public void notifyMilestone(PlayerWinMilestoneEvent event) {
WinMilestoneNotify payload = new WinMilestoneNotify(
event.roomId(),
event.actorId(),
event.displayName(),
event.wins());
for (PlayActor observer : List.copyOf(milestoneObservers)) {
observer.context().boundSession()
.send(payload)
.submit();
}
}
private void rememberObserver(PlayActor actor) {
milestoneObservers.removeIf(existing -> existing.actorId().equals(actor.actorId()));
milestoneObservers.add(actor);
}
}
In its minimal shape, it looks like this.
// <PlayerActor> — the Actor type this Entry Spot manages membership for.
public final class PlayEntrySpot implements ZLinkEntrySpot<PlayerActor> {
private final ZLinkEntrySpotContext context;
// Exposes the context the Framework passed into the constructor, as-is.
@Override
public ZLinkEntrySpotContext context() {
return context;
}
// Called once when the Spot instance is prepared.
@Override
public void configure() {
// JoinGameHandler receives a JoinGame packet addressed to a PlayerActor.
// The @ZLinkSpotActorSend on the handler decides which kind it is.
context.handlers().addHandler(JoinGameHandler.class);
}
// 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.
@Override
public CompletionStage<ZLinkActorCreateResponse> onCreateActor(
PlayerActor actor, ZLinkMessage createRequest) {
actor.setDisplayName(createRequest.decode(CreatePlayer.class).displayName());
return CompletableFuture.completedFuture(ZLinkActorCreateResponse.accept());
}
// Called once the commit finishes for an Actor that was in a User Spot returning.
@Override
public CompletionStage<Void> onJoinedActor(PlayerActor actor) {
return CompletableFuture.completedFuture(null);
}
// Called after the commit for an Actor leaving to a User Spot.
@Override
public CompletionStage<Void> onLeaveActor(PlayerActor actor) {
return CompletableFuture.completedFuture(null);
}
}
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.
context.destroyActor(actor).toCompletableFuture().join();
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.
public final class GameRoom implements ZLinkSpot<PlayerActor> {
private final ZLinkSpotContext context;
@Override
public ZLinkSpotContext context() {
return context;
}
@Override
public CompletionStage<ZLinkSpotActorJoinResult> onActorJoin(
String actorId, ZLinkMessage request) {
JoinGame join = request.decode(JoinGame.class);
return CompletableFuture.completedFuture(hasSeat(join.seat())
? ZLinkSpotActorJoinResult.accept(new Joined(join.seat()))
: ZLinkSpotActorJoinResult.reject(new RoomFull()));
}
@Override
public CompletionStage<Void> onJoinedActor(PlayerActor actor) {
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<Void> onLeaveActor(PlayerActor actor) {
return CompletableFuture.completedFuture(null);
}
}
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.
public final class PlayActorJoinGameHandler {
@ZLinkSpotActorSend
public CompletionStage<Void> joinGame(
PlayEntrySpot entrySpot,
PlayActor actor,
ZLinkMessageContext context,
JoinGameMsg request) {
actor.trackDeferredJoin(request.roomId());
actor.context()
.joinSpot(request.roomId(),
new TicTacToeGameJoinReq(request.roomId(), actor.requirePlayer()))
.timeout(SampleNames.RequestTimeout)
.defer();
return CompletableFuture.completedFuture(null);
}
}
In its minimal shape, it looks like this.
public final class JoinGameHandler
implements ZLinkSpotActorSendHandler<PlayEntrySpot, PlayerActor, JoinGame> {
@Override
public CompletionStage<Void> handle(
PlayEntrySpot entrySpot, // The Spot this Actor currently belongs to.
PlayerActor actor, // The Actor requesting the join.
ZLinkMessageContext messageContext,
JoinGame command) {
actor.context()
.joinSpot(command.spotId(), new JoinGameRequest(command.seat()))
.timeout(Duration.ofSeconds(5))
.defer(); // Starts the join once the current handler succeeds.
return CompletableFuture.completedFuture(null);
}
}
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.
@Override
public CompletionStage<Void> onJoinCompleted(ZLinkActorJoinCompletion completion) {
// The location and membership change committed. accepted.actor() is the current ActorRef.
if (completion instanceof ZLinkActorJoinCompletion.Accepted accepted) {
rememberCurrentLocation(accepted.actor());
// The target's admission callback rejected the join. The location is unchanged.
} else if (completion instanceof ZLinkActorJoinCompletion.Rejected) {
clearPendingJoin();
// Only the error kind is received. Decide whether to retry by checking business state and idempotency.
} else if (completion instanceof ZLinkActorJoinCompletion.Failed failed) {
handleJoinFailure(failed.kind());
}
return CompletableFuture.completedFuture(null);
}
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.
actorClient.sendToActor(playerId, new AwardExperience(10)).submit().toCompletableFuture().join();
PlayerProfile profile = actorClient
.requestToActor(playerId, new GetPlayerProfile())
.timeout(Duration.ofSeconds(3))
.submit(PlayerProfile.class)
.toCompletableFuture().join();
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.
public final class PlayActorRelocationAdapter
implements ZLinkActorRelocationAdapter<PlayActor> {
private static final ObjectMapper JSON = new ObjectMapper();
@Override
public CompletionStage<byte[]> capture(
PlayActor actor,
ZLinkRelocationCancellation cancellation) {
try {
return CompletableFuture.completedFuture(
JSON.writeValueAsBytes(new TransferState(
actor.joinedRoomId(),
actor.playerOrNull(),
actor.destroyAfterEntrySpotJoin(),
actor.disconnected())));
} catch (IOException error) {
return CompletableFuture.failedFuture(error);
}
}
@Override
public CompletionStage<Void> restore(
PlayActor actor,
byte[] state,
ZLinkRelocationCancellation cancellation) {
try {
TransferState transferred = JSON.readValue(state, TransferState.class);
if (transferred.player() != null) {
actor.applyPlayer(transferred.player());
}
if (transferred.roomId() != null && !transferred.roomId().isBlank()) {
actor.joinGame(transferred.roomId());
}
if (transferred.destroyAfterEntrySpotJoin()) {
actor.markForDestroyAfterRoomLeave();
}
if (transferred.disconnected()) {
actor.markDisconnected();
}
return CompletableFuture.completedFuture(null);
} catch (IOException error) {
return CompletableFuture.failedFuture(error);
}
}
public record TransferState(
String roomId,
PlayerInfo player,
boolean destroyAfterEntrySpotJoin,
boolean disconnected) {
}
}
In its minimal shape, it looks like this.
public final class PlayerActorRelocationAdapter
implements ZLinkActorRelocationAdapter<PlayerActor> {
@Override
public CompletionStage<byte[]> capture(PlayerActor actor) {
return CompletableFuture.completedFuture(actor.exportState());
}
@Override
public CompletionStage<Void> restore(PlayerActor actor, byte[] payload) {
actor.importState(payload);
return CompletableFuture.completedFuture(null);
}
}
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