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<PlayerActor, PlayerActorFactory>(
        SampleNames.PlayerActorType,
        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.

ZLinkActorCreateResult result = await actors
    .GetOrCreate(playerId, "player")
    .InMesh("play")
    .Request(new CreatePlayer(displayName))
    .Timeout(TimeSpan.FromSeconds(10))
    .Async(cancellationToken);

ActorRef actor = result switch
{
    ZLinkActorCreateResult.Existing value => value.Actor,
    ZLinkActorCreateResult.Created value => value.Actor,
    ZLinkActorCreateResult.Rejected =>
        throw new InvalidOperationException("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.

ActorRef? current = await actors.FindAsync(playerId, cancellationToken);
SpotRef? currentSpot = await actors.FindSpotAsync(playerId, cancellationToken);

if (current is { } exact)
{
    // Doesn't terminate an Actor whose generation differs.
    await actors.DestroyAsync(exact, cancellationToken);
}

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
OnCreateActorAsync When a new Actor takes this Entry Spot as its first membership. Decides accept/reject
OnJoinedActorAsync Once the commit finishes for an Actor that was in another Spot coming into this Entry Spot
OnLeaveActorAsync Once the commit finishes for an Actor that was in this Entry Spot leaving to another Spot
OnDisconnectActorAsync 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.

internal sealed class PlayEntrySpot(
    IZLinkEntrySpotContext context,
    ILogger<PlayEntrySpot> logger) : IZLinkEntrySpot<PlayActor>
{
    private readonly MilestoneObserverRegistry _milestoneObservers = new();

    public IZLinkEntrySpotContext Context { get; } = context;

    public void Configure()
    {
        // send: schedules the actor's room join.
        Context.Handlers.AddHandler<PlayActorJoinGameHandler>(nameof(JoinGameMsg));
        // request: enables milestone notifications for the actor.
        Context.Handlers.AddHandler<PlayActorObserveMilestoneHandler>(nameof(ObserveMilestoneReq));
        // subscribe: forwards milestone publications to observing actors.
        Context.Handlers.AddSubscribe<PlayerWinMilestoneEventHandler>(
            SampleTopics.PlayerMilestoneChannel,
            SampleTopics.PlayerMilestone);
    }

    public ValueTask<ZLinkActorCreateResponse> OnCreateActorAsync(
        PlayActor actor,
        ZLinkMessage createRequest,
        CancellationToken cancellationToken)
    {
        actor.ApplyPlayer(createRequest.Decode<PlayerActorCreateReq>().Player);
        logger.LogInformation(
            "entry spot: actor created. actor={ActorId}",
            actor.ActorId);
        return ValueTask.FromResult(ZLinkActorCreateResponse.Accept());
    }

    public ValueTask<ZLinkSpotActorJoinResult> OnActorJoinAsync(
        string actorId,
        ZLinkMessage request,
        CancellationToken cancellationToken)
    {
        return ValueTask.FromResult(ZLinkSpotActorJoinResult.Accept(request));
    }

    public async ValueTask OnJoinedActorAsync(
        PlayActor actor,
        CancellationToken cancellationToken)
    {
        logger.LogInformation(
            "entry spot: actor joined. actor={ActorId}",
            actor.ActorId);
        if (!actor.DestroyAfterEntrySpotJoin) return;

        logger.LogInformation(
            "entry spot: actor destroy requested. actor={ActorId}",
            actor.ActorId);
        // Finished-room cleanup is server lifecycle work. It must finish even
        // when the client closes immediately after receiving the leave reply.
        await Context.DestroyActorAsync(actor, CancellationToken.None);
        logger.LogInformation(
            "tictactoe-lifecycle actor-destroy-complete actor={ActorId}",
            actor.ActorId);
    }

    public ValueTask OnLeaveActorAsync(
        PlayActor actor,
        CancellationToken cancellationToken)
    {
        logger.LogInformation(
            "entry spot: actor left. actor={ActorId}",
            actor.ActorId);
        _milestoneObservers.Remove(actor);
        return ValueTask.CompletedTask;
    }

    public ValueTask OnDisconnectActorAsync(
        PlayActor actor,
        CancellationToken cancellationToken)
    {
        actor.MarkDisconnected();
        _milestoneObservers.Remove(actor);
        logger.LogInformation(
            "entry spot: actor disconnected. actor={ActorId}",
            actor.ActorId);
        return ValueTask.CompletedTask;
    }

    public ValueTask SubscribeMilestoneAsync(
        PlayActor actor,
        CancellationToken cancellationToken)
    {
        _milestoneObservers.Subscribe(actor);
        logger.LogInformation(
            "entry spot: milestone observer subscribed. actor={ActorId}",
            actor.ActorId);
        return ValueTask.CompletedTask;
    }

    public async ValueTask NotifyMilestoneAsync(
        PlayerWinMilestoneEvent milestone,
        CancellationToken cancellationToken)
    {
        await _milestoneObservers.NotifyAsync(
            milestone,
            cancellationToken);
    }

    private sealed class MilestoneObserverRegistry
    {
        private readonly Dictionary<string, PlayActor> _observers = new(StringComparer.Ordinal);

        public void Subscribe(PlayActor actor)
        {
            _observers[actor.ActorId] = actor;
        }

        public void Remove(PlayActor actor)
        {
            _observers.Remove(actor.ActorId);
        }

        public async ValueTask NotifyAsync(
            PlayerWinMilestoneEvent milestone,
            CancellationToken cancellationToken)
        {
            var notify = new WinMilestoneNotify(
                milestone.RoomId,
                milestone.ActorId,
                milestone.DisplayName,
                milestone.Wins);

            var observers = _observers.Values.ToArray();
            foreach (var observer in observers)
                await observer.Context.BoundSession.Send(notify)
                    .Async(cancellationToken);
        }
    }
}

In its minimal shape, it looks like this.

// <PlayerActor> — the Actor type this Entry Spot manages membership for.
// Specifying this type is what gives you the membership callbacks below.
public sealed class PlayEntrySpot(IZLinkEntrySpotContext context)
    : IZLinkEntrySpot<PlayerActor>
{
    // Exposes the context the Framework passed into the constructor, as-is. Use this
    // property to access handler registration, Actor termination, and outbound calls.
    public IZLinkEntrySpotContext Context { get; } = context;

    // Called once when the Spot instance is prepared. A handler registered here
    // handles a packet addressed to an Actor that belongs to this Entry Spot.
    public void Configure()
    {
        // JoinGameHandler receives a JoinGame packet addressed to a PlayerActor.
        Context.Handlers
            .AddActorPacket<JoinGameHandler, PlayerActor>();
    }

    // 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.
    public ValueTask<ZLinkActorCreateResponse> OnCreateActorAsync(
        // A new Actor instance, not yet published.
        PlayerActor actor,
        // The value sent via GetOrCreate/Create's .Request(...).
        ZLinkMessage createRequest,
        CancellationToken cancellationToken)
    {
        var request = createRequest.Decode<CreatePlayer>();
        // The Actor owns its own initial state.
        actor.SetDisplayName(request.DisplayName);

        // Accept() makes the Actor Ready; Reject(...) cancels the creation.
        return ValueTask.FromResult(ZLinkActorCreateResponse.Accept());
    }

    // Called once the commit finishes for an Actor that was in a User Spot returning to this Entry Spot.
    // Not called on initial creation or relocation restore.
    public ValueTask OnJoinedActorAsync(
        PlayerActor actor,
        CancellationToken cancellationToken)
        // This sample only receives the notification and has nothing else to do.
        => ValueTask.CompletedTask;

    // Called after the commit for an Actor that was in this Entry Spot leaving to a User Spot.
    // Doesn't mean the Actor disappeared — it means membership moved.
    public ValueTask OnLeaveActorAsync(
        PlayerActor actor,
        CancellationToken cancellationToken)
        => ValueTask.CompletedTask;
}

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.

await Context.DestroyActorAsync(
    actor,
    cancellationToken); // The Entry Spot requests termination of the current 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, OnJoinedActorAsync is called.

public sealed class GameRoom(IZLinkSpotContext context)
    : IZLinkSpot<PlayerActor>
{
    public IZLinkSpotContext Context { get; } = context;

    public ValueTask<ZLinkSpotActorJoinResult> OnActorJoinAsync(
        string actorId,
        ZLinkMessage request,
        CancellationToken cancellationToken)
    {
        var join = request.Decode<JoinGame>();
        return ValueTask.FromResult(
            HasSeat(join.Seat)
                ? ZLinkSpotActorJoinResult.Accept(new Joined(join.Seat))
                : ZLinkSpotActorJoinResult.Reject(new RoomFull()));
    }

    public ValueTask OnJoinedActorAsync(
        PlayerActor actor,
        CancellationToken cancellationToken)
        => ValueTask.CompletedTask;

    public ValueTask OnLeaveActorAsync(
        PlayerActor actor,
        CancellationToken cancellationToken)
        => ValueTask.CompletedTask;
}

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.

internal sealed class PlayActorJoinGameHandler(ILogger<PlayActorJoinGameHandler> logger)
    : IZLinkEntrySpotActorSendHandler<PlayEntrySpot, PlayActor, JoinGameMsg>
{
    public ValueTask HandleAsync(
        PlayEntrySpot entrySpot,
        PlayActor actor,
        IZLinkMessageContext context,
        JoinGameMsg message,
        CancellationToken cancellationToken)
    {
        logger.LogInformation(
            "actor: JoinGameMsg received. actor={ActorId}, roomId={RoomId}",
            actor.ActorId,
            message.RoomId);

        actor.TrackDeferredJoin(message.RoomId);
        actor.Context.JoinSpot(
                message.RoomId,
                new TicTacToeGameJoinReq(message.RoomId, actor.RequirePlayer()))
            .Defer();
        logger.LogInformation(
            "actor: room join scheduled. actor={ActorId}, roomId={RoomId}",
            actor.ActorId,
            message.RoomId);
        return ValueTask.CompletedTask;
    }
}

In its minimal shape, it looks like this.

public sealed class JoinGameHandler
    : IZLinkSpotActorSendHandler<PlayEntrySpot, PlayerActor, JoinGame>
{
    public ValueTask HandleAsync(
        PlayEntrySpot entrySpot,        // The Spot this Actor currently belongs to.
        PlayerActor actor,              // The Actor requesting the join.
        IZLinkMessageContext messageContext,
        JoinGame command,
        CancellationToken cancellationToken)
    {
        actor.Context
            .JoinSpot(command.SpotId, new JoinGameRequest(command.Seat))
            .Timeout(TimeSpan.FromSeconds(5))
            .Defer(); // Starts the join once the current handler succeeds.

        return ValueTask.CompletedTask;
    }
}

The result arrives through the Actor's OnJoinCompletedAsync. 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.

public ValueTask OnJoinCompletedAsync(
    ZLinkActorJoinCompletion completion,
    CancellationToken cancellationToken)
{
    switch (completion)
    {
        // The location and membership change committed. accepted.Actor is the current ActorRef.
        case ZLinkActorJoinCompletion.Accepted accepted:
            RememberCurrentLocation(accepted.Actor);
            break;

        // The target's admission callback rejected the join. The location is unchanged.
        case ZLinkActorJoinCompletion.Rejected:
            ClearPendingJoin();
            break;

        // Only the error kind is received. Decide whether to retry by checking business state and idempotency.
        case ZLinkActorJoinCompletion.Failed failed:
            HandleJoinFailure(failed.Kind);
            break;
    }

    return ValueTask.CompletedTask;
}

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

actor.Context
    .JoinEntrySpot(new LeaveGame(reason))
    .Timeout(TimeSpan.FromSeconds(5))
    .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, new AwardExperience(10))
    .Async(cancellationToken);

PlayerProfile profile = await actorClient
    .RequestToActor(playerId, new GetPlayerProfile())
    .Timeout(TimeSpan.FromSeconds(3))
    .Async<PlayerProfile>(cancellationToken);

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.

internal sealed class PlayActorRelocationAdapter
    : IZLinkActorRelocationAdapter<PlayActor>
{
    public ValueTask<byte[]> CaptureAsync(
        PlayActor actor,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        return ValueTask.FromResult(JsonSerializer.SerializeToUtf8Bytes(
            new PlayActorRelocationState(
            actor.RoomId,
            actor.Player,
            actor.DestroyAfterEntrySpotJoin,
            actor.Disconnected,
            actor.ProcessedJoinOperations)));
    }

    public ValueTask RestoreAsync(
        PlayActor actor,
        ReadOnlyMemory<byte> payload,
        CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var transferred = JsonSerializer.Deserialize<PlayActorRelocationState>(
            payload.Span) ?? throw new InvalidDataException("Actor relocation state is empty.");
        if (transferred.Player is not null) actor.ApplyPlayer(transferred.Player);
        if (!string.IsNullOrEmpty(transferred.RoomId)) actor.JoinRoom(transferred.RoomId);
        if (transferred.DestroyAfterEntrySpotJoin) actor.MarkForDestroyAfterRoomLeave();
        if (transferred.Disconnected) actor.MarkDisconnected();
        actor.RestoreProcessedJoinOperations(transferred.ProcessedJoinOperations);
        return ValueTask.CompletedTask;
    }

    private sealed record PlayActorRelocationState(
        string RoomId,
        PlayerInfo? Player,
        bool DestroyAfterEntrySpotJoin,
        bool Disconnected,
        IReadOnlyCollection<ZLinkActorJoinOperationId> ProcessedJoinOperations);
}

In its minimal shape, it looks like this.

public sealed class PlayerActorRelocationAdapter
    : IZLinkActorRelocationAdapter<PlayerActor>
{
    public ValueTask<byte[]> CaptureAsync(
        PlayerActor actor,
        CancellationToken cancellationToken)
        => ValueTask.FromResult(actor.ExportState());

    public ValueTask RestoreAsync(
        PlayerActor actor,
        ReadOnlyMemory<byte> payload,
        CancellationToken cancellationToken)
    {
        actor.ImportState(payload.Span);
        return ValueTask.CompletedTask;
    }
}

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