Oban Training—Refunding an Order
Mix.install([: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 ChowMojo.Order do
use Ecto.Schema
schema "orders" do
field(:notes, :string)
field(:total, :integer, default: 0)
field(:dispatched_at, :naive_datetime_usec)
field(:delivered_at, :naive_datetime_usec)
timestamps()
end
end
defmodule ChowMojo.ObanCase do
use ExUnit.CaseTemplate
using do
quote do
use Oban.Testing, repo: ChowMojo.Repo
import ChowMojo.ObanCase
end
end
setup do
Ecto.Adapters.SQL.Sandbox.mode(ChowMojo.Repo, {:shared, self()})
Ecto.Adapters.SQL.Sandbox.checkout(ChowMojo.Repo)
end
def insert(:order, :refund_failure) do
insert(notes: "Get it here quickly", total: 99)
end
def insert(params) do
ChowMojo.Order
|> struct!(params)
|> ChowMojo.Repo.insert!()
end
end
defmodule ChowMojo do
def get_order(id), do: ChowMojo.Repo.get(ChowMojo.Order, id)
def refund_order(%{total: 99}, _reason), do: {:error, :unknown_order}
def refund_order(_order, _reason), do: :ok
end
ChowMojo.Repo.start_link()
🏅 Goals
In this exercise you’ll learn how to prevent duplicate jobs with unique options, how to “upsert” jobs by replacing fields on unique conflict, and how to test jobs by draining queues.
Coordinating Refunds
Inevitably, some customers will be unhappy with an order and they’ll want to get their money back. Refunds are a delicate operation because money is involved and we want to guarantee there aren’t duplicate transactions—while keeping the operation in a reliable background job.
This is where unique jobs come in.
To begin, define a Refunder
worker that takes an order id and a refund reason as args, fetches the order, and calls ChowMojo.refund_order/2
to refund the money.
Define the standard perform/1
without any unique options yet:
use Oban.Worker
@impl Worker
def perform(%Job{args: %{"id" => order_id, "reason" => reason}}) do
order_id
|> ChowMojo.get_order()
|> ChowMojo.refund_order(reason)
end
defmodule ChowMojo.Refunder do
# Your turn...
end
So far we have an ordinary worker like the ones we’ve written in previous exercises. It lacks the unique configuration that prevents duplicate jobs.
Modify the Refunder
worker to be unique forever and write a test that verifies only one refund job may exist for an order, even with different reasons.
An Oban
instance is started for :manual
testing where jobs are inserted into the database rather than executing immediately. Your tests should insert multiple jobs with the same order id, and maybe the same reason, then use all_enqueued/1
to ensure there aren’t duplicates.
Insert two jobs with the same id
, but different reason
values, then assert that there is only one enqueued:
test "ensuring only one refund for an order is ever created" do
Oban.insert!(ChowMojo.Refunder.new(%{id: 1, reason: "It was awful"}))
Oban.insert!(ChowMojo.Refunder.new(%{id: 1, reason: "I said it was awful"}))
jobs = all_enqueued(worker: ChowMojo.Refunder)
assert 1 == length(jobs)
end
To make the test pass, configure uniqueness in the worker:
use Oban.Worker, unique: [period: :infinity, keys: [:id]]
ExUnit.start(auto_run: false)
defmodule ChowMojo.UniqueRefundTest do
use ChowMojo.ObanCase
setup do
start_supervised!({Oban, repo: ChowMojo.Repo, testing: :manual})
:ok
end
test "ensuring only one refund for an order is ever created" do
# Your turn...
end
end
ExUnit.run()
At Most Once
It’s essential that refunds are attempted at most one time. Setting max_attempts
to 1
guarantees that the job may execute one time, after which it is discarded
. To ensure the refund job gets discarded after a failure we’ll use Oban.drain_queue/1
to execute the job exactly as it would be in production (but directly in our test process).
Enqueue an refunder job and then drain the default queue:
%{id: order.id, reason: "Doesn't matter"}
|> ChowMojo.Refunder.new()
|> Oban.insert!()
assert %{discard: 1, success: 0} = Oban.drain_queue(queue: :default)
To make the test pass, set max_attempts
in Refunder
:
use Oban.Worker, max_attempts: 1, unique: [period: :infinity, keys: [:id]]
ExUnit.start(auto_run: false)
defmodule ChowMojo.SingleRefundAttemptTest do
use ChowMojo.ObanCase
setup do
start_supervised!({Oban, repo: ChowMojo.Repo, testing: :manual})
:ok
end
test "failed refund attempts are discarded" do
order = insert(:order, :refund_failure)
# Your turn...
end
end
ExUnit.run()
The unique
option offers controls for which fields are checked (:worker
, :queue
, and :args
by default) and how long a job should be unique for (60 seconds by default). It also has :states
to control which job states are considered duplicate. By default it includes all states except for :cancelled
and :discarded
, which is typically appropriate but undesirable when jobs only have a single attempt.
Configure uniqueness for all states and augment the single refund attempt test above to verify that duplicate refunds can’t be inserted after a job’s discarded.
Use a hint
Insert a new a refund job after drain_queue
in the test above and refute anything is enqueued.
%{id: order.id, reason: "Still doesn't matter"}
|> ChowMojo.Refunder.new()
|> Oban.insert!()
refute_enqueued worker: ChowMojo.Refunder
Get all states with Oban.Job.states/0
:
use Oban.Worker,
max_attempts: 1,
unique: [period: :infinity, keys: [:id], states: Oban.Job.states()]
☠️ Extra Challenges
Manually retrying discarded jobs
Once jobs exhaust their possible attempts they’re marked as discarded
and won’t retry again. With only a single attempt, e.g. max_attempts: 1
, it’s possible that a job is discarded during a normal server shutdown and you still want to run it.
Use retry_job/1,2 or retry_all_jobs/2 to resuscitate a discarded job. What happens to the job’s max_attempts
and state
?
Debouncing with replace
Sometimes customers are hasty and click to refund before they’ve written a reason. To compensate for unpredictable customers, schedule refunds for 10 minutes after they’re requested and use replace to reschedule farther in the future.
The replace
option can only be used when building a job, not when defining the worker. That means you must pass it as an option to Refunder.new/2
, or override the Refunder.new/2
callback function to automatically inject replace
options.
Write a test to verify that the job’s scheduled_at
time changes.