Refactoring Process Identifiers

- 4 mins read

I’m thinking about where in the startup flow for game sessions to create database records which will be used to track active games. There are scenarios where existing game sessions will go through process initialization more than once such as rolling deployments where active game processes need to shut down and start up again on another node. If the database records for each game are created in the GenServer initialization, I’ll need to account for both cases of new games and existing games.

One way I can handle both cases is to perform an “upsert” operation which would create a new record if the game session didn’t already exist or it would update the existing record if one exists. However, there is currently no unique identifier for game sessions to determine if a record should be created or updated. The game_id field is only unique to the active game session GenServer processes so I will need to make adjustments to ensure uniqueness to the database table rows.

Separating responsibilities

I recognize that game_id is used for two different purposes. This id is a short code which users use to connect to the game LiveView as well as an internal identier for game session processses. Once the LiveView is mounted, the same game_id is used to send commands to the session process which is registered with to the Registry module with this id.

If I changed game_id to a UUID, users would no longer have a short code to easily join games. To preserve this short code feature, I will have to make a separate id for this purpose and only use game_id as a unique identifier.

The first thought I had is to use an ETS lookup table to map a short code to a game id. As I poke around my code for where to make this change, I wonder if I can use a second Registry to map short codes to the same game session process. ETS has a faster lookup times over Registry, but this short code mapping should only be used during the initial LiveView mount so this benefit won’t have much of an impact. One of the advantages of using another registry instead of ETS is automatic cleanup of registrations when a process terminates. I am also already using a distributed registry with Horde.Registry which will automatically replicate mapping state across cluster nodes during deployments. I choose to use a second Registry and see where that gets me.

Implementing join_code

Game processes are started by calling GameEngine.create_game/2 or GameEngine.continue_game/2 which both take game_id as their first argument. I update these functions to rename this argument as join_code. I also update every place that calls these functions to define a join_code which is equal to the same value as the game id. Once the join code feature is complete, these two variables will hold distinct values.

create_game and continue_game are responsible for starting SessionServer game processes as children of GameEngine.SessionSupervisor. I update the game SessionServer child spec to take a join_code keyword argument in addition to the game state. I pass join_code as a :start_link keyword argument and update the start_link function to accommodate this change.

defmodule Minotaur.GameEngine do
  # ...

  def continue_game(join_code, %Game{} = game) do
    spec = SessionServer.child_spec(join_code: join_code, game_state: game)
    Horde.DynamicSupervisor.start_child(SessionSupervisor, spec)
  end
end
defmodule Minotaur.GameEngine.SessionSupervisor do
  def child_spec(join_code: join_code, game_state: game) do
    %{
      id: "#{__MODULE__}_#{join_code}",
      start: {__MODULE__, :start_link, [[join_code: join_code, game_state: game]]}
    }
  end

  def start_link(join_code: join_code, game_state: game) do
    name = {:via, Horde.Registry, {SessionRegistry, game.id}}
    GenServer.start_link(__MODULE__, {game, join_code}, name: name)
  end

  # ...
end

The GenServer state for SessionServer is currently just a Game struct which does not have a join_code. Since join_code is only used for mapping processes, I am hesitant to add join_code to the Game struct definition. I instead update the GenServer state to a tuple with {game, join_code} to track the code separate from the game state. I apply this state structure change to all SessionServer callbacks.

defmodule Minotaur.GameEngine.SessionSupervisor do
  # ...

  def init({%Game{} = game, join_code}) do
    Process.flag(:trap_exit, true)
    game_state = get_stashed_state(game.id, game)

    {:ok, {game_state, join_code}, {:continue, :continue_game}}
  end
end

The next steps will be to update the game session process lookup logic to use join_code instead of game_id inside the game LiveView.