Quartz.NETQuartz.NET
Home
Features
Discussions
NuGet
GitHub
Home
Features
Discussions
NuGet
GitHub
  • 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
  • Unreleased Releases

    • Quartz 4.x
      • 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 Stores
        • Configuration, Resource Usage and Building a Scheduler
        • Building a Scheduler Without a Host
        • Clustering
        • Execution Groups
        • Node Affinity (Preferred Node)
        • Testing
      • Configuration Reference
      • JSON Configuration
      • Cron Expression Reference
      • Multi-Tenancy
      • Frequently Asked Questions
      • Best Practices
      • Tenancy Patterns
      • Database Schema
      • Database Schema Changes
      • Migration Guide
      • Troubleshooting
      • API Documentation
      • How To's
        • One-Off Job
        • Rescheduling Jobs
        • Multiple Triggers
        • Job Template
        • 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

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

Rescheduling Jobs

Three quite different things get called rescheduling, and they use three different APIs. Picking the wrong one is how a trigger loses its fire history, or how a "just change the priority" edit silently resets the next fire time.

You want toUseFire times
Change when the job runsRescheduleJobrecomputed from the new trigger
Change the trigger's metadataUpdateTriggerDetailspreserved
Retry this firingJobExecutionException { RefireImmediately = true }untouched

Changing the schedule: RescheduleJob

RescheduleJob is delete-and-store in one call. The old trigger goes, the new one is stored, and the new one must name the same job:

ITrigger replacement = TriggerBuilder.Create()
    .WithIdentity("nightly", "reports")
    .ForJob(new JobKey("build-report", "reports"))
    .WithCronSchedule("0 30 2 * * ?")
    .Build();

DateTimeOffset? firstFire = await scheduler.RescheduleJob(
    new TriggerKey("nightly", "reports"),
    replacement,
    cancellationToken);

The new trigger does not have to keep the old name — passing a different WithIdentity renames it — but it does have to carry a job key, because the old trigger is gone before the new one is stored and there is nothing left to inherit it from.

The return value is the new trigger's first fire time, or null if the old trigger was not found. A null return means nothing was stored: the call is not "create if missing". If you are recovering from a state where the trigger may or may not exist, check the result:

DateTimeOffset? next = await scheduler.RescheduleJob(key, replacement, cancellationToken);
if (next is null)
{
    // the old trigger was gone; store the new one on its own terms
    await scheduler.ScheduleJob(replacement, cancellationToken);
}

Because the trigger is replaced, everything derived from the old one is recomputed. PreviousFireTimeUtc starts empty, a SimpleTrigger's repeat count starts over, and a paused trigger comes back in whatever state the new trigger's group implies. Use it when the schedule changed.

Changing metadata in place: UpdateTriggerDetails

UpdateTriggerDetails patches a stored trigger without rescheduling it. Fire times and trigger state are preserved — a paused trigger stays paused, a trigger due in ten minutes is still due in ten minutes.

bool applied = await scheduler.UpdateTriggerDetails(
    new TriggerKey("nightly", "reports"),
    new TriggerDetailsUpdate()
        .WithPriority(10)
        .WithDescription("moved up ahead of the invoice run"),
    cancellationToken);

TriggerDetailsUpdate is a patch, not a snapshot: each With… call marks one property as "change this", and everything you do not call is left alone. That is what makes null meaningful — WithCalendarName(null) disassociates the calendar, where not calling WithCalendarName leaves the existing association in place.

MethodChanges
WithDescription(string?)the description
WithPriority(int)acquisition priority
WithJobDataMap(JobDataMap)the trigger's job data map, wholesale
WithCalendarName(string?)the associated calendar; null or blank disassociates
WithMisfireInstruction(…)the misfire policy — five family-typed overloads
WithMisfireInstructionCode(int)the same, as a raw code
WithExecutionGroup(string?)the execution group; null removes it from every group
WithPreferredNode(PreferredNode)the cluster node pin

The return value is true when the trigger was found and updated, false when the key names nothing.

Two of these do affect firing, just not the fire times: the misfire instruction changes what happens the next time the trigger is late, and the execution group changes which limit the job counts against — from the next acquisition cycle, so a job already running keeps counting against the group it was acquired under.

Misfire instructions are validated against the trigger's family

The same numeric code means a different policy in each trigger family: 1 is FireNow on a simple trigger and FireOnceNow on a cron trigger. The typed overloads carry the family with the value, and the store rejects an update whose family is not the stored trigger's:

// fine — the key resolves to a cron trigger
await scheduler.UpdateTriggerDetails(cronKey, new TriggerDetailsUpdate()
    .WithMisfireInstruction(CronTriggerMisfireInstruction.DoNothing));

// rejected — the key resolves to a cron trigger, not a simple one
await scheduler.UpdateTriggerDetails(cronKey, new TriggerDetailsUpdate()
    .WithMisfireInstruction(SimpleTriggerMisfireInstruction.FireNow));

WithMisfireInstructionCode(int) exists for callers holding a bare number — a value read off the wire, out of configuration, or from ITrigger.MisfireInstructionCode. It skips the family check, which is exactly why the typed overloads are the ones to reach for.

Changed in 4.x

The builders spell this WithMisfireInstruction now, on all five schedule builders — WithMisfireHandlingInstruction… and the MisfireInstruction.* constant class are gone. The typed enums (SimpleTriggerMisfireInstruction, CronTriggerMisfireInstruction, and the three others) are the public vocabulary.

Choosing between them

  • The schedule changed — a different cron expression, a different interval, a new end date: RescheduleJob. There is no way to edit a schedule in place, because a schedule is the trigger.
  • Anything on the table above changed: UpdateTriggerDetails. It is one statement rather than a delete and an insert, it does not disturb the fire times, and it does not need you to rebuild the trigger to change its description.

The tempting middle path — read the trigger, GetJobBuilder-style rebuild it, store it back — is a RescheduleJob with extra steps, and it quietly resets the same state.

Retrying inside the job

A job that failed for a transient reason can ask to be run again immediately:

public sealed class ImportJob(IImportService importer) : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        try
        {
            await importer.Run(cancellationToken);
        }
        catch (TransientImportException ex) when (context.RefireCount < 3)
        {
            throw new JobExecutionException(ex) { RefireImmediately = true };
        }
    }
}

RefireImmediately re-executes the same firing straight away, on the same thread-pool slot, and context.RefireCount counts how many times that has happened — guard on it, or a permanently failing job becomes a hot loop.

The same exception carries two unschedule flags for the failures that are not worth retrying:

  • UnscheduleFiringTrigger = true removes the trigger that fired
  • UnscheduleAllTriggers = true removes every trigger of the job
throw new JobExecutionException($"account {id} no longer exists")
{
    UnscheduleAllTriggers = true,
};

Changed in 4.x

JobExecutionException has four constructors — (), (Exception), (string) and (string, Exception) — and the three flags are init-only properties rather than constructor parameters. The 3.x new JobExecutionException(msg, cause, refireImmediately) shapes are gone; write new JobExecutionException(ex) { RefireImmediately = true }.

Backoff without holding a thread

RefireImmediately means immediately, and an in-job retry loop — Task.Delay, Polly, a while with a sleep — holds a thread-pool slot for the whole backoff. On a scheduler with a pool of ten, three jobs backing off for a minute each have taken a third of the scheduler for a minute.

When the retry can wait, store a one-off trigger and return normally:

public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
    try
    {
        await importer.Run(cancellationToken);
    }
    catch (TransientImportException) when (context.RefireCount == 0)
    {
        ITrigger retry = TriggerBuilder.Create()
            .WithIdentity($"{context.Trigger.Key.Name}-retry-{context.FireInstanceId}", "retries")
            .ForJob(context.JobDetail.Key)
            .StartAt(DateTimeOffset.UtcNow.AddMinutes(5))
            .Build();

        await context.Scheduler.ScheduleJob(retry, cancellationToken);
    }
}

The trigger name matters. Reuse one fixed retry name and the second retry collides with the first — ObjectAlreadyExistsException, from inside a job, which is a confusing place to debug it. The fire instance id is unique per firing and makes a good suffix.

A one-off trigger that has fired and has no next fire time is removed by the store, and the job with it if the job is not durable, so retries do not accumulate.

Recovering triggers that failed

A trigger whose job threw in a way the scheduler could not recover from lands in TriggerState.Error and stops firing. Finding them is a query — and it pages, so a recovery script must loop rather than assume one call sees everything:

TriggerQuery broken = new() { State = TriggerState.Error, Take = 250 };

while (true)
{
    PagedResult<TriggerHeader> page = await scheduler.QueryTriggers(broken, cancellationToken);
    if (page.Items.Count == 0)
    {
        break;
    }

    List<TriggerKey> keys = page.Items.Select(h => h.Key).ToList();
    List<TriggerKey> reset = await scheduler.ResetTriggersFromErrorState(keys, cancellationToken);
    logger.LogInformation("Reset {Count} triggers", reset.Count);

    if (!page.HasMore)
    {
        break;
    }
}

ResetTriggerFromErrorState(key) returns true when the trigger existed and was in the error state, false for a key that names nothing or a trigger that was not in error — the same missing-key rule PauseTrigger, ResumeTrigger and UnscheduleJob follow.

ResetTriggersFromErrorState(keys) does the whole set in one pass, under one lock and one transaction on the ADO store, and returns the keys it actually reset, in the order they were given. Keys it did not apply to are absent, never an error. Resetting raises no scheduler-listener event and signals no scheduling change; the reset triggers are picked up by the next acquisition cycle.

The reset puts the trigger back to Normal, or to Paused if its group is paused.

Tips

Reset is not a fix. A trigger goes into the error state because something about it could not be processed — most often a job type that no longer resolves. Resetting it without addressing that just puts it back into error on the next fire.

See also

  • Job Template — the job skeleton these snippets fit into
  • Querying Jobs and Triggers — paging, filters and the counting idiom
  • More About Triggers — misfire instructions in full
Help us by improving this page!
Last Updated: 8/23/26, 6:42 AM
Contributors: Marko Lahma
Prev
One-Off Job
Next
Multiple Triggers