Search code examples
elixirgen-server

Initialize ETS cache using GenServer


I am just learning about ETS and GenServer and I am trying to initialize a cache when my app starts. It's quite possible that I am designing this incorrectly which is leading to the issue I describe below, so any feedback on that would be helpful.

When the app initializes, the :ets table is created via a worker.

def start_link do
  GenServer.start_link(__MODULE__, :ok)
end

def init(:ok) do
  tab = :ets.new(:my_table, [:set, :named_table])
  :ets.insert(:my_table, {1, "one"})
  {:ok, tab}
end

def lookup(key) do
  :ets.lookup(:my_table, key)
end

iex(1)> MyApp.DataTable.lookup(1)
[{1, "one"}]

So far so good...but now I want to update that table. So I add a call:

def add do
  GenServer.call(self(), :add)
end

def handle_call(:add, _from, tab) do
  tab = :ets.insert(:my_table, {2, "two"})
  {:reply, lookup(2), tab}
end

iex(1)> MyApp.DataTable.add
** (exit) exited in: GenServer.call(#PID<0.157.0>, :add, 5000)
    ** (EXIT) process attempted to call itself
    (elixir) lib/gen_server.ex:598: GenServer.call/3

If I try to modify the call function to GenServer.call(:my_table, :add) or GenServer.call(__MODULE__, :add), I get this error: ** (EXIT) no process. Obviously, I'm doing something wrong with the call.

So I try to directly update the :ets table:

def add_direct do
  :ets.insert(:my_table, {2, "two"})
end

iex(1)> MyApp.DataTable.add_direct
** (ArgumentError) argument error
   (stdlib) :ets.insert(:my_table, {2, "two"})
   (my_app) lib/my_app/data_table.ex:17:
     MyApp.DataTable.add_direct/0

When I run :ets.all(), I can see :my_table. So finally I resort to trying to update it in iex:

iex(2)> :ets.insert(:my_table, {2, "two"})
** (ArgumentError) argument error
    (stdlib) :ets.insert(:my_table, {2, "two"})

Just to make sure I am not entirely crazy, I run this sanity check that does work:

iex(2)> :ets.new(:my_table2, [:set, :named_table])
:my_table2
iex(3)> :ets.insert(:my_table2, {2, "two"})
true

I must be going wrong in the server callback and just a fundamental misunderstanding of how :ets works inside modules.


Solution

  • There are multiple problems with this. I'll try to explain each one:

    iex(1)> MyApp.DataTable.add

    ** (exit) exited in: GenServer.call(#PID<0.157.0>, :add, 5000)

    ** (EXIT) process attempted to call itself

    (elixir) lib/gen_server.ex:598: GenServer.call/3

    This is because you're calling a GenServer method on self. You should call that on the PID returned by start_link.

    If I try to modify the call function to GenServer.call(:my_table, :add) or GenServer.call(MODULE, :add), I get this error: ** (EXIT) no process.

    The first one fails because :my_table is not a registered GenServer name. The second one fails because you're not registering the GenServer with a name.

    So I try to directly update the :ets table:

    This is because ETS tables by default do not allow anyone except the process that created the table to write to the table. You can make the table public by passing :public as an option to :ets.new's last argument. This will allow any process to write to that table.


    There are many ways to fix this. One is to accept a PID in add:

    def add(pid) do
      GenServer.call(pid, :add)
    end
    

    And then call it like:

    iex(1)> {:ok, pid} = A.start_link
    {:ok, #PID<0.86.0>}
    iex(2)> A.add(pid)
    1
    [{2, "two"}]
    

    Another solution is to register the process with a name when you create it:

    def start_link do
      GenServer.start_link(__MODULE__, :ok, [name: __MODULE__])
    end
    

    and then use __MODULE__ in add:

    def add do
      GenServer.call(__MODULE__, :add)
    end
    
    iex(1)> A.start_link
    {:ok, #PID<0.86.0>}
    iex(2)> A.add
    1
    [{2, "two"}]
    

    Registering a process with a name also means you cannot register another process with the same name while the first one is alive, but that's probably fine here since you're using a fixed ETS table name, which are also uniquely named.