Skip to content
Tracon

IJobHandler

Namespace Tracon · Assembly Tracon.Abstractions.dll

The extension point that provides job execution logic for one handler key.

public interface IJobHandler

Registered with AddJobHandler<T>(handlerKey): the key lives on the registration, not on the type, so the same type can serve two keys and a duplicate key is caught at startup. The background worker looks the key up by exact, ordinal match, so registration order never decides which handler runs — a consumer handler cannot shadow a built-in one, and a built-in one cannot shadow a consumer’s.

Lifetime: the handler is registered scoped and resolved from a fresh dependency-injection scope per execution. Scoped dependencies are therefore safe to take in the constructor: two jobs running in parallel, and two attempts of the same job, never share an instance.

Tenant: runs under the ambient tenant — the worker opens the job’s own tenant scope before calling IJobHandler.ExecuteAsync, so an ITenantContext the handler resolves reports JobRecord.TenantId, not the process default. The record is also on JobContext.Job for a handler that prefers to read it explicitly.

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

ExecuteAsync(JobContext, CancellationToken)

Section titled “ ExecuteAsync(JobContext, CancellationToken)”

Executes the job.

ValueTask ExecuteAsync(JobContext context, CancellationToken cancellationToken = default)

context JobContext

The job context: record, items, reporting, and cancellation check.

cancellationToken CancellationToken

The cancellation token. Cancelled when the worker shuts down, when the job is cancelled (POST /api/jobs/{id}/cancel), and when the attempt reaches the job’s time limit.

ValueTask

The completion task.

Stop when the token is cancelled. The worker cancels it for three reasons and tells them apart itself:

  • The worker is shutting down: the job is left to its lease and is leased again later, as after a crash.
  • The job was cancelled: the worker reads the job’s status every TraconSchedulingOptions.PollInterval (ten seconds by default) while the handler runs, and cancels the token when it finds the job JobStatus.Cancelled or gone. The job stays cancelled whatever the handler does next.
  • The attempt ran past JobRecord.MaxDuration (or TraconSchedulingOptions.MaxDurationByHandlerKey): a handler that throws then fails the attempt with JobErrorCodes.MaxDurationExceeded, and the job is leased again while attempts are left. One that returns normally completes the job.

The token is cooperative: a handler that never reads it is not stopped. Its worker waits for it, and its slot stays taken until it returns.

Execution is at-least-once, not exactly-once. The same job — the same JobContext.Job, with the SAME unfiltered JobContext.Items list — can reach IJobHandler.ExecuteAsync more than once: if the handler throws, the job is retried with IJobStore.ReleaseForRetryAsync (if the attempt limit is not exceeded) or marked as JobStatus.Failed; if the process crashes or its lease lapses, another worker (or the same one) re-leases the SAME job and calls IJobHandler.ExecuteAsync again from scratch. Neither case resets item progress. A store error after IJobHandler.ExecuteAsync has returned is not a handler failure: the worker tries the write of the outcome again while it still holds the lease. Only a write that keeps failing until then leaves the job to the lease-lapse case above.

A running handler is never interrupted by its own lease. The worker renews the lease on a timer for as long as IJobHandler.ExecuteAsync has not returned, and it renews without a limit. A job with no time limit (JobRecord.MaxDuration unset, and no entry for its key in TraconSchedulingOptions.MaxDurationByHandlerKey) can therefore run for ever, and a handler that ignores its token holds its worker slot even past its limit. That slot counts against the worker’s concurrency limit, so no replica can take the work instead. Give any handler that calls out to the network its own timeout as well.

Because of this, a handler with side effects (sending an email, charging a payment, calling an external API) MUST be idempotent, or MUST check JobItemRecord.Status itself and skip any item that is not JobItemStatus.Pending — see JobContext.Items. The handlers Tracon ships all do the latter, and the reusable JobHandlerContract in Tracon.Testing.Contracts.Xunit asserts it.