jonnifyelixir
F4 / F4.09 / genserver

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

operation where it runs 2 writes serialized · 4 reads lock-free

      

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

Related in this course