# Write your own job handler

> Tracon documentation. Tracon is a .NET package family that adds a control
> plane on Microsoft Agent Framework, published as NuGet packages that run
> inside your own ASP.NET Core application.
>
> Page: https://tracon.dev/guides/write-your-own-job-handler/
> Summary: Implement a safe IJobHandler for the durable job queue and verify it with Tracon's executable contract suite.

:::note[Preview packages]
Tracon is published as `1.0.0-preview.6`. Use `--prerelease` for discovery or
pin the exact version for reproducible builds.
:::

`IJobHandler` executes a durable job under one **handler key** — a stable
string such as `contoso.nightly-report`. The key is the job's identity: it is
both how the job is classified and how the background worker picks the handler
that runs it. Tracon's own handlers live under the reserved `tracon.`
prefix; you pick a prefix of your own.

```csharp
public sealed class NightlyReportJobHandler(ILogger<NightlyReportJobHandler>? logger = null) : IJobHandler
{
    public async ValueTask ExecuteAsync(JobContext context, CancellationToken cancellationToken = default)
    {
        foreach (var item in context.Items)
        {
            // context.Items is UNFILTERED: a retry hands back items an
            // earlier, now-dead attempt already finished. Skip them.
            if (item.Status != JobItemStatus.Pending)
            {
                continue;
            }

            if (await context.IsCancelledAsync(cancellationToken))
            {
                break;
            }

            logger?.LogInformation("Nightly report generated for '{Subject}'.", item.Input);

            await context.ReportItemAsync(
                new JobItemResult { JobId = context.Job.Id, Seq = item.Seq, Status = JobItemStatus.Completed },
                cancellationToken);
        }
    }
}
```

Register it with the key it answers to:

```csharp
builder.Services.AddJobHandler<NightlyReportJobHandler>("contoso.nightly-report");
```

The worker looks the key up by exact match, so **this call may come before or
after `AddTracon()`** — registration order never decides which handler
runs, and no handler can shadow another. The handler is registered *scoped*
and resolved from a fresh scope for every execution, so it may take scoped
dependencies in its constructor: two jobs running in parallel, and two attempts
of the same job, never share an instance.

```mermaid
flowchart TD
    accTitle: How a job finds its handler
    accDescr: A registration adds a key and a handler type to the registry, which rejects a duplicate key, a reserved key and a malformed key at host start. When the worker leases a job it looks the job's handler key up in that registry; an unregistered key fails the job with a stable error code, and a registered one opens a fresh dependency-injection scope, resolves the handler from it and runs it.
    REG["AddJobHandler&lt;T&gt;(&quot;contoso.nightly-report&quot;)"] --> MAP["Handler registry:<br/>key to type"]
    MAP --> CHECK{"Duplicate, reserved,<br/>or malformed key?"}
    CHECK -- yes --> STOP["Host does not start"]
    CHECK -- no --> READY["Ready"]
    READY --> LEASE["Worker leases a job"]
    LEASE --> LOOK{"Is job.handlerKey<br/>in the registry?"}
    LOOK -- no --> FAIL["Job fails with<br/>UnknownHandlerKey"]
    LOOK -- yes --> SCOPE["New DI scope per execution"]
    SCOPE --> RUN["ExecuteAsync"]
    RUN --> DISPOSE["Scope disposed"]
```

Three mistakes stop the host from starting rather than failing quietly later:

- **Two handlers under one key.** A key identifies exactly one handler.
- **A key inside the `tracon.` namespace.** That prefix is reserved.
- **A malformed key.** 1-128 characters: lowercase ASCII letters, digits,
  `.`, `_`, or `-`, starting with a letter or digit. Uppercase is rejected
  rather than normalized, so one key can never become two.

## Queue work for your handler

`IJobDispatcher` is the supported way to create a job from your own code. It
fills in the identifier, status and timestamps, and refuses a key no handler
serves:

```csharp
public sealed class ReportScheduler(IJobDispatcher jobs)
{
    public ValueTask<JobRecord> QueueNightlyAsync(string tenantId, IReadOnlyList<string> customers)
        => jobs.EnqueueAsync(new JobRequest
        {
            TenantId = tenantId,
            HandlerKey = "contoso.nightly-report",
            TargetName = "nightly-report",
            Items = customers,
            MaxDuration = TimeSpan.FromMinutes(30), // optional: each attempt's time limit
        });
}
```

If a job is somehow queued for a key nobody registered — a stale row, a
mistyped key — the worker fails that job with the stable code
`JobErrorCodes.UnknownHandlerKey` instead of leaving it in the queue forever.
The raw key is written to the log, not to the job's `errorMessage`: that field
is read back over HTTP, and an unregistered key may have come from an
untrusted source.

:::note[Scheduling from outside is opt-in]
`PUT /api/schedules/{name}` accepts only the keys listed in
`TraconSchedulingOptions.HttpSchedulableHandlerKeys`, which defaults to
Tracon's own built-in keys. A handler key is a dispatch identity, so an
unrestricted endpoint would turn every registered handler — including internal
ones you registered for your own background work — into an externally callable
surface. Name your key there only when you want it schedulable over HTTP.

A non-empty list **replaces** that default rather than extending it, so an
allow-list can also narrow the surface. To keep the built-in keys as well, name
them alongside your own:

```csharp
builder.Services.UseScheduling(options =>
{
    foreach (var key in JobHandlerKeys.BuiltIn)
    {
        options.HttpSchedulableHandlerKeys.Add(key);
    }

    options.HttpSchedulableHandlerKeys.Add("contoso.nightly-report");
});
```
:::

## Runtime contract

**Execution is at-least-once, not exactly-once.** The same job — the same
`JobContext.Job`, with the SAME item list — can reach `ExecuteAsync` more than
once: a thrown exception is retried up to the job's attempt limit
(`JobRetryException.RetryAfter` controls the delay), and a worker that
crashes, or whose lease lapses, lets another worker (or the same one, on its
next poll) re-lease the job and call `ExecuteAsync` again from scratch.
Neither path resets item progress. A store error after `ExecuteAsync` has
returned is not a handler failure: the worker tries the write of the outcome
again while it still holds the lease, and does not call the handler again for
it.

**A running handler is never interrupted by its own lease.** The worker renews
the lease on a timer for as long as `ExecuteAsync` has not returned. A job is
bounded only by a time limit you give it: `JobRequest.MaxDuration` for one job,
or `Tracon:Scheduling:MaxDurationByHandlerKey` for every job of a handler key
(the job's own value wins; no value means no limit). The limit applies to each
attempt.

**The `CancellationToken` you receive is cancelled for three reasons**, and the
worker decides the outcome from the reason, not from the exception your
handler throws:

| Reason | When | Outcome |
|---|---|---|
| The worker stops | Host shutdown | Nothing is written; the job is leased again when its lease runs out |
| The job is cancelled | `POST /api/jobs/{id}/cancel`, read every `PollInterval` (10 seconds by default) | The job stays `Cancelled`, with no error |
| The time limit passes | The attempt ran longer than its limit | The attempt fails with `tracon.job.max-duration-exceeded`; the job is leased again while attempts are left |

A handler that returns normally after its token was cancelled completes the
job: the work it did stands. A handler that ignores its token is waited for,
and keeps its worker slot (which counts against `MaxConcurrentJobs`, default 2)
until it returns. Pass the token to every call the handler makes.

`JobContext.Items` reflects that: it carries **every** item, not only the
`Pending` ones. On a retry, items already `Completed` or `Failed` from an
earlier attempt are present too. A handler with a side effect (an email, a
payment, a call to an external system) must therefore either be idempotent on
its own, or — the pattern every built-in handler uses, shown above — check
`JobItemRecord.Status` and skip anything that is not `Pending`.

`IsCancelledAsync` reflects a `POST /api/jobs/{id}/cancel` request at the moment
you ask; check it between items so a canceled job stops before its next item
even when the token has not fallen yet. `ReportItemAsync` must be called for every item the
handler actually processes — it updates the item's persisted status and the
job's `doneItems`/`failedItems` counters, which the job list and detail
endpoints read.

A handler that throws is retried by the worker itself; there is no need to
catch and report a job-level failure by hand. Report per-item failures with
`JobItemStatus.Failed` and continue the loop instead, if a single bad input
should not stop the rest of the batch — a batch with both successful and
failed items still completes as `Completed`, and only a batch where every
item failed completes as `Failed`. Keep the `ReportItemAsync` call outside the
`try` that guards the item's own work: inside it, a store error while reporting
a finished item is caught as that item's failure, and work that succeeded is
recorded as `Failed`.

## Verify your implementation

Add the contract package to your test project:

```bash
dotnet add package Tracon.Testing.Contracts.Xunit --prerelease
```

```csharp
using Tracon.Testing.Contracts.Scheduling;

public sealed class NightlyReportJobHandlerTests : JobHandlerContract
{
    protected override string HandlerKey => "contoso.nightly-report";

    protected override ValueTask<IJobHandler> CreateHandlerAsync()
        => ValueTask.FromResult<IJobHandler>(new NightlyReportJobHandler());

    protected override JobItemRecord CreateItem(int sequence, JobItemStatus status)
        => new() { Id = Guid.NewGuid(), JobId = JobId, Seq = sequence, Input = $"customer-{sequence}", Status = status };
}
```

The inherited tests verify that a `Completed` item is not processed again,
that a retry with mixed item statuses only processes the `Pending` ones, and
that cancellation is observed between items. They do not test the queue
itself — leasing, retry scheduling, or persistence; see
[Reliable runs](/guides/reliability/) for that.
`samples/Tracon.Samples.CustomJobHandler` in the repository runs this same
contract as a package consumer, and also boots a real host to prove the sample
handler actually receives a queued job.

## Read next

- [Jobs, schedules, and queues](/guides/background-work/) — leasing, retries, cancellation, and multi-instance behavior
- [Reliable runs](/guides/reliability/) — what happens to a queued run when a worker dies during execution
- [Testing](/guides/testing/) — test package guidance
