Elixir GenServer: Fault-tolerant services with supervisors
A GenServer
is a generic server process in Elixir that abstracts and manages the lifecycle and state of a server process. It handles incoming requests in sequential order, which is particularly useful for stateful operations.
Starting the GenServer
def start_link(opts \\ []) do
topic = Keyword.get(opts, :topic, @default_topic)
GenServer.start_link(__MODULE__, initial_state(topic), opts)
end
def init(state) do
broadcast_update(:update, state)
{:ok, state}
end
-
start_link
: Checks the options for atopic
and starts the GenServer. On success, it returns the process identifier (pid
). -
init/1
: Called on startup,init
broadcasts the initial state.
GenServer Message Functions
Synchronous (handle_call
)
Synchronous functions handle requests where the caller expects a reply. When a synchronous message is sent, the caller blocks and waits for a response.
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
:get_state
: Retrieves and returns the current state of the GenServer.
Asynchronous (handle_cast
)
Asynchronous functions handle fire-and-forget requests from the caller. The caller sends the message and continues execution without waiting for a response.
def handle_cast({:mark, position}, state) do
case Map.get(state.board, position) do
nil ->
new_board = Map.put(state.board, position, state.current_player)
{new_player, new_winner, new_game_status} = GameLogic.update_game(state, new_board)
new_state = %{
state
| board: new_board,
current_player: new_player,
win: new_winner,
game_status: new_game_status
}
broadcast_update(:update, new_state)
{:noreply, new_state}
_ ->
{:noreply, state}
end
end
:mark
: Marks a position on the Tic Tac Toe board. If the position is occupied, the request is ignored. If the position is empty, the board is updated, and the new state is broadcast.
GenServer Public Functions
In Elixir, a GenServer encapsulates its behaviors within a public-facing API. This design pattern shields the GenServer from direct message-passing and prevents unauthorized access or manipulation of the server’s state.
def reset(pid) do
GenServer.stop(pid, :normal)
end
def get_state(pid) do
GenServer.call(pid, :get_state)
end
def mark(pid, position) when position in @valid_positions do
GenServer.cast(pid, {:mark, position})
end
def mark(_pid, _position), do: {:error, :invalid_position}
Public API Functions:
-
reset/1
: Terminates the GenServer process gracefully. This allows the supervisor to restart and initialize a new server process. -
get_state/1
: Retrieves the current state of the GenServer through a synchronous call. This ensures the calling process waits for a response before proceeding. -
mark/2
: Sends an asynchronous message to mark a position on the board if the position is valid. If the position is invalid, it returns an error.
This public API resembles the functionality of Elixir’s Agent
module but adds the requirement for a process identifier (pid
). The pid
specifies which GenServer instance to interact with, allowing multiple game sessions to run concurrently and independently.
Registry
The Agent version of TicTacToe created a single global process where all clients shared the same process and state. In this version, we generate three processes (:tic
, :tac
, and :toe
) and let the client choose which one to use.
Each time a process boots (or reboots), it receives a new process identifier (pid
), making it difficult for clients to know which pid
to use. To address this, we use a Registry.
defmodule GameApp.Games.TicTacToe.Registry do
use GenServer
alias GameApp.Games.TicTacToe.GenServer, as: TicTacToe
@tic_tac_toe_processes [:tic, :tac, :toe]
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def init(state) do
Enum.each(@tic_tac_toe_processes, fn name ->
{:ok, _pid} = start_tic_tac_toe(name)
end)
{:ok, state}
end
def start_tic_tac_toe(name) do
topic = Atom.to_string(name)
DynamicSupervisor.start_child(
GameApp.DynamicSupervisor,
{TicTacToe, [topic: topic, name: name]}
)
end
def reset(name) do
GenServer.stop(name, :normal)
end
def crash_server(name) do
GenServer.cast(name, :crash)
end
def get_state(name) do
GenServer.call(name, :get_state)
end
def mark(name, position) do
GenServer.cast(name, {:mark, position})
end
end
The Registry provides similar behaviors to the GenServer (reset
, get_state
, mark
), but instead of calling by pid
, it calls by a known name (:tic
, :tac
, or :toe
). This allows clients to choose from a fixed list of names rather than dealing with continually changing process identifiers.
By using a Registry, we can manage multiple game instances and ensure clients can reliably find and interact with the correct process, even if processes are restarted and their pids
change. This setup improves the scalability and fault tolerance of the application by allowing multiple independent game instances to run concurrently.
Client
Instead of calling the GenServer directly, the client calls the Registry with a known server name. This approach allows clients to interact with specific instances of the TicTacToe game.
defmodule GameAppWeb.TicTacToeLive do
use GameAppWeb, :live_view
alias GameApp.Games.TicTacToe.Registry
def mount(%{"server" => server}, _session, socket) do
server_atom = String.to_existing_atom(server)
state = Registry.get_state(server_atom)
Phoenix.PubSub.subscribe(GameApp.PubSub, state.topic)
{:ok, assign(socket, game_state: state, tictactoe_server: server_atom)}
end
def handle_event("reset", _, socket) do
Registry.reset(socket.assigns.tictactoe_server)
{:noreply, socket}
end
def handle_event("mark", %{"position" => position}, socket) do
Registry.mark(socket.assigns.tictactoe_server, String.to_atom(position))
{:noreply, socket}
end
def handle_info({:update, new_state}, socket) do
{:noreply, assign(socket, game_state: new_state)}
end
end
This approach ensures that clients can reliably find and interact with the correct process, improving scalability and fault tolerance. The client module provides a clean and organized way to handle game events and updates, maintaining a responsive and interactive user experience.
Crashing
To purposefully crash the GenServer, raise an exception.
GenServer Crash
def crash_server(pid) do
GenServer.cast(pid, :crash)
end
def handle_cast(:crash, state) do
broadcast_update(:crash, state)
raise "BOMB!!!"
end
Registry Crash
Add the crash logic to the registry:
def crash_server(name) do
GenServer.cast(name, :crash)
end
Client Crash
Add the crash handling logic to the client:
def handle_event("crash", _, socket) do
Registry.crash_server(socket.assigns.tictactoe_server)
{:noreply, socket}
end
def handle_info({:crash, _state}, socket) do
{:noreply,
socket
|> put_flash(:error, "Server crashed!")}
end
When the GenServer crashes, the supervisor will automatically restart it, generating a new pid
. The Registry will seamlessly update to target this new process for subsequent named calls.
Unlike the Agent example, the client’s call to crash the process is a fire-and-forget message. Due to this loose coupling, the server must send a crash message.
Testing
Now that we have processes operating in isolation, tests can run asynchronously.