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.
val locationOptions =
    ZLinkRedisLocationOptions()
        .setConnectionString("127.0.0.1:6379")
        .setKeyPrefix("zlink-tutorial-kotlin:location:")
options.addLocationStore(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(
    ZLinkRedisRelocationStore(
        ZLinkRedisRelocationOptions()
            .setConnectionString("127.0.0.1:6379")
            .setKeyPrefix("zlink-tutorial-kotlin: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.
//
// ZLinkSuspendingSpot is the Kotlin base: it turns every CompletionStage
// callback into a suspending one. Its type argument is the actor type the room
// can admit; this room admits none, so it names the base type and rejects every
// join. Actors are a later chapter.
class GameRoom(override val context: ZLinkSpotContext) : ZLinkSuspendingSpot<ZLinkActor>() {

    private val chat = mutableListOf<String>()
    private var title = "untitled"

    init {
        // Handler classes are named here rather than scanned, the same way the
        // channel registrations name their group. Each takes the target room as
        // its first argument.
        context.handlers().addHandler<PostChatHandler>()
        context.handlers().addHandler<GetRoomStateHandler>()
    }

    // 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 suspend fun onCreateSuspending(request: ZLinkMessage): ZLinkSpotCreateResponse {
        val body = request.decode<OpenRoom>()
        title = body.title
        return ZLinkSpotCreateResponse.accept()
    }

    // No actor ever joins this room, so the three membership callbacks below say
    // so and do nothing else.
    override suspend fun onActorJoinSuspending(
        actorId: String,
        request: ZLinkMessage,
    ): ZLinkSpotActorJoinResult {
        return ZLinkSpotActorJoinResult.reject()
    }

    override suspend fun onJoinedActorSuspending(actor: ZLinkActor) {}

    override suspend fun onLeaveActorSuspending(actor: ZLinkActor) {}

    fun append(line: String) {
        chat.add(line)
    }

    fun state(): RoomState {
        return RoomState(title, chat.toList())
    }
}

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 their group. Each takes the target room as
// its first argument.
context.handlers().addHandler<PostChatHandler>()
context.handlers().addHandler<GetRoomStateHandler>()

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.
val 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<GameRoom>("game-room") { 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(
    ZLinkRedisLocationStore(
        ZLinkRedisLocationOptions()
            .setConnectionString("127.0.0.1:6379")
            .setKeyPrefix("zlink-tutorial-kotlin: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.
val mesh =
    options
        .addRouteMesh("game")
        .listen("tcp://127.0.0.1:7601")
        // The advertised endpoint must match the endpoint passed to
        // connect(routingId, endpoint). Both use 127.0.0.1 here.
        .setAdvertiseHost("127.0.0.1")
        .setRoutingId(RoutingId.from("game-server-1"))

The call that creates the Spot in that mesh follows.

@PostMapping("/rooms")
suspend fun openRoom(@RequestBody request: OpenRoom): String =
    rooms
        .create("game-room") // Picks the factory and the candidate nodes.
        .inMesh("game")
        .request(request) // Reaches the room's create callback.
        .await()
        // From here on the room is addressed by this id alone.
        .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")
suspend fun postChat(
    @PathVariable roomId: String,
    @RequestBody message: PostChat,
): ResponseEntity<Void> {
    // The id is enough; the Framework resolves where the room currently runs.
    route.sendToSpot(roomId, message).await()

    return 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}")
suspend fun roomState(@PathVariable roomId: String): RoomState =
    route
        .requestToSpot<RoomState>(roomId, GetRoomState())
        .timeout(Duration.ofSeconds(3))
        .await()

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/kotlin