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
      • 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

Clustering

A cluster is several scheduler instances sharing one database.

  • Load balancing: whichever node acquires a trigger runs it.
  • Failover: when a node dies, the others recover the jobs it was running, if the job asked for it with RequestRecovery().

Clustering needs a persistent store; RAMJobStore has nothing to share. Use LocalTransactionJobStore, which UsePersistentStore registers.

Enabling it

builder.Services.AddQuartz(q =>
{
    q.ConfigureScheduler(options =>
    {
        // every node in the cluster shares this name: it is what makes them one cluster
        options.InstanceName = "orders";

        // ...and each needs its own id. Generating one is the easy way to be sure.
        options.GenerateInstanceId = true;
    });

    q.UsePersistentStore(store =>
    {
        store.UseSqlServer(connectionString);
        store.UseClustering();
    });
});
  • InstanceName must be the same on every node. It is the SCHED_NAME column of every row. Nodes with different names sharing a database are two schedulers that cannot see each other's work, not a cluster.
  • InstanceId must be different on every node. A node recognises its own check-in row and fired triggers by it. GenerateInstanceId = true derives one at startup from the registered IInstanceIdGenerator (by default host name and timestamp); the flat value AUTO used to mean this. Set an explicit id when something outside Quartz names the node, such as node affinity, which pins a trigger to an id and needs one that survives a restart.
  • The store configuration must match. Nodes may differ in thread pool size and other process-local settings, but must use the same tables, table prefix and serializer.

UseClustering() also turns on database locking; clustering does not work without it.

Tuning the check-in

store.UseClustering(cluster =>
{
    cluster.CheckinInterval = TimeSpan.FromSeconds(10);
    cluster.CheckinMisfireThreshold = TimeSpan.FromSeconds(20);
});
OptionDefaultWhat it does
CheckinInterval00:00:07.5How often this node writes "still alive" to QRTZ_SCHEDULER_STATE.
CheckinMisfireThreshold00:00:07.5How long past a missed check-in another node waits before treating this one as dead and recovering its triggers.
  • A shorter interval notices a dead node sooner, at the cost of more database traffic from every node.
  • Raise the threshold if a node is declared dead while merely busy or on a slow database. A false positive recovers its running jobs, so they run twice.

Caution

Never run clustering on separate machines, unless their clocks are synchronized using some form of time-sync service (daemon) that runs very regularly (the clocks must be within a second of each other). See https://www.nist.gov/pml/time-and-frequency-division/services/internet-time-service-its if you are unfamiliar with how to do this.

Caution

Never start (scheduler.Start()) a non-clustered instance against the same set of database tables that any other instance is running (Start()ed) against. You may get serious data corruption, and will definitely experience erratic behavior.

Caution

Monitor and ensure that your nodes have enough CPU resources to complete jobs. When some nodes are in 100% CPU, they may be unable to update the job store and other nodes can consider these jobs lost and recover them by re-running.

Batching trigger acquisition

A scheduler acquires the triggers it is about to fire in batches. MaxBatchSize defaults to 0, automatic:

StoreTriggers per acquisition
Persistent, clustered or notup to the thread pool's MaxConcurrency
In memory1
  • A persistent store batches. A round there is round trips and a commit, so one round for every trigger already due is cheaper per firing.
  • On a cluster a batch takes the lock, and still pays. Above 1, every acquisition cycle takes the TRIGGER_ACCESS row lock, including cycles that acquire nothing. The measured gain is in Sizing a cluster.
  • MaxBatchSize = 1 keeps a cluster's acquisition off the lock. With AcquireTriggersWithinLock off (the default), a round of one takes no cluster-wide lock.
  • In memory a batch is not a clear win. It measured faster for repeating triggers and slower for a burst of one-offs, so the default stays at one.
  • An explicit value wins. MaxBatchSize = 1 is 4.2's behaviour on every store.

Two settings decide a batch's size:

OptionFlat keyDefault
Scheduler:MaxBatchSizequartz.scheduler.batchTriggerAcquisitionMaxCount0 (automatic)
Scheduler:BatchTriggerAcquisitionFireAheadTimeWindowquartz.scheduler.batchTriggerAcquisitionFireAheadTimeWindow00:00:00
  • MaxBatchSize is the upper bound on how many triggers one acquisition takes.
  • The window decides how many it actually takes: a batch ends at the first trigger's fire time, or now if that is later, plus the window. At zero, the default, a batch takes the triggers already due, or those due at the same instant as the first. Raising MaxBatchSize alone batches a backlog, not spread-out fire times.

For fire times that are close but not equal, change both:

q.ConfigureScheduler(options =>
{
    options.MaxBatchSize = 10;
    options.BatchTriggerAcquisitionFireAheadTimeWindow = TimeSpan.FromSeconds(1);
});
  • Batch when many triggers fire at once (a few hundred at the top of the hour): one acquisition and one TRIGGERS_FIRED round trip replace one of each per trigger. Do not batch triggers a minute apart.
  • Triggers fire early, by up to the window. A one-second window on a schedule with second-level precision changes behaviour.
  • MaxBatchSize may not exceed the thread pool's MaxConcurrency; startup rejects it. Triggers acquired beyond the available threads are held by this node, unfireable by any other, until the pool drains.

A serial job across nodes

A [DisallowConcurrentExecution] job runs on one node at a time, and its other triggers wait. The run's end wakes only the node it ran on, which fires the ones it may. On a cluster, a node with nothing to do looks again sooner when a run elsewhere holds back a trigger of its own:

The triggerIts node looks again
Its fire was refused, or acquisition passed it over, because the job runs elsewhereafter 100 ms, then twice as long each round
Pinned to the node, and held back by a run elsewherethe same
None held backafter IdleWaitTime, as before
  • While the store still reports the trigger held, the wait stops growing at 5 seconds, or IdleWaitTime if that is shorter. A held trigger fires within about 5 seconds of the run ending, and a run that lasts hours costs the node a look every 5 seconds.
  • Once nothing is held, the wait doubles on to IdleWaitTime and stays there.
  • When a pinned trigger's serial job has changed hands since the last look, the wait starts again at 100 ms, once it has grown to 400 ms.
  • Off a cluster nothing changes: a run's end wakes its own node. Through a subclassed driver delegate a held pinned trigger is found after IdleWaitTime; see What the delegate cannot reach.

Before 4.4 a node waited the whole IdleWaitTime in each case, and a pinned trigger fired that late every time (#3988).

Seeing the cluster

Check-ins land in QRTZ_SCHEDULER_STATE. IScheduler.QueryClusterNodes() reads them without SQL:

List<ClusterNode> nodes = await scheduler.QueryClusterNodes();

foreach (ClusterNode node in nodes)
{
    string marker = node.IsCurrentNode ? " (this node)" : "";
    Console.WriteLine($"{node.InstanceId}{marker}: {node.State}, last check-in {node.LastCheckInUtc:u}");
}

// The verdicts come from the same predicate the failover sweep applies, so a node reported
// Failed is one whose in-flight work the cluster is about to take over.
List<ClusterNode> failed = nodes.FindAll(node => node.State == ClusterNodeState.Failed);

Each ClusterNode carries InstanceId, LastCheckInUtc, the CheckInInterval that node was configured with, IsCurrentNode, and a State:

StateMeans
Alivechecking in
Overduemissed a check-in; nothing is recovered from it
Failedthe next check-in pass will take over its work and delete its row
  • The answering node is listed first, then the rest by instance id. The answering node is always listed, even before its first check-in.
  • The state comes from the same predicate the failover sweep uses, so the listing and the recovery cannot disagree. A failed node is listed for a while, then disappears.
  • The states are what this node believes, by its own clock; another reason the clocks must agree.
  • A scheduler that is not clustered returns itself, Alive, with both times null, so a caller need not check whether clustering is on.

To see what each node is doing, join the listing to QueryFireInstances on SchedulerInstanceId:

List<ClusterNode> nodes = await scheduler.QueryClusterNodes();
PagedResult<FireInstance> firings = await scheduler.QueryFireInstances(new FireInstanceQuery
{
    // both states: what a node is holding is as interesting as what it is running, and a
    // reservation left behind by a dead node is what recovery is about to clear
    State = null
});

foreach (ClusterNode node in nodes)
{
    int running = firings.Items.Count(firing =>
        firing.SchedulerInstanceId == node.InstanceId && firing.State == FireInstanceState.Executing);

    Console.WriteLine($"{node.InstanceId} ({node.State}) is running {running} job(s)");
}

The same listing is behind GET /schedulers/{name}/nodes in the HTTP API and the Cluster page of the dashboard.

When a node leaves

A node that is shut down, rather than killed, hands back what it can.

It stops firing before it stops running jobs. The scheduling loop is halted and waited for first:

  • every trigger it had reserved but not fired is released to WAITING for another node's next pass;
  • a firing it had already committed (fired-trigger row written, trigger moved to its next instant) is dispatched, not dropped.

This holds whether or not the shutdown waits for jobs. Before 4.1 an unwaited shutdown could close the thread pool under its own loop and lose that occurrence, recoverable only through RequestRecovery.

It completes the firings it can.

ShutdownRunning jobs
Shutdown(waitForJobsToComplete: true)waited for; nothing is left over
Shutdown(waitForJobsToComplete: false) (the AddQuartzHostedService default)not waited for, but executions in flight get a couple of seconds to report completion before the job store closes

A completion issued after the store closes is refused: the firing stays EXECUTING and, for a [DisallowConcurrentExecution] job, its trigger stays BLOCKED. A job still working when the window closes is abandoned and leaves what a crashed node leaves. A peer resolves it once the check-in lapses, after CheckinInterval + CheckinMisfireThreshold plus a grace period for an execution that may still be alive.

It does not delete its check-in row. The QRTZ_SCHEDULER_STATE row keeps its last timestamp until a peer declares the node failed and recovers it. So a node that leaves is declared failed on the same schedule as one that crashed. Wait for jobs, and allow the window above, so the node leaves nothing to recover.

Asking for recovery

Failover recovers a dead node's executions, only for jobs that call RequestRecovery():

q.AddJob<ChargeInvoicesJob>(j => j
    .WithIdentity("charge-invoices")
    .RequestRecovery());
  • It is off by default and does nothing without a persistent store.
  • When a clustered scheduler decides a peer stopped checking in, it examines every fired-trigger row the peer left. If the job requested recovery, the row becomes a new trigger in the RECOVERING_JOBS group with the original trigger's job data. Otherwise the row is deleted and that occurrence is lost.
  • Read it with IJobDetail.RequestsRecovery. A recovery firing sets IJobExecutionContext.Recovering.
  • A firing a graceful shutdown cancels is not recovered unless the store says so; see A job a shutdown stops.

Which jobs should ask, what recovery re-runs and what it does not, and how a recovered firing identifies itself: What RequestsRecovery re-runs, and when, which also covers making a job safe to re-run. Recovery is for an interrupted execution. A job that threw completed its firing; to re-run it, use a retry policy on its trigger.

Related:

  • Operating a Cluster: rolling upgrades, instance ids in containers, sizing, backup
  • Running under an External Leader Election: when the election already exists
  • Quartz.Examples.Worker: a worker service with a persistent store, configured as this lesson describes
Help us by improving this page!
Last Updated: 10/6/26, 2:13 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
Building a Scheduler Without a Host
Next
Execution Groups