PhoenixKit.Jobs.Kind behaviour (phoenix_kit v2.55.1)

Copy Markdown View Source

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
end

Start 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. progress is %{cursor:, done:, failed:, total:}; done and failed are increments of this batch (the engine adds them in the checkpoint transaction, so a replayed batch cannot count twice), cursor is 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; result is a map kept on the run, progress the last batch's increments. The result comes first; the engine and the state machine order the two the other way round ({:done, progress, result}), which PhoenixKit.Jobs.RunWorker.batch_outcome/3 converts 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 after seconds. (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

control()

@type control() :: :pause | :resume | :cancel | :retry

progress()

@type progress() :: %{
  optional(:cursor) => map(),
  optional(:done) => non_neg_integer(),
  optional(:failed) => non_neg_integer(),
  optional(:total) => non_neg_integer() | nil
}

result()

@type result() ::
  {:more, progress(), keyword()}
  | {:done, map()}
  | {:done, map(), progress()}
  | {:snooze, non_neg_integer()}
  | {:error, term()}

Callbacks

batch(t)

@callback batch(PhoenixKit.Jobs.Run.t()) :: result()

Does one batch of the run.

controls()

(optional)
@callback controls() :: [control()]

The controls an admin is offered (default: all).

idempotent?()

@callback idempotent?() :: boolean()

Whether replaying a batch is safe. See the moduledoc.

kind()

@callback kind() :: String.t()

The kind's name, dotted: "storage.reconcile". Stored on every run.

max_attempts()

(optional)
@callback max_attempts() :: pos_integer()

Oban attempts for one batch (default 3).

module_key()

@callback module_key() :: String.t()

The module key it belongs to: the Activity log's module, and the filter on the Jobs page.

on_finish(t, state)

(optional)
@callback on_finish(PhoenixKit.Jobs.Run.t(), state :: String.t()) :: any()

After the run finished (state: completed, failed or cancelled). Idempotent.

on_start(args)

(optional)
@callback on_start(args :: map()) :: :ok | {:error, term()}

Validates a start. {:error, reason} refuses it. No side effects.

permission()

(optional)
@callback permission() :: String.t() | nil

A permission needed besides jobs.manage to control a run of this kind, or nil.

queue()

(optional)
@callback queue() :: atom()

The Oban queue (default :default).

restart()

(optional)
@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).

timeout()

(optional)
@callback timeout() :: pos_integer() | :infinity

How long one batch may take, in milliseconds (default ten minutes).

title(args, scope)

@callback title(args :: map(), scope :: :site | {String.t(), String.t()}) :: String.t()

A title fixed when the run starts (never recomputed from live data).