defmodule Cache do
use GenServer
def start_link(name), do: GenServer.start_link(__MODULE__, name, name: name)
def fetch(cache, key, loader) do
case :ets.lookup(cache, key) do
[{^key, value}] -> {:ok, value}
[] -> GenServer.call(cache, {:load, key, loader}, :infinity)
end
end
def invalidate(cache, key), do: GenServer.call(cache, {:invalidate, key})
@impl true
def init(name) do
# Named after the server, so `fetch/3` can read it
# by name. `:protected`: everyone reads, only the
# owner writes.
:ets.new(name, [:set, :protected, :named_table, read_concurrency: true])
{:ok, %{table: name, waiting: %{}, loads: %{}}}
end
@impl true
def handle_call({:load, key, loader}, from, state) do
cond do
# Loaded while this caller was on its way here.
match?([_], :ets.lookup(state.table, key)) ->
[{^key, value}] = :ets.lookup(state.table, key)
{:reply, {:ok, value}, state}
# Already loading: join the waiters.
Map.has_key?(state.waiting, key) ->
{:noreply, update_in(state.waiting[key], &[from | &1])}
# First miss: start the one load.
true ->
task = Task.async(loader)
{:noreply,
%{
state
| waiting: Map.put(state.waiting, key, [from]),
loads: Map.put(state.loads, task.ref, key)
}}
end
end
def handle_call({:invalidate, key}, _from, state) do
:ets.delete(state.table, key)
{:reply, :ok, state}
end
@impl true
def handle_info({ref, result}, state) when is_reference(ref) do
Process.demonitor(ref, [:flush])
{key, loads} = Map.pop!(state.loads, ref)
{waiters, waiting} = Map.pop!(state.waiting, key)
# Only successes are cached. A failed load is
# returned to everyone who waited, and the next
# request tries again.
with {:ok, value} <- result, do: :ets.insert(state.table, {key, value})
for from <- waiters, do: GenServer.reply(from, result)
{:noreply, %{state | waiting: waiting, loads: loads}}
end
end
{:ok, _} = Cache.start_link(Profiles)
stampede.("single-flight", fn -> Cache.fetch(Profiles, :user_42, loader) end)