Job context

Read facts about the current run with IJobContext, log with them, and wrap runs in middleware.

On this page

IJobContext gives a handler read-only facts about the run it is in: the job ID, which run this is, the queue, metadata, schedule and more. Inject it into the handler's constructor. It is not a HandleAsync argument, so handlers that do not need it simply leave it out.

Text
public sealed class SendInvoiceHandler(IJobContext context, ILogger<SendInvoiceHandler> logger, IMailer mailer)
    : IJobHandler<SendInvoice>
{
    public async Task HandleAsync(SendInvoice job, CancellationToken ct)
    {
        logger.LogInformation("Sending invoice {InvoiceId}, job {JobId}, run {Run} of {Max}",
            job.InvoiceId, context.JobId, context.ExecutionNumber, context.MaxExecutions);

        // The job ID is the same on every retry: a good idempotency key.
        await mailer.SendAsync(job.InvoiceId, idempotencyKey: context.JobId.ToString(), ct);
    }
}

There is one context per run. It can only be resolved inside the run's DI scope; resolving it anywhere else throws InvalidOperationException.

What it exposes

Identity

PropertyMeaning
ApplicationThe application name.
JobIdThe job's permanent ID. Stable across retries and manual retries.
AttemptIdThe ID of this one attempt. New on every try.
JobNameThe stored job name, for example email.welcome.
CreatedAtWhen the store created the job.

Runs and budget

PropertyMeaning
ExecutionNumberWhich run this is within the current generation, from 1.
MaxExecutionsThe run budget for this generation.
Generation1 for the original dispatch. A manual retry or rerun starts generation 2, 3 and so on, each with a fresh budget.

ExecutionNumber == MaxExecutions means this is the last automatic try. You might log louder, or send a different alert, on that run.

Routing and payload

PropertyMeaning
Queue, PriorityWhere the job was routed.
OriginalPayloadVersionThe version of the stored bytes.
CurrentPayloadVersionThe version handed to the handler after upgrades.
SerializerProfileId, SerializerProfileVersionrunwick.json, 1.
GetPayload<TJob>()The same upgraded payload the handler received. Useful in middleware. TJob must be the registered current type.

Metadata, tracing and fences

PropertyMeaning
MetadataBusiness metadata passed at dispatch, such as a tenant.
TraceThe W3C traceparent and tracestate captured at dispatch.
FencesOne fencing token per capacity-1 resource scope this run holds. See Rate limits and concurrency.
ScheduleThe schedule occurrence that created this job, or null.
CancellationCausesWhich cancellation causes Runwick has seen. See Cancellation.
ItemsA per-run dictionary for passing data between middleware and the handler. Never saved.

Everything except Items and CancellationCauses stays the same for the whole run.

Scheduled jobs

For a job created by a schedule, Schedule holds:

PropertyMeaning
ScheduleNameThe schedule key.
ScheduledAtThe occurrence this job belongs to.
CoveredFrom, CoveredUntilThe time range the job covers. After a missed run with the RunOnce policy, one job can cover several occurrences.
Text
if (context.Schedule is { } s)
{
    logger.LogInformation("Cleanup for {From:u} to {Until:u}", s.CoveredFrom, s.CoveredUntil);
}

What it does not expose

The context is read-only by design. It has no database connection, no lease, no way to extend the lease or the timeout, and no progress reporting. Lease renewal is done by the worker, not the handler, so a handler cannot keep a job alive forever.

If you need to report progress, write it to your own table or cache keyed by JobId.

Logging

Runwick does not add a logging scope for you. Inject ILogger<T> as usual and include the IDs you need:

Text
using (logger.BeginScope(new Dictionary<string, object>
{
    ["JobId"] = context.JobId.ToString(),
    ["AttemptId"] = context.AttemptId.ToString(),
}))
{
    await DoWorkAsync(job, ct);
}

When tracing is on, each run gets a process {queue} span, and the handler runs inside a runwick.execute child span. Spans your own code starts become its children, and the run is linked to the dispatch that created it. See Observability.

Middleware

Middleware wraps every run, like ASP.NET Core middleware wraps a request. It receives the same context and token as the handler:

Text
public sealed class TenantMiddleware(ITenantAccessor tenants) : IJobMiddleware
{
    public async Task<JobExecutionResult> InvokeAsync(IJobContext context, JobExecutionDelegate next, CancellationToken ct)
    {
        if (!context.Metadata.TryGetValue("tenant", out var tenant))
        {
            return JobExecutionResult.Failed("tenant_missing", "Every job needs a tenant.");
        }

        tenants.Current = tenant;
        context.Items["tenant"] = tenant;
        return await next();   // call exactly once
    }
}

A middleware either calls next() exactly once and returns its result, or returns a result without calling it. Calling next() twice, or returning while it is still running, is a contract fault and fails the job permanently.

ResultMeaning
JobExecutionResult.Succeeded()Success. Return it explicitly if you skip the handler on purpose.
JobExecutionResult.Retry(reason, delay)Retryable failure. Costs one run.
JobExecutionResult.Failed(code, message)Permanent failure.
JobExecutionResult.Cancelled(reason)Ends the job as cancelled.

Register middleware globally, per job, or by alias so that a contracts assembly can name middleware without referencing its code:

Text
runwick.UseMiddleware<TenantMiddleware>();

The order, outer to inner, is: global middleware in registration order, then attribute aliases by Order (ties by alias name), then fluent per-job middleware, then the handler. Middleware is scoped and resolved fresh for every run. A worker that sees an alias nobody registered refuses to start (unknown_middleware_alias); a producer-only app does not check aliases.

Middleware cannot extend leases, reset budgets or finish a job itself. Those are the worker's job.

Next