# `PhoenixKit.Jobs`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.55.1/lib/phoenix_kit/jobs.ex#L1)

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`.

# `kind`

```elixir
@type kind() :: module() | String.t()
```

# `run_scope`

```elixir
@type run_scope() :: :site | {atom() | String.t(), String.t()}
```

# `active_run`

```elixir
@spec active_run(kind(), run_scope()) :: PhoenixKit.Jobs.Run.t() | nil
```

The active run of `kind` for `run_scope`, or nil.

# `can_start?`

```elixir
@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.

# `cancel`

```elixir
@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.

# `controls_for`

```elixir
@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.

# `count_runs`

```elixir
@spec count_runs(keyword()) :: non_neg_integer()
```

How many runs match the filters of `list_runs/1`.

# `disable_system`

> This function is deprecated. Jobs is always on.

Does nothing: Jobs is always on. Kept for callers of the module this used to be.

# `enable_system`

> This function is deprecated. Jobs is always on.

Does nothing: Jobs is always on. Kept for callers of the module this used to be.

# `enabled?`

> This function is deprecated. Jobs is always on.

```elixir
@spec enabled?() :: boolean()
```

Always `true`: Jobs is core. Kept for callers of the module this used to be;
there is no toggle.

# `get_config`

```elixir
@spec get_config() :: map()
```

The Oban job counts by state and whether Jobs is on.

# `get_job_stats`

```elixir
@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, ...}

# `get_run`

```elixir
@spec get_run(String.t()) :: PhoenixKit.Jobs.Run.t() | nil
```

A run, or nil.

# `heartbeat`

```elixir
@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).

# `history`

```elixir
@spec history(PhoenixKit.Jobs.Run.t() | String.t()) :: [PhoenixKit.Activity.Entry.t()]
```

The Activity entries of one run, oldest first.

# `kinds`

```elixir
@spec kinds() :: [module()]
```

The kind modules known now (`PhoenixKit.Jobs.Kinds.all/0`).

# `list_runs`

```elixir
@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`.

# `pause`

```elixir
@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).

# `resume`

```elixir
@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`.

# `retry`

```elixir
@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.

# `run_inline`

```elixir
@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.

# `start`

```elixir
@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`).

---

*Consult [api-reference.md](api-reference.md) for complete listing*
