Extend
Write your own job handler
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.
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:
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.
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<T>("contoso.nightly-report")"] --> 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
Section titled “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:
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.
Runtime contract
Section titled “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
Section titled “Verify your implementation”Add the contract package to your test project:
dotnet add package Tracon.Testing.Contracts.Xunit --prereleaseusing 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 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
Section titled “Read next”- Jobs, schedules, and queues — leasing, retries, cancellation, and multi-instance behavior
- Reliable runs — what happens to a queued run when a worker dies during execution
- Testing — test package guidance