defmodule Vela.Backend.ETS.TableOwner do @moduledoc """ Owns the ETS tables for a cache instance. ETS tables are destroyed when their owner process dies. By keeping a dedicated, crash-resistant process as the owner (and heir), we ensure the tables survive even if the backend process crashes. """ use GenServer # ---- Public API ---- def start_link(opts) do name = Keyword.fetch!(opts, :name) GenServer.start_link(__MODULE__, opts, name: table_owner_name(name)) end @doc "Returns the name of the data ETS table for a cache." def data_table(cache_name), do: :"vela_data_#{cache_name}" @doc "Returns the name of the TTL index ETS table for a cache." def ttl_table(cache_name), do: :"vela_ttl_#{cache_name}" # ---- GenServer Callbacks ---- @impl true def init(opts) do name = Keyword.fetch!(opts, :name) data_table = data_table(name) ttl_table = ttl_table(name) # :set → each key is unique (like a hash map) # :public → any process can read/write (no bottleneck) # read_concurrency → optimises for many concurrent readers # write_concurrency → uses internal striping to allow concurrent writes # named_table → accessible by name, not just the reference :ets.new(data_table, [ :set, :public, :named_table, read_concurrency: true, write_concurrency: true ]) # :ordered_set → entries are sorted by key # We key TTL entries as {expires_at, key} so a range scan # from {0, nil} to {now, nil} finds all expired entries cheaply. :ets.new(ttl_table, [ :ordered_set, :public, :named_table, read_concurrency: true, write_concurrency: true ]) # Set ourselves as the heir of both tables. # If we give the tables to another process and THAT process dies, # the tables come back to us automatically. :ets.setopts(data_table, {:heir, self(), :data_table}) :ets.setopts(ttl_table, {:heir, self(), :ttl_table}) {:ok, %{name: name, data_table: data_table, ttl_table: ttl_table}} end # Called by the VM when a table is transferred to us (heir recovery) @impl true def handle_info({:"ETS-TRANSFER", table, _from_pid, _data}, state) do # The backend crashed and we got the table back. # When the backend restarts, it will call give_table/2 to reclaim it. {:noreply, Map.put(state, :recovered_table, table)} end @doc """ Called by the backend after it starts up to take ownership of the tables. """ def give_tables(cache_name, new_owner_pid) do owner = table_owner_name(cache_name) GenServer.call(owner, {:give_tables, new_owner_pid}) end @impl true def handle_call({:give_tables, new_owner_pid}, _from, state) do :ets.give_away(state.data_table, new_owner_pid, :data_table) :ets.give_away(state.ttl_table, new_owner_pid, :ttl_table) {:reply, :ok, state} end defp table_owner_name(cache_name), do: :"vela_table_owner_#{cache_name}" end