Ecto v2.0.2 Ecto.Adapters.SQL.Sandbox
A pool for concurrent transactional tests.
The sandbox pool is implemented on top of an ownership mechanism. When started, the pool is in automatic mode, which means the repository will automatically check connections out as with any other pool.
The mode/2
function can be used to change the pool mode to
manual or shared. In both modes, the connection must be explicitly
checked out before use. When explicit checkouts are made, the
sandbox will wrap the connection in a transaction by default and
control who has access to it. This means developers have a safe
mechanism for running concurrent tests against the database.
Database support
While both PostgreSQL and MySQL support SQL Sandbox, only PostgreSQL supports concurrent tests while running the SQL Sandbox. Therefore, do not run concurrent tests with MySQL as you may run into deadlocks due to its transaction implementation.
Example
The first step is to configure your database to use the
Ecto.Adapters.SQL.Sandbox
pool. You set those options in your
config/config.exs
(or preferrably config/test.exs
) if you
haven’t yet:
config :my_app, Repo,
pool: Ecto.Adapters.SQL.Sandbox
Now with the test database properly configured, you can write transactional tests:
# At the end of your test_helper.exs
# Set the pool mode to manual for explicitly checkouts
Ecto.Adapters.SQL.Sandbox.mode(Repo, :manual)
defmodule PostTest do
# Once the mode is manual, tests can also be async
use ExUnit.Case, async: true
setup do
# Explicitly get a connection before each test
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
end
test "create post" do
# Use the repository as usual
assert %Post{} = Repo.insert!(%Post{})
end
end
Collaborating processes
The example above is straight-forward because we have only a single process using the database connection. However, sometimes a test may need to interact with multiple processes, all using the same connection so they all belong to the same transaction.
Before we discuss solutions, let’s see what happens if we try to use a connection from a new process without explicitly checking it out first:
setup do
# Explicitly get a connection before each test
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
end
test "create two posts, one sync, another async" do
task = Task.async(fn ->
Repo.insert!(%Post{title: "async"})
end)
assert %Post{} = Repo.insert!(%Post{title: "sync"})
assert %Post{} = Task.await(task)
end
The test above will fail with an error similar to:
** (RuntimeError) cannot find ownership process for #PID<0.35.0>
That’s because the setup
block is checking out the connection only
for the test process. Once we spawn a Task, there is no connection
assigned to it and it will fail.
The sandbox module provides two ways of doing so, via allowances or by running in shared mode.
Allowances
The idea behind allowances is that you can explicitly tell a process which checked out connection it should use, allowing multiple processes to collaborate over the same connection. Let’s give it a try:
test "create two posts, one sync, another async" do
parent = self()
task = Task.async(fn ->
Ecto.Adapters.SQL.Sandbox.allow(Repo, parent, self())
Repo.insert!(%Post{title: "async"})
end)
assert %Post{} = Repo.insert!(%Post{title: "sync"})
assert %Post{} = Task.await(task)
end
And that’s it, by calling allow/3
, we are explicitly assigning
the parent’s connection (i.e. the test process’ connection) to
the task.
Because allowances use an explicit mechanism, their advantage is that you can still run your tests in async mode. The downside is that you need to explicitly control and allow every single process. This is not always possible. In such cases, you will want to use shared mode.
Shared mode
Shared mode allows a process to share its connection with any other process automatically, without relying on explicit allowances. Let’s change the example above to use shared mode:
setup do
# Explicitly get a connection before each test
:ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
# Setting the shared mode must be done only after checkout
Ecto.Adapters.SQL.Sandbox.mode(Repo, {:shared, self()})
end
test "create two posts, one sync, another async" do
task = Task.async(fn ->
Repo.insert!(%Post{title: "async"})
end)
assert %Post{} = Repo.insert!(%Post{title: "sync"})
assert %Post{} = Task.await(task)
end
By calling mode({:shared, self()})
, any process that needs
to talk to the database will now use the same connection as the
one checked out by the test process during the setup
block.
Make sure to always check a connection out before setting the mode
to {:shared, self()}
.
The advantage of shared mode is that by calling a single function, you will ensure all upcoming processes and operations will use that shared connection, without a need to explicitly allow them. The downside is that tests can no longer run concurrently in shared mode.
Summing up
There are two mechanisms for explicit ownerships:
Using allowances - requires explicit allowances via
allow/3
. Tests may run concurrently.- Using shared mode - does not require explicit allowances. Tests cannot run concurrently.
FAQ
When running the sandbox mode concurrently, developers may run into issues we explore in the upcoming sections.
“owner exited while client is still running”
In some situations, you may see error reports similar to the one below:
21:57:43.910 [error] Postgrex.Protocol (#PID<0.284.0>) disconnected:
** (DBConnection.Error) owner #PID<> exited while client #PID<> is still running
Such errors are usually followed by another error report from another process that failed while executing a database query.
To understand the failure, we need to answer the question: who are the owner and client processes? The owner process is the one that checks out the connection, which, in the majority of cases, is the test process, the one running your tests. In other words, the error happens because the test process has finished, either because the test succeeded or because it failed, while the client process was trying to get information from the database. Since the owner process, the one that owns the connection, no longer exists, Ecto will check the connection back in and notify the client process using the connection that the connection owner is no longer available.
This can happen in different situations. For example, imagine you query a GenServer in your test that is using a database connection:
test "gets results from GenServer" do
{:ok, pid} = MyAppServer.start_link()
Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), pid)
assert MyAppServer.get_my_data_fast(timeout: 1000) == [...]
end
In the test above, we spawn the server and allow it to perform database queries using the connection owned by the test process. Since we gave a timeout of 1 second, in case the database takes longer than one second to reply, the test process will fail, due to the timeout, making the “owner down” message to be printed because the server process is still waiting on a connection reply.
In some situations, such failures may be intermittent. Imagine that you allow a process that queries the database every half second:
test "queries periodically" do
{:ok, pid} = PeriodicServer.start_link()
Ecto.Adapters.SQL.Sandbox.allow(Repo, self(), pid)
# more tests
end
Because the server is querying the database from time to time, there is a chance that, when the test exists, the periodic process may be querying the database, regardless of test success or failure.
Database deadlocks
Since the sandbox relies on concurrent transactional tests, there is a chance your tests may trigger deadlocks in your database. This is specially true with MySQL, where the solutions presented here are not enough to avoid deadlocks and thefore making the use of concurrent tests with MySQL prohibited.
However, even on databases like PostgreSQL, deadlocks can still occur. For example, consider this scenario:
Transaction 1: Transaction 2:
begin
begin
update posts where id = 1
update posts where id = 2
update posts where id = 1
update posts where id = 2
**deadlock**
There are different ways to avoid this problem. One of them is to make sure your tests work on distinct data. Regardless of your choice between using fixtures or factories for test data, make sure you get a new set of data per test. This is specially important for data that is meant to be unique like user emails.
For example, instead of:
def insert_user do
Repo.insert! %User{email: "sample@example.com"}
end
prefer:
def insert_user do
Repo.insert! %User{email: "sample-#{counter()}@example.com"}
end
defp counter do
System.unique_integer [:positive]
end
Deadlocks may happen in other circumstances. If you believe you are hitting a scenario that has not been described here, please report an issue so we can improve our examples. As a last resort, you can always disable the test triggering the deadlock from running asynchronously by setting “async: false”.
Summary
Functions
Allows the allow
process to use the same connection as parent
Checks in the connection back into the sandbox pool
Checks a connection out for the given repo
Sets the mode for the repo
pool
Functions
Allows the allow
process to use the same connection as parent
.
Checks a connection out for the given repo
.
The process calling checkout/2
will own the connection
until it calls checkin/2
or until it crashes when then
the connection will be automatically reclaimed by the pool.
Options
:sandbox
- when true the connection is wrapped in a transaction. Defaults to true.:isolation
- set the query to the given isolation level