Elixir by Example

Expiring keys

Builds on Sending and Receiving MessagesGenServer

Edit any cell and run it in your browser Download source

Step 6's keys never went away. A cache needs a TTL. Each row now stores an expiry time. Reads ignore expired rows right away, and a periodic sweep (a message the GenServer sends itself) deletes them so memory doesn't grow forever.

defmodule KV.Cache do
  use GenServer

  @table __MODULE__

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)

  def put(key, value, ttl_ms), do: GenServer.call(__MODULE__, {:put, key, value, ttl_ms})

  # Reads check the expiry themselves, so a key is
  # invisible the moment it expires, not whenever the
  # next sweep happens to run.
  def get(key) do
    now = now()

    case :ets.lookup(@table, key) do
      [{^key, value, expires}] when expires > now -> {:ok, value}
      _ -> :error
    end
  end

  def size, do: :ets.info(@table, :size)

  # Sweep on demand, so we can show it without
  # waiting for the timer.
  def sweep, do: GenServer.call(__MODULE__, :sweep)

  defp now, do: System.monotonic_time(:millisecond)

  @impl true
  def init(opts) do
    :ets.new(@table, [:set, :protected, :named_table, read_concurrency: true])
    interval = Keyword.get(opts, :sweep_every, 1_000)
    Process.send_after(self(), :sweep, interval)
    {:ok, %{interval: interval}}
  end

  @impl true
  def handle_call({:put, k, v, ttl}, _from, s) do
    :ets.insert(@table, {k, v, now() + ttl})
    {:reply, :ok, s}
  end

  def handle_call(:sweep, _from, s), do: {:reply, delete_expired(), s}

  # The timer message arrives in `handle_info`. We
  # re-arm it every time, so it keeps running.
  @impl true
  def handle_info(:sweep, s) do
    delete_expired()
    Process.send_after(self(), :sweep, s.interval)
    {:noreply, s}
  end

  # `select_delete` removes every row whose expiry is
  # in the past, inside ETS, without copying rows out.
  defp delete_expired do
    :ets.select_delete(@table, [{{:_, :_, :"$1"}, [{:"=<", :"$1", now()}], [true]}])
  end
end

Monotonic time is used deliberately: wall-clock time can jump (NTP, daylight saving), monotonic can't.

{:ok, _} = KV.Cache.start_link(sweep_every: 60_000)

KV.Cache.put("session:ada", :token_a, 50)
KV.Cache.put("session:grace", :token_g, 60_000)

IO.inspect(KV.Cache.get("session:ada"), label: "ada right away")
Process.sleep(100)
IO.inspect(KV.Cache.get("session:ada"), label: "ada after 100 ms")
IO.inspect(KV.Cache.size(), label: "rows before sweep")
IO.inspect(KV.Cache.sweep(), label: "swept")
IO.inspect(KV.Cache.get("session:grace"), label: "grace")
ada right away: {:ok, :token_a}
ada after 100 ms: :error
rows before sweep: 2
swept: 1
grace: {:ok, :token_g}

KV is now concurrent, supervised, fast to read and self-cleaning, but it lives on one machine. If that node dies, everything is gone. The Distributed track picks up here: connecting nodes, replicating buckets with :pg process groups, and persisting with Mnesia.