Tutorial
Setup a supervised background task in Phoenix
This post was updated 27 Mar
There are times when you need to spawn a background process for a longer running task. And especially if you are interacting with an external system, you can’t be sure if the task will crash or not. But as we know, Elixir has built-in functionality to handle this and makes it easy to get started with. So, in this tutorial, I will start with a LiveView component that just contains a button. The only idea is that pressing the button will trigger a process that takes a long time and might actually result in a crash. In that case, the supervisor should restart it.
STEP 1 - Setup the LiveView
For this example I want to set up a basic LiveView with a clickable button and a handle_event for the button click. It can also have two states, loading true or false and a result.
# lib/my_app_web/live/task_starter_live.ex
defmodule MyAppWeb.TaskStarterLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
assigns = [
loading: false,
result: nil
]
{:ok, assign(socket, assigns)}
end
def render(assigns) do
~H"""
<div class="mt-10">
<button class="btn btn-primary" phx-click="start-task">Long running task that might crash</button>
</div>
<div class="mt-10">
<%= if @result || @loading do %>
<%= if @loading do %>
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1">
<circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline>
</svg>
<% else %>
Result: {@result}
<% end %>
<% end %>
</div>
"""
end
def handle_event("start-task", _, socket) do
assigns = [
loading: true
]
{:noreply, assign(socket, assigns)}
end
end
STEP 2 - Setup the worker
So, every time anyone clicks the button, I want to start the worker. The basic scaffold for the file will look like:
# lib/my_app/worker.ex
defmodule MyApp.Worker do
use Task
def start_link(arg) do
Task.start_link(__MODULE__, :run, [arg])
end
def run(_arg) do
end
end
Note that this worker uses Elixir’s Task with use Task.
When the code for the worker is in place, we can return to the LiveView module and add the code for spawning the worker:
# lib/my_app_web/live/task_starter_live.ex
alias MyApp.Worker
...
def handle_event("start-task", _, socket) do
Supervisor.start_link([{Worker, nil}], strategy: :one_for_one)
assigns = [
loading: true
]
{:noreply, assign(socket, assigns)}
end
NOTE after this change, make sure to restart the server.
If I click the button nothing happens. And that is basically just because I haven’t implemented any functionality.
If I add a IO.puts "Hey" inside the run function
def run(_arg) do
IO.puts "Hey"
end
Refresh the page and press the button. I should see in the console:
Perfect. Everything works!
STEP 3 - Add functionality to the worker
I want to simulate that my worker needs to perform a slow task with an uncertain outcome.
def run(_arg) do
:timer.sleep(3000)
number = Enum.random(0..3)
if number == 1 do
IO.puts "CRASH"
raise inspect(number)
end
IO.puts number
number
end
So to simulate the slowness, there is a 3 second sleep. Then I generate a number between 0 and 3. If the number is 1, the process crashes.
That basically looks like this:
The main issue here is that if it crashes, it doesn’t start again. That is most likely what you want.
Task has a default :restart of :temporary. That is easily fixable. I will instruct my Task process to restart. I can do that with the line restart: :transient.
# lib/my_app/worker.ex
defmodule MyApp.Worker do
use Task, restart: :transient
...
Now, when it crashes, it will restart the task and then hopefully come to a better outcome.
STEP 4 - Report back to LiveView
It would be nice if I could get the task to report back to the LiveView when there is a success. This can easily be done using Phoenix PubSub.
Once again, open the worker to add some code.
# lib/my_app/worker.ex
alias MyApp.PubSub
@topic inspect(__MODULE__)
def run(_arg) do
...
number
|> notify_subscribers()
end
defp notify_subscribers(number) do
Phoenix.PubSub.broadcast(PubSub, @topic, {:message, number})
end
def subscribe do
Phoenix.PubSub.subscribe(PubSub, @topic)
end
And in the LiveView I first need to make sure that I subscribe to the PubSub channel. And I also need to add the handle_info function that actually listens to when the worker is done and returns a result.
# lib/my_app_web/live/task_starter_live.ex
defmodule MyAppWeb.TaskStarterLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
if connected?(socket) do
Worker.subscribe()
end
...
end
...
def handle_info({:message, number}, socket) do
assigns = [
loading: false,
result: number
]
{:noreply, assign(socket, assigns)}
end
end
With that in place, I can refresh the page and test the button again. As soon as the worker is done, the result should be visible.
Conclusion
This is just one of many ways to run an async task. I would go for a pattern like this if I need the task to be supervised and also, if more than one process needs to know about the outcome.