Session 13: GenServer - The Generic Server
Mix.install([])
Introduction
In Session 12, you learned that OTP formalizes the patterns you built manually
in Phase 2. Now itβs time to transform your ProcessAgent into a proper
GenServer - the workhorse of OTP applications.
GenServer (Generic Server) implements the client-server pattern by separating generic server logic from your application-specific behavior.
Sources for This Session
This session synthesizes concepts from:
Learning Goals
By the end of this session, youβll be able to:
- Implement all GenServer callbacks correctly
- Convert your ProcessAgent to a GenServer
- Choose between synchronous (call) and asynchronous (cast) patterns
- Handle system messages with handle_info
- Understand GenServer state management
Section 1: GenServer Architecture
π€ Opening Reflection
Before we dive in, think about your Phase 2 ProcessAgent.loop/1:
# In your ProcessAgent, you had:
# 1. A receive block that waits for messages
# 2. Pattern matching on message types
# 3. State threaded through recursive calls
# 4. Some messages needed replies, others didn't
# Question: What would happen if you forgot to call loop(new_state) in a branch?
# Question: What if you sent the wrong format back to a caller expecting a reply?
your_answers = """
Forgot loop(new_state): ???
Wrong reply format: ???
"""
# Answer:
# - Forgot loop: Process dies silently after handling one message!
# - Wrong format: Caller hangs forever waiting for the expected response pattern
The Client-Server Split
GenServer enforces a clean separation:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Client API β
β (Functions other processes call - runs in CALLER'S process) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β GenServer.call/cast - Message Boundary β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Server Callbacks β
β (Handle messages - runs in SERVER'S process) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
π€ Understanding the Boundary
# Consider this GenServer module:
defmodule MyServer do
use GenServer
# Client API
def get_value(pid) do
IO.puts("In get_value, my pid is: #{inspect(self())}")
GenServer.call(pid, :get)
end
# Server Callback
@impl true
def handle_call(:get, _from, state) do
IO.puts("In handle_call, my pid is: #{inspect(state.server_pid)}")
{:reply, state.value, state}
end
end
# Question: When you call MyServer.get_value(server_pid), which process runs each part?
boundary_understanding = %{
get_value_runs_in: nil, # :caller or :server?
genserver_call_runs_in: nil, # :caller or :server?
handle_call_runs_in: nil # :caller or :server?
}
# Answer:
# get_value_runs_in: :caller - Client API runs in the calling process
# genserver_call_runs_in: :caller - It sends a message and waits
# handle_call_runs_in: :server - Callbacks run in the GenServer process
This boundary is crucial for understanding timeouts, blocking, and performance!
Section 2: The Callback Contract
GenServer requires you to implement specific callbacks. Letβs explore each one.
Required vs Optional Callbacks
# Let's see what GenServer expects:
IO.puts("Required callbacks:")
IO.inspect(GenServer.behaviour_info(:callbacks))
IO.puts("\nOptional callbacks:")
IO.inspect(GenServer.behaviour_info(:optional_callbacks))
The Complete Callback Set
| Callback | When Called | Must Return |
|---|---|---|
init/1 |
Process starts |
{:ok, state} or {:stop, reason} |
handle_call/3 |
Sync request arrives |
{:reply, response, state} |
handle_cast/2 |
Async message arrives |
{:noreply, state} |
handle_info/2 |
Other message arrives |
{:noreply, state} |
terminate/2 |
Process stopping | Any (ignored) |
code_change/3 |
Hot code upgrade |
{:ok, new_state} |
π€ Why These Specific Callbacks?
# Think about why GenServer has THIS specific set of callbacks:
# 1. Why is init/1 separate from start_link?
init_purpose = """
Your answer: ???
Hint: What if initialization takes time or might fail?
"""
# 2. Why are handle_call and handle_cast separate?
call_vs_cast = """
Your answer: ???
Hint: What information does handle_call have that handle_cast doesn't?
"""
# 3. Why does handle_info exist at all?
handle_info_purpose = """
Your answer: ???
Hint: What sends messages that AREN'T GenServer.call or GenServer.cast?
"""
# Answers:
# 1. init/1 runs in the new process, can do setup that should be isolated
# If it fails, the starter process gets {:error, reason} cleanly
# 2. handle_call has `from` and must reply; cast doesn't need to
# This makes the contract explicit and prevents bugs
# 3. handle_info catches: monitors (:DOWN), timers, linked process exits,
# messages from non-GenServer code, etc.
Section 3: Implementing Each Callback
init/1 - Process Initialization
defmodule CounterServer do
use GenServer
# Client API
def start_link(initial_count) do
GenServer.start_link(__MODULE__, initial_count)
end
# Server Callbacks
@impl true
def init(initial_count) when is_integer(initial_count) do
# This runs in the NEW process
# Caller blocks until init completes
{:ok, %{count: initial_count, started_at: DateTime.utc_now()}}
end
def init(_invalid) do
{:stop, :invalid_initial_value}
end
end
π€ init/1 Return Values
# init/1 can return several things. Match each return to its effect:
init_returns = %{
"{:ok, state}" => "???",
"{:ok, state, timeout}" => "???",
"{:ok, state, :hibernate}" => "???",
"{:stop, reason}" => "???",
":ignore" => "???"
}
# Fill in your answers, then check:
# "{:ok, state}" => "Start normally with this state"
# "{:ok, state, timeout}" => "Start, but send :timeout to handle_info after N ms"
# "{:ok, state, :hibernate}" => "Start, then hibernate to save memory"
# "{:stop, reason}" => "Don't start, return {:error, reason} to caller"
# ":ignore" => "Don't start, return :ignore to caller (used for conditional starts)"
handle_call/3 - Synchronous Requests
From Learn You Some Erlang:
> Execution is blocked in the process that spawned the server while waiting > for confirmation from init.
Similarly, handle_call blocks the caller until you reply.
defmodule CounterServer do
use GenServer
# Client API
def get(pid), do: GenServer.call(pid, :get)
def increment(pid), do: GenServer.call(pid, :increment)
def add(pid, n), do: GenServer.call(pid, {:add, n})
# Server Callbacks
@impl true
def handle_call(:get, _from, state) do
{:reply, state.count, state}
end
def handle_call(:increment, _from, state) do
new_count = state.count + 1
{:reply, new_count, %{state | count: new_count}}
end
def handle_call({:add, n}, _from, state) do
new_count = state.count + n
{:reply, new_count, %{state | count: new_count}}
end
end
π€ The from Parameter
# handle_call receives a `from` parameter. What is it?
# Let's explore:
def handle_call(:who_called, from, state) do
IO.inspect(from, label: "from parameter")
{:reply, from, state}
end
# Run this and observe:
# from = {#PID<0.123.0>, #Reference<0.456.789.0>}
# Question: Why is `from` a tuple of {pid, reference}?
# Question: When would you use `from` directly instead of just returning {:reply, ...}?
from_purpose = """
Your answers: ???
"""
# Answer:
# - The reference is a unique tag to match the reply to the request
# - You'd use `from` with GenServer.reply/2 for async replies:
# def handle_call(:slow_operation, from, state) do
# spawn(fn ->
# result = do_slow_thing()
# GenServer.reply(from, result)
# end)
# {:noreply, state} # Don't block!
# end
handle_cast/2 - Asynchronous Messages
defmodule CounterServer do
use GenServer
# Client API - async versions
def increment_async(pid), do: GenServer.cast(pid, :increment)
def reset_async(pid), do: GenServer.cast(pid, :reset)
# Server Callbacks
@impl true
def handle_cast(:increment, state) do
{:noreply, %{state | count: state.count + 1}}
end
def handle_cast(:reset, state) do
{:noreply, %{state | count: 0}}
end
end
π€ Call vs Cast Decision
# For each operation, decide: should it be call or cast?
operations = [
{:get_current_count, "Client needs to display the count"},
{:increment_counter, "Just add 1, don't need result"},
{:set_to_value, "Set count to specific value, confirm it worked"},
{:log_event, "Record something happened"},
{:validate_input, "Check if input is valid, return true/false"}
]
# Your reasoning:
your_decisions = %{
get_current_count: nil, # :call or :cast?
increment_counter: nil,
set_to_value: nil,
log_event: nil,
validate_input: nil
}
# Answers with reasoning:
# get_current_count: :call - Need the value back
# increment_counter: :cast - Fire and forget is fine
# set_to_value: Could be either! :call if you need confirmation, :cast if you trust it
# log_event: :cast - Logging shouldn't block the caller
# validate_input: :call - MUST get the validation result back
# Key insight: If you need the result, use call. If you don't, cast is faster.
handle_info/2 - Everything Else
handle_info/2 catches all messages that arenβt from GenServer.call/cast:
defmodule MonitoredServer do
use GenServer
def start_link(monitored_pid) do
GenServer.start_link(__MODULE__, monitored_pid)
end
@impl true
def init(monitored_pid) do
# Set up a monitor - we'll get :DOWN messages in handle_info
ref = Process.monitor(monitored_pid)
{:ok, %{monitored: monitored_pid, ref: ref, alive: true}}
end
@impl true
def handle_info({:DOWN, ref, :process, pid, reason}, state) do
if ref == state.ref do
IO.puts("Monitored process #{inspect(pid)} died: #{inspect(reason)}")
{:noreply, %{state | alive: false}}
else
{:noreply, state}
end
end
def handle_info(:timeout, state) do
# Called when {:ok, state, timeout_ms} was returned
IO.puts("Timeout fired!")
{:noreply, state}
end
def handle_info(msg, state) do
# Catch-all for unexpected messages
IO.puts("Unexpected message: #{inspect(msg)}")
{:noreply, state}
end
end
π€ What Arrives in handle_info?
# List all the types of messages that arrive in handle_info:
handle_info_receives = [
# 1. ???
# 2. ???
# 3. ???
# 4. ???
# 5. ???
]
# Answers:
# 1. :DOWN messages from Process.monitor/1
# 2. {:EXIT, pid, reason} when trapping exits
# 3. :timeout from {:ok, state, timeout} or {:noreply, state, timeout}
# 4. Messages from Process.send_after/3
# 5. Raw messages sent with send/2 (not GenServer.call/cast)
# 6. Messages from external systems (ports, NIFs)
terminate/2 - Cleanup
From Learn You Some Erlang:
> Whatever was done in init/1 should have its opposite in terminate/2.
defmodule ResourceServer do
use GenServer
@impl true
def init(_) do
# Open a resource
file = File.open!("/tmp/server.log", [:write])
{:ok, %{file: file}}
end
@impl true
def terminate(reason, state) do
# Clean up the resource
IO.puts("Terminating because: #{inspect(reason)}")
File.close(state.file)
:ok
end
end
π€ When Does terminate/2 Run?
# terminate/2 is called in these scenarios - but not all of them!
scenarios = [
{:return_stop, "handle_* returns {:stop, reason, state}"},
{:parent_shutdown, "Supervisor tells child to stop"},
{:normal_exit, "GenServer.stop(pid, :normal)"},
{:crash, "Unhandled exception in handle_*"},
{:kill, "Process.exit(pid, :kill)"},
{:linked_crash, "Linked process crashes (not trapping exits)"}
]
# For each, will terminate/2 be called? true or false?
terminate_called = %{
return_stop: nil,
parent_shutdown: nil,
normal_exit: nil,
crash: nil,
kill: nil,
linked_crash: nil
}
# Answers:
# return_stop: true - This is the normal way to stop
# parent_shutdown: true - Supervisor sends shutdown, terminate runs
# normal_exit: true - Clean shutdown
# crash: true - OTP catches it, calls terminate
# kill: FALSE - :kill cannot be caught or handled!
# linked_crash: FALSE (unless trapping exits) - Process dies immediately
Section 4: Converting ProcessAgent to AgentServer
Now letβs apply everything to convert your Phase 2 ProcessAgent into a proper GenServer.
Side-by-Side Comparison
# Phase 2: ProcessAgent.loop/1
defp loop(state) do
receive do
{:get_state, from} ->
send(from, {:state, state})
loop(state)
{:remember, key, value} ->
new_memory = Map.put(state.memory, key, value)
loop(%{state | memory: new_memory})
{:recall, key, from} ->
value = Map.get(state.memory, key)
send(from, {:recalled, key, value})
loop(state)
{:process_next, from} ->
case state.inbox do
[] ->
send(from, {:empty, nil})
loop(state)
[task | rest] ->
result = handle_task(state, task)
send(from, {:processed, task, result})
loop(%{state | inbox: rest, processed_count: state.processed_count + 1})
end
:stop ->
:ok
end
end
π€ Plan the Conversion
# Before writing code, plan each receive branch's conversion:
conversion_plan = %{
"{:get_state, from}" => %{
callback: nil, # :handle_call, :handle_cast, or :handle_info?
reason: "???"
},
"{:remember, key, value}" => %{
callback: nil,
reason: "???"
},
"{:recall, key, from}" => %{
callback: nil,
reason: "???"
},
"{:process_next, from}" => %{
callback: nil,
reason: "???"
},
":stop" => %{
callback: nil,
reason: "???"
}
}
# Fill in your plan, then check:
# {:get_state, from} -> :handle_call - Needs to return state to caller
# {:remember, key, value} -> :handle_cast - Fire and forget
# {:recall, key, from} -> :handle_call - Needs to return value to caller
# {:process_next, from} -> :handle_call - Needs to return result to caller
# :stop -> Use GenServer.stop/1 instead, which triggers terminate/2
The Complete AgentServer
defmodule AgentServer do
@moduledoc """
A GenServer-based agent that processes tasks and maintains memory.
This is the Phase 3 (OTP) version of Phase 2's ProcessAgent.
"""
use GenServer
alias AgentFramework.Message
# ============================================
# Client API
# ============================================
@doc "Start an agent with the given name."
def start_link(name, opts \\ []) when is_binary(name) do
initial_memory = Keyword.get(opts, :memory, %{})
GenServer.start_link(__MODULE__, {name, initial_memory})
end
@doc "Get the agent's current state."
def get_state(pid) do
GenServer.call(pid, :get_state)
end
@doc "Store a value in the agent's memory."
def remember(pid, key, value) do
GenServer.cast(pid, {:remember, key, value})
end
@doc "Recall a value from the agent's memory."
def recall(pid, key) do
GenServer.call(pid, {:recall, key})
end
@doc "Send a task to the agent's inbox."
def send_task(pid, action, params \\ %{}) do
GenServer.cast(pid, {:send_task, action, params})
end
@doc "Process the next task in the inbox."
def process_next(pid) do
GenServer.call(pid, :process_next)
end
@doc "Get the number of tasks in the inbox."
def inbox_count(pid) do
GenServer.call(pid, :inbox_count)
end
# ============================================
# Server Callbacks
# ============================================
@impl true
def init({name, initial_memory}) do
state = %{
name: name,
status: :idle,
memory: initial_memory,
inbox: [],
processed_count: 0
}
{:ok, state}
end
@impl true
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
def handle_call({:recall, key}, _from, state) do
value = Map.get(state.memory, key)
{:reply, value, state}
end
def handle_call(:inbox_count, _from, state) do
{:reply, length(state.inbox), state}
end
def handle_call(:process_next, _from, state) do
case state.inbox do
[] ->
{:reply, {:empty, nil}, state}
[task | rest] ->
result = handle_task(task)
new_state = %{state |
inbox: rest,
processed_count: state.processed_count + 1,
memory: Map.put(state.memory, :last_result, result)
}
{:reply, {:ok, task, result}, new_state}
end
end
@impl true
def handle_cast({:remember, key, value}, state) do
new_memory = Map.put(state.memory, key, value)
{:noreply, %{state | memory: new_memory}}
end
def handle_cast({:send_task, action, params}, state) do
task_id = generate_id()
message = Message.task(task_id, action, params)
{:noreply, %{state | inbox: state.inbox ++ [message]}}
end
@impl true
def handle_info(msg, state) do
IO.puts("[AgentServer #{state.name}] Unexpected message: #{inspect(msg)}")
{:noreply, state}
end
@impl true
def terminate(reason, state) do
IO.puts("[AgentServer #{state.name}] Terminating: #{inspect(reason)}")
:ok
end
# ============================================
# Private Functions
# ============================================
defp handle_task(%Message{type: :task, payload: %{action: :search, params: params}}) do
query = Map.get(params, :query, "")
{:ok, "Search results for: #{query}"}
end
defp handle_task(%Message{type: :task, payload: %{action: :analyze, params: params}}) do
data = Map.get(params, :data, "")
{:ok, "Analysis of: #{data}"}
end
defp handle_task(%Message{type: :task, payload: %{action: action}}) do
{:error, {:unknown_action, action}}
end
defp generate_id do
:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
end
end
π€ Compare the Implementations
# Look at the ProcessAgent vs AgentServer and answer:
comparison_questions = %{
# 1. How many lines is ProcessAgent.loop/1 vs AgentServer callbacks?
lines_comparison: "???",
# 2. What's MISSING from AgentServer that ProcessAgent had?
missing_code: "???",
# 3. What does AgentServer have that ProcessAgent didn't?
new_features: "???",
# 4. Which would be easier to debug? Why?
easier_debug: "???"
}
# Answers:
# 1. ProcessAgent ~90 lines for loop; AgentServer ~60 lines for callbacks
# 2. Missing: The receive block, recursive loop(state) calls, managing `from` manually
# 3. New: @impl annotations, terminate callback, better error handling, debugging support
# 4. AgentServer - OTP tools like :sys.get_state, tracing, clear callback boundaries
Section 5: Advanced Patterns
Timeouts
GenServer supports automatic timeouts:
defmodule IdleDetector do
use GenServer
@idle_timeout 5_000 # 5 seconds
@impl true
def init(_) do
# Will receive :timeout in handle_info after 5 seconds of no messages
{:ok, %{last_activity: DateTime.utc_now()}, @idle_timeout}
end
@impl true
def handle_call(:ping, _from, state) do
# Reset timeout by including it in return
{:reply, :pong, %{state | last_activity: DateTime.utc_now()}, @idle_timeout}
end
@impl true
def handle_info(:timeout, state) do
IO.puts("Been idle since #{state.last_activity}")
# Could hibernate, checkpoint to disk, etc.
{:noreply, state, @idle_timeout}
end
end
Named Processes
defmodule SingletonServer do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, nil, name: __MODULE__)
end
def get_value do
# Can call by name instead of pid
GenServer.call(__MODULE__, :get)
end
@impl true
def init(_), do: {:ok, 42}
@impl true
def handle_call(:get, _from, state), do: {:reply, state, state}
end
π€ When to Use Names?
# Decide: pid or name for each scenario?
naming_scenarios = [
{:config_server, "One per application, accessed everywhere"},
{:worker_pool, "Many identical workers"},
{:user_session, "One per connected user"},
{:database_connection, "Shared connection pool"},
{:temp_calculator, "Spawned for one calculation, then dies"}
]
# Your answers:
naming_decisions = %{
config_server: nil, # :name or :pid?
worker_pool: nil,
user_session: nil,
database_connection: nil,
temp_calculator: nil
}
# Answers:
# config_server: :name - Global singleton, needs to be found easily
# worker_pool: :pid - Many instances, use supervisor to track them
# user_session: :name (via Registry) - Need to find by user_id
# database_connection: :name - Pool manager is singleton, workers are pids
# temp_calculator: :pid - Ephemeral, caller already has reference
Section 6: Interactive Exercises
Exercise 1: Build a Simple Cache
defmodule SimpleCache do
use GenServer
# TODO: Implement a cache with:
# - put(key, value) - store a value (async, cast)
# - get(key) - retrieve a value (sync, call)
# - delete(key) - remove a value (async, cast)
# - clear() - remove all values (async, cast)
# - stats() - return %{size: N, hits: N, misses: N} (sync, call)
# Client API
def start_link(_opts) do
# Your code here
end
def put(cache, key, value) do
# Your code here
end
def get(cache, key) do
# Your code here
end
# Server Callbacks
@impl true
def init(_) do
# Your code here - what's the initial state?
end
# Add handle_call and handle_cast implementations...
end
Exercise 2: Add Expiration
# Extend your cache to support expiration:
# - put(key, value, ttl_ms) - value expires after ttl_ms milliseconds
# - Expired values should return nil from get/1
# - Bonus: Clean up expired entries periodically using handle_info(:cleanup, ...)
# Hint: Store {value, expires_at} tuples
# Hint: Use Process.send_after(self(), :cleanup, interval) for periodic cleanup
Exercise 3: Try AgentServer
# If you've implemented AgentServer above, test it:
{:ok, agent} = AgentServer.start_link("TestAgent")
# Store some memory
AgentServer.remember(agent, :context, "researching OTP")
# Recall it
AgentServer.recall(agent, :context)
# Send tasks
AgentServer.send_task(agent, :search, %{query: "GenServer patterns"})
AgentServer.send_task(agent, :analyze, %{data: "some data"})
# Check inbox
AgentServer.inbox_count(agent)
# Process tasks
AgentServer.process_next(agent)
AgentServer.process_next(agent)
# Check state
AgentServer.get_state(agent)
Key Takeaways
- GenServer splits client/server - Client API runs in caller, callbacks run in server
- Call = sync, Cast = async - Use call when you need the result
- handle_info catches everything else - Monitors, timers, raw messages
- init/terminate are opposites - Setup and cleanup
- Return tuples drive behavior - {:reply, β¦}, {:noreply, β¦}, {:stop, β¦}
- Less code, more power - GenServer handles the loop, timeouts, tracing
Whatβs Next?
In the next session, weβll explore Supervisors:
- Restart strategies (one_for_one, one_for_all, rest_for_one)
- DynamicSupervisor for runtime children
- Child specifications
- Converting AgentMonitor to AgentSupervisor
Youβll build a supervision tree that automatically restarts your agents!