Powered by AppSignal & Oban Pro

Oban Training—Signing Up

02_signing_up.livemd

Oban Training—Signing Up

Mix.install([:faker, :kino, :oban, :postgrex])

Logger.configure(level: :info)

Application.put_env(:chow_mojo, ChowMojo.Repo,
  pool: Ecto.Adapters.SQL.Sandbox,
  url: "postgres://localhost:5432/chow_mojo_dev"
)

defmodule ChowMojo.Repo do
  use Ecto.Repo, otp_app: :chow_mojo, adapter: Ecto.Adapters.Postgres
end

defmodule CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add(:name, :text, null: false)
      add(:email, :text, null: false)
      add(:address, :text)

      timestamps()
    end

    create(index(:users, [:email], unique: true))
  end
end

defmodule ChowMojo.User do
  use Ecto.Schema

  import Ecto.Changeset

  schema "users" do
    field(:email, :string)
    field(:name, :string)

    timestamps()
  end

  def insert_changeset(params) do
    cast(%__MODULE__{}, params, ~w(email name)a)
  end
end

defmodule ChowMojo do
  def get_user(id) do
    ChowMojo.Repo.get(ChowMojo.User, id)
  end

  def send_welcome_email(%{email: email}) do
    send(self(), {:delivered, email})

    {:ok, email}
  end
end

ChowMojo.Repo.start_link()

Ecto.Migrator.run(ChowMojo.Repo, [{1, CreateUsers}], :up, all: true)

Ecto.Adapters.SQL.Sandbox.mode(ChowMojo.Repo, :manual)

🏅 Goals

In this exercise you’ll create a job that delivers follow-up email(s) in the background after a new user signs up. Pushing email delivery into the background makes delivery more reliable due to error handling and retries with backoff.

Welcoming Users

We’ll start by creating a ChowMojo.WelcomeEmail worker that delivers a welcome email with ChowMojo.send_welcome_email/1 after users sign up. Pass options to use Oban.Worker that configure the worker to use the :email queue.

Your perform/1 function should accept an args map like %{"id" => id} and use ChowMojo.get_user/1 to fetch the user. Job args are always serialized into a JSON map, and structs such as Ecto schema aren’t serializable.

Use a Hint

Configure the worker’s queue with queue: :email. Then, write a perform/1 function that accepts a job struct with string args, fetches the user, then sends a welcome email.

defmodule ChowMojo.WelcomeWorker do
  use Oban.Worker, queue: :email

  @impl Worker
  def perform(%Job{args: %{"id" => id}}) do
    id
    |> ChowMojo.get_user()
    |> ChowMojo.send_welcome_email()
  end
end
defmodule ChowMojo.WelcomeWorker do
  # Your turn...
end

Now use Oban.insert/1 to enqueue a ChowMojo.WelcomeWorker job for your new worker after a user is created within ChowMojo.create_user/1. Remember, only pass in the user’s id, not the entire User struct.

Use a Hint

Take the {:ok, user} returned from Repo.insert/1 and insert a WelcomeWorker job:

def create_user(params) do
  changeset = ChowMojo.User.insert_changeset(params)

  with {:ok, user} <- ChowMojo.Repo.insert(changeset) do
    %{id: user.id}
    |> ChowMojo.WelcomeWorker.new()
    |> Oban.insert!()

    {:ok, user}
  end
end
defmodule ChowMojo.Users do
  @spec create_user(map) :: {:ok, ChowMojo.User.t()} | {:error, Ecto.Changeset.t()}
  def create_user(params) do
    changeset = ChowMojo.User.insert_changeset(params)

    {:ok, user} = ChowMojo.Repo.insert(changeset)
  end
end

Testing

To verify the job is enqueued with the necessary arguments in the correct queue we’ll write a test. But first, we need to configure Oban for testing.

Oban offers two testing modes: :inline and :manual. The :inline mode executes jobs directly in the test process and avoids touching the database at all, whereas :manual mode uses Ecto’s sandbox to insert jobs in an isolated transaction. In either case, Oban doesn’t run any queue processes to simplify testing.

We’ll use both modes to test our aspects of the WelcomeWorker, starting with :inline. Modify setup/0 to start Oban in :inline mode. (Note: Typically this configuration goes in test.exs and you run tests with mix test)

Use a Hint

Add testing: :inline to where Oban is started:

start_supervised!({Oban, repo: ChowMojo.Repo, testing: :inline})
ExUnit.start(auto_run: false)

defmodule ChowMojo.InlineUsersTest do
  use ExUnit.Case

  setup do
    # Your turn...
    start_supervised!({Oban, repo: ChowMojo.Repo})

    :ok = Ecto.Adapters.SQL.Sandbox.checkout(ChowMojo.Repo)
  end

  test "creating a user delivers a welcome email" do
    params = %{email: "shannon@sorentwo.com", name: "Shannon"}

    {:ok, user} = ChowMojo.Users.create_user(params)

    assert_email_delivered(user.email)
  end

  defp assert_email_delivered(email) do
    assert_receive {:delivered, ^email}, 250, "expected an email delivered to #{email}"
  end
end

ExUnit.run()

Once the WelcomeWorker job executes successfully you’ll see one test pass with zero failures. You’ve exercised the full welcome flow within the test process, without touching the database! Now we’ll write another test using the helpers provided by Oban.Testing to assert that the job is enqueued in the database properly.

In this test the testing mode is already set to :manual. Setup the testing helpers, then use assert_enqueued/1 to verify the job is enqueued.

Use a Hint

Assert the job is enqueued by checking the :worker, :queue, and :args:

assert_enqueued worker: ChowMojo.WelcomeWorker, queue: :email, args: %{id: user.id}
ExUnit.start(auto_run: false)

defmodule ChowMojo.ManualUsersTest do
  use ExUnit.Case

  # Your turn...

  setup do
    start_supervised!({Oban, repo: ChowMojo.Repo, testing: :manual})

    :ok = Ecto.Adapters.SQL.Sandbox.checkout(ChowMojo.Repo)
  end

  test "creating a user enqueues a welcome email" do
    params = %{email: "shannon@sorentwo.com", name: "Shannon"}

    {:ok, user} = ChowMojo.Users.create_user(params)

    # Your turn...
  end
end

ExUnit.run()

☠️ Extra Challenges

Schedule a follow-up job

Create another worker and deliver a follow-up job one day in the future after user creation. The follow-up should call ChowMojo.second_day_email/1 to deliver the email.

  1. Use the schedule_in option to delay job execution until a later time
  2. Try using the {:days, N} shorthand rather than calculating using raw seconds
  3. Write a test to verify the email is scheduled for delivery in the future

Enqueue in a transaction

Because Oban jobs are database records they can be inserted alongside your domain objects within a transaction. Use Oban.insert/3 to insert everything together.

  1. Use an Ecto.Multi to create the User and insert both jobs in a single transaction
  2. Use a function to create each job so you can reference the Multi’s changes

Home

Placing an Order