Designing an Agent with side-effects on start_link

Hi all!

Some time ago I started to work on an application and I designed one of it’s workers with a start_link/0 such as this:

  def start_link do
    Logger.info("Starting worker")

    status = @my_service.get_api_response!()
    Agent.start_link(fn -> status end, name: __MODULE__)
  end

I wasn’t too worried because the business logic didn’t have any side-effects apart from that start_link and the workaround I found was to simply run mix test witht the --no-start flag while starting up all the necessary applicaitions in the test_helper.ex.

The issue I’ve encountered is that when trying to adopt Phoenix now, it’s no longer convenient to run mix start --no-start, so reality kinda called back and made me deal with the real issue: How would I design an Agent/Genserver which needs to pull its initial status from an external HTTP API?

I would do this with a GenServer (I do not use Agent). Not in start_link, but in the init function…

There is even a handle_continue to avoid blocking when initialization is long.

How would you test that? When I run mix test the Supervisor spins up all its children, including that one and all modules mocked with Mox are loaded.

That means that @my_service.get_api_response!() is invoked before being able to configure the mocks in the tests.

I get errors similar to this:

** (Mix) Could not start application myapp: MyApp.Application.start(:normal, []) returned an error: shutdown: failed to start child: MyApp.TheWorker
    ** (EXIT) an exception was raised:
        ** (Mox.UnexpectedCallError) no expectation defined for MyApp.Service.get_api_response!/0 in process #PID<0.346.0> with args []
            (mox) lib/mox.ex:599: Mox.__dispatch__/4
            (londibot) lib/londibot/tfl/status_broker.ex:16: MyApp.TheWorker.start_link/0
            (stdlib) supervisor.erl:379: :supervisor.do_start_child_i/3
            (stdlib) supervisor.erl:365: :supervisor.do_start_child/2
            (stdlib) supervisor.erl:349: anonymous fn/3 in :supervisor.start_children/2
            (stdlib) supervisor.erl:1157: :supervisor.children_map/4
            (stdlib) supervisor.erl:315: :supervisor.init_children/2
            (stdlib) gen_server.erl:374: :gen_server.init_it/2

Sorry, I am not using Mox and cannot help You on this one.

How would you test that?

Following dependency rejection you would design the system to obtain the initial state before even attempting to start the GenServer - then there is no dependency that needs to be mocked out because the initial state is handed in as a plain value.

3 Likes

That’s what I thought initially – pass it throuh, and the Supervisors’ callback would look like this:

  def start(_type, _args) do
    import Supervisor.Spec, warn: false

    children = [
      {MyApp.TheWorker, @my_service.get_api_response!()},
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end

The issue is that it looks like mix first spins up the Application, then runs the test_helper.exs and lastly the tests. That means that since the mocks are set in the test_helper.exs, it doesn’t matter what I inject to the worker in the Supervisor callback, it will always be a mock whose get_api_response!/0 function doesn’t have an expectation set.

I think one solution could be to give it always an empty state as an initial state, I would however need to be able to tell the supervisor to feed it with a different state on restart, because I can’t afford to feed it an empty state every time it crashes. Is that somehow possible?

How about just not testing the genserver started by your supervision tree, but starting up additional ones with custom configuration just for your tests:

setup do
  {:ok, pid} = start_supervised({MyApp.TheWorker, mock_config()})
  {:ok, pid: pid}
end

test "whatever", %{pid: pid} do
  assert MyApp.TheWorker.do_stuff(pid)
end
1 Like

--no-start fixes that

--no-start - does not start applications after compilation

https://hexdocs.pm/mix/Mix.Tasks.Test.html#module-command-line-options

So mix test without --no-start runs integration tests while mix test --no-start only runs the fast isolated tests.

1 Like

The usual idiom would be to have another process, external to the worker, that holds onto that state and then supplies it to the worker when requested (maybe in the worker’s handle_continue?)

One possible sequence of events would look like:

  • supervisor boots the “state fetcher/holder” process

  • fetcher/holder boots up

  • (optional) if in production, the fetcher/holder can make the get_api_response call from handle_continue. Otherwise, that call is deferred until the worker needs the data.

  • supervisor starts the worker process

  • worker process makes a blocking call from its handle_continue to the holder process to get the state

  • in production, that call returns promptly once get_api_response has completed

  • in test, that call would block (maybe with receive?) waiting for the go-ahead from the test setup code

  • if the worker process crashes, the fetcher/holder can supply the already-loaded state to it when it restarts

1 Like

That sounds like a fine solution. How would you code the logical branches around being in production or in test? I know using Mix.env/0 isn’t an option, since that isn’t available in production, and having an environment variable which holds the values :production or :test seems like a workaround. What’s idiomatic way of dealing with that? :thinking:

Two things come to mind:
Unless you want the whole application to fail at startup if the initial state request fails, issue an Agent.cast after the start_link to initialize the Agent. Let the cast handler do the init. Since that cast will be first in the message queue, nothing will get to the Agent before it’s initialized. Also, in the init, you can use an Application env to control if it pulls that initial state. While you can’t use Mix.env, you can use a config that’s dependent on the Mix.env that’s set up at compile/release.

2 Likes

Hmmm… would the Application callback look somewhat like this?

  def start(_type, _args) do
    children = [
        MyApp.TheWorker, []}
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    value = Supervisor.start_link(children, opts)

    Agent.cast(MyApp.TheWorker, fn _ -> status() end)
    value
  end

  defp status when @env == :test, do: []
  defp status, do: @my_service.get_api_response!()

Being @env simply config :my_app, environment: Mix.env()

This is definitely the easiest solution, and since the cast is async, I’m assuming that Application.start/2 will return before the process receives the message of the cast, so if it crashes on that cast, it can be restarted by the supervisor. Is this a right assumption?

The thing that puts me off is having that environment check… I’m just not sure of another way of doing it without overcomplicating it.

That was my thought. As for the config, I’d make it explicit rather than literal, i.e.:

config :my_app, MyApp.TheWorker, initialize: Mix.env != :test

Also, in general I don’t recommend doing Application.get_env into an attribute since it is set at compile time and means that you cannot alter the behavior. In other words, do the Application.get_env in runtime code.

All that being said. you could use Mox to mock the @my_service. Then the condition is not at this level but the mock of the service.