🎓 A1Learner
🔍
0 of 46 lessons completed

Elixir Project: Key-Value Store

A key-value store is a table that holds one value under each key. This store keeps its table in ETS and runs under a supervisor.

Put and get use keys

A value is stored under a key and read back with the same key. A missing key answers with an error.

defmodule Store do
  use GenServer
  def start_link(_arg) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end
  def init(_state) do
    table = :ets.new(:kv_store, [:set, :public])
    {:ok, %{table: table}}
  end
  def handle_call({:put, key, value}, _from, state) do
    :ets.insert(state.table, {key, {value, :never}})
    {:reply, :ok, state}
  end
  def handle_call({:put_expiring, key, value, mark}, _from, state) do
    :ets.insert(state.table, {key, {value, mark}})
    {:reply, :ok, state}
  end
  def handle_call({:get, key}, _from, state) do
    result = case :ets.lookup(state.table, key) do
      [{_key, {value, _mark}}] -> {:ok, value}
      [] -> :error
    end
    {:reply, result, state}
  end
  def handle_call({:delete, key}, _from, state) do
    :ets.delete(state.table, key)
    {:reply, :ok, state}
  end
  def handle_call(:keys, _from, state) do
    keys = :ets.tab2list(state.table) |> Enum.map(fn {key, _entry} -> key end) |> Enum.sort()
    {:reply, keys, state}
  end
  def handle_call({:purge, limit}, _from, state) do
    expired = :ets.tab2list(state.table) |> Enum.filter(fn {_key, {_value, mark}} -> is_integer(mark) and mark <= limit end) |> Enum.map(fn {key, _entry} -> key end)
    Enum.each(expired, fn key -> :ets.delete(state.table, key) end)
    {:reply, length(expired), state}
  end
  def handle_call(:dump, _from, state) do
    entries = :ets.tab2list(state.table) |> Enum.map(fn {key, {value, _mark}} -> {key, value} end) |> Enum.sort()
    {:reply, entries, state}
  end
end
{:ok, _pid} = Store.start_link([])
GenServer.call(Store, {:put, "name", "Ana"})
GenServer.call(Store, {:put, "city", "Oslo"})
IO.inspect(GenServer.call(Store, {:get, "name"}))
IO.inspect(GenServer.call(Store, {:get, "age"}))
GenServer.stop(Store)

Deleting removes a key

After a delete, the key is gone from the key list. The other keys stay in sorted order.

defmodule Store do
  use GenServer
  def start_link(_arg) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end
  def init(_state) do
    table = :ets.new(:kv_store, [:set, :public])
    {:ok, %{table: table}}
  end
  def handle_call({:put, key, value}, _from, state) do
    :ets.insert(state.table, {key, {value, :never}})
    {:reply, :ok, state}
  end
  def handle_call({:put_expiring, key, value, mark}, _from, state) do
    :ets.insert(state.table, {key, {value, mark}})
    {:reply, :ok, state}
  end
  def handle_call({:get, key}, _from, state) do
    result = case :ets.lookup(state.table, key) do
      [{_key, {value, _mark}}] -> {:ok, value}
      [] -> :error
    end
    {:reply, result, state}
  end
  def handle_call({:delete, key}, _from, state) do
    :ets.delete(state.table, key)
    {:reply, :ok, state}
  end
  def handle_call(:keys, _from, state) do
    keys = :ets.tab2list(state.table) |> Enum.map(fn {key, _entry} -> key end) |> Enum.sort()
    {:reply, keys, state}
  end
  def handle_call({:purge, limit}, _from, state) do
    expired = :ets.tab2list(state.table) |> Enum.filter(fn {_key, {_value, mark}} -> is_integer(mark) and mark <= limit end) |> Enum.map(fn {key, _entry} -> key end)
    Enum.each(expired, fn key -> :ets.delete(state.table, key) end)
    {:reply, length(expired), state}
  end
  def handle_call(:dump, _from, state) do
    entries = :ets.tab2list(state.table) |> Enum.map(fn {key, {value, _mark}} -> {key, value} end) |> Enum.sort()
    {:reply, entries, state}
  end
end
{:ok, _pid} = Store.start_link([])
GenServer.call(Store, {:put, "a", 1})
GenServer.call(Store, {:put, "b", 2})
GenServer.call(Store, {:put, "c", 3})
GenServer.call(Store, {:delete, "b"})
IO.inspect(GenServer.call(Store, :keys))
GenServer.stop(Store)

Purging removes expired entries only

Purging with a limit of 5 removes the entry marked 3. The entry marked 9 and the permanent entry stay.

defmodule Store do
  use GenServer
  def start_link(_arg) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end
  def init(_state) do
    table = :ets.new(:kv_store, [:set, :public])
    {:ok, %{table: table}}
  end
  def handle_call({:put, key, value}, _from, state) do
    :ets.insert(state.table, {key, {value, :never}})
    {:reply, :ok, state}
  end
  def handle_call({:put_expiring, key, value, mark}, _from, state) do
    :ets.insert(state.table, {key, {value, mark}})
    {:reply, :ok, state}
  end
  def handle_call({:get, key}, _from, state) do
    result = case :ets.lookup(state.table, key) do
      [{_key, {value, _mark}}] -> {:ok, value}
      [] -> :error
    end
    {:reply, result, state}
  end
  def handle_call({:delete, key}, _from, state) do
    :ets.delete(state.table, key)
    {:reply, :ok, state}
  end
  def handle_call(:keys, _from, state) do
    keys = :ets.tab2list(state.table) |> Enum.map(fn {key, _entry} -> key end) |> Enum.sort()
    {:reply, keys, state}
  end
  def handle_call({:purge, limit}, _from, state) do
    expired = :ets.tab2list(state.table) |> Enum.filter(fn {_key, {_value, mark}} -> is_integer(mark) and mark <= limit end) |> Enum.map(fn {key, _entry} -> key end)
    Enum.each(expired, fn key -> :ets.delete(state.table, key) end)
    {:reply, length(expired), state}
  end
  def handle_call(:dump, _from, state) do
    entries = :ets.tab2list(state.table) |> Enum.map(fn {key, {value, _mark}} -> {key, value} end) |> Enum.sort()
    {:reply, entries, state}
  end
end
{:ok, _pid} = Store.start_link([])
GenServer.call(Store, {:put_expiring, "temp", "x", 3})
GenServer.call(Store, {:put_expiring, "later", "y", 9})
GenServer.call(Store, {:put, "perm", "z"})
IO.inspect(GenServer.call(Store, {:purge, 5}))
IO.inspect(GenServer.call(Store, :keys))
GenServer.stop(Store)

Example

defmodule Store do
  use GenServer
  def start_link(_arg) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end
  def init(_state) do
    table = :ets.new(:kv_store, [:set, :public])
    {:ok, %{table: table}}
  end
  def handle_call({:put, key, value}, _from, state) do
    :ets.insert(state.table, {key, {value, :never}})
    {:reply, :ok, state}
  end
  def handle_call({:put_expiring, key, value, mark}, _from, state) do
    :ets.insert(state.table, {key, {value, mark}})
    {:reply, :ok, state}
  end
  def handle_call({:get, key}, _from, state) do
    result = case :ets.lookup(state.table, key) do
      [{_key, {value, _mark}}] -> {:ok, value}
      [] -> :error
    end
    {:reply, result, state}
  end
  def handle_call({:delete, key}, _from, state) do
    :ets.delete(state.table, key)
    {:reply, :ok, state}
  end
  def handle_call(:keys, _from, state) do
    keys = :ets.tab2list(state.table) |> Enum.map(fn {key, _entry} -> key end) |> Enum.sort()
    {:reply, keys, state}
  end
  def handle_call({:purge, limit}, _from, state) do
    expired = :ets.tab2list(state.table) |> Enum.filter(fn {_key, {_value, mark}} -> is_integer(mark) and mark <= limit end) |> Enum.map(fn {key, _entry} -> key end)
    Enum.each(expired, fn key -> :ets.delete(state.table, key) end)
    {:reply, length(expired), state}
  end
  def handle_call(:dump, _from, state) do
    entries = :ets.tab2list(state.table) |> Enum.map(fn {key, {value, _mark}} -> {key, value} end) |> Enum.sort()
    {:reply, entries, state}
  end
end
{:ok, sup} = Supervisor.start_link([Store], strategy: :one_for_one)
GenServer.call(Store, {:put, "name", "Ana"})
GenServer.call(Store, {:put, "city", "Oslo"})
GenServer.call(Store, {:put_expiring, "draft", "v1", 2})
IO.inspect(GenServer.call(Store, {:purge, 5}))
IO.inspect(GenServer.call(Store, :dump))
Supervisor.stop(sup)

Key points

  • Put: putting a key stores a value under that key.
  • Get: getting a key returns its value, or an error when the key is missing.
  • Delete: deleting a key removes it from the table.
  • Expiry: an expiring entry carries a plain number mark, not a clock time.
  • Purge: purging removes every entry whose mark is at or below the given limit.

In this example, the store runs under a supervisor and holds two permanent entries plus one expiring entry. Purging with a limit of 5 removes the one expiring entry, and the dump shows the two remaining entries in key order. The supervisor is stopped at the end.

Note

the table lives inside the store process, so stopping the store removes the table.

Try it Yourself

Edit the code, then press Run.

Running is simulated

Exercise

The store holds an entry marked 3, an entry marked 9, and a permanent entry. Purging with a limit of 5 removes how many entries?