towerops/vendor/oban_pro/lib/oban/pro/testing.ex

1334 lines
39 KiB
Elixir

defmodule Oban.Pro.Testing do
@moduledoc """
Advanced helpers for testing supervised Oban instances, workers, and making assertions about
enqueued jobs.
The `Oban.Pro.Testing` module is a drop-in replacement for `Oban.Testing`, with additional
functions tailored toward integration testing and Pro modules.
## Usage in Tests
The most convenient way to use `Oban.Pro.Testing` is to `use` the module:
use Oban.Pro.Testing, repo: MyApp.Repo
Other repo-specific configuration options can also be used:
use Oban.Pro.Testing, repo: MyApp.Repo, prefix: "private", log: :debug
If you already have `use Oban.Testing` in your tests or test cases, simply replace it with `use
Oban.Pro.Testing`.
## Naming Convention
The testing helpers in this module adhere to the following naming convention:
* `perform_*` — executes jobs locally, without touching the database, for unit testing.
* `drain_*` — execute jobs inline, for integration testing.
* `run_*` — insert jobs into the database and execute them inline, for integration testing.
## Shared Repo Options
The `use` macro accepts all of these repo-specific configuration options, and they may be passed
to all database functions (`run_`, `drain_`, etc.)
* `:log` — a usable log level or `false` to disable logging. See `t:Logger.level/0` for valid
options.
* `:prefix` — an optional database prefix. Defaults to `public`.
* `:repo` — the name of an Ecto repo, which should be running in sandbox mode.
"""
import Ecto.Query
import ExUnit.Assertions, only: [assert: 2, flunk: 1]
import ExUnit.Callbacks, only: [on_exit: 2, start_supervised!: 1]
alias Ecto.Adapters.SQL.Sandbox
alias Ecto.Changeset
alias Oban.{Config, Job, Repo, Worker}
alias Oban.Pro.{Batch, Utils, Workflow}
alias Oban.Pro.Chunk
alias Oban.Pro.Engines.Smart
alias Oban.Queue.Executor
@type repo_option :: {:log, false | Logger.level()} | {:prefix, String.t()} | {:repo, module()}
@type drain_option ::
repo_option()
| {:queue, atom()}
| {:with_limit, pos_integer()}
| {:with_recursion, boolean()}
| {:with_safety, boolean()}
| {:with_scheduled, boolean()}
| {:with_summary, boolean()}
@type drain_summary :: %{
cancelled: non_neg_integer(),
completed: non_neg_integer(),
discarded: non_neg_integer(),
exhausted: non_neg_integer(),
retryable: non_neg_integer(),
scheduled: non_neg_integer(),
suspended: non_neg_integer()
}
@type drain_result :: drain_summary() | [Job.t()]
@type perform_option :: Job.option() | repo_option()
@typedoc """
Batch callback identifiers, correlating to a `handle_` callback function.
"""
@type callback :: :attempted | :completed | :discarded | :exhausted
@doc false
defmacro __using__(repo_opts) do
if not Keyword.has_key?(repo_opts, :repo) do
raise ArgumentError, "testing requires a :repo option to be set"
end
quote do
def all_enqueued(opts \\ []) do
unquote(repo_opts)
|> Keyword.merge(opts)
|> Oban.Pro.Testing.all_enqueued()
end
def assert_enqueue(opts \\ [], fun) do
unquote(repo_opts)
|> Keyword.merge(opts)
|> Oban.Pro.Testing.assert_enqueue(fun)
end
def assert_enqueued(opts \\ [], timeout \\ :none) do
unquote(repo_opts)
|> Keyword.merge(opts)
|> Oban.Pro.Testing.assert_enqueued(timeout)
end
def refute_enqueue(opts \\ [], fun) do
unquote(repo_opts)
|> Keyword.merge(opts)
|> Oban.Pro.Testing.refute_enqueue(fun)
end
def refute_enqueued(opts \\ [], timeout \\ :none) do
unquote(repo_opts)
|> Keyword.merge(opts)
|> Oban.Pro.Testing.refute_enqueued(timeout)
end
def build_job(worker, args, opts \\ []) do
Oban.Pro.Testing.build_job(worker, args, Keyword.merge(unquote(repo_opts), opts))
end
def drain_jobs(opts \\ []) do
unquote(repo_opts)
|> Keyword.merge(opts)
|> Oban.Pro.Testing.drain_jobs()
end
def perform_job(job) when is_struct(job) do
Oban.Pro.Testing.perform_job(job, unquote(repo_opts))
end
def perform_job(job, opts) when is_struct(job) and is_list(opts) do
Oban.Pro.Testing.perform_job(job, Keyword.merge(unquote(repo_opts), opts))
end
def perform_job(worker, args, opts \\ []) do
Oban.Pro.Testing.perform_job(worker, args, Keyword.merge(unquote(repo_opts), opts))
end
def perform_callback(worker, callback, args, opts \\ []) do
opts = Keyword.merge(unquote(repo_opts), opts)
Oban.Pro.Testing.perform_callback(worker, callback, args, opts)
end
def perform_chunk(worker, args, opts \\ []) do
Oban.Pro.Testing.perform_chunk(worker, args, Keyword.merge(unquote(repo_opts), opts))
end
def run_batch(batch, opts \\ []) do
Oban.Pro.Testing.run_batch(batch, Keyword.merge(unquote(repo_opts), opts))
end
def run_chain([_ | _] = chain, opts \\ []) do
Oban.Pro.Testing.run_chain(chain, Keyword.merge(unquote(repo_opts), opts))
end
def run_chunk([_ | _] = chunk, opts \\ []) do
Oban.Pro.Testing.run_chunk(chunk, Keyword.merge(unquote(repo_opts), opts))
end
def run_jobs([_ | _] = changesets, opts \\ []) do
Oban.Pro.Testing.run_jobs(changesets, Keyword.merge(unquote(repo_opts), opts))
end
def run_workflow(%_{} = workflow, opts \\ []) do
Oban.Pro.Testing.run_workflow(workflow, Keyword.merge(unquote(repo_opts), opts))
end
def start_supervised_oban!(opts \\ []) do
unquote(repo_opts)
|> Keyword.merge(opts)
|> Oban.Pro.Testing.start_supervised_oban!()
end
end
end
@conf_keys []
|> Config.new()
|> Map.from_struct()
|> Map.keys()
@callbacks ~w(attempted cancelled completed discarded exhausted retryable)a
@default_supervised_opts [
engine: Oban.Pro.Engines.Smart,
notifier: Oban.Notifiers.Isolated,
peer: Oban.Peers.Isolated,
stage_interval: :infinity,
shutdown_grace_period: 250
]
@empty_summary %{
cancelled: 0,
completed: 0,
discarded: 0,
exhausted: 0,
retryable: 0,
scheduled: 0,
suspended: 0
}
@doc """
Construct a job from a worker, args, and options.
This is a wrapper around `Oban.Testing.build_job/3` that ensures compatibility with Pro worker
features by using the Smart engine.
The helper makes the following assertions:
* That the worker implements the `Oban.Worker` behaviour
* That the options provided build a valid job
This helper is used to build jobs for execution by `perform_job/2`.
## Options
See [shared options](#module-shared-repo-options) for additional repo-specific options.
## Examples
Build a job without args:
job = build_job(MyWorker, %{})
Build a job with stringified args:
assert %{args: %{"id" => 1}} = build_job(MyWorker, %{id: 1})
Build a job with custom options:
assert %{attempt: 5, priority: 9} = build_job(MyWorker, %{}, attempt: 5, priority: 9)
"""
@doc since: "1.6.8"
@spec build_job(Worker.t(), term(), [Job.option() | repo_option()]) :: Job.t()
def build_job(worker, args, opts) when is_atom(worker) and is_list(opts) do
{_conf_opts, job_opts} = Keyword.split(opts, @conf_keys)
Oban.Testing.build_job(worker, args, job_opts)
end
@doc """
Retrieve all currently enqueued jobs matching a set of criteria.
This is a wrapper around `Oban.Testing.all_enqueued/1`, see `Oban.Testing` for more details.
## Options
See [shared options](#module-shared-repo-options) for additional repo-specific options.
"""
@doc since: "0.11.0"
@spec all_enqueued(keyword()) :: [Job.t()]
def all_enqueued(opts) do
{repo, opts} = Keyword.pop!(opts, :repo)
Oban.Testing.all_enqueued(repo, opts)
end
@doc """
Assert that one or more jobs were enqueued during a function call.
Any pre-existing jobs are ignored for the assertion. If the assertion passes then the function's
return value is passed back.
## Options
See `assert_enqueued/2` for standard options, and [shared options](#module-shared-repo-options)
for additional repo-specific options.
## Examples
Assert that a `MyApp.Worker` job was added to the `default` queue:
assert_enqueue([queue: :default, worker: MyApp.Worker], fn ->
MyApp.do_some_business()
end)
Make an assertion about the return value:
result = assert_enqueue([worker: MyApp.Worker], &MyApp.more_business/0)
assert {:ok, _} = result
"""
@doc since: "1.1.0"
@spec assert_enqueue(keyword(), (-> return)) :: return when return: any()
def assert_enqueue(opts, fun) when is_function(fun, 0) do
opts = expand_decorated(opts)
{enqueued, returned} = diff_enqueued(opts, fun)
if Enum.any?(enqueued) do
returned
else
flunk("""
Expected a job matching:
#{inspect_opts(opts)}
to be enqueued.
""")
end
end
@doc """
Assert that a job with particular criteria is enqueued.
This is a wrapper around `Oban.Testing.assert_enqueued/2` with additions to support jobs built
with `Oban.Pro.Decorator` decorated functions.
Only values for the provided fields are checked. For example, an assertion made on `worker:
"MyWorker"` will match _any_ jobs for that worker, regardless of every other field.
## Options
The following options are supported in addition to all `t:Oban.Job` fields:
* `:decorated` — a function capture for the decorated function, e.g. `&MyApp.Foo.bar/2`. By
default, only the module and function name are matched. To match on `args`, a _full list_ of
arguments must be provided rather than the standard map.
See [shared options](#module-shared-repo-options) for additional repo-specific options.
## Examples
Assert that a job is enqueued for a certain worker and args:
assert_enqueued worker: MyWorker, args: %{id: 1}
Assert that a job is enqueued for a particular queue and priority:
assert_enqueued queue: :business, priority: 3
Assert that a job's args deeply match:
assert_enqueued args: %{config: %{enabled: true}}
Use the `:_` wildcard to assert that a job's meta has a key with any value:
assert_enqueued meta: %{batch_id: :_}
Assert on a decorated function with any args:
assert_enqueued decorated: &MyApp.notify/2
Assert on exact args used to enqueue a decorated function:
assert_enqueued args: ["example", 123], decorated: &MyApp.notify/2
"""
@doc since: "0.11.0"
@spec assert_enqueued(keyword(), timeout() | :none) :: true
def assert_enqueued(opts, timeout \\ :none) do
opts = expand_decorated(opts)
if timeout == :none do
Oban.Testing.assert_enqueued(opts)
else
Oban.Testing.assert_enqueued(opts, timeout)
end
end
@doc """
Refute that any jobs were enqueued during a function call.
Any pre-existing jobs are ignored for the refutation. If the refutation passes then the
function's return value is passed back.
## Options
See `refute_enqueued/2` for standard options, and [shared options](#module-shared-repo-options)
for additional repo-specific options.
## Examples
Refute that a `MyApp.Worker` job was added to the `default` queue:
refute_enqueue([queue: :default, worker: MyApp.Worker], fn ->
MyApp.do_some_business()
end)
Make a refutation about the return value:
result = refute_enqueue([worker: MyApp.Worker], &MyApp.more_business/0)
assert {:ok, _} = result
"""
@doc since: "1.1.0"
@spec refute_enqueue(keyword(), (-> return)) :: return when return: any()
def refute_enqueue(opts, fun) when is_function(fun, 0) do
opts = expand_decorated(opts)
{enqueued, returned} = diff_enqueued(opts, fun)
if Enum.empty?(enqueued) do
returned
else
flunk("""
Expected no jobs matching:
#{inspect_opts(opts)}
to be enqueued.
""")
end
end
@doc """
Refute that a job with particular criteria is enqueued.
This is a wrapper around `Oban.Testing.refute_enqueued/2` with additions to support jobs built
with `Oban.Pro.Decorator` decorated functions.
## Options
The following options are supported in addition to all `t:Oban.Job` fields:
* `:decorated` — a function capture for the decorated function, e.g. `&MyApp.Foo.bar/2`. By
default, only the module and function name are matched. To match on `args`, a _full list_ of
arguments must be provided rather than the standard map.
See [shared options](#module-shared-repo-options) for additional repo-specific options.
## Examples
Refute that a job is enqueued for a certain worker:
refute_enqueued worker: MyWorker
Refute that a job is enqueued for a certain worker and args:
refute_enqueued worker: MyWorker, args: %{id: 1}
Refute that a job's nested args match:
refute_enqueued args: %{config: %{enabled: false}}
Use the `:_` wildcard to refute that a job's meta has a key with any value:
refute_enqueued meta: %{batch_id: :_}
Refute a decorated function was enqueued with any args:
refute_enqueued decorated: &MyApp.notify/2
Refute that exact args were used to enqueue a decorated function:
refute_enqueued args: ["example", 123], decorated: &MyApp.notify/2
"""
@doc since: "0.11.0"
@spec refute_enqueued(keyword(), timeout() | :none) :: false
def refute_enqueued(opts, timeout \\ :none) do
opts = expand_decorated(opts)
if timeout == :none do
Oban.Testing.refute_enqueued(opts)
else
Oban.Testing.refute_enqueued(opts, timeout)
end
end
@doc """
Synchronously execute jobs in one or all queues, from within the current process.
Jobs that are enqueued by a process when `Ecto` is in sandbox mode are only visible to that
process. Calling `drain_jobs/1` allows you to control when the jobs are executed and to wait
synchronously for all jobs to complete.
This function provides several distinct advantages over the standard `Oban.drain_queue/2`:
* It can drain jobs across one or all queues simultaneously
* It can return the drained jobs rather than a count summary
* It optimizes the defaults for testing batches, workflows, etc.
* It always uses the `Smart` engine to guarantee that Pro worker features work as expected
## Options
* `:queue` - an atom specifying the queue to drain, or `:all` to drain jobs across all queues at
once. Defaults to `:all` when no queue is provided.
* `:with_limit` — the maximum number of jobs to fetch for draining at once. The limit only
impacts how many jobs are fetched at once, _not_ concurrency. When recursion is enabled this
is how many jobs are processed per-iteration, and it defaults to `1`. Otherwise, there isn't a
limit and all available jobs are fetched.
* `:with_recursion` — whether to draining jobs recursively, or all in a single pass. Either way,
jobs are processed sequentially, one at a time. Recursion is required when jobs insert other
jobs (e.g. batches), or depend on the execution of other jobs (e.g. workflows). Defaults to
`true`.
* `:with_safety` — whether to silently catch errors when draining. When `false`, raised
exceptions or unhandled exits are reraised (unhandled exits are wrapped in `Oban.CrashError`).
Defaults to `false`.
* `:with_scheduled` — whether to include scheduled or retryable jobs when draining. In recursive
mode, which is the default, this will include snoozed jobs, and may lead to an infinite loop if
the job snoozes repeatedly. Defaults to `true`.
* `:with_summary` — whether to summarize execution results with a map of counts by state, or
return a list of each job that was drained. Defaults to `true`, which returns a summary map.
See [shared options](#module-shared-repo-options) for additional repo-specific options.
## Examples
Drain all available jobs across all queues:
assert %{completed: 3} = drain_jobs(queue: :all)
Drain and return all executed jobs without a count summary:
assert [%Oban.Job{}, %Oban.Job{}] = drain_jobs(with_summary: false)
Drain including a job that you expect to raise:
assert_raise RuntimeError, fn -> drain_jobs(queue: :risky) end
Drain without recursion to identify snoozed jobs:
assert %{scheduled: 3} = drain_jobs(with_recursion: false)
Drain without staging scheduled jobs:
assert %{completed: 1, scheduled: 0} = drain_jobs(with_scheduled: false)
Drain within a custom prefix:
assert %{completed: 3} = drain_jobs(queue: :default, prefix: "private")
Drain using a specific repo (necessary when calling this function directly):
assert %{completed: 3} = drain_jobs(queue: :default, repo: MyApp.Repo)
"""
@doc since: "0.11.0"
@spec drain_jobs([drain_option()]) :: drain_result()
def drain_jobs(opts) when is_list(opts) do
conf = init_conf(opts)
opts = Keyword.drop(opts, @conf_keys)
with_limit = if Keyword.get(opts, :with_recursion, true), do: 1, else: 999_999
opts =
opts
|> Map.new()
|> Map.put_new(:queue, :all)
|> Map.update!(:queue, &if(&1 == :all, do: :__all__, else: &1))
|> Map.put_new(:with_limit, with_limit)
|> Map.put_new(:with_recursion, true)
|> Map.put_new(:with_safety, false)
|> Map.put_new(:with_scheduled, true)
|> Map.put_new(:with_summary, true)
Process.put(:oban_draining, true)
drain(conf, [], opts)
after
Process.delete(:oban_draining)
end
@doc """
Execute a job using the given config options.
This is a wrapper around `Oban.Testing.perform_job/2` that ensures the Smart engine is used for
compatibility with Pro worker features.
See `perform_job/3` for more details and examples.
## Options
See [shared options](#module-shared-repo-options) for additional repo-specific options.
## Examples
Execute a job without any options:
job = build_job(MyWorker, %{id: 1})
assert :ok = perform_job(job)
Execute a job with a custom prefix and repo:
assert :ok = perform_job(job, prefix: "private", repo: MyApp.Repo)
"""
@doc since: "1.6.8"
@spec perform_job(Job.t(), [repo_option()]) :: Worker.result()
def perform_job(%Job{} = job, opts) when is_list(opts) do
opts = Keyword.put_new(opts, :engine, Smart)
Oban.Testing.perform_job(job, opts)
end
@doc """
Construct a job and execute it with a worker module.
This is a wrapper around `Oban.Testing.perform_job/3` that ensures the Smart engine is used for
compatibility with Pro worker features.
## Options
See [shared options](#module-shared-repo-options) for additional repo-specific options.
## Examples
Successfully execute a job with some string arguments:
assert :ok = perform_job(MyWorker, %{"id" => 1})
Successfully execute a job and assert that it returns an error tuple:
assert {:error, _} = perform_job(MyWorker, %{"bad" => "arg"})
Execute a job with the args keys automatically stringified:
assert :ok = perform_job(MyWorker, %{id: 1})
"""
@doc since: "0.11.0"
@spec perform_job(Worker.t(), term(), [perform_option()]) :: Worker.result()
def perform_job(worker, args, opts) do
opts = Keyword.put_new(opts, :engine, Smart)
Oban.Testing.perform_job(worker, args, opts)
end
@doc """
Change the testing mode within the context of a function.
This is a direct delegation to `Oban.Testing.with_testing_mode/2`. Only `:manual` and `:inline`
mode are supported, as `:disabled` implies that supervised queues and plugins are running and
this function won't start any processes.
## Examples
Switch to `:manual` mode when an Oban instance is configured for `:inline` testing:
with_testing_mode(:manual, fn ->
Oban.insert(MyWorker.new(%{id: 123}))
assert_enqueued worker: MyWorker, args: %{id: 123}
end)
Visa-versa, switch to `:inline` mode:
with_testing_mode(:inline, fn ->
{:ok, %Job{state: "completed"}} = Oban.insert(MyWorker.new(%{id: 123}))
end)
"""
@doc since: "1.6.8"
@spec with_testing_mode(:inline | :manual, (-> any())) :: any()
defdelegate with_testing_mode(mode, fun), to: Oban.Testing
@doc """
Construct and execute a job with a batch `handle_*` callback.
This helper verifies that the batch worker exports the requested callback handler,
along with the standard assertions made by `perform_job/3`.
## Examples
Execute the `handle_attempted` callback without any args:
assert :ok = perform_callback(MyBatch, :attempted, %{})
Execute the `handle_exhausted` callback with args:
assert :ok = perform_callback(MyBatch, :exhausted, %{for_account_id: 123})
"""
@doc since: "0.11.0"
@spec perform_callback(Worker.t(), callback(), term(), [perform_option()]) :: Worker.result()
def perform_callback(worker, callback, args, opts) when is_list(opts) do
assert_valid_callback(worker, callback)
opts =
opts
|> Keyword.put_new(:meta, %{})
|> Keyword.update!(:meta, &Map.put_new(&1, "callback", to_string(callback)))
|> Keyword.update!(:meta, &Map.put_new(&1, "batch_id", Oban.Pro.UUIDv7.generate()))
perform_job(worker, args, opts)
end
@doc """
Construct a list of jobs and process them with a Chunk worker.
Like `perform_job/3`, this helper reduces boilerplate when constructing jobs and checks for
common pitfalls. Unlike `perform_job/3`, this helper calls the chunk's `process/1` function
directly and it **won't trigger telemetry events**.
## Examples
Successfully process a chunk of jobs:
assert :ok = perform_chunk(MyChunk, [%{id: 1}, %{id: 2}])
Process a chunk of jobs with job options:
assert :ok = perform_chunk(MyChunk, [%{id: 1}, %{id: 2}], attempt: 5, priority: 3)
"""
@doc since: "0.11.0"
@spec perform_chunk(Worker.t(), [term()], [perform_option()]) :: Chunk.result()
def perform_chunk(worker, args_list, opts) when is_list(args_list) and is_list(opts) do
{conf_opts, opts} = Keyword.split(opts, @conf_keys)
conf = Config.new(conf_opts)
opts =
opts
|> Keyword.merge(worker.__opts__())
|> Keyword.put_new(:attempt, 1)
chunk =
Enum.reduce_while(args_list, [], fn args, acc ->
now = DateTime.utc_now()
job =
args
|> worker.new(opts)
|> Changeset.update_change(:args, &json_encode_decode/1)
|> Changeset.put_change(:attempted_at, now)
|> Changeset.put_change(:scheduled_at, now)
|> Changeset.apply_action!(:insert)
|> Map.replace!(:conf, conf)
case Oban.Pro.Worker.before_process(worker, job, opts) do
{:ok, job} ->
{:cont, [job | acc]}
{:error, error} ->
{:halt, {:error, error}}
end
end)
if is_list(chunk) do
chunk
|> Enum.reverse()
|> worker.process()
|> tap(&assert_valid_chunk_result/1)
else
chunk
end
end
@doc """
Insert and execute a complete batch of jobs, along with callbacks, within the test process.
## Options
Accepts all options for `drain_jobs/1`, including the repo-specific options listed in
[shared-options](#module-shared-repo-options).
## Examples
Run a batch:
ids
|> Enum.map(&MyBatch.new(%{id: &1}))
|> MyBatch.new_batch()
|> run_batch()
Run a batch with a specific repo (necessary when calling this function directly):
run_batch(my_batch, repo: MyApp.Repo, prefix: "private")
"""
@doc since: "0.11.0"
@spec run_batch(Batch.t(), [drain_option()]) :: drain_result()
def run_batch(batch, opts) when is_list(opts) do
opts = Keyword.put(opts, :batch_ids, [batch.opts.batch_id])
batch.changesets
|> Enum.to_list()
|> run_jobs(opts)
end
@doc """
Insert and execute chained jobs within the test process.
## Options
Accepts all options for `drain_jobs/1`, including the repo-specific options listed in
[shared-options](#module-shared-repo-options).
## Examples
Run all jobs in a chain:
1..10
|> Enum.map(&MyChain.new(%{id: &1}))
|> run_chain()
"""
@doc since: "1.1.0"
@doc deprecated: "Use run_jobs/2 instead"
def run_chain(chain, opts) when is_list(opts) do
chain_keys = ~w(hold_snooze on_cancelled on_discarded wait_retry wait_sleep wait_snooze)a
chain_opts = opts |> Keyword.take(chain_keys) |> Map.new()
chain
|> Enum.map(fn changeset ->
Changeset.update_change(changeset, :meta, &Map.merge(&1, chain_opts))
end)
|> run_jobs(opts)
end
@doc """
Insert and execute chunked jobs within the test process.
This helper overrides the chunk's `timeout` to force immediate processing of jobs up to the
chunk size.
## Options
Accepts all options for `drain_jobs/1`, including the repo-specific options listed in
[shared-options](#module-shared-repo-options).
## Examples
Run jobs in chunks:
1..50
|> Enum.map(&MyChunk.new(%{id: &1}))
|> run_chunk()
Run a chunk with an overriden size:
1..10
|> Enum.map(&MyChunk.new(%{id: &1}))
|> run_chunk(size: 10)
Run chunks with an explicit repo (necessary when calling this function directly):
run_chunk(changesets, repo: MyApp.Repo, prefix: "private")
Run chunks only for a specific queue:
run_chunk(changesets, queue: "default")
"""
@doc since: "0.11.0"
@spec run_chunk([Job.changeset()], [drain_option()]) :: drain_result()
def run_chunk([_ | _] = chunk, opts) when is_list(opts) do
chunk_opts =
opts
|> Keyword.take(~w(by leading size)a)
|> Map.new(fn
{:by, by} -> {:chunk_by, Utils.normalize_by(by)}
{:leading, leading} -> {:chunk_leading, leading}
{:size, size} -> {:chunk_size, size}
end)
merge = fn meta -> Map.merge(meta, chunk_opts) end
chunk
|> Enum.map(fn change -> Changeset.update_change(change, :meta, merge) end)
|> run_jobs(Keyword.put_new(opts, :with_limit, 1))
end
@doc """
Insert and execute jobs synchronously, within the test process.
This is the basis of all other `run_*` helpers.
## Options
Accepts all options for `drain_jobs/1`, including the repo-specific options listed in
[shared-options](#module-shared-repo-options).
## Examples
Run a list of jobs:
ids
|> Enum.map(&MyWorker.new(%{id: &1}))
|> run_jobs()
Run jobs with an explicit repo (necessary when calling this function directly):
run_jobs(changesets, repo: MyApp.Repo)
"""
@doc since: "0.11.0"
@spec run_jobs([Job.changeset()], [drain_option()]) :: drain_result()
def run_jobs([_ | _] = changesets, opts) when is_list(changesets) and is_list(opts) do
conf = init_conf(opts)
Oban.Engine.insert_all_jobs(conf, changesets, [])
opts
|> Keyword.put_new(:queue, :all)
|> drain_jobs()
end
@doc """
Insert and execute a workflow synchronously, within the test process.
This helper augments the workflow with options optimized for testing, but it will still respect
all standard workflow options.
## Options
Accepts all options for `drain_jobs/1`, including the repo-specific options listed in
[shared-options](#module-shared-repo-options).
## Examples
Run a basic workflow:
MyFlow.new_workflow()
|> MyFlow.add(:a, MyFlow.new(%{id: 1}))
|> MyFlow.add(:b, MyFlow.new(%{id: 2}), deps: [:a])
|> MyFlow.add(:c, MyFlow.new(%{id: 3}), deps: [:b])
|> run_workflow()
Run a workflow and match on returned jobs, but be careful that the execution order may differ
from the insertion order:
workflow =
MyFlow.new_workflow()
|> MyFlow.add(:a, MyFlow.new(%{id: 1}))
|> MyFlow.add(:b, MyFlow.new(%{id: 2}), deps: [:a])
|> MyFlow.add(:c, MyFlow.new(%{id: 3}), deps: [:b])
[_job_a, _job_b, _job_c] = run_workflow(workflow, with_summary: false)
Run a workflow with increased parallelism to prevent slow or deadlocked workflows:
run_workflow(my_expansive_workflow, with_limit: 5)
Run a workflow with an explicit repo (necessary when calling this function directly):
run_workflow(workflow, repo: MyApp.Repo)
"""
@doc since: "0.11.0"
@spec run_workflow(Workflow.t(), Workflow.new_opts() | drain_option()) :: drain_result()
def run_workflow(%Workflow{} = workflow, opts \\ []) when is_list(opts) do
conf = init_conf(opts)
Oban.Engine.insert_all_jobs(conf, workflow.changesets, [])
workflow_ids =
workflow.changesets
|> Enum.map(& &1.changes.meta.workflow_id)
|> Enum.uniq()
opts
|> Keyword.put(:workflow_ids, workflow_ids)
|> drain_jobs()
# The workflow facilitator won't run "held" jobs, so we have to reload everything that was
# inserted to get all jobs and their real fields. Additionally, the workflow ids may be added
# while the workflow is executing so they must be refreshed afterwards.
workflow_ids = all_workflow_ids(conf, workflow_ids)
jobs =
Job
|> where([j], j.meta["workflow_id"] in ^workflow_ids)
|> order_by([j], asc_nulls_last: j.completed_at, asc: j.id)
|> then(&Repo.all(conf, &1))
if Keyword.get(opts, :with_summary, true) do
Enum.reduce(jobs, @empty_summary, fn job, acc ->
state =
if job.state == "discarded" and job.attempt >= job.max_attempts do
:exhausted
else
String.to_existing_atom(job.state)
end
Map.update(acc, state, 1, &(&1 + 1))
end)
else
jobs
end
end
@doc """
Start a supervised Oban instance under the test supervisor.
All valid Oban options are accepted. The supervised instance is registered with a unique
reference, rather than the default `Oban`. That prevents any conflict with Oban instances
started by your Application, or with other tests running asynchronously.
Furthermore, this helper automatically adds sandbox allowances for any plugins or queue
producers, allowing tests to run async.
After the test finishes the test process will wait for the Oban instance to shut down cleanly.
## Running Jobs
By default, the supervised instance won't process any jobs because the `stage_interval` is set
to `:infinity`. Set the `stage_interval` to a low value to process jobs normally, without manual
draining.
## Options
Any option accepted by `Oban.start_link/1` is acceptable, including the repo-specific options
listed in [shared options](#module-shared-repo-options).
## Examples
Start a basic supervised instance without any queues or plugins:
name = start_supervised_oban!(repo: MyApp.Repo)
Oban.insert(name, MyWorker.new())
Start the supervisor with a single queue and polling every 10ms:
start_supervised_oban!(repo: MyApp.Repo, stage_interval: 10, queues: [alpha: 10])
"""
@doc since: "0.11.0"
@spec start_supervised_oban!([Oban.option()]) :: Registry.key()
def start_supervised_oban!(opts) when is_list(opts) do
opts = conf_opts(opts)
name = Keyword.fetch!(opts, :name)
repo = Keyword.fetch!(opts, :repo)
attach_auto_allow(repo, name)
start_supervised!({Oban, opts})
name
|> Oban.config()
|> ensure_queues_started()
name
end
defp json_encode_decode(map) do
map
|> Oban.JSON.encode!()
|> Oban.JSON.decode!()
end
# Enqueued Helpers
defp diff_enqueued(opts, fun) do
existing = all_enqueued(opts)
returned = fun.()
inserted = all_enqueued(opts)
existing_ids = Enum.map(existing, & &1.id)
difference = Enum.reject(inserted, &(&1.id in existing_ids))
{difference, returned}
end
defp inspect_opts(opts) do
opts
|> Keyword.drop([:log, :prefix, :repo])
|> inspect(charlists: :as_lists, pretty: true)
end
defp expand_decorated(opts) do
case Keyword.fetch(opts, :decorated) do
{:ok, fun} when is_function(fun) ->
info = Function.info(fun)
args = %{mod: inspect(info[:module]), fun: info[:name], arg: args_to_term(info, opts)}
opts
|> Keyword.delete(:decorated)
|> Keyword.merge(args: args, worker: Oban.Pro.Decorator)
{:ok, term} ->
raise ArgumentError,
"expected :decorated option to be a function capture, got: #{inspect(term)}"
:error ->
opts
end
end
defp args_to_term(info, opts) do
arity = info[:arity]
case Keyword.fetch(opts, :args) do
{:ok, list} when is_list(list) and length(list) == arity ->
"term-" <> Utils.encode64(list)
{:ok, list} when is_list(list) ->
raise ArgumentError, "expected :args list to have arity #{arity}, got: #{length(list)}"
{:ok, term} ->
raise ArgumentError, "expected :args option to be a list, got: #{inspect(term)}"
:error ->
:_
end
end
# Callback Helpers
defp assert_valid_callback(worker, callback) do
assert is_atom(callback) and callback in @callbacks, """
Expected callback to be included in #{inspect(@callbacks)}, got: #{inspect(callback)}
"""
assert Code.ensure_loaded?(worker), """
Expected worker to be an existing module, got: #{inspect(worker)}
"""
new_fun = Map.fetch!(Batch.callbacks_to_functions(), to_string(callback))
old_fun = Map.fetch!(Batch.callbacks_to_deprecated(), to_string(callback))
assert function_exported?(worker, new_fun, 1) or function_exported?(worker, old_fun, 1), """
Expected #{inspect(new_fun)} callback to be implemented
"""
end
# Startup Helpers
defp attach_auto_allow(repo, name) do
telemetry_name = "oban-auto-allow-#{inspect(name)}"
:telemetry.attach_many(
telemetry_name,
[[:oban, :engine, :init, :start], [:oban, :plugin, :init]],
&__MODULE__.auto_allow/4,
{name, repo, self()}
)
on_exit(name, fn -> :telemetry.detach(telemetry_name) end)
end
@doc false
def auto_allow(_event, _measure, %{conf: conf}, {name, repo, test_pid}) do
if conf.name == name, do: Sandbox.allow(repo, test_pid, self())
end
defp ensure_queues_started(%{name: name, queues: queues}) do
with [_ | _] <- queues do
queues
|> Keyword.keys()
|> Enum.each(&Oban.check_queue(name, queue: &1))
end
end
# Draining Helpers
defp conf_opts(opts) do
conf_opts = Keyword.take(opts, @conf_keys)
@default_supervised_opts
|> Keyword.merge(conf_opts)
|> Keyword.put_new(:name, make_ref())
end
defp init_conf(opts) do
opts
|> conf_opts()
|> Config.new()
|> tap(&Registry.register(Oban.Registry, &1.name, &1))
end
defp drain(conf, acc, opts) do
if opts.with_scheduled, do: stage_scheduled(conf)
case Smart.fetch_for_drain(conf, opts) do
[_ | _] = jobs ->
executed =
jobs
|> streamline_pro_workers()
|> Enum.map(fn job ->
conf
|> Executor.new(job, safe: opts.with_safety)
|> Executor.call()
end)
if opts.with_recursion do
drain(conf, acc ++ executed, with_workflow_ids(conf, opts))
else
complete_drain(conf, executed, opts)
end
[] ->
case opts do
%{workflow_ids: [_ | _], flushed: true} ->
complete_drain(conf, acc, opts)
%{workflow_ids: [_ | _] = workflow_ids} ->
# Final flush to release held jobs whose deps became satisfied during processing.
Enum.each(workflow_ids, &Workflow.on_flush(&1, conf))
drain(conf, acc, Map.put(opts, :flushed, true))
_ ->
complete_drain(conf, acc, opts)
end
end
end
defp stage_scheduled(conf) do
query = where(Job, [j], j.state in ["scheduled", "retryable"])
Repo.update_all(conf, query, set: [state: "available"])
end
defp streamline_pro_workers(jobs) do
Enum.map(jobs, fn %{meta: meta} = job ->
case meta do
%{"chain_key" => _} ->
meta =
meta
|> Map.put_new("wait_retry", 1)
|> Map.put_new("wait_sleep", 1)
|> Map.put_new("wait_snooze", 1)
%{job | meta: meta}
%{"chunk_size" => _} ->
meta =
meta
|> Map.put("chunk_sleep", 1)
|> Map.put("chunk_timeout", 0)
%{job | meta: meta}
_ ->
job
end
end)
end
defp with_workflow_ids(conf, opts) do
case opts do
%{workflow_ids: [_ | _]} ->
Map.put(opts, :workflow_ids, all_workflow_ids(conf, opts.workflow_ids))
_ ->
opts
end
end
defp all_workflow_ids(conf, [_ | _] = workflow_ids) do
all_workflow_ids(conf, workflow_ids, MapSet.new(workflow_ids))
end
defp all_workflow_ids(_conf, ids), do: ids
defp all_workflow_ids(conf, search_ids, seen) do
graft_query =
Job
|> select([j], j.meta["graft_id"])
|> where([j], j.meta["workflow_id"] in ^search_ids)
|> where([j], not is_nil(j.meta["graft_id"]))
|> distinct(true)
graft_ids = Repo.all(conf, graft_query)
all_search_ids = search_ids ++ graft_ids
query =
Job
|> select([j], j.meta["workflow_id"])
|> where([j], j.meta["sup_workflow_id"] in ^all_search_ids)
|> distinct(true)
new_ids =
conf
|> Repo.all(query)
|> Enum.reject(&MapSet.member?(seen, &1))
case new_ids do
[] -> MapSet.to_list(seen)
ids -> all_workflow_ids(conf, ids, MapSet.union(seen, MapSet.new(ids)))
end
end
defp complete_drain(_conf, executed, %{with_summary: true}) do
Enum.reduce(executed, @empty_summary, fn exec, acc ->
state =
case exec.state do
:cancelled -> :cancelled
:discard -> :discarded
:exhausted -> :exhausted
:failure -> :retryable
:snoozed -> :scheduled
:success -> :completed
end
Map.update(acc, state, 1, &(&1 + 1))
end)
end
defp complete_drain(conf, executed, _opts) do
ids =
executed
|> Enum.map(& &1.job.id)
|> Enum.uniq()
jobs =
conf
|> Repo.all(where(Job, [j], j.id in ^ids))
|> Map.new(fn job -> {job.id, job} end)
Enum.map(ids, &Map.fetch!(jobs, &1))
end
# Chunk Helpers
@ops ~w(cancel discard error)a
defp assert_valid_chunk_result(result) do
valid? =
case result do
:ok ->
true
{:ok, _value} ->
true
{ops, _reason, [_ | _]} when ops in @ops ->
true
[{_op, {_reason, _jobs}} | _] = list ->
Keyword.keyword?(list) and
Enum.all?(list, &match?({_key, {_reason, [_ | _]}}, &1))
_ ->
false
end
assert valid?, """
Expected result to be one of
- `:ok`
- `{:ok, value}`
- `{:cancel, reason, jobs}`
- `{:discard, reason, jobs}`
- `{:error, reason, jobs}`
- `[cancel: {reason, jobs}, discard: {reason, jobs}, error: {reason, jobs}]`
Instead received:
#{inspect(result, pretty: true)}
"""
end
end