Tutorial

Setup a supervised background task in Phoenix

This post was updated 01 May - 2020

pubsub phoenix otp liveview

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 cant be sure that task crasher 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/tutorial_web/live/task_starter_live.ex
defmodule TutorialWeb.TaskStarterLive do
  use Phoenix.LiveView

  def mount(_session, socket) do
    assigns = [
      loading: false,
      result: nil
    ]

    {:ok, assign(socket, assigns)}
  end

  def render(assigns) do
    ~L"""
    <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/tutorial/worker.ex
defmodule Tutorial.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 Elixirs Task with use Task

When the code for the worker is in place, can return to the LiveView module and add the code for spawning the worker:

# lib/tutorial_web/live/task_starter_live.ex
alias Tutorial.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 presses the button I should in the console see:

Perfect. Everything works!

STEP 3 - Add functionality to the worker

I want to simulate that my worker need 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 the simulate the slowness, there is a 3 second sleep. That I generate I 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/tutorial/worker.ex
defmodule AwesomeDemo.Demo.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 of 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 in my worker.

# lib/tutorial/worker.ex
alias Tutorial.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/tutorial_web/live/task_starter_live.ex
defmodule TutorialWeb.TaskStarterLive do
  use Phoenix.LiveView

  def mount(_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

With that in place, I can refresh the page and test the button again. As soon as the worker are 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.

Related Tutorials

Published 04 May - 2021
Updated 05 May - 2022

How to combine Phoenix LiveView with Alpine.js

No matter how great Phoenix LiveView is, there is still some use case for sprinking some JS in your app to improve UX. For example, tabs, dropdowns,..

Published 23 Dec - 2020

Getting Started with Phoenix and LiveView