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
    • 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
    • Retrying Failed Jobs
    • Job Continuations
    • 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
    • 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 Continuations

A continuation is an ordinary trigger that waits, in the store, for another trigger's firing to end — and is released or discarded by how it ended. "Reconcile the ledger once tonight's import has worked", "page operations if it did not", "clean up whatever happened".

The wait is held by the job store, not by the process that arranged it. Nothing is running while a continuation waits, no misfire accrues, and whichever node runs the parent is the node that settles the continuation — inside the parent's own lock and transaction. A crash cannot lose one, and neither can the death of the node that scheduled it.

The model

A trigger carrying a Continuation is stored in TriggerState.Awaiting and is never acquired while it is there. When the parent's firing completes:

  • an outcome the continuation's ContinuationCondition names releases it — into Normal, or Paused if its group is, with its next fire time set to the later of now and its own start time;
  • any other outcome discards it: the trigger is deleted and its listeners told it is finalized, because the firing it was waiting for has been and gone.

Settlement is one-shot. Every statement that settles a continuation names Awaiting, and a settled trigger no longer holds it, so a continuation is released or discarded exactly once. A continuation is not a subscription to a schedule — for that, see a recurring chain below.

The parent is a TriggerKey rather than a JobKey: a continuation waits for one firing, and a job may be fired by several triggers.

Declaring one

StartAfter sits beside StartAt and StartNow on the trigger builder, and composes with a schedule rather than replacing one:

builder.Services.AddQuartz(q =>
{
    q.AddJob<DataImportJob>(j => j.WithIdentity("import", "nightly"));
    q.AddTrigger<DataImportJob>(t => t
        .WithIdentity("import", "nightly")
        .ForJob("import", "nightly")
        .WithCronSchedule("0 0 2 * * ?"));

    // Reconciliation has no schedule of its own: it runs when the import has run, and only
    // if the import worked. Until then the trigger sits in the store in Awaiting.
    q.AddJob<ReconcileJob>(j => j.WithIdentity("reconcile", "nightly"));
    q.AddTrigger<ReconcileJob>(t => t
        .WithIdentity("reconcile", "nightly")
        .ForJob("reconcile", "nightly")
        .StartAfter(new TriggerKey("import", "nightly")));
});

The condition is flags, so "whenever it did not get there" needs no member of its own:

// "Tell operations whenever the import does not get there": a failure the retry policy has
// given up on, or a firing somebody interrupted. A success discards this trigger.
ITrigger alert = TriggerBuilder.Create<AlertOpsJob>(scheduler.TimeProvider)
    .WithIdentity("alert", "nightly")
    .ForJob("alert", "nightly")
    .StartAfter(
        new TriggerKey("import", "nightly"),
        ContinuationCondition.OnFailure | ContinuationCondition.OnCancellation)
    .Build();

await scheduler.ScheduleJob(alert, cancellationToken: cancellationToken);
ContinuationConditionReleased by
OnSuccess (the default)the parent's job ran and returned
OnFailureit ran and threw, with no retry left to take
OnCancellationthe firing's token was signalled and the job stopped rather than finished
OnVetoa trigger listener refused the firing, so the job never ran
OnAnyOutcomeall four

A condition that names no outcome at all is refused: it would discard the trigger whatever the parent did, which is a schedule nobody means to write.

StartTimeUtc stays a floor rather than a schedule, so "an hour after the import, and never before nine" is expressible:

// StartAfter composes with the rest of the builder rather than replacing it. The start time
// stays a floor, so a continuation released at 03:00 still waits until 09:00; and the
// schedule is the schedule the released trigger then keeps.
ITrigger report = TriggerBuilder.Create<ReconcileJob>(scheduler.TimeProvider)
    .WithIdentity("report", "nightly")
    .ForJob("reconcile", "nightly")
    .StartAfter(new TriggerKey("import", "nightly"))
    .StartAt(DateTimeOffset.UtcNow.Date.AddDays(1).AddHours(9))
    .Build();

await scheduler.ScheduleJob(report, cancellationToken: cancellationToken);

The outcome table

The outcome says what the firing did:

Outcome of the parent's firingReleasesWhat it is
ExecutionOutcome.SucceededOnSuccess, OnAnyOutcomethe job ran and returned
ExecutionOutcome.FailedOnFailure, OnAnyOutcomethe job ran and threw
ExecutionOutcome.CancelledOnCancellation, OnAnyOutcomethe firing's token was signalled and the job stopped
ExecutionOutcome.VetoedOnVeto, OnAnyOutcomea trigger listener refused the firing
ExecutionOutcome.NotExecutednothingthe occurrence did not happen — a listener abandoned it, the job could not be built, the scheduler could not dispatch it. The continuation keeps waiting

A retry settles nothing. A failure the trigger's retry policy answers with another attempt is still reported as Failed — the job did run and it did throw — but the occurrence is not over, and what says so is the instruction rather than the outcome: every store skips settling on SchedulerInstruction.RetryTrigger. So a continuation waiting on OnSuccess survives the parent's first hiccup, and one waiting on OnFailure is released when the attempts are spent rather than at the first of them.

When the parent is deleted

A parent removed while continuations await it is the one settlement with no outcome to match. A continuation that did not care how the firing ended — OnAnyOutcome — is released anyway; anything narrower is parked in TriggerState.Error, with ISchedulerListener.TriggerInError, for an operator to see rather than silently deleted or left waiting forever.

ResetTriggerFromErrorState on such a trigger gives it the fire time a release would have given it, so resetting one means running it. PauseTrigger on a trigger that is still Awaiting answers false: there is nothing to hold back that is not already held back.

Seeing what is waiting

A trigger listing carries the continuation, so "why is this not running" is answerable without materializing a trigger per row:

PagedResult<TriggerHeader> waiting = await scheduler.QueryTriggers(
    new TriggerQuery { State = TriggerState.Awaiting },
    cancellationToken);

foreach (TriggerHeader trigger in waiting.Items)
{
    // A listing says what each one is waiting for and what releases it, without loading a
    // trigger per row.
    logger.LogInformation(
        "{Trigger} is waiting for {Parent} ({Condition})",
        trigger.Key,
        trigger.ContinuesAfter,
        trigger.ContinuationCondition);
}

The dashboard has an Awaiting only filter on its trigger listing, and the trigger's detail page shows what it continues after and which outcomes release it. Over the HTTP API the state is "Awaiting" and the header carries continuesAfterTriggerName, continuesAfterTriggerGroup and continuationCondition.

One call, for a firing whose time is another firing's completion

When the job takes a typed input, the one-off overloads have a form with no time argument — because the time is the parent's completion:

// One firing of the import, six hours from now. The key it answers with is the handle.
ScheduledOneOffJob import = await scheduler.ScheduleJob<DataImportJob, ImportRequest>(
    new ImportRequest("eu-west"),
    TimeSpan.FromHours(6),
    cancellationToken: cancellationToken);

// And one firing of the reconciliation after it. There is no time argument, because the
// time is the import's completion.
ScheduledOneOffJob reconcile = await scheduler.ScheduleJob<ReconcileJob, ImportRequest>(
    new ImportRequest("eu-west"),
    Continuation.After(import.TriggerKey),
    cancellationToken: cancellationToken);

Declaring one in a scheduling file

A continuation is a setting rather than a kind of trigger, so both scheduling-file formats take it on any trigger they can already declare. In XML:

<trigger>
  <cron>
    <name>reconcile</name>
    <group>nightly</group>
    <job-name>reconcileJob</job-name>
    <continues-after>
      <name>import</name>
      <group>nightly</group>
    </continues-after>
    <continuation-condition>OnFailure|OnCancellation</continuation-condition>
    <cron-expression>0 0 2 * * ?</cron-expression>
  </cron>
</trigger>

and in JSON — a standalone quartz_jobs.json or the Quartz:Schedule section of appsettings.json:

{
  "Name": "reconcile",
  "Group": "nightly",
  "JobName": "reconcileJob",
  "ContinuesAfter": { "Name": "import", "Group": "nightly" },
  "ContinuationCondition": "OnFailure|OnCancellation",
  "Cron": { "Expression": "0 0 2 * * ?" }
}

An omitted Group is the default group and an omitted condition is OnSuccess. The parent is named, never resolved, so it may be declared later in the same file, or not be in the file at all because it is already in the store. An outcome that is not one — or a condition with nothing to wait for beside it — is refused as the file is read, naming the trigger.

A recurring chain is a listener

A continuation settles once. "Run the cleanup whenever the nightly job fails" is not that, and it is not a continuation: it is JobChainingJobListener, which learned the same vocabulary:

JobChainingJobListener chain = new("nightly-chain");

// Every time the import fails, run the alert. A listener link fires on every completion,
// where a continuation settles once - which is the difference between the two.
chain.AddJobChainLink(
    new JobKey("import", "nightly"),
    new JobKey("alert", "nightly"),
    ContinuationCondition.OnFailure);

builder.Services.AddQuartz(q => q.AddJobListener(chain));

The differences are worth stating plainly. A link is a process-local arrangement on the node that ran the first job, re-registered on every start, and the follow-up is fired rather than scheduled — there is no trigger to see, pause or query. A continuation is a row in the store that survives a restart and is settled by whichever node ran the parent.

Upgrading a running cluster

The columns a continuation lives in arrived in 4.2, and a 4.1 node cannot settle one: a parent completing there leaves the triggers waiting on that firing exactly where they are.

So the order is: run the migration, roll every node to 4.2, and only then start scheduling continuations. Rolling the migration itself while 4.1 nodes are still running is safe — the columns are nullable with no default — and a 4.2 node refuses to start against a database that has not taken it, naming the column and the script. The migration guide has the whole of it.

See also

  • One-Off Job — the typed one-call overloads, of which the continuation form is one
  • Retrying Failed Jobs — what a retry is, and why it settles nothing
  • More About Triggers — misfire instructions, priorities and calendars
  • Trigger and Job Listeners — JobChainingJobListener, and what a veto is
Help us by improving this page!
Last Updated: 9/19/26, 9:30 PM
Contributors: Marko Lahma, Claude Opus 5 (1M context)
Prev
Retrying Failed Jobs
Next
Multiple Triggers