defmodule Oban.Pro.Batch do @moduledoc """ Batches link the execution of many jobs as a group and runs optional callbacks after jobs are processed. This allows your application to coordinate the execution of any number of jobs in parallel. ## Usage Batches are composed of one or more Pro workers that are linked with a shared `batch_id` and optional callbacks. As a simple example, let's define a worker that delivers daily emails: ```elixir defmodule MyApp.EmailBatch do use Oban.Pro.Worker, queue: :mailers @behaviour Oban.Pro.Batch @impl Oban.Pro.Worker def process(%Job{args: %{"email" => email}}) do MyApp.Mailer.daily_update(email) end @impl Oban.Pro.Batch def batch_completed(_job) do Logger.info("BATCH COMPLETE") :ok end end ``` Now, create a batch with `new/1` by passing a list of job changesets: ```elixir emails |> Enum.map(&MyApp.EmailBatch.new(%{email: &1})) |> Batch.new() |> Oban.insert_all() ``` After all jobs in the batch are `completed`, the `batch_completed/1` callback will be triggered. ## Handler Callbacks After jobs in the batch are processed, a callback job may be inserted. There are four optional batch handler callbacks that a worker may define: | callback | enqueued after | | --------------------- | -------------------------------------------------------- | | `c:batch_attempted/1` | _all_ jobs `executed` at least once | | `c:batch_cancelled/1` | the _first_ job is `cancelled` | | `c:batch_completed/1` | _all_ jobs in the batch are `completed` | | `c:batch_discarded/1` | the _first_ job is `discarded` | | `c:batch_exhausted/1` | _all_ jobs are `completed`, `cancelled`, or `discarded` | | `c:batch_retryable/1` | the _first_ job is `retryable` | Each callback runs in a separate, isolated job, so it may be retried or discarded like any other job. The callback function receives an `Oban.Job` struct with the `batch_id` in `meta` and should return `:ok` (or another valid `Worker.result()`). Here we'll implement each of the optional handler callbacks and have them print out the batch status along with the `batch_id`: ```elixir defmodule MyApp.BatchWorker do use Oban.Pro.Worker @behaviour Oban.Pro.Batch @impl Oban.Pro.Worker def process(_job), do: :ok @impl Oban.Pro.Batch def batch_attempted(%Job{meta: %{"batch_id" => batch_id}}) do IO.inspect({:attempted, batch_id}) :ok end @impl Oban.Pro.Batch def batch_cancelled(%Job{meta: %{"batch_id" => batch_id}}) do IO.inspect({:cancelled, batch_id}) :ok end @impl Oban.Pro.Batch def batch_completed(%Job{meta: %{"batch_id" => batch_id}}) do IO.inspect({:completed, batch_id}) :ok end @impl Oban.Pro.Batch def batch_discarded(%Job{meta: %{"batch_id" => batch_id}}) do IO.inspect({:discarded, batch_id}) :ok end @impl Oban.Pro.Batch def batch_exhausted(%Job{meta: %{"batch_id" => batch_id}}) do IO.inspect({:exhausted, batch_id}) :ok end @impl Oban.Pro.Batch def batch_retryable(%Job{meta: %{"batch_id" => batch_id}}) do IO.inspect({:retryable, batch_id}) :ok end end ``` ## Hybrid Batches Batches may also be built from a variety of workers, though you must provide an explicit worker for callbacks: ```elixir mail_jobs = Enum.map(mail_args, &MyApp.MailWorker.new/1) push_jobs = Enum.map(push_args, &MyApp.PushWorker.new/1) [callback_worker: MyApp.CallbackWorker] |> Batch.new() |> Batch.add(mail_jobs) |> Batch.add(push_jobs) ``` Without an explicit `callback_worker`, any worker in the batch may be used for callbacks. That makes callback handling unpredictable and unexpected. ## Customizing Batches Batches accept a variety of options for grouping and callback customization. ### Batch ID By default a `batch_id` is generated as a time-ordered random [UUIDv7][uuid]. UUIDs are sufficient to ensure uniqueness between workers and nodes for any time period. However, if you require control, you can override `batch_id` generation at the worker level or pass a value directly to the `new/2` function. ```elixir Batch.new(batch_id: "custom-batch-id") ``` ### Batch Name Batches accept an optional name to describe the purpose of the batch, beyond the generated id or individual jobs in it. While the `batch_id` must be unique, the `batch_name` doesn't have to be, so it can be used as a general purpose label. ```elixir Batch.new(batch_name: "nightly-etl") ``` ### Callback Workers For some batches, notably those with heterogeneous jobs, it's necessary to specify a different worker for callbacks. That is easily accomplished by passing the `:callback_worker` option to `new/2`: ```elixir Batch.new(callback_worker: MyCallbackWorker) ``` The callback worker **must** be an `Oban.Pro.Worker` that defines one or more of the batch callback handlers. ### Callback Options By default, callback jobs have an empty `args` map and inherit other options from batch jobs. With `callback_opts`, you can set standard job options for batch callback jobs (only `args`, `max_attempts`, `meta`, `priority`, `queue`, and `tags` are allowed). For example, here we're passing a webhook URLs as `args`: ```elixir Batch.new(callback_opts: [args: %{webhook: "https://web.hook"}]) ``` Here, we change the callback queue and knock the priority down: ```elixir Batch.new(callback_opts: [queue: :callbacks, priority: 9]) ``` Be careful to minimize `callback_opts` as they are stored in each batch job's meta. ## Fetching Batch Jobs To pull more context from batch jobs, it's possible to load all jobs from the batch with `all_jobs/2` and `stream_jobs/2`. The functions take a single batch job and returns a list or stream of all non-callback jobs in the batch, which you can then operate on with `Enum` or `Stream` functions. As an example, imagine you have a batch that ran for a few thousand accounts and you'd like to notify admins that the batch is complete. ```elixir defmodule MyApp.BatchWorker do use Oban.Pro.Worker @behaviour Oban.Pro.Batch @impl Oban.Pro.Batch def batch_completed(%Job{} = job) do {:ok, account_ids} = job |> Oban.Pro.Batch.all_jobs() |> Enum.map(& &1.args["account_id"]) account_ids |> MyApp.Accounts.all() |> MyApp.Mailer.notify_admins_about_batch() end ``` Fetching a thousand jobs may be alright, but for larger batches you don't want to load that much data into memory. Instead, you can use `stream_jobs` to iterate through them lazily: ```elixir {:ok, account_ids} = MyApp.Repo.transaction(fn -> job |> Batch.stream_jobs() |> Stream.map(& &1.args["account_id"]) |> Enum.to_list() end) ``` Streaming is provided by Ecto's `Repo.stream`, and it must take place within a transaction. While it may be overkill for small batches, for batches with tens or hundreds of thousands of jobs, it will prevent massive memory spikes or the database grinding to a halt. [uuid]: https://datatracker.ietf.org/doc/html/draft-peabody-dispatch-new-uuid-format-04#section-5.2 """ @behaviour Oban.Pro.Flusher @behaviour Oban.Pro.Handler import Ecto.Query, only: [limit: 2, order_by: 2, select: 3, union_all: 2, where: 3] alias Ecto.Changeset alias Oban.{Job, Repo, Validation, Worker} alias Oban.Pro.Engines.Smart alias Oban.Pro.Workflow require Logger @type changeset :: Job.changeset() @type callback_opts :: [ args: Job.args(), max_attempts: pos_integer(), meta: map(), priority: 0..9, queue: atom() | String.t(), tags: Job.tags() ] @type batch_opts :: [ batch_id: String.t(), batch_name: String.t(), callback_opts: callback_opts(), callback_worker: module() ] @type repo_opts :: [timeout: timeout()] @type t :: %__MODULE__{changesets: Enumerable.t(changeset), opts: map()} @callbacks_to_functions %{ "attempted" => :batch_attempted, "cancelled" => :batch_cancelled, "completed" => :batch_completed, "discarded" => :batch_discarded, "exhausted" => :batch_exhausted, "retryable" => :batch_retryable } @callbacks_to_deprecated %{ "attempted" => :handle_attempted, "cancelled" => :handle_cancelled, "completed" => :handle_completed, "discarded" => :handle_discarded, "exhausted" => :handle_exhausted, "retryable" => :handle_retryable } @callbacks_to_combined Map.merge( @callbacks_to_functions, @callbacks_to_deprecated, fn _k, new, old -> [new, old] end ) @callbacks_to_states %{ "attempted" => ~w(scheduled available executing), "completed" => ~w(scheduled available executing retryable cancelled discarded), "cancelled" => ~w(cancelled), "discarded" => ~w(discarded), "exhausted" => ~w(scheduled retryable available executing), "retryable" => ~w(retryable) } @all_states Enum.map(Job.states(), &to_string/1) @doc """ Called after all jobs in the batch were attempted at least once. If a `batch_attempted/1` function is defined it is executed by an isolated callback job. ## Example Print when all jobs were attempted: def batch_attempted(%Job{meta: %{"batch_id" => batch_id}}) do IO.inspect(batch_id, label: "Attempted") end """ @callback batch_attempted(job :: Job.t()) :: Worker.result() @doc """ Called after any jobs in the batch have a `cancelled` state. If a `batch_cancelled/1` function is defined it is executed by an isolated callback job. ## Example Print when any jobs are discarded: def batch_cancelled(%Job{meta: %{"batch_id" => batch_id}}) do IO.inspect(batch_id, label: "Cancelled") end """ @callback batch_cancelled(job :: Job.t()) :: Worker.result() @doc """ Called after all jobs in the batch have a `completed` state. If a `batch_completed/1` function is defined it is executed by an isolated callback job. ## Example Print when all jobs are completed: def batch_completed(%Job{meta: %{"batch_id" => batch_id}}) do IO.inspect(batch_id, label: "Completed") end """ @callback batch_completed(job :: Job.t()) :: Worker.result() @doc """ Called after any jobs in the batch have a `discarded` state. If a `batch_discarded/1` function is defined it is executed by an isolated callback job. ## Example Print when any jobs are discarded: def batch_discarded(%Job{meta: %{"batch_id" => batch_id}}) do IO.inspect(batch_id, label: "Discarded") end """ @callback batch_discarded(job :: Job.t()) :: Worker.result() @doc """ Called after all jobs in the batch have either a `cancelled`, `completed` or `discarded` state. If a `batch_exhausted/1` function is defined it is executed by an isolated callback job. ## Example Print when all jobs are completed or discarded: def batch_exhausted(%Job{meta: %{"batch_id" => batch_id}}) do IO.inspect(batch_id, label: "Exhausted") end """ @callback batch_exhausted(job :: Job.t()) :: Worker.result() @doc """ Called after any jobs in the batch have a `retryable` state. If a `batch_retryable/1` function is defined it is executed by an isolated callback job. ## Example Print when any jobs are retryable: def batch_retryable(%Job{meta: %{"batch_id" => batch_id}}) do IO.inspect(batch_id, label: "Retryable") end """ @callback batch_retryable(job :: Job.t()) :: Worker.result() @optional_callbacks batch_attempted: 1, batch_cancelled: 1, batch_completed: 1, batch_discarded: 1, batch_exhausted: 1, batch_retryable: 1 defstruct changesets: [], opts: [] defguardp is_list_or_stream(enum) when is_list(enum) or is_struct(enum, Stream) # Handler Callbacks @impl Oban.Pro.Handler def on_start do events = [ [:oban, :engine, :cancel_all_jobs, :stop] ] :telemetry.attach_many("oban.batch", events, &__MODULE__.handle_event/4, nil) end @impl Oban.Pro.Handler def on_stop do :telemetry.detach("oban.batch") end @doc false def handle_event(_event, _timing, %{conf: conf, jobs: jobs}, _) do for %{meta: %{"batch_id" => batch_id}} = job <- jobs do on_flush(job, batch_id, conf) end end # Constants @doc false def callbacks_to_functions, do: @callbacks_to_functions @doc false def callbacks_to_deprecated, do: @callbacks_to_deprecated # Public Functions @doc false def new, do: new([], []) @doc false def new([%Changeset{} | _] = changesets), do: new(changesets, []) def new(%Stream{} = changesets), do: new(changesets, []) def new(opts) when is_list(opts), do: new([], opts) @doc """ Build a new batch from a list or stream of job changesets and some options. ## Examples Build an empty batch without any jobs or options: Batch.new() Build a batch from a list of changesets: 1..3 |> Enum.map(fn id -> MyWorker.new(%{id: id}) end) |> Batch.new() Build a batch from a stream: stream_of_args |> Stream.map(&MyWorker.new/1) |> Batch.new() Build a batch with callback options: Batch.new(list_of_jobs, batch_id: "abc-123", callback_worker: MyApp.CallbackWorker) """ @spec new(Enumerable.t(changeset()), batch_opts()) :: t() def new(changesets, opts) when is_list_or_stream(changesets) and is_list(opts) do validate!(opts) opts = opts |> Keyword.put(:batch, true) |> Keyword.put_new(:callback_opts, []) |> Keyword.update!(:callback_opts, &Map.new/1) |> Keyword.put_new_lazy(:batch_id, &Oban.Pro.UUIDv7.generate/0) |> Map.new() changesets = Stream.map(changesets, &put_meta(&1, opts)) %__MODULE__{changesets: changesets, opts: opts} end defp put_meta(changeset, opts) do meta = changeset |> Changeset.get_change(:meta, %{}) |> Map.merge(opts) Changeset.put_change(changeset, :meta, meta) end defp validate!(opts) do Validation.validate_schema!(opts, batch_id: :string, batch_name: :string, callback_opts: :list, callback_worker: :module ) end @doc """ Add one or more jobs to a batch. ## Examples Add jobs to an existing batche: Batch.add(batch, Enum.map(args, &MyWorker.new/1)) Add jobs to a batch one at a time: Batch.new() |> Batch.add(MyWorker.new(%{})) |> Batch.add(MyWorker.new(%{})) |> Batch.add(MyWorker.new(%{})) """ @spec add(t(), Enumerable.t(changeset())) :: t() def add(batch, %Changeset{} = changeset), do: add(batch, [changeset]) def add(%__MODULE__{} = batch, changesets) when is_list_or_stream(changesets) do changesets = Stream.map(changesets, &put_meta(&1, batch.opts)) %{batch | changesets: Stream.concat(batch.changesets, changesets)} end @doc """ Append to a batch from an existing batch job. The `batch_id` and any other batch options are propagated to the newly created batch. > #### Appending and Callbacks {: .warning} > > Batch callback jobs are _only inserted once_. Appending to a batch where callbacks were already > triggered, e.g. `batch_completed`, won't re-trigger the callback. ## Examples Build an empty batch from an existing batch job: job |> Batch.append() |> Batch.add(MyWorker.new(%{})) |> Batch.add(MyWorker.new(%{})) |> Oban.insert_all() Build a batch from an existing job with overriden options: Batch.append(job, callback_worker: MyApp.OtherWorker) """ @spec append(Job.t(), batch_opts()) :: t() def append(%Job{meta: %{"batch_id" => _} = meta}, opts \\ []) when is_list(opts) do orig_opts = for {key, val} <- meta, key in ~w(batch_id batch_name callback_opts callback_worker), do: {String.to_existing_atom(key), val} orig_opts |> Keyword.merge(opts) |> new() end @doc """ Get all non-callback jobs from a batch. ## Examples Fetch results from all jobs from within a `batch_completed/1` callback: def batch_completed(%Job{} = job) do results = job |> Batch.all_jobs() |> Enum.map(&fetch_recorded/1) ... end Get all batch jobs from anywhere using the `batch_id`: Batch.all_jobs("some-uuid-1234-5678") Get all jobs from anywhere with a custom Oban instance name: Batch.all_jobs(MyApp.Oban, "some-uuid-1234-5678") """ @spec all_jobs(Oban.name(), Job.t() | String.t(), [repo_opts()]) :: [Job.t()] def all_jobs(name \\ Oban, job_or_batch_id, opts \\ []) def all_jobs(_name, %Job{conf: conf, meta: %{"batch_id" => batch_id}}, opts) do Repo.all(conf, batch_query(batch_id), opts) end def all_jobs(name, batch_id, opts) when is_binary(batch_id) do name |> Oban.config() |> Repo.all(batch_query(batch_id), opts) end def all_jobs(%Job{} = job, opts, []), do: all_jobs(Oban, job, opts) @doc """ Cancel all non-callback jobs in a batch. Cancelling jobs via this function bulk-updates their state in the database without loading them into memory or resolving their workers. As a result, **worker `after_process/3` hooks are not called** for cancelled jobs. If you need to execute cleanup logic or other side effects when jobs are cancelled, consider using batch callbacks like `batch_cancelled/1` or implement cleanup logic at the application level. ## Examples Cancel jobs with the `job` in a `process/1` function: def process(job) do if should_stop_processing?(job.args) do Batch.cancel_jobs(job) else ... end end Cancel jobs from anywhere using the `batch_id`: Batch.cancel_jobs("some-uuid-1234-5678") Cancel jobs from anywhere with a custom Oban instance name: Batch.cancel_jobs(MyApp.Oban, "some-uuid-1234-5678") """ @spec cancel_jobs(Oban.name(), Job.t() | String.t()) :: {:ok, non_neg_integer()} def cancel_jobs(oban_name \\ Oban, job_or_batch_id) def cancel_jobs(_oban_name, %Job{conf: conf, meta: %{"batch_id" => batch_id}}) do cancel_jobs(conf.name, batch_id) end def cancel_jobs(oban_name, batch_id) when is_binary(batch_id) do Oban.cancel_all_jobs(oban_name, batch_query(batch_id)) end @doc """ Create a batch from a workflow. All jobs in the workflow are augmented to also be part of a batch. ## Examples Create a batch from a workflow without any extra options: Workflow.new() |> Workflow.add(:step_1, MyWorker.new(%{id: 123})) |> Workflow.add(:step_2, MyWorker.new(%{id: 345}), deps: [:step_1]) |> Workflow.add(:step_3, MyWorker.new(%{id: 456}), deps: [:step_2]) |> Batch.from_workflow() |> Oban.insert_all() Create a batch with callback options: Batch.from_workflow(workflow, callback_opts: [priority: 3], callback_worker: MyApp.Worker) """ @spec from_workflow(Workflow.t(), batch_opts()) :: t() def from_workflow(%Workflow{changesets: changesets}, opts \\ []) do new(changesets, opts) end @doc """ Stream all non-callback jobs from a batch. ## Examples Stream all batch jobs from within a `batch_completed/1` callback: def batch_completed(%Job{} = job) do {:ok, account_ids} = MyApp.Repo.transaction(fn -> job |> Batch.stream_jobs() |> Enum.map(& &1.args["account_id"]) end) # use_account_ids end Stream all batch jobs from anywhere using the `batch_id`: MyApp.Repo.transaction(fn -> "some-uuid-1234-5678" |> Batch.stream_jobs() |> Enum.map(& &1.args["account_id"]) end) Stream all batch jobs using a custom Oban instance name: MyApp.Repo.transaction(fn -> MyApp.Oban |> Batch.stream_jobs("some-uuid-1234-5678") |> Enum.map(& &1.args["account_id"]) end) """ @spec stream_jobs(Oban.name(), Job.t() | String.t(), [repo_opts()]) :: Enum.t() def stream_jobs(name \\ Oban, job_or_batch_id, opts \\ []) def stream_jobs(_name, %Job{conf: conf, meta: %{"batch_id" => batch_id}}, opts) do Repo.stream(conf, batch_query(batch_id), opts) end def stream_jobs(name, batch_id, opts) when is_binary(batch_id) do name |> Oban.config() |> Repo.stream(batch_query(batch_id), opts) end def stream_jobs(%Job{} = job, opts, []), do: stream_jobs(Oban, job, opts) defp batch_query(batch_id) do Job |> where([j], fragment("? \\? 'batch_id'", j.meta)) |> where([j], fragment("? ->> 'batch_id'", j.meta) == ^batch_id) |> where([j], is_nil(fragment("? ->> 'callback'", j.meta))) |> where([j], j.state in @all_states) |> order_by(asc: :id) end # Event Handling @doc false @impl Oban.Pro.Flusher def to_flush_mfa(job, conf) do case job.meta do %{"batch_id" => _, "callback" => _} -> :ignore %{"batch_id" => batch_id} -> {__MODULE__, :on_flush, [job, batch_id, conf]} _ -> :ignore end end @doc false def on_flush(job, batch_id, conf) do batch_worker = job.meta["batch_callback_worker"] || job.meta["callback_worker"] || job.worker with {:ok, worker} <- Worker.from_string(batch_worker), supported = supported_callbacks(worker), {:ok, {states, exists}} <- states_for_callbacks(supported, batch_id, conf) do for callback <- supported, callback not in exists, callback_ready?(callback, states) do insert_callback(callback, worker, job, conf) end end end defp supported_callbacks(worker) do for {name, [new, old]} <- @callbacks_to_combined, function_exported?(worker, new, 1) or function_exported?(worker, old, 1), do: name end defp states_for_callbacks([], _batch_id, _conf), do: :ok defp states_for_callbacks(callbacks, batch_id, conf) do state_query = callbacks |> Enum.flat_map(&Map.fetch!(@callbacks_to_states, &1)) |> Enum.uniq() |> Enum.reduce(:none, fn state, acc -> query = Job |> select([_], [type(^state, :string)]) |> where([j], fragment("? \\? 'batch_id'", j.meta)) |> where([j], fragment("? ->> 'batch_id'", j.meta) == ^batch_id) |> where([j], is_nil(fragment("? ->> 'callback'", j.meta))) |> where([j], j.state == ^state) |> limit(1) if acc == :none, do: query, else: union_all(acc, ^query) end) exist_query = Enum.reduce(callbacks, :none, fn callback, acc -> query = Job |> select([_j], [type(^callback, :string)]) |> where([j], fragment("? \\? 'batch_id'", j.meta)) |> where([j], fragment("? ->> 'batch_id'", j.meta) == ^batch_id) |> where([j], fragment("? ->> 'callback'", j.meta) == ^callback) |> where([j], j.state in @all_states) if acc == :none, do: query, else: union_all(acc, ^query) end) Repo.transaction(conf, fn -> states = conf |> Repo.all(state_query) |> List.flatten() exists = conf |> Repo.all(exist_query) |> List.flatten() {states, exists} end) end defp callback_ready?(callback, batch_states) do ready? = @callbacks_to_states |> Map.fetch!(callback) |> Enum.any?(&(&1 in batch_states)) # Other callbacks use a negated query to avoid counting `completed` jobs. if callback in ~w(cancelled discarded retryable) do ready? else not ready? end end defp insert_callback(callback, worker, job, conf) do call_opts = job.meta |> Map.get("callback_opts", %{}) |> Map.put_new("args", Map.get(job.meta, "batch_callback_args", %{})) |> Map.put_new("meta", Map.get(job.meta, "batch_callback_meta", %{})) |> Map.put_new("queue", Map.get(job.meta, "batch_callback_queue", job.queue)) unique = [ period: :infinity, fields: [:worker, :queue, :meta], keys: [:batch_id, :callback], states: Job.states() ] {args, call_opts} = Map.pop(call_opts, "args") {meta, call_opts} = Map.pop(call_opts, "meta") xtra_meta = job.meta |> Map.take(~w(batch_id batch_name)) |> Map.put("callback", callback) |> Map.merge(meta) opts = call_opts |> Keyword.new(fn {key, val} -> {String.to_existing_atom(key), val} end) |> Keyword.put(:meta, xtra_meta) |> Keyword.put(:unique, unique) changeset = worker.new(args, opts) if not changeset.valid? and Keyword.has_key?(changeset.errors, :args) do changeset |> structured_error_message() |> Logger.error() else {:ok, Smart.insert_job(conf, changeset, [])} end end defp structured_error_message(changeset) do """ [Oban.Pro.Batch] can't insert batch callback because it has invalid keys: #{get_in(changeset.errors, [:args, Access.elem(0)])} Use one of the following options to restore batch callbacks: * Modify structured `keys` or `required` to allow the missing keys * Include the required arguments with the `batch_callback_args` option * Specify a different callback worker with the `batch_callback_worker` option """ end end