Alex Martsinovich

Software Engineer
Home Posts Github LinkedIn

5-minute guide to Elixir caller tracking

Caller tracking has been part of Elixir since 2018, and yet it remains a relatively obscure mechanism. Let's fix this with a 5-minute guide.

What is caller tracking

Asynchronous tests are the best. They are possible because in many cases we can trace what is happening in the application all the way back to the test. We say that a test process "owns" things, and other processes may be "allowed" to access those things.

Caller tracking is a convention that allows us to know which processes are related to each other and automatically allow them to access things owned by a test. Examples of such things are Ecto.Sandbox transactions and Mox mocks.

In some cases, caller tracking is handled automatically and you don't need to do anything, such as if you use the Task module. But in a lot of other cases you need to take care of caller tracking yourself. The most common example is a GenServer.

Genserver with caller tracking

Whenever you start a GenServer, equip it with a :"$callers" value in the process dictionary. It should include whatever the calling process had in its process dictionary under the same key and the caller PID. Remember: start_link is executed in the caller, while init runs in the server!

defmodule MyGenserver do
  use GenServer
  
  def start_link(args) do
    callers = Process.get(:"$callers") || []
    new_callers = [self() | callers]
    GenServer.start_link(__MODULE__, {new_callers, args})
  end

  @impl GenServer
  def init({callers, _args}) do
    Process.put(:"$callers", callers)
    {:ok, nil}
  end
end

Now whenever you start this GenServer in your test, it will be able to access Ecto transactions without any additional setup.

It's dangerous to spawn alone! Take this.

Further reading

Ancestor and Caller Tracking bit in Task module documentation

What Does NimbleOwnership Do Anyway?

How to Async Tests in Elixir

Gist with full single file example