jonnifyelixir
F5 / F5.06 / choosing

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

GENSERVER a process with a state loop · holds state · runs decide / evolve the engine AGENT a GenServer for one value · get / update · no command logic ETS a shared table · concurrent reads · owned by a process

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

COMMAND {:enroll, …} mailbox PORTAL.ENGINE single writer · decide / evolve owns the table refresh :ETS :portal_cache READERS progress_of/1 concurrent · lock-free · :ets.lookup/2 served

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

Related in this course