Lesson 14 / 25
ETS, Agents and Shared State
Store shared state efficiently with ETS and simple state with Agents.
Where state lives
Elixir has several places for state. A GenServer serialises access and is ideal for coordinated updates. An Agent is a simplified wrapper around state with Agent.get and Agent.update, handy for small, simple state. ETS (Erlang Term Storage) is an in-memory key-value store built into the VM, with constant-time lookups from any process, without going through a single server process, which makes it ideal for caches, counters and lookup tables read by many processes. Tables are created with :ets.new(name, options), using types :set, :ordered_set, :bag or :duplicate_bag, access :public, :protected (the default; only the owner writes) or :private, and read_concurrency: true for read-heavy tables. An ETS table belongs to the process that created it and is deleted when that process dies, so create tables in a long-lived, supervised process. :ets.update_counter provides atomic increments. For persistent or distributed storage, use a database, :persistent_term for rarely-changing global config, or libraries such as Cachex and Nebulex.
A read-heavy cache in ETS
A GenServer owns the table; readers go straight to ETS.
defmodule Shop.PriceCache do
use GenServer
@table :price_cache
def start_link(_opts), do: GenServer.start_link(__MODULE__, nil, name: __MODULE__)
# readers do not go through the GenServer: direct, concurrent lookups
def get(sku) do
case :ets.lookup(@table, sku) do
[{^sku, price, expires_at}] ->
if System.monotonic_time(:second) < expires_at, do: {:ok, price}, else: :miss
[] ->
:miss
end
end
def put(sku, price, ttl_seconds \\ 300) do
expires_at = System.monotonic_time(:second) + ttl_seconds
:ets.insert(@table, {sku, price, expires_at})
:ok
end
def hit(counter), do: :ets.update_counter(@table, {:stats, counter}, 1, {{:stats, counter}, 0})
@impl true
def init(nil) do
:ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
{:ok, nil} # the table lives as long as this process
end
end
# Simple state with an Agent
{:ok, agent} = Agent.start_link(fn -> %{} end)
Agent.update(agent, &Map.put(&1, "pen", 120))
IO.inspect(Agent.get(agent, &Map.get(&1, "pen"))) # 120A notice board versus a clerk
Asking a GenServer is like queueing to ask a clerk. ETS is a notice board in the corridor: anyone can read it at the same time without waiting, while one person is responsible for keeping it up to date.
Quick check: Why is ETS often faster than a GenServer for read-heavy caches?
- It stores data on disk
- It compresses data
- It is written in JavaScript
- Many processes can read ETS concurrently without sending messages through a single process
Answer
Many processes can read ETS concurrently without sending messages through a single process — ETS lookups avoid the serialisation of a single server process.