F5.06 · part 1 of 3
Choosing where state lives
Three places can hold live in-memory state on the BEAM, and they are not interchangeable. A GenServer is a process with a state loop: it serializes access and can run real logic on each message, so it can fold events and evolve. An Agent is a GenServer narrowed to one job — hold a value, answer get and update — with no place to put command logic. ETS is a shared in-memory table with concurrent, lock-free reads, but it is a key-value store, not a place to run a reducer, and it lives only as long as its owner process. The engine runs the F5.04 contract and the F5.05 fold on every command, so it needs the one that holds state and executes code: a GenServer. ETS can join later for read-heavy projections.
Three holders, one job each. Select one to see what it gives and whether the engine can use it.
Three holders
The question is not which is fastest but which can do the engine's work. The engine has to take a command, run a contract, decide on events, and fold them into new state — that is logic plus state, in order, one write at a time. Only a process with its own loop offers that. Select a holder.
State holder · select one
holder: GenServer
for the engine: holds state + runs decide/evolve
Pick the holder by the work it must do, not by raw speed. The engine needs state and logic together, and that is what a process loop is.
In code
The three shapes, side by side, then the choice. An Agent is the shortest way to hold
a value but has nowhere to run the contract; ETS is a shared table you read concurrently; the engine is a
GenServer because it has to hold state and fold events on every command.
# three places live state can sit on the BEAM: # GenServer — a process with a loop; holds state and runs decide/evolve (the engine) # Agent — a GenServer narrowed to one value; get/update, no logic # ETS — a shared in-memory table; concurrent reads, owned by a process Agent.update(pid, fn state -> Map.put(state, k, v) end) # holds a value; no place for a contract :ets.insert(table, {k, v}) # shared table; great for reads, not a reducer # the engine runs a contract and a fold on every command, so it owns its state in a process: defmodule Portal.Engine do use GenServer # state is the fold of the log (F5.05), held between calls end
value or engine
An Agent holds a value; ETS holds a table; only a process loop holds state and runs logic.
a GenServer
The engine folds and evolves on each command, so it lives in a GenServer.
Next: the engine GenServer —
init folds the log, and handle_call runs the command and the
query.
An ETS read-through cache
An ETS read-through cache splits the read path from the write path. Hot reads — a
lesson lookup, a learner's progress — are served straight from an :ets table,
which, per Erlang — ets, supports concurrent,
lock-free reads from any process. A read never enters the engine's mailbox, so reads scale with the schedulers
rather than queueing behind one process. Writes are unchanged: every command still goes through
Portal.Engine, the GenServer from the previous lesson, which stays the
single writer — per Elixir —
GenServer, its mailbox serializes those writes. The engine owns the table and refreshes it after each
evolve, so the fold of the log remains the one source of truth and the table is only a
derived view of it.
The table is created :protected: one writer, many readers. The query no longer asks
the engine for the answer — it reads the table directly with :ets.lookup/2. The
cost is the honest one for any cache: the table is a projection that must be rebuilt on restart, so
init refills it from the folded state before serving reads.
Two paths · one source of truth
Writes funnel through one process; reads fan out across many. The dashed lines are the live paths — one in, one out of the table.
In code, the engine creates the table in init, fills it from the folded state, and
rewrites the touched keys after every command. The query reads the table without a message to the engine.
defmodule Portal.Engine do use GenServer # init folds the log (F5.05), then builds the read-through table from that state @impl true def init(events) do state = replay(events) # :protected — this process writes, any process reads; :read_concurrency for hot reads :ets.new(:portal_cache, [:named_table, :protected, read_concurrency: true]) refresh_cache(state) {:ok, state} end # a command: the engine stays the single writer — decide, evolve, then refresh the table @impl true def handle_call({:enroll, user_id, course_id}, _from, state) do case decide(state, {:enroll, user_id, course_id}) do {:ok, events} -> next = Enum.reduce(events, state, &evolve/2) refresh_cache(next) # derived view follows the source of truth {:reply, :ok, next} {:error, _} = err -> {:reply, err, state} # bad command: state and table untouched end end # write the projection the read path serves: {user_id, progress} defp refresh_cache(state) do Enum.each(State.all_progress(state), fn {user_id, progress} -> :ets.insert(:portal_cache, {user_id, progress}) end) end # the hot read bypasses the mailbox entirely — concurrent, lock-free, straight off the table def progress_of(user_id) do case :ets.lookup(:portal_cache, user_id) do [{^user_id, progress}] -> {:ok, progress} [] -> {:error, :not_enrolled} end end end # a write goes through the one writer; the read never does: Portal.Engine.progress_of("u-7") # => {:ok, 0.4} — read off :ets, no GenServer call Portal.Engine.progress_of("u-stranger") # => {:error, :not_enrolled}
read path / write path
Reads scale on a shared table; writes serialize through one process — the two paths share nothing but the truth.
ets plus GenServer
:ets.lookup/2 serves reads; Portal.Engine writes and
refreshes the table after each evolve.
Keep one writer and let the reads spread out: the engine still owns the truth, and the
:ets table is a fast, throwaway view it rewrites on every change.
References
Primary sources for this lesson, and where it connects in the course.
Sources
- Elixir — GenServer — a process that holds state behind a contract.
- Elixir — Agent — the minimal state-holding process.
- Erlang — ets — in-memory tables for shared, fast reads.