F4.09 · part 3 of 3 · the session store
Own it with a GenServer
The Portal’s session store is one of these CHAMPs behind a GenServer.
Portal.Auth.current_user/1 runs on every request, so reads must not queue;
start_session/1 and logout must not race. The split is clean because the map is
immutable: the GenServer serializes writes through its mailbox and publishes each new snapshot, while
reads traverse the published snapshot with no lock at all.
A burst of six operations against the store. Compare owning writes with a GenServer (reads stay concurrent) against routing everything through one mailbox. Select a mode.
Reads run free, writes take a number
Each write goes through the GenServer one at a time and bumps the snapshot version; each read sees the latest published version without queuing. Routing reads through the mailbox too would make the process a bottleneck. Select a mode to see where each operation runs.
Edge mode · select one
writes: 2, serialized
reads: 4, lock-free
A GenServer is the right owner for writes because its mailbox serializes them; it is the wrong path for reads, and immutability means reads do not need it — they walk a snapshot that no writer can disturb.
Advanced: publishing the snapshot
The mechanism is a single published reference to the current root — in
:persistent_term or an ETS cell. A read fetches that reference and traverses the
CHAMP; because the structure is never mutated in place, the read needs no lock and cannot see a half-finished
write. A write is a GenServer.call: the process computes the new root with the
structural sharing of the previous dive, publishes it, and replies. Writes are serialized, so there is no lost
update; reads are concurrent, so current_user/1 scales with the request load.
This is why the immutable, partitioned CHAMP and the GenServer fit together. A mutable map behind a lock would force readers to wait for writers; an immutable map lets the writer build the next snapshot off to the side and swap it in atomically, with readers continuing on the old one until they next fetch the reference. The GenServer’s job shrinks to owning the write path and publishing roots.
defmodule Portal.Auth do use GenServer # read: lock-free, off the published snapshot — runs on every request def current_user("SES" <> _ = sid) do :persistent_term.get(:sessions) |> Champ.get(sid) end # write: serialized through the mailbox, publishes a new snapshot def handle_call({:start, sid, user}, _from, champ) do champ = Champ.put(champ, sid, user) :persistent_term.put(:sessions, champ) {:reply, :ok, champ} end end
an immutable snapshot
A root no writer mutates in place.
lock-free reads, owned writes
Readers walk the published root; the GenServer serializes writes and swaps in the next one.
That completes F4.09: a partitioned, immutable CHAMP keyed by branded ids, owned by a GenServer — the Portal’s session store, entity registry, and progress tracker in one shape. Next module: F4.10 — Practical recipes in Elixir, which turns these structures into everyday patterns.
References
Primary sources for this lesson, and where it connects in the course.
Sources
- Elixir — GenServer — the mailbox that serializes writes.
- Erlang — persistent_term — publishing an immutable root for lock-free reads.
- Elixir — Agent — the simpler state-owner this pattern generalizes.
Related in this course
- F4.09.2 · Structural sharing — why a new snapshot is cheap to publish.
- F4.08 · Branded ids & persistence — the same id on disk and at the edge.
- F4.09 · Branded CHAMP maps & GenServer — the module hub.
- F4 · Algorithms & Data Structures