Skip to content

Spot

Guide Home | Previous: Channel Messaging | Next: Actor

What you get from this chapter

You can create a stateful object addressed by id, send messages to it, and receive answers. The code in this chapter comes from the language-specific example repositories.

In Channel Messaging a call was received by one of the nodes serving the name. That path does not work when the recipient is already decided. A Spot is a stateful object found by id, and it processes the work addressed to it in a single line. This chapter goes as far as creating one Spot and calling it; the differences between the kinds and the full lifecycle are covered by How Spots Execute.

1. The Role of a Spot

Spot creates units that remember something and process the work addressed to them in order — one chat room, one matchmaking queue. A channel cannot express that: a channel call goes to one of the nodes serving that name, so two messages sent to the same room can arrive at different nodes.

▶ Try it in the interactive tour — Lobby and dungeon

A Spot has an id. Messages sent to the same id always arrive at the same Spot, and inside that Spot they run one at a time. A Spot's fields therefore need no lock.

↗ View larger

Neither the creator nor the caller picks a node. That much matches a channel; what differs is that the target is settled to exactly one.

2. The Location Store — a Prerequisite for Registering a Spot

A Spot is called by id, not by host. That requires somewhere to record which node it is on right now. That is the Location Store, and it is required once you register even one Spot.

// Rooms are addressed by id, not by host, so their current location is
// kept here. Every node reads and writes the same store under the same
// prefix.
var locationOptions =
        new ZLinkRedisLocationOptions()
                .setConnectionString("127.0.0.1:6379")
                .setKeyPrefix("zlink-tutorial-java:location:");
options.addLocationStore(new ZLinkRedisLocationStore(locationOptions));

Register a Relocation Store alongside it. Registering a Spot factory is itself the condition, so it is required even with relocation turned off as it is in this chapter.

// Registering any Spot factory requires this store, even with
// relocation turned off: the registration itself is the condition.
options.addRelocationStore(
        new ZLinkRedisRelocationStore(
                new ZLinkRedisRelocationOptions()
                        .setConnectionString("127.0.0.1:6379")
                        .setKeyPrefix("zlink-tutorial-java:relocation:")));

A node that only calls needs the Location Store alone — the calling side.

3. The Receiving Side — the Node That Hosts the Spot

The receiving process

The code in this section goes into the process that runs the Spot.

3.1 Writing the Spot

A Spot holds its state in fields and initializes itself from the payload it receives when it is created.

// A room owns its own state and is addressed by a global SpotId. Messages sent
// to one room run one at a time, so the fields below need no synchronization.
//
// Every Java user Spot names the actor type it can admit. This room admits
// none, so it names the base type and rejects every join. Actors are a later
// chapter.
public final class GameRoom implements ZLinkSpot<ZLinkActor> {

    private final ZLinkSpotContext context;
    private final List<String> chat = new ArrayList<>();

    private String title = "untitled";

    public GameRoom(ZLinkSpotContext context) {
        this.context = context;
        // Handler classes are named here rather than scanned, the same way the
        // channel registrations name theirs. Each takes the target room as its
        // first argument.
        context.handlers().addHandler(PostChatHandler.class);
        context.handlers().addHandler(GetRoomStateHandler.class);
    }

    @Override
    public ZLinkSpotContext context() {
        return context;
    }

    // Runs before the room accepts any message. Rejecting here means the create
    // call fails and no room exists. Omit this method to accept every request.
    @Override
    public CompletionStage<ZLinkSpotCreateResponse> onCreate(ZLinkMessage request) {
        var body = request.decode(Contracts.OpenRoom.class);
        title = body.title();
        var accepted = ZLinkSpotCreateResponse.accept();
        return CompletableFuture.completedFuture(accepted);
    }

    // No actor ever joins this room, so the three membership callbacks below say
    // so and do nothing else.
    @Override
    public CompletionStage<ZLinkSpotActorJoinResult> onActorJoin(
            String actorId, ZLinkMessage request) {
        var rejected = ZLinkSpotActorJoinResult.reject();
        return CompletableFuture.completedFuture(rejected);
    }

    @Override
    public CompletionStage<Void> onJoinedActor(ZLinkActor actor) {
        return CompletableFuture.completedFuture(null);
    }

    @Override
    public CompletionStage<Void> onLeaveActor(ZLinkActor actor) {
        return CompletableFuture.completedFuture(null);
    }

    void append(String line) {
        chat.add(line);
    }

    Contracts.RoomState state() {
        return new Contracts.RoomState(title, List.copyOf(chat));
    }
}

To refuse at creation, refuse in the create callback. The create call then fails and no Spot remains. Omit that callback and every create request is accepted.

3.2 Writing the Handlers

A handler receives the target Spot as its first argument. Unlike a channel handler, it works on that Spot's state directly.

// Handler classes are named here rather than scanned, the same way the
// channel registrations name theirs. Each takes the target room as its
// first argument.
context.handlers().addHandler(PostChatHandler.class);
context.handlers().addHandler(GetRoomStateHandler.class);

A handler with no return value receives a message that arrived by send; a handler that returns a value makes that value the response. Within one room the two handlers run one at a time.

3.3 Registration

The mesh node picks the Object Server role once, and the Spot factory is registered on it.

// A mesh node picks this role once. Keep the builder and reuse it,
// because calling objects().server() a second time is rejected at
// startup.
ZLinkMeshObjectServerBuilder objects = mesh.objects().server();

// "game-room" is the stable type a caller names when opening a room.
// Any node that registers it is a candidate to host one. Exactly one
// relocation policy is required; moving a live room to another node is
// a separate topic.
objects.addSpotFactory(
        "game-room", GameRoom.class, factory -> factory.disableRelocation());

The name given at registration is the stable type. The calling side names it when opening a room, and the nodes that registered the same name become the candidates. Exactly one relocation policy is required; this chapter turns it off. To preserve state through an adapter, choose preserveStateWith at this registration — Relocation's adapter shows the code that captures and restores that state.

4. The Calling Side — the Node That Calls a Spot

The calling process

The code in this section goes into the other process, the one that creates the room and calls it. It pairs with the section above.

The calling node does not run the Spot, so it registers no factory. It picks the client role on that mesh instead, and registers the Location Store it reads locations from under the same prefix.

// Rooms are looked up by whoever calls them, so a node that hosts none
// still needs the store, pointed at the same prefix. Every other peer
// this node needs is named by hand below.
options.addLocationStore(
        new ZLinkRedisLocationStore(
                new ZLinkRedisLocationOptions()
                        .setConnectionString("127.0.0.1:6379")
                        .setKeyPrefix("zlink-tutorial-java:location:")));

// client() rules out registering factories. This node creates and
// calls rooms; a node that picked server() runs them.
mesh.objects().client();

4.1 Creating

Create a Spot by naming the stable type: a type used consistently for the same id. The id that comes back is the address for every later call.

First, the tutorial Server defines the game route mesh. A mesh name names the set of nodes where a Spot may be placed, and inMesh selects a node that participates in that route mesh to host the Spot. The receiving side of Channel Messaging shows how to register a route mesh.

// Both sides must name the mesh identically, or they never see each other
// as peers. The routing id names this node; without it the Framework assigns
// a generated one, which a caller cannot type into a URL.
//
// A caller that names a routing id in connect(...) compares the advertised
// endpoint against the endpoint it dialed. Both use 127.0.0.1 here.
ZLinkMeshNodeBuilder mesh =
        options.addRouteMesh("game")
                .listen("tcp://127.0.0.1:7501")
                .setAdvertiseHost("127.0.0.1")
                .setRoutingId(RoutingId.from("game-server-1"));

The call that creates the Spot in that mesh follows.

@PostMapping("/rooms")
CompletionStage<String> openRoom(@RequestBody Contracts.OpenRoom request) {
    return rooms.create("game-room") // Picks the factory and the candidate nodes.
            .inMesh("game")
            .request(request) // Reaches the room's create callback.
            .submit()
            // From here on the room is addressed by this id alone.
            .thenApply(created -> created.spot().spotId());
}

4.2 Calling

Give only the id received at creation. The Framework finds which node it is on right now.

@PostMapping("/rooms/{roomId}/chat")
CompletionStage<ResponseEntity<Void>> postChat(
        @PathVariable String roomId, @RequestBody Contracts.PostChat message) {
    // The id is enough; the Framework resolves where the room currently runs.
    return route.sendToSpot(roomId, message)
            .submit()
            .thenApply(ignored -> ResponseEntity.accepted().build());
}

When you need an answer, send a request. The target may be moving, so give a timeout alongside it.

@GetMapping("/rooms/{roomId}")
CompletionStage<Contracts.RoomState> roomState(@PathVariable String roomId) {
    return route.requestToSpot(roomId, new Contracts.GetRoomState())
            .timeout(Duration.ofSeconds(3))
            .submit(Contracts.RoomState.class);
}

5. What You See When You Run It

With the tutorial Server and Client running as the README's Run section specifies, send these curl requests to the Client's HTTP surface: each HTTP response appears on curl stdout, and handler records appear on the Server process's stdout or in server.log.

curl -X POST http://127.0.0.1:5080/rooms \
  -H 'Content-Type: application/json' -d '{"title":"lobby"}'
# "8213420c-03ff-47a6-8410-ddfb3df26660"

curl -X POST http://127.0.0.1:5080/rooms/8213420c-03ff-47a6-8410-ddfb3df26660/chat \
  -H 'Content-Type: application/json' -d '{"playerId":"p1","text":"hello"}'
# 202

curl http://127.0.0.1:5080/rooms/8213420c-03ff-47a6-8410-ddfb3df26660
# {"title":"lobby","chat":["p1: hello"]}

The Framework makes the id. The second call does not wait for a response; the third receives the answer the room produced. The room held its state between the two calls.

6. The Other Kinds of Spot

What this chapter created is the Spot an application creates explicitly, and most rooms, stages, and zones are that one. There is also the kind the Framework creates when the Object Server starts, and the kind created when the first message arrives. They differ in when they are created and in which lifecycle callbacks they receive.

▶ Try it in the interactive tour — Instance Spot

The differences between the kinds and the full lifecycle are covered by Spot.

  • The other unit addressed by id — Actor
  • The path that calls by name — Channel Messaging
  • What the Location Store records — How Channels Work
  • A running version of this chapter's code — framework/languages/java/tutorial/java