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

One-Off Job

To run a job once, now or at a chosen time, trigger a stored job on demand or schedule a job and trigger built on the spot.

A job registered ahead of time, triggered on demand

Register the job at startup:

builder.Services.AddQuartz(q =>
{
    q.AddJob<AnExampleJob>(j => j
        .WithIdentity("name", "group")
        .StoreDurably());
});

StoreDurably() keeps a job with no triggers in the store; otherwise it is deleted when it has none.

Trigger it from anywhere with the scheduler. TriggerJob fires it once, immediately, and leaves nothing behind:

public async ValueTask RunNow(IScheduler scheduler, CancellationToken cancellationToken)
{
    await scheduler.TriggerJob(new JobKey("name", "group"), cancellationToken: cancellationToken);
}

A JobDataMap passed to it is merged over the job's data for this firing only:

public async ValueTask RunNow(IScheduler scheduler, string customer, CancellationToken cancellationToken)
{
    JobDataMap data = new() { { "CustomerId", customer } };
    await scheduler.TriggerJob(new JobKey("name", "group"), data, cancellationToken);
}

For a job not registered at startup, use AddJob:

IJobDetail job = JobBuilder.Create<AnExampleJob>()
    .WithIdentity("name", "group")
    .StoreDurably()
    .Build();

await scheduler.AddJob(job, new AddJobOptions { Replace = true }, cancellationToken);
  • Replace allows re-registering a taken name; without it the call throws ObjectAlreadyExistsException.
  • StoreNonDurableWhileAwaitingScheduling stores a non-durable job with no trigger, expecting one. Once a trigger arrives the job is ordinary, deleted when it has no triggers left.

A job and a trigger built on the spot

public async ValueTask ScheduleOnce(IScheduler scheduler, CancellationToken cancellationToken)
{
    IJobDetail job = JobBuilder.Create<AnExampleJob>()
        .WithIdentity("name", "group")
        .Build();

    ITrigger trigger = TriggerBuilder.Create()
        .WithIdentity("name", "group")
        .StartAt(DateTimeOffset.UtcNow.AddMinutes(5))
        .Build();

    await scheduler.ScheduleJob(job, trigger, cancellationToken: cancellationToken);
}

No StoreDurably(): both are removed once the trigger has fired and has nothing left to do.

A trigger with no schedule fires once, at its start time; StartNow() means as soon as the scheduler gets to it. A bare .WithSimpleSchedule() changes nothing, so add one only to configure it:

ITrigger trigger = TriggerBuilder.Create()
    .WithIdentity("name", "group")
    .StartNow()
    .WithSimpleSchedule(x => x
        .WithMisfireInstruction(SimpleTriggerMisfireInstruction.FireNow))
    .Build();

Misfire behaviour

A one-shot trigger on the default SmartPolicy resolves to FireNow: a firing missed while the scheduler was down runs when it returns. See SimpleTriggers.

A payload and a time, in one call

For a job with a typed input, pass the job type, the input and the time:

public sealed record SendInvoice(string CustomerId, decimal Amount);

public sealed class SendInvoiceJob : IJob<SendInvoice>
{
    public ValueTask Execute(IJobExecutionContext context, SendInvoice input, CancellationToken cancellationToken = default)
    {
        // input.CustomerId, input.Amount
        return default;
    }
}

public sealed class Invoicing
{
    public async ValueTask Remind(IScheduler scheduler, ILogger logger, SendInvoice invoice, CancellationToken cancellationToken)
    {
        ScheduledOneOffJob firing = await scheduler.ScheduleJob<SendInvoiceJob, SendInvoice>(
            invoice,
            TimeSpan.FromDays(7),
            // Named, so it can be replaced or cancelled; grouped by the thing it is about, so the
            // whole conversation can be cancelled at once. Replacing(name) is the preset for the
            // pair, because a firing with no name of its own has nothing to replace.
            OneOffJobOptions.Replacing($"invoice-{invoice.CustomerId}") with { Group = invoice.CustomerId },
            cancellationToken);

        // What was arranged: the trigger's key, and when the store says it will first fire.
        logger.LogInformation("Reminder {Trigger} scheduled for {At}", firing.TriggerKey, firing.FirstFireTimeUtc);

        // ... and to call it off:
        await scheduler.UnscheduleJob(firing.TriggerKey, cancellationToken);
    }
}

One overload takes a DateTimeOffset, the other a TimeSpan from now. Both return a ScheduledOneOffJob:

MemberMeaning
TriggerKeythe stored firing; cancel it with this, or replace it by scheduling the same name again
FirstFireTimeUtcwhen the store says it will fire; for a kept firing, when that one fires
OutcomeCreated, Replaced or Kept; see Scheduling the same name twice

Read anything else with GetTrigger.

What is stored:

  • One durable job per job type at SchedulerConstants.ScheduledJobKey<TJob>(), which is (typeof(TJob).Name, SchedulerConstants.ScheduledJobGroup), plus one trigger per call, so there is no job churn however many firings are pending.
  • The job is stored idempotently on the first call on a scheduler, then remembered: safe from several nodes at once, and one round trip instead of two from the second call.
  • It stays after the last firing is cancelled: one row per job type.

OneOffJobOptions holds the TriggerBuilder settings: Name and Group (by default a generated identifier, in a group named after the job type), Description, Priority, ExecutionGroup, MisfireInstruction, and OnConflict. The group is the correlation axis: give one saga, tenant or conversation one group to list, pause or unschedule its firings together.

A stored typed job, run now

From 4.7. Pass TriggerJob the input instead of a JobDataMap. It fires a stored IJob<TInput> now and returns the key of the trigger that fires it:

public async ValueTask RunNow(IScheduler scheduler, ILogger logger, SendInvoice invoice, CancellationToken cancellationToken)
{
    // A durable SendInvoiceJob stored under this key, fired now with its input.
    TriggerKey firing = await scheduler.TriggerJob(new JobKey("send-invoice", "billing"), invoice, cancellationToken);

    // The manual run's trigger: ExecutionHistoryQuery.Trigger finds the run once it has ended.
    logger.LogInformation("Invoice run {Trigger} requested", firing);
}
  • It is the firing TriggerJob(jobKey) makes: a one-shot trigger in the default group, recorded as manual, in the job's execution group.
  • The input is stored as UsingInput stores it, by the scheduler's IJobInputSerializer.
  • Find the run with the key: ExecutionHistoryQuery.Trigger in the history, or FireInstanceQuery.TriggerName while it runs.
  • Trimmed or native AOT: declare the input type with SystemTextJsonSerializerRegistry.AddTypeInfoResolver.
  • Over HttpScheduler, pass a string. A structured input is refused before it is sent, because the wire carries it as a job data value. Serialize it to the JSON the host's serializer reads.
  • TriggerJob(jobKey, token) fires the job with no input, as TriggerJob(jobKey, cancellationToken: token) does.

Scheduling the same name twice

OnConflict decides what a call does when a firing is already scheduled under its Name:

public async ValueTask SyncAccount(IScheduler scheduler, ILogger logger, string accountId, CancellationToken cancellationToken)
{
    // Idempotent enqueue: "make sure a sync is scheduled". A second call while one is pending
    // stores nothing and answers with the pending one.
    ScheduledOneOffJob sync = await scheduler.ScheduleJob<SendInvoiceJob, SendInvoice>(
        new SendInvoice(accountId, 0m),
        TimeSpan.FromMinutes(5),
        new OneOffJobOptions { Name = $"sync-{accountId}", OnConflict = TriggerConflict.Keep },
        cancellationToken);

    if (sync.Outcome == ScheduleOutcome.Kept)
    {
        logger.LogInformation("A sync was already pending, at {At}", sync.FirstFireTimeUtc);
    }
}
OnConflictA firing already pending under the nameUse it for
Throw (default)the call throws ObjectAlreadyExistsExceptiona name that must be new
Replacethe new firing replaces it; Outcome is Replaceddebounce: only the last call fires
Keepit stays, nothing is stored; Outcome is Keptidempotent enqueue: "make sure this is scheduled"
KeepEarlierwhichever fires first staysdebounce with a deadline that only moves closer
  • The store decides under its lock. Two calls with one name at once store one firing; one is told Created, the other Kept.
  • Pending means a fire time ahead. A paused firing is pending. A firing that has started running is not, so Keep stores a new one after it.
  • Replace = true is the older spelling of OnConflict = TriggerConflict.Replace, and OneOffJobOptions.Replacing(name) sets OnConflict. Replace = true beside another OnConflict throws ArgumentException.
  • A kept call raises no JobScheduled, because nothing was scheduled.
  • "Unique for a period after the job ran" is not covered: a finished firing leaves nothing to conflict with.

A group default that a cancellation contract has to know about

Group defaults to the job type's name, not TriggerKey.DefaultGroup. Code cancelling with new TriggerKey(id) targets the default group, so it silently finds nothing for firings scheduled here without Group = TriggerKey.DefaultGroup. An integration moving to the one-liner under an existing trigger-key contract must set the group its callers expect.

For a per-tenant execution limit, name the tenant's group at the call site: new OneOffJobOptions { ExecutionGroup = $"tenant:{invoice.CustomerId}" }. ExecutionGroup is stored as written. A job type's [ExecutionGroup] applies when ExecutionGroup is not set, if it has no placeholders; with placeholders the call throws FormatException, because a one-off trigger's job data is its input.

RequestRecovery describes the durable job, not the trigger: the job is marked RequestsRecovery, so a firing cut short by a hard shutdown re-runs when the scheduler returns:

public async ValueTask Remind(IScheduler scheduler, SendInvoice invoice, CancellationToken cancellationToken)
{
    await scheduler.ScheduleJob<SendInvoiceJob, SendInvoice>(
        invoice,
        TimeSpan.FromDays(7),
        new OneOffJobOptions { RequestRecovery = true },
        cancellationToken);
}

The job is stored once per scheduler instance, so the first call's value wins for the life of the process, like the job's description, durability and type.

A schedule of your own on the same job

Point a recurring trigger of your own at ScheduledJobKey<TJob>(); do not add your own job to the reserved group:

public async ValueTask Nightly(IScheduler scheduler, CancellationToken cancellationToken)
{
    // A schedule of its own, pointed at the job the one-liner keeps rather than at a second job
    // built here: same job, same payload shape, one more trigger.
    ITrigger nightly = TriggerBuilder.Create<SendInvoiceJob>(scheduler.TimeProvider)
        .WithIdentity("nightly", "invoicing")
        .ForJob(SchedulerConstants.ScheduledJobKey<SendInvoiceJob>())
        .WithCronSchedule("0 0 2 * * ?")
        .UsingInput(new SendInvoice("all", 0m))
        .Build();

    await scheduler.ScheduleJob(nightly, cancellationToken: cancellationToken);
}
  • SchedulerConstants.ScheduledJobGroup is reserved for the one-liner's jobs, but its job is meant to be pointed at.
  • The key comes from the type, so it is known before anything is scheduled; the job appears on the first one-call overload.
  • A store refuses a trigger whose job is missing. If your path can run first, store the job yourself with AddJob under the same key and AddJobOptions.Replacing, which is idempotent with the one-liner.

A firing whose time is another firing's completion

The third overload takes the TriggerKey of an earlier firing instead of a time, and stores a continuation: held in TriggerState.Awaiting, never acquired, and released or discarded by the first firing's outcome.

public sealed class InvoiceRun
{
    public async ValueTask Send(IScheduler scheduler, ImportRequest request, CancellationToken cancellationToken)
    {
        ScheduledOneOffJob import = await scheduler.ScheduleJob<DataImportJob, ImportRequest>(
            request,
            TimeSpan.FromMinutes(5),
            cancellationToken: cancellationToken);

        // The second firing's time is the first firing's completion, so the overload takes a
        // Continuation where the others take a DateTimeOffset or a TimeSpan.
        await scheduler.ScheduleJob<ReconcileJob, ImportRequest>(
            request,
            Continuation.After(import.TriggerKey, ContinuationCondition.OnSuccess),
            cancellationToken: cancellationToken);
    }
}
  • Continuation.After(parent) waits for success; pass a ContinuationCondition for others, such as OnAnyOutcome for a cleanup.
  • For a floor ("and never before nine"), build the trigger by hand with StartAfter and StartAt.

Reading input written by an older schema

IJob<TInput> fails a firing whose input is missing. Midway through converting a 3.x job, the store still holds triggers with the payload in flat JobDataMap keys and nothing under QRTZ_JOB_INPUT, so each would throw. Keep the job an IJob while both shapes exist:

public sealed class SendInvoiceCompatJob : IJob
{
    public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        // A firing scheduled by 4.x carries the whole payload under one key. One scheduled before the
        // upgrade carries the flat keys the 3.x job wrote, and there is nothing to read there — which
        // is an answer here, where an IJob<SendInvoice> would have failed the firing instead.
        if (!context.TryGetInput(out SendInvoice? invoice) || invoice is null)
        {
            invoice = new SendInvoice(
                context.MergedJobDataMap.GetString("CustomerId")!,
                context.MergedJobDataMap.Get<decimal>("Amount"));
        }

        return Send(invoice, cancellationToken);
    }

    private static ValueTask Send(SendInvoice invoice, CancellationToken cancellationToken) => default;
}

TryGetInput returns false only when the key is absent; a present but unreadable value still throws. Once the old triggers have drained, switch to IJob<TInput>.

Calling off a whole correlation

public async ValueTask<int> CustomerWentAway(IScheduler scheduler, string customerId, CancellationToken cancellationToken)
{
    // Every firing scheduled under this customer's group goes in one call: the group the one-liner
    // put them in is the handle for calling all of them off, and nothing has to list the keys first.
    List<TriggerKey> calledOff = await scheduler.UnscheduleJobs(
        GroupMatcher<TriggerKey>.GroupEquals(customerId),
        cancellationToken);

    // The answer names what went, so "there was nothing left to cancel" is a count, not a guess.
    return calledOff.Count;
}
  • UnscheduleJobs(GroupMatcher<TriggerKey>) removes every trigger in the matching groups and returns the removed keys (empty if none).
  • The store resolves the group under the lock that empties it, so a firing another node added a moment earlier is removed too.
  • DeleteJobs(GroupMatcher<JobKey>) does the same for jobs and their triggers. Do not use it to cancel a correlation: the one-liner's durable job is shared by every firing of its type.

A matcher is required

Both throw ArgumentNullException on a null matcher, unlike the pause and resume group forms, which read it as the default group. A mistaken pause can be resumed; a delete cannot.

Scheduling over a firing that is already there

The ScheduleJob overloads taking ScheduleJobOptions replace an existing trigger in one call, inside the store's lock:

// Trigger only - the job it names is already stored.
await scheduler.ScheduleJob(trigger, new ScheduleJobOptions { Replace = true }, cancellationToken);

// Job and trigger together, in one store operation.
await scheduler.ScheduleJob(job, trigger, new ScheduleJobOptions { Replace = true }, cancellationToken);
  • They replace CheckExists → UnscheduleJob → ScheduleJob: three round trips and a race with other nodes.
  • options has no default here, or scheduler.ScheduleJob(trigger) would be ambiguous.
  • A replaced trigger keeps its previous fire time, so context.PreviousFireTimeUtc survives the rewrite. Set PreviousFireTimeUtc on the new trigger to override it.

For the other conflict modes on a trigger you built yourself, use ScheduleTrigger:

public async ValueTask<ScheduleOutcome> ScheduleOnce(IScheduler scheduler, ITrigger trigger, CancellationToken cancellationToken)
{
    // Any trigger, not only the one-liner's: the store decides under its lock.
    ScheduleTriggerResult result = await scheduler.ScheduleTrigger(trigger, TriggerConflict.KeepEarlier, cancellationToken);

    // Created, Replaced or Kept; result.NextFireTimeUtc is when the stored trigger fires.
    return result.Outcome;
}

It takes the same four TriggerConflict values and answers with the outcome and the stored trigger's next fire time. Over HTTP it is onConflict on …/triggers/schedule.

Help us by improving this page!
Last Updated: 10/26/26, 7:29 AM
Contributors: Marko Lahma, Claude Opus 5.5
Next
Rescheduling Jobs