Table of Contents

Interface IJobExecutionMiddleware

Namespace
Quartz
Assembly
Quartz.dll

Wraps every job execution a scheduler performs, so a cross-cutting concern has somewhere to live that is neither the job nor a listener.

public interface IJobExecutionMiddleware

Examples

public sealed class TenantScopeMiddleware(TenantContext tenants) : IJobExecutionMiddleware
{
    public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken = default)
    {
        using IDisposable scope = tenants.Enter(context.Trigger.Key.Group);
        await next(context, cancellationToken);
    }
}

Remarks

A log scope, a tenant context, a timeout, a translation of what a third-party library throws: each of these has to surround the call to the job, and a listener cannot. Listeners are notification-only — they are told a job is about to run and told what it did, but the job runs between the two notifications rather than inside them, so a listener cannot open an await using around it, cannot decline to run it, and cannot catch what it threw. Before this existed the only place to put such code was a job that wrapped another job, which is why several frameworks built on Quartz ship exactly that adapter.

What a middleware may do. Run code before and after next; not call it at all, which means the job does not run; catch what it threw and rethrow something else; and set ambient state that the job and everything it calls will read. What it may not do is decide what the scheduler does with the trigger afterwards, except in the way a job decides it — by throwing a JobExecutionException, whose RefireImmediately and unschedule flags are honoured exactly as they are when the job raises one itself.

Where it runs. Inside the execution span and the duration measurement, so what a middleware costs is part of what the firing cost, and outside the run shell's exception handling, so an exception a middleware lets out is classified as though the job had thrown it. It runs after the trigger and job listeners have been notified that the job is about to execute, which means a fire a listener vetoed never reaches the pipeline at all.

Order. Middleware is registered on the scheduler's builder and runs in registration order, outermost first: the first registered sees the firing before the second, and sees the result after it. The chain is composed once when the scheduler is built, so a middleware instance is shared by every firing of that scheduler and must not keep per-firing state in a field. Per-firing state belongs in an AsyncLocal<T>, or in the job's dependency-injection scope — which is prepared by ConfigureJobScope and read back through IJobExecutionContextAccessor. No IServiceScope is threaded through this signature, because the scope belongs to the firing rather than to any one middleware.

It is built once, from the container's root. The name is ASP.NET Core's, but the lifetime is a listener's: the chain is composed when the scheduler's resources are built, from the root provider, so a middleware's constructor dependencies must be singletons. A scoped one throws Cannot resolve scoped service … from root provider where scope validation is on — the Host's default in Development — and is a captive dependency living as long as the scheduler where it is not. Take IServiceScopeFactory and open a scope inside Invoke(IJobExecutionContext, JobExecutionDelegate, CancellationToken), or read the firing's own scope through IJobExecutionContextAccessor.

A middleware added by ConfigureAllQuartzSchedulers is always inner to one a scheduler's own AddQuartz callback added, whichever call was written first. A library contributes to every scheduler in the container, and what a library wraps — an outbox, a unit of work — belongs inside what the application wraps, such as its tenant scope.

The token. Forward the one you were given. Passing a different token to next changes what the job's Execute parameter is without changing CancellationToken, so the two stop being the same token and a job that reads the context sees the wrong one — which is the trap in writing a timeout as a middleware.

Not a retry mechanism. Catching a failure and awaiting a delay before calling next again holds a thread-pool slot for the whole wait and loses the attempt if the process stops; a trigger's retry policy is the tool for that.

Methods

Invoke(IJobExecutionContext, JobExecutionDelegate, CancellationToken)

Executes this stage of the pipeline.

ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken = default)

Parameters

context IJobExecutionContext

The firing being executed.

next JobExecutionDelegate

The rest of the pipeline. Await it to run the job; do not, and the job does not run.

cancellationToken CancellationToken

Signalled when this execution is interrupted. Pass it on to next and to anything else awaited here.

Returns

ValueTask

See Also