Job runs: long background work an admin can watch and control.
A run is a durable record of one logical piece of work — a backfill, a
reconcile of one library, an import, a broadcast — that Oban executes batch by
batch. Pause, resume, cancel and retry are state changes on the run; the worker
reads the state before every batch and never queues the next one for a run that
was stopped. Jobs are core and always on: Admin → Jobs lists the runs, their
progress and history, and jobs.manage controls them.
The design, and why it is shaped this way, is
dev_docs/plans/2026-10-03-job-runs.md.
Declaring a kind
A module implements PhoenixKit.Jobs.Kind for each kind of run it has and lists
them in PhoenixKit.Module.job_kinds/0.
Starting a run
A person, with a scope (the permission is checked against the active role):
PhoenixKit.Jobs.start(scope, MyApp.Jobs.ImportRows, :site, args: %{"file" => "a.csv"})Boot, cron, a Mix task or a trigger — the trusted door, which never takes the place of a user:
PhoenixKit.Jobs.System.start(MyApp.Jobs.ImportRows, :site, mode: :cron)A second start while a run of the kind and scope is active returns that run
({:ok, run, :existing}); a kind with restart/0 returning :restart also asks
it to begin a fresh pass.
Controls
pause/2, resume/2, cancel/2 and retry/2 take a scope and a run (or its
uuid). A pause or cancel while a batch is executing is a request: the run is
pausing / cancelling until the batch finishes, and resume is refused
meanwhile. Each control returns {:ok, run} or {:error, reason} —
:unauthorized, :unknown_kind, :control_not_offered, :not_found or the
state machine's (PhoenixKit.Jobs.StateMachine).
Reading
list_runs/1, count_runs/1, get_run/1, active_run/2, history/1. Live
changes arrive over PhoenixKit.Jobs.Events.
Summary
Functions
The active run of kind for run_scope, or nil.
Whether scope may start a run of kind (jobs.manage and the kind's own
permission against the active role) — what a screen asks before it offers a
button that starts one.
Cancels a run. A batch already executing finishes first; its work is kept.
The controls scope may offer on run right now: the state allows them, the
kind offers them, and the person holds jobs.manage and the kind's own
permission. For the Jobs page, which shows only what would work.
How many runs match the filters of list_runs/1.
Does nothing: Jobs is always on. Kept for callers of the module this used to be.
Does nothing: Jobs is always on. Kept for callers of the module this used to be.
Always true: Jobs is core. Kept for callers of the module this used to be;
there is no toggle.
The Oban job counts by state and whether Jobs is on.
Job statistics from the Oban jobs table (read through Oban's configured prefix; all zeros where no Oban instance runs in this VM, and a database error is raised, not hidden).
A run, or nil.
Tells the engine a long batch is still alive, and checks that it still holds the
run. Call between steps of a batch that may run for minutes. :ok, or {:error, :claim_lost} when something else holds the run now (stop working).
The Activity entries of one run, oldest first.
The kind modules known now (PhoenixKit.Jobs.Kinds.all/0).
Runs, newest first. Filters (all optional): :module, :kind, :state (one
or a list; :active for the unfinished ones), :scope (:site or
{type, uuid}), :limit (default 25), :offset.
Pauses a run (see the moduledoc for what a pause while a batch executes means).
Resumes a paused run. Refused while the run is still pausing.
Starts a new run like a failed or cancelled one (same kind, scope and args), recording which it retries. A finished run is never changed.
Runs a run in the calling process to its end (or until it is paused or
cancelled by someone else): what a Mix task or a script does instead of
queueing. It goes through the same claim, checkpoint and failure rules as a
queued run, and refuses a run a batch holds ({:error, :claimed}). Mode
defaults to "script"; no Oban job is made. :on_progress is called with the
run after every batch that made progress (a Mix task prints from it). A
{:snooze, s} or a batch's schedule_in: is waited out — in short slices, so a
pause or a cancel ends the wait.
Starts a run of kind for run_scope on behalf of the person behind scope.
Options: :args. Returns {:ok, run, :started | :existing} or an error.
Types
Functions
@spec active_run(kind(), run_scope()) :: PhoenixKit.Jobs.Run.t() | nil
The active run of kind for run_scope, or nil.
@spec can_start?(PhoenixKit.Users.Auth.Scope.t() | nil, kind()) :: boolean()
Whether scope may start a run of kind (jobs.manage and the kind's own
permission against the active role) — what a screen asks before it offers a
button that starts one.
@spec cancel(PhoenixKit.Users.Auth.Scope.t(), PhoenixKit.Jobs.Run.t() | String.t()) :: {:ok, PhoenixKit.Jobs.Run.t()} | {:error, term()}
Cancels a run. A batch already executing finishes first; its work is kept.
@spec controls_for(PhoenixKit.Users.Auth.Scope.t(), PhoenixKit.Jobs.Run.t()) :: [ PhoenixKit.Jobs.Kind.control() ]
The controls scope may offer on run right now: the state allows them, the
kind offers them, and the person holds jobs.manage and the kind's own
permission. For the Jobs page, which shows only what would work.
@spec count_runs(keyword()) :: non_neg_integer()
How many runs match the filters of list_runs/1.
Does nothing: Jobs is always on. Kept for callers of the module this used to be.
Does nothing: Jobs is always on. Kept for callers of the module this used to be.
@spec enabled?() :: boolean()
Always true: Jobs is core. Kept for callers of the module this used to be;
there is no toggle.
@spec get_config() :: map()
The Oban job counts by state and whether Jobs is on.
@spec get_job_stats() :: map()
Job statistics from the Oban jobs table (read through Oban's configured prefix; all zeros where no Oban instance runs in this VM, and a database error is raised, not hidden).
iex> PhoenixKit.Jobs.get_job_stats()
%{available: 5, scheduled: 2, executing: 1, completed: 100, ...}
@spec get_run(String.t()) :: PhoenixKit.Jobs.Run.t() | nil
A run, or nil.
@spec heartbeat(PhoenixKit.Jobs.Run.t()) :: :ok | {:error, :claim_lost}
Tells the engine a long batch is still alive, and checks that it still holds the
run. Call between steps of a batch that may run for minutes. :ok, or {:error, :claim_lost} when something else holds the run now (stop working).
For a queued batch this is observability and a cooperative check only: the
sweeper never reads it, and judges the batch by its own Oban job. For a script
(run_inline/3) it renews the lease that keeps the sweeper from taking the run
(PhoenixKit.Jobs.Run.inline_lease_seconds/0, an hour).
@spec history(PhoenixKit.Jobs.Run.t() | String.t()) :: [PhoenixKit.Activity.Entry.t()]
The Activity entries of one run, oldest first.
@spec kinds() :: [module()]
The kind modules known now (PhoenixKit.Jobs.Kinds.all/0).
@spec list_runs(keyword()) :: [PhoenixKit.Jobs.Run.t()]
Runs, newest first. Filters (all optional): :module, :kind, :state (one
or a list; :active for the unfinished ones), :scope (:site or
{type, uuid}), :limit (default 25), :offset.
@spec pause(PhoenixKit.Users.Auth.Scope.t(), PhoenixKit.Jobs.Run.t() | String.t()) :: {:ok, PhoenixKit.Jobs.Run.t()} | {:error, term()}
Pauses a run (see the moduledoc for what a pause while a batch executes means).
@spec resume(PhoenixKit.Users.Auth.Scope.t(), PhoenixKit.Jobs.Run.t() | String.t()) :: {:ok, PhoenixKit.Jobs.Run.t()} | {:error, term()}
Resumes a paused run. Refused while the run is still pausing.
@spec retry(PhoenixKit.Users.Auth.Scope.t(), PhoenixKit.Jobs.Run.t() | String.t()) :: {:ok, PhoenixKit.Jobs.Run.t(), :started | :existing} | {:error, term()}
Starts a new run like a failed or cancelled one (same kind, scope and args), recording which it retries. A finished run is never changed.
@spec run_inline(module(), run_scope(), keyword()) :: {:ok, PhoenixKit.Jobs.Run.t()} | {:error, term()}
Runs a run in the calling process to its end (or until it is paused or
cancelled by someone else): what a Mix task or a script does instead of
queueing. It goes through the same claim, checkpoint and failure rules as a
queued run, and refuses a run a batch holds ({:error, :claimed}). Mode
defaults to "script"; no Oban job is made. :on_progress is called with the
run after every batch that made progress (a Mix task prints from it). A
{:snooze, s} or a batch's schedule_in: is waited out — in short slices, so a
pause or a cancel ends the wait.
The claim a script takes is a lease (PhoenixKit.Jobs.Run.inline_lease_seconds/0,
an hour, renewed by heartbeat/1): the sweeper has no Oban job to ask about a
script and leaves its claim alone while the lease stands. A script that dies
leaves its run held until the lease runs out, when the sweeper takes the run back
(and a new script may take it over). Like PhoenixKit.Jobs.System, this is
trusted application code: it checks no permission.
@spec start(PhoenixKit.Users.Auth.Scope.t(), kind(), run_scope(), keyword()) :: {:ok, PhoenixKit.Jobs.Run.t(), :started | :existing} | {:error, term()}
Starts a run of kind for run_scope on behalf of the person behind scope.
Options: :args. Returns {:ok, run, :started | :existing} or an error.
{:error, :raced} means the start met a run that finished at that very moment
three times over: the trigger was not recorded — try again.
{:error, :in_transaction} means it was called inside a transaction of the
caller's, which would let a broadcast or on_finish/2 escape a rollback: call it
after the transaction commits (see PhoenixKit.Jobs.Engine).