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

Node affinity controls which cluster node runs a specific trigger. Use it when a job keeps in-memory state between runs, such as a cache or a warmed-up connection, and should keep running on the same node. Affinity decides where a trigger runs; Execution Groups decide how many threads of a node a category of job may use. The two compose.

Concepts

A preferred node is an optional property of a trigger naming the scheduler instance that should acquire it. It is per trigger, so for job-level affinity set the same value on all of a job's triggers.

ITrigger.PreferredNode is a PreferredNode value:

ValueMeans
PreferredNode.For("node-1")pinned to that scheduler instance id (the node's Scheduler:InstanceId)
PreferredNode.Autoauto-pin: the first node to fire the trigger claims it
PreferredNode.None (the default)no preference; standard Quartz behavior

A preferred node is a strong preference with automatic failover, not a hard constraint. Acquisition skips triggers pinned to live nodes; if the pinned node is not checking in, other nodes take over. See Failover behavior.

Two columns on QRTZ_TRIGGERS store it:

PREFERRED_NODEPREFERRED_NODE_AUTOMeaning
NULLfalseNo affinity (PreferredNode.None, the default)
'*'falseAuto-pin requested, not yet claimed (PreferredNode.Auto)
'node-1'trueAuto-claimed by node-1
'node-1'falseNamed pin to node-1 (PreferredNode.For("node-1"))

The node name is stored verbatim and the auto-claim separately, so no instance id is reserved: a node may be called auto:thing or *-west. PreferredNode.For refuses only the protocol's markers (*, _, null).

Setting the preferred node

Use TriggerBuilder.WithPreferredNode():

// Pin to a specific node
ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("myTrigger")
    .ForJob(job)
    .WithPreferredNode(PreferredNode.For("production-node-1"))
    .WithCronSchedule("0 0/5 * * * ?")
    .Build();
// Auto-pin: the first node to fire it claims it
ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("myTrigger")
    .ForJob(job)
    .WithPreferredNode(PreferredNode.Auto)
    .WithCronSchedule("0 0/5 * * * ?")
    .Build();

Read it back from any ITrigger:

ITrigger? t = await scheduler.GetTrigger(new TriggerKey("myTrigger"));
PreferredNode pin = t!.PreferredNode;   // GetTrigger returns null when there is no such trigger
string? node = pin.Node;         // "production-node-1"; null when unpinned or an unclaimed auto-pin
bool auto = pin.IsAutomatic;     // false for a pin you named
bool unpinned = pin.IsNone;

Warning

The value must match the instance id exactly. Pins are compared in SQL with the database's string collation, so a value differing only in case is a different node, and on a case-sensitive database it never matches.

In a scheduling file

A deployment-specific scheduling file can set a pin. In JSON (the Quartz:Schedule section of appsettings.json, or a standalone quartz_jobs.json) it is a common trigger field:

{
  "Name": "nightlyReport",
  "JobName": "reportJob",
  "PreferredNode": "production-node-1",
  "Cron": { "Expression": "0 0 2 * * ?" }
}

In quartz_jobs.xml it is an optional <preferred-node> element, after <retry-policy> and before <job-data-map>:

<trigger>
  <cron>
    <name>nightlyReport</name>
    <job-name>reportJob</job-name>
    <preferred-node>production-node-1</preferred-node>
    <cron-expression>0 0 2 * * ?</cron-expression>
  </cron>
</trigger>
  • Both formats spell * for an automatic pin.
  • Both leave the trigger unpinned when the value is absent, so re-reading a file clears a pin set some other way, as it clears an execution group.
  • A reserved node name is refused when the file is read, naming the trigger.

Warning

A file naming a node that this cluster does not have pins the trigger to a node that is never live, so any node fires it and the pin does nothing. Name a node only in a file that belongs to one deployment; use * in a file read by more than one.

Auto-pin mode

With PreferredNode.Auto:

  1. Any node can acquire the trigger, as usual.
  2. The first node to fire it writes its own instance id to PREFERRED_NODE and sets PREFERRED_NODE_AUTO. The write is a compare-and-swap against the value seen at acquisition, so a concurrent re-pin or clear wins over the claim.
  3. From then on only that node acquires the trigger, until it stops checking in.

Use it when you do not know node names at configuration time but want a trigger to stay put.

Rebuilding an auto-pinned trigger keeps the auto-claim:

// The rebuilt trigger is still auto-pinned, so it will still fail over if that node dies
ITrigger rebuilt = trigger.GetTriggerBuilder().WithDescription("updated").Build();

The pin carries its own auto-claim flag, so a pin moved to another trigger arrives unchanged:

// a pin you named; IsAutomatic is false
ITrigger named = trigger.GetTriggerBuilder()
    .WithPreferredNode(PreferredNode.For("node-2"))
    .Build();

// no preference at all
ITrigger unpinned = trigger.GetTriggerBuilder()
    .WithPreferredNode(PreferredNode.None)
    .Build();

ITrigger.PreferredNode is read-only, like the rest of a trigger. To change it, rebuild the trigger and pass it to IScheduler.RescheduleJob, or update the trigger in place.

Failover behavior

When the preferred node stops checking in:

  1. Acquisition. A node whose last check-in is older than the cluster check-in threshold is treated as dead, so surviving nodes can acquire its pinned triggers immediately, without waiting for cluster recovery.
  2. Steal on fire. A node that fires a trigger still auto-claimed by a stale node takes the pin over by compare-and-swap, so affinity moves to a live node instead of bouncing.
  3. Cluster recovery. When recovery confirms a node dead, its auto-claimed pins are reset to unclaimed auto-pins before its state row is deleted. Any eligible node can then claim them, respecting execution group limits.
  4. Named pins are kept. They are never re-pinned. Other nodes run the trigger while the node is down; when it checks in again, it takes the trigger back.

Updating the preferred node at runtime

Re-pin without rescheduling:

await scheduler.UpdateTriggerDetails(
    new TriggerKey("myTrigger"),
    new TriggerDetailsUpdate().WithPreferredNode(PreferredNode.For("node-2")));

Pass PreferredNode.None to clear the preference:

await scheduler.UpdateTriggerDetails(
    new TriggerKey("myTrigger"),
    new TriggerDetailsUpdate().WithPreferredNode(PreferredNode.None));

Requirements and limitations

  • Clustering and a stable instance id. Affinity only applies in a cluster (store.UseClustering(), see Clustering) with a stable Scheduler:InstanceId. With no id set, UseClustering() turns GenerateInstanceId on, and the default generator's id changes on every restart, so a stored pin names a node that no longer exists. Set InstanceId on each node that a pin names, or use PreferredNode.Auto. A node with a generated id logs event 4031 at start when declared triggers are pinned with PreferredNode.For, naming up to five of them.
  • RAMJobStore ignores it. The pin is stored and returned but never filters acquisition; a single-node in-memory scheduler always runs the trigger.
  • Pinned to a node that never registers. If the target instance id has never checked in, the trigger is eligible everywhere. Affinity is a preference, not a guarantee; check the id's spelling.
  • A live but saturated node keeps its pin. If the pinned node is up but its execution group is at its limit, the trigger waits for that node. Failover reacts to node death, not to busyness.
  • A node that refuses the trigger's group keeps its pin too. A trigger pinned to a node whose routing refuses its execution group waits while that node is live: the pin and the route are both WHERE clauses, and no node satisfies both. That node's health check reports the group as unserved.
  • Brief spread during failover. Between a node dying and ownership moving, a fast-firing trigger may run on more than one surviving node.

Schema

PREFERRED_NODE and PREFERRED_NODE_AUTO are part of the 4.x QRTZ_TRIGGERS schema. When upgrading from 3.x, apply the script for your database in database/migrations/4.0/. If you already ran 3.19's optional node-affinity migration, the columns exist and no data migration is needed; both versions store pins identically. See Database Schema Changes.

Help us by improving this page!
Last Updated: 10/19/26, 11:45 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
Execution Groups
Next
Testing