Quartz.NETQuartz.NET
Home
Features
Blog
Discussions
NuGet
GitHub
Home
Features
Blog
Discussions
NuGet
GitHub
  • Getting Started

    • Overview
    • Quartz 4 Quick Start
    • Tutorial
      • Using Quartz
      • Jobs And Triggers
      • More About Jobs & JobDetails
      • Job Data
      • More About Triggers
      • Querying Jobs and Triggers
      • Simple Triggers
      • Cron Triggers
      • RecurrenceTrigger
      • Time and TimeProvider
      • Trigger and Job Listeners
      • Scheduler Listeners
      • Job Execution Middleware
      • Job Stores
      • Configuration, Resource Usage and Building a Scheduler
      • Building a Scheduler Without a Host
      • Clustering
      • Execution Groups
      • Node Affinity (Preferred Node)
      • Testing
      • Compile-Time Checks
      • Declaring Jobs with Attributes
      • Delegate Jobs
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • Comparison
    • Frequently Asked Questions
    • Best Practices
    • Before You Go Live
    • Operating a Cluster
    • Log Events
    • Tenancy Patterns
    • Database Schema
    • Database Schema Changes
    • Migration Guide
    • Troubleshooting
    • API Documentation
  • How To's
    • One-Off Job
    • Rescheduling Jobs
    • Backfill
    • Retrying Failed Jobs
    • Pausing with a Reason
    • Job Continuations
    • Overlap Policy
    • Progress and Execution Logs
    • Job Outcomes
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • Coming from Hangfire
    • Coming from TickerQ
    • Embedding Quartz in a Library
    • Running under an External Leader Election
    • Publishing Trimmed and Native AOT
    • Extending Quartz: what is open, what is closed, and how to ask
    • A Job Store of Your Own
    • A Driver Delegate for a New Database
    • Persisting a Custom Trigger Type
    • A Lock Handler of Your Own
  • Packages

    • Quartz Core Additions

      • Jobs
      • Serialization (System.Text.Json)
      • JSON Serialization
      • Plugins
    • Integrations

      • Aspire Integration
      • ASP.NET Core Integration
      • HTTP API
      • HTTP Client
      • Dashboard
      • Dashboard Agent
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • Observability
      • Redis Lock Handler
      • TimeZoneConverter Integration
      • Weasel Schema Management
    • 3rd Party Plugins for Quartz
  • Quartz 3.x

    • Getting Started

      • Quartz 3 Quick Start
      • Tutorial
        • Using Quartz
        • Library Overview
        • Jobs And Triggers
        • More About Jobs
        • More About Triggers
        • Execution Groups
        • Node Affinity (Preferred Node)
        • Simple Triggers
        • Cron Triggers
        • RecurrenceTrigger
        • Trigger and Job Listeners
        • Scheduler Listeners
        • Job Stores
        • Tuning the Scheduler
        • Configuration, Resource Usage and SchedulerFactory
        • Advanced (Enterprise) Features
      • Configuration Reference
      • JSON Configuration
      • Multi-Tenancy
      • Frequently Asked Questions
      • Best Practices
      • Tenancy Patterns
      • Troubleshooting
      • API Documentation
      • Database Schema
      • Database Schema Changes
      • Migration Guide
      • Miscellaneous Features
    • How To's

      • One-Off Job
      • Multiple Triggers
      • Job Template
      • Using the CronTrigger
      • Rescheduling Jobs
    • Packages

      • Quartz Core Additions

        • Dashboard
        • Jobs
        • Serialization (System.Text.Json)
        • Serialization (Newtonsoft Json.NET)
        • Plugins
      • Integrations

        • ASP.NET Core Integration
        • Hosted Services Integration
        • Microsoft DI Integration
        • Multiple Schedulers with Microsoft DI
        • OpenTelemetry Integration
        • OpenTracing Integration
        • Redis Lock Handler
        • TimeZoneConverter Integration
      • 3rd Party Plugins for Quartz
  • Old Releases

    • Quartz 2.x
      • Quartz 2 Quick Start
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
        • CronTrigger Tutorial
      • Configuration Reference
      • Migration Guide
      • API Documentation
    • Quartz 1.x
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
      • API Documentation
  • License

Job Execution Middleware

Middleware wraps every job a scheduler executes. Use it for a cross-cutting concern that must surround the call to the job: a log scope, a tenant context, a metric, a rate limit, translating a third-party library's exceptions.

A listener cannot do this: the job runs between its two notifications, so it cannot open an await using around the job, skip it, or catch its exception. Before 4.0 such code had to be a job that wrapped another job.

The interface

public delegate ValueTask JobExecutionDelegate(IJobExecutionContext context, CancellationToken cancellationToken);

public interface IJobExecutionMiddleware
{
    ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken = default);
}

next is the rest of the chain, ending in the job. Await it to run the job; if you do not, the job does not run. Awaiting it twice runs the job twice in one firing; that is not a retry (see Translating exceptions).

Writing one

A middleware that puts the firing's tenant on every line the job logs:

public sealed class TenantLogScopeMiddleware(ILogger<TenantLogScopeMiddleware> logger) : IJobExecutionMiddleware
{
    public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken = default)
    {
        // Which firing a line belongs to is AddJobLogScope's; this adds what only the application knows.
        using IDisposable? scope = logger.BeginScope(new Dictionary<string, object?>
        {
            ["tenant"] = context.MergedJobDataMap.GetString("tenant"),
        });

        await next(context, cancellationToken);
    }
}

For the firing's own identity, use the built-in log scope instead of writing one.

Registering

Register middleware where the scheduler is configured, in the same three shapes as listeners: built by the container, built by you from the container, or an instance you already have.

builder.AddQuartz(q =>
{
    // built by the container, so it can take dependencies of its own
    q.AddJobMiddleware<TenantLogScopeMiddleware>();

    // built by you, from this scheduler's services
    q.AddJobMiddleware(provider => new MeteredMiddleware(provider.GetRequiredService<IMeterFactory>()));

    // one you already have
    q.AddJobMiddleware(new TenantScopeMiddleware());
});

The standalone builder takes the same calls, because it is an IQuartzBuilder:

IScheduler scheduler = await QuartzSchedulerBuilder
    .Create(q => q
        .UseInMemoryStore()
        .AddJobMiddleware<TenantLogScopeMiddleware>())
    .BuildScheduler();

Tips

Registering a middleware for AddQuartz("reporting", …) puts it in that scheduler's pipeline only, like its listeners and job store.

Warning

A middleware is built once, from the container's root, with the scheduler's other resources. Its 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 elsewhere becomes a captive dependency that lives as long as the scheduler.

Take an IServiceScopeFactory and open a scope inside Invoke instead:

public sealed class AuditMiddleware(IServiceScopeFactory scopeFactory) : IJobExecutionMiddleware
{
    public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken = default)
    {
        // A middleware is built once, from the container's root, so a scoped service cannot be a
        // constructor parameter. Resolve it per firing instead.
        await using AsyncServiceScope scope = scopeFactory.CreateAsyncScope();
        AuditLog audit = scope.ServiceProvider.GetRequiredService<AuditLog>();

        await audit.Starting(context.JobDetail.Key, cancellationToken);
        await next(context, cancellationToken);
    }
}

The firing's own scope, the one the job was resolved from, is reachable through IJobExecutionContextAccessor; see Per-firing state.

Order

Middleware runs in registration order, outermost first. The first registered sees the firing before the second and sees its result after it. Plan a log scope or a transaction around this:

q.AddJobMiddleware<A>();     A ─┐
q.AddJobMiddleware<B>();        B ─┐
                                   job
                                B ─┘
                             A ─┘
  • Each call adds a stage; registering a type twice puts it in the chain twice.
  • The chain is composed once, when the scheduler is built, and one instance of each middleware serves every firing. Keep no per-firing state in a field; see Per-firing state.
  • A middleware registered through ConfigureAllQuartzSchedulers always runs inside one registered in a scheduler's own AddQuartz callback, whichever call comes first. So what a library embedding Quartz wraps (an outbox, a unit of work) runs inside what the application wraps (its tenant scope), and a library cannot change that by registering earlier.

Where it runs

Middleware runsSo
after the trigger and job listeners have been notifieda fire a listener vetoed never reaches the pipeline
inside the execution span and the duration measurementits cost counts as the firing's, and anything it traces is a child of Quartz.Job.Execute
outside the run shell's exception handlingwhat it throws is classified as though the job threw it
inside the store's concurrency handling[DisallowConcurrentExecution] is enforced above the pipeline, so it never sees two firings of one job overlap

Short-circuiting

A middleware that does not call next stops the job from running. Everything else about the firing is unchanged: listeners are notified as usual, and the trigger is left where a successful execution leaves it.

public sealed class FeatureFlagMiddleware(FeatureFlags flags) : IJobExecutionMiddleware
{
    public ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken = default)
    {
        // Not calling next means the job does not run. The firing still completes, the listeners are
        // still notified, and the trigger is left where a successful execution leaves it.
        return flags.IsEnabled(context.JobDetail.Key) ? next(context, cancellationToken) : default;
    }
}

This is not a veto. A trigger listener's VetoJobExecution is the scheduler's refusal: it raises JobExecutionVetoed and ends the firing. A middleware that skips the job is invisible from outside.

Translating exceptions

A middleware runs outside the run shell's exception classification. A JobExecutionException it throws is honoured like one the job raised, including RefireImmediately and the unschedule flags, and a plain exception is wrapped the same way. Use middleware to tell Quartz what a library's failures mean:

public sealed class TransientFailureMiddleware : IJobExecutionMiddleware
{
    public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken = default)
    {
        try
        {
            await next(context, cancellationToken);
        }
        catch (HttpRequestException e) when (e.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
        {
            // Quartz understands this; it does not understand HttpRequestException.
            throw new JobExecutionException(e) { RefireImmediately = true };
        }
    }
}

Warning

Catching a failure and awaiting a delay before calling next again is not a retry. It holds a thread-pool slot for the whole wait, and the attempt is lost if the process stops. Use a trigger's retry policy.

Per-firing state

One middleware instance serves every firing, so do not keep firing state in a field. Use one of these:

An AsyncLocal<T>. The value travels with the execution context, so the job and everything it calls read the value their own firing set:

public sealed class TenantScopeMiddleware : IJobExecutionMiddleware
{
    public async ValueTask Invoke(IJobExecutionContext context, JobExecutionDelegate next, CancellationToken cancellationToken = default)
    {
        // An AsyncLocal, not a field: one instance of this middleware serves every firing the scheduler
        // performs, and several of them can be in flight at once.
        TenantScope.Current.Value = context.Trigger.Key.Group;
        try
        {
            await next(context, cancellationToken);
        }
        finally
        {
            TenantScope.Current.Value = null;
        }
    }
}

The job's dependency-injection scope. ConfigureJobScope runs once per firing, before anything in the scope is resolved, and receives the TriggerFiredBundle (see also More About Jobs):

builder.AddQuartz(q =>
{
    // Populated once per firing, before anything in the job's scope is resolved.
    q.ConfigureJobScope((scope, bundle, scheduler) =>
        scope.ServiceProvider.GetRequiredService<TenantHolder>().Tenant = bundle.Trigger.Key.Group);
});

Invoke is not passed an IServiceScope, because the scope belongs to the firing, not to a middleware. Code that needs the firing reads IJobExecutionContextAccessor.Current, which is set for the whole execution, including inside the pipeline on the way in and out.

context.MergedJobDataMap, to pass something to a listener. An AsyncLocal does not reach one: an async method restores its caller's execution context, so a value set in Invoke is gone when the run shell notifies listeners.

  • The merged map is this firing's own copy of the job's and trigger's data, shared by everything that holds the context. A value put in it is visible to the job, the rest of the pipeline and the listeners for the whole firing.
  • Writing to it persists nothing; neither stored map is touched.
  • Data that must outlive the firing goes in context.JobDetail.JobDataMap on a job marked [PersistJobDataAfterExecution], which the job store writes back.

The cancellation token

Forward the token you were given. Passing a different one to next changes the job's Execute parameter but not IJobExecutionContext.CancellationToken, so a job that reads the context sees the wrong token. For that reason the built-in timeout interrupts the firing instead of passing a new token.

A log scope per firing

AddJobLogScope (4.3) registers the log scope middleware Quartz ships. Every line logged inside the pipeline names its firing:

builder.AddQuartz(q =>
{
    // first, so that what every other middleware logs names the firing too
    q.AddJobLogScope();
});
Scope entryValue
quartz.job.name, quartz.job.groupthe job's key
quartz.trigger.name, quartz.trigger.groupthe trigger's key
quartz.fire.instance.idthis firing's FireInstanceId
  • The names are the execution span's attributes, so a log query and a trace query use the same values.
  • Opt-in, because a scope is an AsyncLocal write that copies the execution context on every firing. The scheduler's scope (quartz.scheduler.name, quartz.scheduler.id) is opened once per scheduling loop and needs no call.
  • Register it first, so every other middleware's lines carry it too. Listener notifications and the run shell's own lines about the firing are outside the pipeline, and do not.
  • Calling it twice for one scheduler opens one scope.
  • A provider shows scopes only when it includes them: IncludeScopes = true on the console formatter or on OpenTelemetry's logger.

Timing a job out

AddJobTimeout registers the timeout middleware Quartz ships. It replaces JobInterruptMonitorPlugin, which is gone in 4.0 along with its "AutoInterruptable" and "MaxRunTime" job-data-map keys.

builder.AddQuartz(q =>
{
    // every job gets five minutes, unless it says otherwise
    q.AddJobTimeout(TimeSpan.FromMinutes(5));

    // or: no scheduler-wide budget, and only the jobs carrying [JobTimeout] are bounded
    q.AddJobTimeout();
});

A job sets its own budget with [JobTimeout], as it declares [DisallowConcurrentExecution]. The attribute is inherited from a base class or from an interface the job implements, so a contract can set the budget for everything that implements it:

// Thirty seconds for this job, whatever the scheduler's default is.
[JobTimeout("00:00:30")]
public sealed class ReportJob : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        // Forward the token: a job that never looks at it cannot be stopped by anything, and is simply
        // reported as having timed out once it finally returns.
        await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
    }
}

// No timeout at all, whatever the scheduler's default is.
[JobTimeout("00:00:00")]
public sealed class NightlyRebuildJob : IJob
{
    public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) => default;
}

Precedence:

  1. The job type's [JobTimeout], whenever there is one. Zero means no timeout: the job is exempt from the scheduler-wide default.
  2. Otherwise, AddJobTimeout's argument.
  3. Without an argument, or when AddJobTimeout was never called, nothing is bounded.

What a timeout does:

  1. The firing is interrupted through IScheduler.InterruptFireInstance, the same path as an operator's interrupt. IJobExecutionContext.CancellationToken, the token the job holds, is cancelled and ISchedulerListener.JobInterrupted is raised.
  2. Only the firing that overran is interrupted, by its fire instance id; two concurrent executions of one job are timed separately.
  3. The middleware raises a JobExecutionException naming the budget. Without it the timeout would look like success, because the run shell treats a cancelled context token as a completed firing: no listener would hear of it, no error would be produced, and nothing would retry.

A timeout is a retryable failure. It arrives as a JobExecutionException, so the trigger's RetryPolicy decides what happens next, as for any failure. A retry is a new acquisition with a new fire instance, so the pipeline runs again with the full budget.

Warning

A job that ignores its CancellationToken cannot be stopped. Cancellation is cooperative and nothing in .NET aborts running code. Such a job runs to completion, holding its thread-pool slot, and is reported as timed out only when it returns, so a budget does not free capacity for it. Turn on CA2016, the analyzer that flags a job not forwarding its token.

An exception the job threw that is not a cancellation is kept even when the budget had expired, because it says more about what went wrong. The overrun is logged either way.

Middleware or a listener?

Use middleware when you need toUse a listener when you need to
wrap the execution — a scope, a stopwatch, a transactionbe told that something happened
decide whether the job runs at allveto a fire (ITriggerListener.VetoJobExecution)
catch or translate what the job threwreact to the failure the run shell reports
set ambient state the job will readreact to scheduling events that are not executions at all — a trigger paused, the scheduler shutting down
act only on this scheduler's job executionsselect which jobs or triggers you hear about, with a matcher

Listeners stay notification-only. The two compose: a middleware does its work and a listener records what happened.

Help us by improving this page!
Last Updated: 10/16/26, 2:35 AM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
Scheduler Listeners
Next
Job Stores