Search code examples
elixirecto

How to avoid race condition during testing handle_cast with database query?


there is a simple GenServer which performs a simple Ecto query within async call:

defmodule App.Notifications.Manager do
  def send(user, event) do
    IO.write "manager pid "
    IO.inspect self()
    GenServer.cast(__MODULE__, {:email, user, event})
  end

  def handle_cast({:email, user, event}, state) do
    IO.write "manager server pid "
    IO.inspect self()
    App.Repo.all(App.User)
    {:noreply, state}
  end
end

and relevant test that looks like that:

defmodule App.Notifications.EventManagerTest do
  use    App.ModelCase

  test "send a message", context do
    IO.puts "start test"
    IO.inspect self()
    App.Notifications.Manager.send(context.user, context.event)
    IO.puts "finish test"
  end
end

the tests itself are performed in shared mode

defmodule App.ModelCase do
  #...

  setup tags do
    :ok = Ecto.Adapters.SQL.Sandbox.checkout(App.Repo)

    unless tags[:async] do
      Ecto.Adapters.SQL.Sandbox.mode(App.Repo, {:shared, self()})
    end

    :ok
  end
end

Now, mix test is leading to the race condition:

..................start test
#PID<0.499.0>
manager pid #PID<0.499.0>
finish test
.manager server pid #PID<0.283.0>
12:31:04.787 [error] GenServer App.Notifications.Manager terminating
** (stop) exited in: GenServer.call(#PID<0.500.0>, {:checkout, #Reference<0.0.1.1627>, true, 15000}, 5000)
    ** (EXIT) shutdown: "owner #PID<0.499.0> exited with: shutdown"
    (db_connection) lib/db_connection/ownership/proxy.ex:32: DBConnection.Ownership.Proxy.checkout/2
    (db_connection) lib/db_connection.ex:701: DBConnection.checkout/2
    (db_connection) lib/db_connection.ex:608: DBConnection.run/3
    (db_connection) lib/db_connection.ex:449: DBConnection.prepare_execute/4
    (ecto) lib/ecto/adapters/sql.ex:224: Ecto.Adapters.SQL.sql_call/6
    (ecto) lib/ecto/adapters/sql.ex:396: Ecto.Adapters.SQL.execute_and_cache/7
    (ecto) lib/ecto/repo/queryable.ex:127: Ecto.Repo.Queryable.execute/5
    (ecto) lib/ecto/repo/queryable.ex:40: Ecto.Repo.Queryable.all/4
    (ave88) lib/ave88/notifications/manager.ex:46: Ave88.Notifications.Manager.handle_cast/2
    (stdlib) gen_server.erl:615: :gen_server.try_dispatch/4
    (stdlib) gen_server.erl:681: :gen_server.handle_msg/5
    (stdlib) proc_lib.erl:240: :proc_lib.init_p_do_apply/3
..............................................

Finished in 2.9 seconds (1.1s on load, 1.7s on tests)
65 tests, 0 failures

As you can see handle_cast occur after test has finished. App uses latest versions of db_connection - 1.0.0-rc.1 and ecto - 2.0.0-rc.6.


Solution

  • One way to do this would be to add a handle_call that would return just a dummy value, and then invoke it after the cast from your tests to make sure all the queued up casts are executed by the GenServer. This works because a GenServer handles all the casts/calls in the order they're received in. If you make 10 long running casts and then 1 call, the call would return after the 10 casts are finished executing one by one.

    In App.Notifications.Manager, add:

    def handle_call(:ping, _from, state) do
      {:reply, :pong, state}
    end
    

    Then, in your test, after

    App.Notifications.Manager.send(context.user, context.event)
    

    add

    GenServer.call(App.Notifications.Manager, :ping)