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

The transitions of a job run, as pure functions (no database, no clock, no
Oban): `transition/3` takes a run, an event and the time, and answers the fields to
change and the effects to carry out, or why the event does not apply.

`PhoenixKit.Jobs.Engine` does the I/O — it locks the run row, calls this,
writes the changes, inserts the Oban job a `{:dispatch, seconds}` effect asks
for and the Activity entry a `{:log, …}` effect asks for, all in one
transaction. Keeping the rules here makes them testable without a database
(`dev_docs/plans/2026-10-03-job-runs.md`, §3.2, §14 R1, R2, R5, R7).

## Events

  * `{:claim, token}` — a batch is about to work. Needs `queued` or `running`
    and no claim held. A pending restart is taken in here.
  * `{:checkpoint, token, outcome}` — the batch holding `token` is done.
    `outcome` is `{:more, progress, opts}`, `{:done, progress, result}`,
    `{:snooze, seconds}`, `{:fail, message}` (final) or `{:release, message}`
    (an error Oban will retry).
  * `{:pause, actor}`, `{:resume, actor}`, `{:cancel, actor}`
  * `:request_restart` — a trigger asked for a fresh pass
  * `{:heartbeat, token}`
  * `{:rescue, limit}` — the sweeper found the dispatch lost; refused while a
    batch holds the run
  * `{:fail, message}` — the run cannot go on (a job the sweeper found
    discarded); refused while a batch holds the run — a batch ends its own run
    through `{:checkpoint, token, {:fail, message}}`

## Precedence when a batch ends

| State | Batch returns more / snoozes | Batch is done |
|---|---|---|
| `running`, no restart pending | next batch dispatched | `completed` |
| `running`, restart pending | pass restarts (`queued`, counters reset) | pass restarts |
| `pausing` | `paused`, nothing dispatched | `completed` (nothing is left to pause) |
| `cancelling` | `cancelled` | `cancelled` |

A pending restart never overrides a pause or a cancel; it waits.

# `actor`

```elixir
@type actor() :: String.t() | nil
```

# `effect`

```elixir
@type effect() :: {:dispatch, non_neg_integer()} | {:log, String.t(), map()}
```

# `error`

```elixir
@type error() ::
  :inactive
  | :claimed
  | :claim_lost
  | :already_pausing
  | :already_paused
  | :already_cancelling
  | :draining
  | :not_paused
  | :cancelling
  | :finished
  | :not_active
```

# `event`

```elixir
@type event() ::
  {:claim, String.t()}
  | {:checkpoint, String.t(), outcome()}
  | {:pause, actor()}
  | {:resume, actor()}
  | {:cancel, actor()}
  | :request_restart
  | {:heartbeat, String.t()}
  | {:rescue, pos_integer()}
  | {:fail, String.t()}
```

# `outcome`

```elixir
@type outcome() ::
  {:more, progress(), keyword()}
  | {:done, progress(), map()}
  | {:snooze, non_neg_integer()}
  | {:fail, String.t()}
  | {:release, String.t()}
```

# `progress`

```elixir
@type progress() :: %{
  optional(:cursor) =&gt; map(),
  optional(:done) =&gt; non_neg_integer(),
  optional(:failed) =&gt; non_neg_integer(),
  optional(:total) =&gt; non_neg_integer() | nil
}
```

# `transition`

```elixir
@spec transition(PhoenixKit.Jobs.Run.t(), event(), DateTime.t()) ::
  {:ok, map(), [effect()]} | {:error, error()}
```

Applies `event` to `run` at `now`: `{:ok, changes, effects}` — `changes` is a
map of fields to write, `effects` what to do besides — or `{:error, reason}`.

---

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