Skip to main content

lib/table_heir.ex

defmodule ActiveMemory.TableHeir do
  @moduledoc """
  Stable "owner of last resort" for the ETS tables created by `ActiveMemory.Store`.

  Each ETS table is created with this process set as its ETS `:heir`. When a
  store process terminates, ETS transfers the table to this process instead of
  destroying it, preserving the data. When the store restarts it reclaims the
  table via `claim/1`, so ETS data survives store crashes and restarts.

  This process is started automatically by `ActiveMemory.Application`; no changes
  to a host application's supervision tree and no changes to the `Store` API are
  required. When the heir is not running, stores fall back to the previous
  behaviour of creating a fresh table.
  """

  use GenServer

  @name __MODULE__

  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: @name)
  end

  @doc """
  Reclaim ownership of `table` for the calling process.

  Returns `:ok` when the heir is currently holding `table` and has handed it back
  to the caller, or `{:error, :not_held}` when the heir is not the table's owner
  (the table does not exist, or it is owned by another process).

      iex> ActiveMemory.TableHeir.claim(:a_table_the_heir_does_not_hold)
      {:error, :not_held}
  """
  @spec claim(atom()) :: :ok | {:error, :not_held}
  def claim(table) do
    GenServer.call(@name, {:claim, table, self()})
  end

  @doc """
  Return the pid of the running heir, or `nil` when it is not started.
  """
  @spec whereis() :: pid() | nil
  def whereis, do: Process.whereis(@name)

  @impl true
  def init(_opts), do: {:ok, MapSet.new()}

  @impl true
  def handle_call({:claim, table, pid}, _from, held_tables) do
    case held?(table) do
      true ->
        :ets.give_away(table, pid, table)
        {:reply, :ok, MapSet.delete(held_tables, table)}

      false ->
        {:reply, {:error, :not_held}, held_tables}
    end
  end

  @impl true
  def handle_info({:"ETS-TRANSFER", _table_ref, _from, table}, held_tables) do
    {:noreply, MapSet.put(held_tables, table)}
  end

  def handle_info(_message, held_tables), do: {:noreply, held_tables}

  defp held?(table) do
    case :ets.whereis(table) do
      :undefined -> false
      _table_ref -> :ets.info(table, :owner) == self()
    end
  end
end