defmodule KV.Disk do
# The slow disk: an Agent holding the log. Each write
# takes 10 ms, then tells the store it's done.
def start, do: Agent.start_link(fn -> [] end, name: __MODULE__)
def write(store, entry) do
Agent.cast(__MODULE__, fn log ->
Process.sleep(10)
send(store, :written)
[entry | log]
end)
end
def log, do: Agent.get(__MODULE__, &Enum.reverse/1)
end
defmodule KV.Store do
use GenServer
@table __MODULE__
def start_link(capacity), do: GenServer.start_link(__MODULE__, capacity, name: __MODULE__)
def put(key, value), do: GenServer.call(__MODULE__, {:put, key, value}, :infinity)
def get(key) do
case :ets.lookup(@table, key) do
[{^key, value}] -> {:ok, value}
[] -> :error
end
end
# Waits (deferred reply again) until everything
# buffered is on disk.
def flush, do: GenServer.call(__MODULE__, :flush, :infinity)
def stats, do: GenServer.call(__MODULE__, :stats)
@impl true
def init(capacity) do
:ets.new(@table, [:set, :protected, :named_table, read_concurrency: true])
{:ok,
%{
capacity: capacity,
buffer: :queue.new(),
waiting: :queue.new(),
writing: false,
longest: 0,
flushers: []
}}
end
@impl true
def handle_call({:put, key, value}, from, state) do
:ets.insert(@table, {key, value})
if :queue.len(state.buffer) < state.capacity do
state = %{state | buffer: :queue.in({key, value}, state.buffer)}
{:reply, :ok, state |> track() |> write_next()}
else
{:noreply, %{state | waiting: :queue.in({from, {key, value}}, state.waiting)}}
end
end
def handle_call(:flush, from, state) do
{:noreply, maybe_flushed(%{state | flushers: [from | state.flushers]})}
end
def handle_call(:stats, _from, state) do
{:reply, %{longest_buffer: state.longest, capacity: state.capacity}, state}
end
# The disk finished an entry: drop it from the buffer,
# let one waiting writer in, and start the next write.
@impl true
def handle_info(:written, state) do
{_, buffer} = :queue.out(state.buffer)
state =
case :queue.out(state.waiting) do
{{:value, {from, entry}}, waiting} ->
GenServer.reply(from, :ok)
%{state | buffer: :queue.in(entry, buffer), waiting: waiting}
{:empty, _} ->
%{state | buffer: buffer}
end
{:noreply, %{state | writing: false} |> track() |> write_next() |> maybe_flushed()}
end
# The entry being written stays at the head of the
# buffer until the disk confirms it.
defp write_next(%{writing: false} = state) do
case :queue.peek(state.buffer) do
{:value, entry} ->
KV.Disk.write(self(), entry)
%{state | writing: true}
:empty ->
state
end
end
defp write_next(state), do: state
defp track(state), do: %{state | longest: max(state.longest, :queue.len(state.buffer))}
defp maybe_flushed(state) do
if :queue.is_empty(state.buffer) and state.flushers != [] do
for from <- state.flushers, do: GenServer.reply(from, :ok)
%{state | flushers: []}
else
state
end
end
end
{:ok, _} = KV.Disk.start()
{:ok, _} = KV.Store.start_link(3)