> For the complete AWA documentation index, see [`llms.txt`](../llms.txt).

# Lifecycle hooks

Lifecycle hooks let application code react to the points in a job's life — when it starts, finishes, retries, parks on an external callback, and so on — without putting that logic inside the job handler itself. Typical uses are metrics, alerting, and job-type-specific cleanup on permanent failure.

Hooks are registered on the `ClientBuilder` and keyed by job kind. They work for both the typed (`register::<T, _>()`) and raw (`register_worker(...)`) worker paths.

## Registering a hook

```rust
use awa::{Client, JobEvent, QueueConfig};

let client = Client::builder(pool)
    .queue("email", QueueConfig::default())
    .register::<SendEmail, _, _>(|args, _ctx| async move { /* ... */ Ok(awa::JobResult::Completed) })
    // Typed hook: args are deserialized from the committed job snapshot.
    .on_event::<SendEmail, _, _>(|event| async move {
        match event {
            JobEvent::Started { job, .. }    => metrics::started(&job.queue),
            JobEvent::Completed { duration, .. } => metrics::completed(duration),
            JobEvent::Exhausted { error, .. } => alert::job_failed(&error),
            _ => {}
        }
    })
    .build()?;
```

`on_event::<T>()` gives you typed `args`; `on_event_kind("send_email", ...)` gives an untyped handler keyed by the kind string. Multiple hooks may be registered for the same kind; they stack and run sequentially.

## Events

| Event | Fires when |
| --- | --- |
| `Started` | the claim transaction commits, just before the handler runs (`job.state == running`) |
| `WaitingForCallback` | the handler returned `WaitForCallback` and the job parked (`job.state == waiting_external`; `job.callback_id` is set) |
| `Completed` | the job finished successfully |
| `Retried` | the attempt failed (or `RetryAfter`) and the job will run again |
| `Exhausted` | the job exhausted its retries, or failed terminally, and moved to `failed` |
| `Cancelled` | the job was cancelled |
| `Rescued` | maintenance rescued the job (expired callback, stale heartbeat, or exceeded deadline) — `reason: RescueReason` carries which |

How `JobResult` / outcomes map to events:

| Outcome | Event |
| --- | --- |
| claim commits | `Started` |
| `Ok(Completed)` | `Completed` |
| `Ok(RetryAfter)` / retryable `Err` (retries left) | `Retried` |
| retries exhausted (final attempt returned `RetryAfter` or a retryable `Err`), or `Err(Terminal)` | `Exhausted` |
| `Ok(Cancel)` | `Cancelled` |
| `Ok(Snooze)` | none — an internal reschedule, not an outcome |
| `Ok(WaitForCallback)` | `WaitingForCallback` |

## Callbacks

A job that returns `WaitForCallback` parks in `waiting_external` and emits `WaitingForCallback`. The terminal event fires later, when the callback is resolved — **but only if you resolve it through the worker `Client`**, which owns the hook registry:

```rust
// In your callback endpoint (same process as the worker Client):
let outcome = client.resolve_callback(callback_id, payload, default_action, None).await?;
// or the lower-level forms:
client.complete_external(callback_id, payload, None).await?; // -> Completed
client.fail_external(callback_id, error, None).await?;       // -> Exhausted
client.retry_external(callback_id, None).await?;             // -> Retried
```

Resolving through the bare `awa_model::admin::*` functions (or the CLI, or a process that has no worker `Client`) still transitions the job correctly but fires no hook — hooks are an in-process, worker-side facility. Dispatch happens once, in the process that performs the resolution, mirroring how an inline outcome fires once from the worker that claimed the job.

For a callback-driven `Completed`, `duration` is `Duration::ZERO` (no handler ran during the parked phase).

A callback that _resumes_ the job to `running` emits no event itself; the job re-enters execution and emits `Started` plus a terminal event as usual.

## Semantics

Hooks are **best-effort, in-process notifications** — not a durable workflow mechanism:

- They observe **committed** state. An outcome hook carries the post-transition `JobRow`; a stale/rejected finalization emits no outcome event (though a `Started` may already have fired for that attempt).
- They do not block executor progress or queue capacity, and `shutdown()` does not wait for them. They can be lost on process crash or shutdown.
- **Dispatch is not atomic with the state transition.** The hook runs _after_ the job's transition commits, as a separate in-process step — it is not a transactional outbox. If the process dies between the commit and the dispatch, the job state is durable but the event is lost. The same applies to callback resolution: `complete_external` / `resolve_callback` commit the outcome first, then dispatch.
- `Started` is dispatched detached, so a very fast job may finish before its `Started` hook completes — do not rely on strict started-before-outcome ordering for short jobs.
- A panicking hook is logged; later hooks for the same kind still run.

If a side effect must fire exactly once or survive a crash, do not drive it from a hook at all — the dispatch may never run, and enqueueing the follow-up work _from_ the hook just inherits that same unreliability. See the next section for the durable mechanism.

## Durable follow-up jobs (`on_*_enqueue`)

For side effects that **must survive a process crash** — sending a welcome email after signup, kicking off downstream work, persisting an audit row — use the transactional follow-up API instead of an `on_event` hook. For worker-driven outcomes (the trigger handler returned `Ok`/`Err`) and for callback resolution via the worker `Client` — `complete_external`, `fail_external`, `retry_external`, and `resolve_callback` — the follow-up `INSERT`s in the **same database transaction** as the triggering state transition: either both commit or both roll back, with no in-between. Maintenance rescue dispatches follow-ups in a **separate transaction** (best-effort) — see the [atomicity table below](#atomicity-guarantees) before designing a workflow that depends on rescue-driven follow-ups landing.

```rust
use awa::{Client, EnqueueRequest, QueueConfig};

let client = Client::builder(pool)
    .queue("signup", QueueConfig::default())
    .register::<Signup, _, _>(|_, _| async move { Ok(awa::JobResult::Completed) })
    // When a Signup completes, enqueue a WelcomeEmail in the *same* tx.
    // Closure receives the trigger args and the post-completion JobRow.
    .on_completed_enqueue::<Signup, WelcomeEmail, _>(|args, job| WelcomeEmail {
        user_id: args.user_id,
        signup_job_id: job.id,
    })
    .build()?;
```

There is one builder per outcome, plus a `_with` variant whose closure returns `Option<EnqueueRequest<F>>`. Return `Some` to override `InsertOpts` on the follow-up (queue, priority, max_attempts, metadata, tags, unique, run_at, deadline_duration, ordering_key), or `None` to enqueue nothing ([Conditional follow-ups](#conditional-follow-ups)):

| Builder | Fires when | Closure signature |
| --- | --- | --- |
| `on_completed_enqueue` | `JobResult::Completed` | `(args, &job)` |
| `on_cancelled_enqueue` | `JobResult::Cancel(reason)` | `(args, &job, &reason)` |
| `on_retried_enqueue` | retryable err (retries left) or `RetryAfter` | `(args, &job, &error, attempt, next_run_at)` |
| `on_exhausted_enqueue` | retries exhausted, or `Err(Terminal)` | `(args, &job, &error, attempt)` |
| `on_waiting_for_callback_enqueue` | `JobResult::WaitForCallback` | `(args, &job)` |
| `on_rescued_enqueue` | maintenance rescue (expired callback / stale heartbeat / deadline) | `(args, &job, RescueReason)` |

An `on_*_enqueue_with` closure returns `Option<EnqueueRequest<F>>`:

```rust
.on_completed_enqueue_with::<Signup, WelcomeEmail, _>(|args, job| {
    Some(
        EnqueueRequest::new(WelcomeEmail { user_id: args.user_id })
            .queue("email")
            .priority(1),
    )
})
```

### Conditional follow-ups

Return `None` from a `_with` closure to enqueue nothing for that trigger. The trigger's transition commits either way:

```rust
.on_exhausted_enqueue_with::<Charge, NotifyFinance, _>(|args, _job, _error, _attempt| {
    (args.amount_cents >= 100_000).then(|| EnqueueRequest::new(NotifyFinance { charge_id: args.id }))
})
```

The closure decides from the trigger's args, its job row and the outcome's context. It runs wherever the follow-up insert runs, so this works for every outcome, including callback resolutions (`complete_external`, `resolve_callback`) where no handler runs. For worker-driven outcomes and callback resolutions that is inside the transition's transaction. For maintenance rescue it is the separate transaction opened after the rescue commits, so a conditional `on_rescued_enqueue_with` is no more atomic than any rescue follow-up (see [Atomicity guarantees](#atomicity-guarantees)). A job whose kind has a registered `Completed` spec still takes the per-job completion transaction when the closure returns `None`. When the decision depends on something the handler computed, stage the follow-up from the handler instead (see [Handler-staged follow-ups](#handler-staged-follow-ups)).

### Deduplicating follow-ups

Give the follow-up unique options to collapse duplicates. When several triggers produce a follow-up with the same unique key, the first insert wins and the later ones are skipped: each trigger's transition still commits, and exactly one follow-up job exists. With `UniqueOpts::default()` the key covers the follow-up's kind and args, so put only the deduplication key in the follow-up's args. Custom options can add the queue or a time period, or set `by_args: false` so the key leaves args out; then follow-ups of the same kind collapse whatever their args. `UniqueOpts::states` decides how long the deduplication lasts: the default includes `completed`, so a finished follow-up keeps blocking duplicates until its terminal row is pruned. Drop `completed` from the states to block duplicates only while the existing follow-up is still scheduled, available, running or retryable.

```rust
.on_completed_enqueue_with::<StageA, StageB, _>(|args, _job| {
    Some(EnqueueRequest::new(StageB { item_id: args.item_id }).unique(UniqueOpts::default()))
})
```

`.unique()` and `.metadata()` set one option each. `with_opts` replaces every option, including an earlier `.queue()` or `.priority()`.

### Handler-staged follow-ups

Registered specs see only the trigger's args and job row. When the follow-up needs a value the handler computes, such as the ID of the item it resolved, the handler stages the follow-up itself ([ADR-046](../adr/046-handler-staged-follow-ups/index.md)):

```rust
.register::<StageA, _, _>(|args, ctx| {
    // A cloneable handle: the returned future cannot borrow `ctx`.
    let follow_ups = ctx.follow_ups();
    async move {
        let item_id = resolve_item(&args).await?;
        follow_ups.enqueue_on_completion(
            EnqueueRequest::new(StageB { item_id }).unique(UniqueOpts::default()),
        )?;
        Ok(JobResult::Completed)
    }
})
```

A `Worker` impl can call `ctx.enqueue_on_completion(..)` directly. In Python, the job passed to the handler has the same method, with the keyword arguments of `Transaction.insert`:

```python
@client.task(StageA, queue="stage_a")
async def resolve(job):
    item_id = await resolve_item(job.args)
    job.enqueue_on_completion(StageB(item_id=item_id), unique_opts={})
```

Staged follow-ups are inserted in the same transaction as the attempt's completion, after any registered `Completed` specs, with the same [deduplication](#deduplicating-follow-ups). The rules:

- **Only `Completed` inserts them.** `RetryAfter`, `Snooze`, `Cancel`, an error, a panic, a rescued or superseded attempt, and `Client::complete_external` all discard them. A retried attempt starts empty and stages again when its handler re-runs. `WaitForCallback` discards them with a warning and the `awa.job.staged_follow_ups_discarded` counter; to build a follow-up from a callback payload, wait with `ctx.wait_for_callback` inside the handler, then stage.
- **A failed stage fails the attempt.** Staging serializes the args, checks the limits, and rejects anything the insert would reject (priority outside 1–4, `max_attempts` outside 1–1000, kind or queue names over 200 characters, more than 20 tags, NUL bytes, a `run_at` outside the years 1–9999), returning `StageError` (Python: `awa.StagingError`, a `TerminalError`, also raised when args or options do not convert). If the handler ignores the error and returns `Completed`, the attempt fails terminally instead of completing without its follow-up.
- **Staging closes when the handler returns.** A task the handler spawned and did not await gets `StageError::Sealed`.
- **Limits per attempt:** 16 follow-ups and 64 KiB of serialized requests, counting kind, queue, args, metadata, tags and ordering key (`MAX_STAGED_FOLLOW_UPS`, `MAX_STAGED_BYTES`). Larger fan-out belongs in a dedicated fan-out job.
- **Cost.** A job that stages takes a per-job completion transaction instead of the batched path; jobs that stage nothing are unaffected.
- **Metrics.** `awa.job.staged_follow_ups_committed` counts the follow-ups of committed completions by `awa.follow_up.result`: `inserted`, or `duplicate` when a job with the same unique key already existed. The Grafana dashboard's "Staged Follow-ups" panel shows both beside the discards.
- **Deploy order.** A worker that does not know the follow-up's kind fails it terminally. Deploy the workers that handle the follow-up first, or put it on a queue only they poll.

The test harnesses behave the same way: `awa_testing::TestClient::work_one` and Python's `AwaTestClient.work_one` insert staged follow-ups when the handler completes, and `awa.testing.HandlerJob` records them in `job.staged` for unit tests without a database.

A handler that also wants to leave a record of what it did for people to inspect can record a [job output](../job-output/index.md) beside the follow-up. The follow-up still gets its value in its args: a job must not read another job's output, and registered specs receive the row without it. `Completed` events carry the output.

### Atomicity guarantees

| Emission site | Atomic with state commit? |
| --- | --- |
| Worker-driven outcomes (handler returns `Ok`/`Err`) on either storage engine | **yes** — one transaction, one commit |
| Callback resolution on `Client` (`complete_external`, `fail_external`, `retry_external`, `resolve_callback`) on either storage engine | **yes** — one transaction, one commit. A spec INSERT failure (or a panic in the user-supplied closure) rolls the callback transition back together with the follow-up; `Client::*_external` returns `Err` and the external sender can retry. |
| Maintenance rescue (stale heartbeat / deadline / expired callback) | **no — best-effort.** The rescue commits first, then specs dispatch in a separate tx. A spec failure leaves the rescue applied and is logged. |

The trigger transition and the follow-up `INSERT` either both commit or neither does — exactly-once _enqueue_ per committed worker outcome or callback resolution. The one exception is a follow-up with unique options whose key already exists: that insert is skipped and the transition commits on its own (see [Deduplicating follow-ups](#deduplicating-follow-ups)). The follow-up itself, once enqueued, is delivered with Awa's usual at-least-once semantics, so the follow-up handler still needs to be safe under re-execution.

Rescue stays best-effort by design: making it atomic would couple rescue liveness to the correctness of user follow-up code (a panic in `on_rescued_enqueue` would roll back a stale-heartbeat rescue and leave the job stuck in `running`). Zero-loss rescue notifications belong in an outbox/sweeper, not inlined into the rescue tx.

### When to choose which API

- **Observation** (metrics, traces, alerts) → `on_event` — cheap, in-process, acceptable to drop a sample on a crash.
- **Side effect that must survive a crash** (send email, persist record, kick off downstream work) → `on_*_enqueue` — slightly more expensive (it's an Awa job) but durable, retried, DLQ'd, and visible in admin.
- **Follow-up built from a value the handler computes** → stage it with `enqueue_on_completion`. It has the same atomicity as `on_completed_enqueue`, only for `Completed`.

If you can't tell which you want, use `on_*_enqueue`. The cost of an unnecessary follow-up is bounded; the cost of a lost reliable side effect is not.

See [ADR-015](../adr/015-post-commit-lifecycle-hooks/index.md) for hook rationale, [ADR-029](../adr/029-transactional-followup-jobs/index.md) for the follow-up enqueue design and atomicity matrix, and [ADR-046](../adr/046-handler-staged-follow-ups/index.md) for handler-staged follow-ups.
