A kind of job run: what a piece of long background work is and how one batch
of it is done. A module declares its kinds with PhoenixKit.Module.job_kinds/0
(a host can list extra ones under config :phoenix_kit, job_kinds: [...]).
defmodule MyApp.Jobs.ImportRows do
use PhoenixKit.Jobs.Kind
@impl true
def kind, do: "my_app.import_rows"
@impl true
def module_key, do: "my_app"
@impl true
def title(%{"file" => name}, _scope), do: "Import " <> name
# Replaying a batch (a crash after the work, before the checkpoint) is safe.
@impl true
def idempotent?, do: true
@impl true
def batch(run) do
case MyApp.Import.next_rows(run.args, run.cursor, 100) do
{[], _} -> {:done, %{"imported" => run.done}, %{done: 0}}
{rows, cursor} ->
MyApp.Import.insert(rows)
{:more, %{cursor: cursor, done: length(rows)}, schedule_in: 1}
end
end
endStart one with PhoenixKit.Jobs.start/4 (a person) or
PhoenixKit.Jobs.System.start/3 (boot, cron, a script). The design is
dev_docs/plans/2026-10-03-job-runs.md.
What batch/1 returns
{:more, progress, opts}— checkpoint and carry on.progressis%{cursor:, done:, failed:, total:};doneandfailedare increments of this batch (the engine adds them in the checkpoint transaction, so a replayed batch cannot count twice),cursoris where the next batch resumes,total(optional) replaces the known total.opts:schedule_in: seconds— the pause before the next batch.{:done, result}or{:done, result, progress}— the run is finished;resultis a map kept on the run,progressthe last batch's increments. The result comes first; the engine and the state machine order the two the other way round ({:done, progress, result}), whichPhoenixKit.Jobs.RunWorker.batch_outcome/3converts at the boundary.{:snooze, seconds}— nothing was done (waiting on something outside): the progress is left as it was, the claim is released and the next batch is dispatched afterseconds. (Queued, that is a delayed Oban job; inline, the script waits.){:error, reason}— the batch failed. Oban retries it (max_attempts/0); the last failure fails the run.
A batch may raise; that is an {:error, _}. A long batch can call
PhoenixKit.Jobs.heartbeat/1 between steps.
The side-effect contract
A crash after external work and before the checkpoint replays that batch.
idempotent?/0 says the kind survives that. It is a declaration, not a
check: the engine never calls it and enforces nothing; it is there so a reader
(and a future guard at the start boundary) knows what the author promised. A kind
that is not idempotent (a broadcast that sends mail) must keep its own per-item
marks, and the engine will not pretend otherwise.
Replay is also about counts. done/failed are added in the checkpoint
transaction, so a replayed batch cannot count twice — but work done by a batch
that crashed before its checkpoint is not counted by anyone, and a kind that
selects "what is still to do" (rather than walking a cursor) will not see it
again. Counts recovered from a crash are therefore approximate unless the
kind keeps durable per-item accounting; the Jobs page and the Mix task show what
the run recorded.
When on_finish/2 runs
After the run reaches a terminal state and after the commit, once, in the process that made the transition. It is best effort: a crash right after the commit, a callback that raises, or a kind that is no longer available skips it for good — an Oban retry sees a finished run and does not replay it. Use it for something whose loss is harmless (dropping a cache, a log line). Work that must happen when a run ends needs its own durable record and retry.
Summary
Callbacks
Does one batch of the run.
The controls an admin is offered (default: all).
Whether replaying a batch is safe. See the moduledoc.
The kind's name, dotted: "storage.reconcile". Stored on every run.
Oban attempts for one batch (default 3).
The module key it belongs to: the Activity log's module, and the filter on the Jobs page.
After the run finished (state: completed, failed or cancelled). Idempotent.
Validates a start. {:error, reason} refuses it. No side effects.
A permission needed besides jobs.manage to control a run of this kind, or nil.
The Oban queue (default :default).
What a second start does while a run of this kind and scope is active:
:merge (the existing run is returned) or :restart (it takes a fresh pass,
at its next batch boundary).
How long one batch may take, in milliseconds (default ten minutes).
A title fixed when the run starts (never recomputed from live data).
Types
@type control() :: :pause | :resume | :cancel | :retry
@type progress() :: %{ optional(:cursor) => map(), optional(:done) => non_neg_integer(), optional(:failed) => non_neg_integer(), optional(:total) => non_neg_integer() | nil }
Callbacks
@callback batch(PhoenixKit.Jobs.Run.t()) :: result()
Does one batch of the run.
@callback controls() :: [control()]
The controls an admin is offered (default: all).
@callback idempotent?() :: boolean()
Whether replaying a batch is safe. See the moduledoc.
@callback kind() :: String.t()
The kind's name, dotted: "storage.reconcile". Stored on every run.
@callback max_attempts() :: pos_integer()
Oban attempts for one batch (default 3).
@callback module_key() :: String.t()
The module key it belongs to: the Activity log's module, and the filter on the Jobs page.
@callback on_finish(PhoenixKit.Jobs.Run.t(), state :: String.t()) :: any()
After the run finished (state: completed, failed or cancelled). Idempotent.
Validates a start. {:error, reason} refuses it. No side effects.
@callback permission() :: String.t() | nil
A permission needed besides jobs.manage to control a run of this kind, or nil.
@callback queue() :: atom()
The Oban queue (default :default).
@callback restart() :: :merge | :restart
What a second start does while a run of this kind and scope is active:
:merge (the existing run is returned) or :restart (it takes a fresh pass,
at its next batch boundary).
@callback timeout() :: pos_integer() | :infinity
How long one batch may take, in milliseconds (default ten minutes).
A title fixed when the run starts (never recomputed from live data).