Powered by AppSignal & Oban Pro

GenServers Part 2: Supervision & Advanced Patterns

genserver_demo_part2.livemd

GenServers Part 2: Supervision & Advanced Patterns

Recap

Last time we learned:

  • What processes are and how they work
  • How to make processes long-lived with recursive message loops
  • How processes can hold state
  • How GenServers provide a clean abstraction over all of this

Today we’ll cover:

  • Supervision: keeping your processes alive when things go wrong
  • Name Registration: accessing processes without tracking PIDs
  • Other callbacks: handle_info and handle_continue

What happens when a GenServer terminates?

Let’s try it:

defmodule TerminatingServer do
  use GenServer

  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, %{}, opts)
  end

  def init(state) do
    {:ok, state}
  end

  def terminate_me(pid) do
    GenServer.call(pid, :terminate)
  end

  def handle_call(:terminate, _from, state) do
    # Return a :stop tuple to gracefully terminate
    {:stop, :normal, :ok, state}
  end
end
{:ok, pid} = TerminatingServer.start_link()
Process.alive?(pid)
TerminatingServer.terminate_me(pid)
# Is it still alive?
Process.alive?(pid)

The process is dead. In a real application, this is a problem. What if that GenServer was managing important state, like active auction bids?

Enter: Supervisors

Supervisors are special processes that watch other processes and restart them when they die.

According to the HexDocs:

A supervisor is a process which supervises other processes, which we refer to as child processes. Supervisors are used to build a hierarchical process structure called a supervision tree. Supervision trees provide fault-tolerance and encapsulate how our applications start and shutdown.

Let’s create a supervisor for our terminating server:

# Start a supervisor with our TerminatingServer as a child
children = [
  {TerminatingServer, name: TerminatingServer}
]

{:ok, sup_pid} = Supervisor.start_link(children, strategy: :one_for_one)
# The supervisor started our server for us!
Process.alive?(Process.whereis(TerminatingServer))
# Let's terminate it
TerminatingServer.terminate_me(TerminatingServer)
# Give the supervisor a moment to restart it
Process.sleep(100)
Process.alive?(Process.whereis(TerminatingServer))

The supervisor automatically restarted our server!

Supervision Strategies

Supervisors support different strategies for how to handle child failures:

  • :one_for_one - If a child crashes, only that child is restarted. This is the most common strategy.
  • :one_for_all - If a child crashes, all children are terminated and restarted. Use this when children depend on each other.
  • :rest_for_one - If a child crashes, that child and all children started after it are terminated and restarted. Use this when children have dependencies in order.
defmodule CounterServer do
  use GenServer

  def get(name), do: GenServer.call(name, :get)
  def increment(name), do: GenServer.call(name, :increment)

  def start_link(opts) do
    name = Keyword.fetch!(opts, :name)
    GenServer.start_link(__MODULE__, 0, name: name)
  end

  @impl true
  def init(count), do: {:ok, count}

  @impl true
  def handle_call(:get, _from, count) do
    {:reply, count, count}
  end

  @impl true
  def handle_call(:increment, _from, count) do
    {:reply, count + 1, count + 1}
  end
end
# Define three `CounterServer` processes to be supervised
children = [
  Supervisor.child_spec({CounterServer, name: :counter1}, id: :counter1),
  Supervisor.child_spec({CounterServer, name: :counter2}, id: :counter2),
  Supervisor.child_spec({CounterServer, name: :counter3}, id: :counter3)
]

{:ok, sup} = Supervisor.start_link(children, strategy: :one_for_all)

# Get initial counts
IO.puts("Initial Counts")
IO.inspect(CounterServer.get(:counter1), label: "Counter 1")
IO.inspect(CounterServer.get(:counter2), label: "Counter 2")
IO.inspect(CounterServer.get(:counter3), label: "Counter 3")
# Increment :counter1 three times
for _i <- 1..3 do
  CounterServer.increment(:counter1)
end

# Get updated counts
IO.puts("Updated Counts")
IO.inspect(CounterServer.get(:counter1), label: "Counter 1")
IO.inspect(CounterServer.get(:counter2), label: "Counter 2")
IO.inspect(CounterServer.get(:counter3), label: "Counter 3")
# Kill :counter3 - what do we expect to happen?
:counter3
|> Process.whereis()
|> Process.exit(:shutdown)

Process.sleep(100)

# Get counts after restart - all should be back to 0
IO.puts("Counts After Restart")
IO.inspect(CounterServer.get(:counter1), label: "Counter 1")
IO.inspect(CounterServer.get(:counter2), label: "Counter 2")
IO.inspect(CounterServer.get(:counter3), label: "Counter 3")

Dynamic Supervisors

Sometimes you don’t know how many children you need at startup. For example, in an auction app, you might start a new GenServer for each auction as it’s created.

That’s where DynamicSupervisor comes in:

# Start a dynamic supervisor
{:ok, dyn_sup} = DynamicSupervisor.start_link(strategy: :one_for_one)
# Dynamically start children as needed
{:ok, pid1} = DynamicSupervisor.start_child(dyn_sup, {CounterServer, name: :dynamic_counter1})
{:ok, pid2} = DynamicSupervisor.start_child(dyn_sup, {CounterServer, name: :dynamic_counter2})

# They're supervised!
DynamicSupervisor.which_children(dyn_sup)

We can also remove children from the DynamicSupervisor without crashing everything.

# Update the state for :dynamic_counter1
for _i <- 1..3 do
  CounterServer.increment(:dynamic_counter1)
end

IO.inspect(CounterServer.get(:dynamic_counter1), label: "Counter 1")
IO.inspect(CounterServer.get(:dynamic_counter2), label: "Counter 2")
# Termiante :dynamic_counter2
DynamicSupervisor.terminate_child(dyn_sup, pid2)

# Expect :dynamic_counter1's state to be preserved
IO.inspect(CounterServer.get(:dynamic_counter1), label: "Counter 1")

The Supervision Tree

In real applications, supervisors supervise other supervisors, creating a tree structure.

Application Supervisor
├── Database Supervisor
│   ├── Connection Pool
│   └── Query Cache
└── Web Supervisor
    ├── Endpoint
    └── Presence Tracker

This is called the “supervision tree” and it’s how Elixir applications achieve fault tolerance. If a supervised process crashes, the rest of the application continues running. The supervisor determines how to handle its children without affecting the rest of the application.

Name Registration

So far we’ve been using process names like name: CrashyServer. This is called name registration, and it’s incredibly useful.

Without name registration, you need to track PIDs:

{:ok, pid} = CounterServer.start_link(name: nil)
CounterServer.get(pid)  # Have to pass the PID around everywhere

With name registration, you can use atoms:

{:ok, _pid} = CounterServer.start_link(name: :my_counter)
CounterServer.get(:my_counter)

Other Callbacks: handle_continue

handle_continue/2 is called right after init/1 returns, but doesn’t block the start_link call.

This is useful for expensive initialization work that you don’t want to block the supervisor:

defmodule SlowStartServer do
  use GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, :ok, opts)
  end

  def init(:ok) do
    IO.puts("Init started (fast)")
    # Return immediately with :continue
    {:ok, %{}, {:continue, :load_data}}
  end

  def handle_continue(:load_data, _state) do
    IO.puts("Loading data (slow)...")
    Process.sleep(2000)
    IO.puts("Data loaded!")
    {:noreply, %{data: "loaded"}}
  end
end
# This returns immediately, even though loading takes 2 seconds
{:ok, pid} = SlowStartServer.start_link([])
IO.puts("start_link returned!")
Process.sleep(2500)
:sys.get_state(pid)

This prevents your supervisor from timing out while waiting for children to start.

Other Callbacks: handle_info

handle_info/2 handles messages sent directly to the GenServer with send/2 (instead of GenServer.call or GenServer.cast).

This is useful for:

  • System messages (like :EXIT signals from linked processes)
  • Timer messages (from :timer.send_interval/2 or Process.send_after/3)
  • Custom protocols between processes
defmodule TickerServer do
  use GenServer

  def start_link(opts) do
    GenServer.start_link(__MODULE__, :ok, opts)
  end

  def init(state) do
    # Schedule a message to ourselves every second
    :timer.send_interval(1000, :tick)
    {:ok, state}
  end

  def handle_info(:tick, state) do
    IO.puts("Tick!")
    {:noreply, state}
  end
end
{:ok, timer_pid} = TickerServer.start_link([])
Process.sleep(3500)
Process.exit(timer_pid, :shutdown)

Putting It All Together

In our auction app, we’ll use all of these concepts:

  • GenServers to manage individual auction state (current bid, time remaining)
  • DynamicSupervisor to start/stop auction processes as auctions are created
  • Registry for name registration to find auctions by ID
  • handle_info for countdown timers
  • handle_continue for loading auction data from the database
  • Supervision tree to keep everything running reliably

Let’s pop over to the auction app to see it all in action!

Key Takeaways

  • Supervisors automatically restart crashed processes
  • Different supervision strategies (:one_for_one, :one_for_all, :rest_for_one) handle failures differently
  • DynamicSupervisors let you add/remove children at runtime
  • Name registration makes processes easier to access
  • handle_info handles raw messages and timers
  • handle_continue enables non-blocking initialization

Resources