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

Job Outcomes

A job can say what its run achieved: a result, a one-line summary and named metrics. The execution history records them, and keeps a status per job that outlives its rows.

Report a result

Set IJobExecutionContext.Result to a JobRunReport:

public sealed class ReleaseStaleReservationsJob : IJob
{
    public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        int scanned = await CountReservations(cancellationToken);
        int released = await ReleaseStale(cancellationToken);

        // A run that found nothing to do is a success, recorded as Skipped.
        context.Result = released == 0
            ? JobRunReport.Skipped("no stale reservations").With("scanned", scanned)
            : JobRunReport.Succeeded($"released {released}").With("scanned", scanned).With("released", released);
    }

    private static ValueTask<int> CountReservations(CancellationToken cancellationToken) => new(1200);

    private static ValueTask<int> ReleaseStale(CancellationToken cancellationToken) => new(0);
}
JobRunResultMeansA success
Succeeded (0)The job did its workYes
Failed (1)The job threw, or reported a failureNo
Cancelled (2)The firing was interrupted and the job stoppedNo
Skipped (3)The job found nothing to doYes

The history stores the integer. New members are appended; none is renumbered.

Which result is recorded

The first rule that holds wins:

  1. The firing was cancelled: Cancelled.
  2. The job threw: Failed.
  3. context.Result is an IJobRunReport: its Result.
  4. Otherwise: Succeeded.
  • The summary and metrics are recorded whichever rule wins.
  • Any other context.Result is ignored. NativeJob keeps an exit code there.
  • ExecutionHistoryEntry.Succeeded is true for Succeeded and Skipped.

Throw to make the scheduler act

A reported Failed is history only. To have the scheduler act on a failure, throw.

ThrowJobRunReport.Failed(…)
Recorded as FailedYesYes
Retried under the trigger's retry policyYesNo
Runs OnFailure continuationsYesNo
Raises TriggerRetriesExhaustedYesNo
context.OutcomeFailedSucceeded

Limits

WhatLimitOver it
Summary1,000 characters, JobRunReport.MaxSummaryLengthCut, never inside a surrogate pair
Metrics as JSON4,000 characters, JobRunReport.MaxMetricsLengthDropped whole, with log event 1059
A metric valueIts text must not throwMetrics dropped whole, with log event 1060, naming the metric. The run is still recorded

Metrics are written by value type, without reflection:

ValueJSON
string, bool, nullAs is
Integers, decimal, finite double and floatNumber
NaN, ±InfinityString: "NaN", "Infinity", "-Infinity"
DateTimeOffset, DateTimeRound-trip string, "O"
TimeSpanConstant string, "c"
GuidString
EnumIts name
Anything elseIts invariant-culture text
  • Every character outside ASCII is escaped, so 4,000 characters is also 4,000 bytes.
  • With(name, value) returns a copy. A name already present takes the new value.

What else the history records

WhereWhat
ExecutionHistoryEntry.ResultThe result. null on a row written before 4.4
ExecutionHistoryEntry.EffectiveResultResult, or Succeeded/Failed from Succeeded on an older row. Filters, tiers and statuses use it
Summary, MetricsJsonWhat the job reported
Manualtrue for a run IScheduler.TriggerJob asked for
FireInstanceIdThe firing's id, as on its span and log scope. Not unique across restarts
The misfire feedA vetoed firing, with MisfireReason.Vetoed. CountMisfires does not count it

TriggerJob marks its trigger with SchedulerConstants.ManualTrigger (QRTZ_MANUAL_TRIGGER = "true") in the trigger's JobDataMap. The key persists in every store and shows in MergedJobDataMap.

Keep history by result

builder.Services.AddQuartzExecutionHistory(options =>
{
    options.Retention = TimeSpan.FromDays(1);
    options.RetentionByResult[JobRunResult.Failed] = TimeSpan.FromDays(30);
    options.RetentionByResult[JobRunResult.Skipped] = TimeSpan.FromHours(1);
    options.MisfireRetention = TimeSpan.FromDays(7);

    // A job that runs every second keeps its latest 100 runs, and every failure.
    options.MaxEntriesPerJob = 100;
    options.MaxEntriesPerScheduler = 20_000;
});
ExecutionHistoryOptionsDefaultWhat
Retention24 hoursAge of every result without a tier
RetentionByResultEmptyAge per JobRunResult, matched on EffectiveResult
MisfireRetentionnull: RetentionAge of the misfire feed
MaxEntriesPerJob0: no capRows kept per job, earliest-fired out first. Failed rows are exempt
MaxEntriesPerScheduler2000The backstop, per feed, oldest out first whatever the result. 0 records nothing
  • Every age must be positive and MaxEntriesPerJob must not be negative, or the host fails at startup.
  • TimeSpan.MaxValue keeps a result for good, within MaxEntriesPerScheduler.
  • Both histories apply all five. The database history applies them by a sweep; its reads apply only the longest age.

Read a job's status

JobRunStatus? status = await history.GetJobRunStatus(schedulerName, new JobKey("release-stale", "billing"));
if (status is { ConsecutiveFailures: > 0 })
{
    Console.WriteLine($"failing {status.ConsecutiveFailures}x since {status.LastSucceededAtUtc}: {status.LastFailureMessage}");
}

PagedResult<JobRunStatus> failing = await history.QueryJobRunStatuses(
    new JobRunStatusQuery { SchedulerName = schedulerName, Failing = true });
JobRunStatusWhat
LastFiredAtUtc, LastResult, LastDuration, LastSummary, LastEntryId, LastSchedulerInstanceIdThe run that fired latest
LastSucceededAtUtcThe latest Succeeded or Skipped run
LastFailedAtUtc, LastFailureMessageThe latest Failed run, retried or not: its exception message, else its summary
ConsecutiveFailuresOccurrences in a row that failed for good. A success resets it; Cancelled and a retried failure leave it
RunCount, FailureCountEvery run; occurrences that failed for good
FirstFiredAtUtcThe earliest run recorded
  • A status is folded from each row as it is recorded, so it outlives trimming.
  • A run that completes after a later-fired run is counted. It does not change the Last* run fields or ConsecutiveFailures.
  • JobRunStatusQuery pages by job group, then name. Failing = true lists ConsecutiveFailures > 0; Jobs names the jobs.
StoreStatuses
In-memory historyAt most MaxEntriesPerScheduler per scheduler. The job that ran longest ago goes first
Database historyOne per job, in QRTZ_JOB_STATUS, committed with each row. Deleted once its job is gone and its last run is older than the longest age
The one AddQuartzHttpClient registersThe host's, when it is 4.4 or later and its store keeps them; otherwise NotSupportedException
An IDashboardHistoryStore of your ownNotSupportedException

One job run many times at once

The database history writes each run's row and its job's status in one transaction. Runs of one job that complete at the same moment take turns on that job's status row, each holding it through its commit, before its trigger completes. Measured on PostgreSQL with one job, 500 one-off firings and 10 workers: 113–248 firings a second with the row alone, 77–189 with the row and the status. With the history off, or with runs spread over many jobs, nothing waits. See The execution history's status row.

Filter the history

PagedResult<ExecutionHistoryEntry> page = await history.QueryExecutions(new ExecutionHistoryQuery
{
    SchedulerName = schedulerName,
    Job = new JobKey("release-stale", "billing"),
    FiredFrom = since,
    Results = [JobRunResult.Failed, JobRunResult.Cancelled]
});

foreach (ExecutionHistoryEntry row in page.Items)
{
    // EffectiveResult answers for rows written before 4.4, which carry no Result.
    Console.WriteLine($"{row.FiredAtUtc:O} {row.EffectiveResult} {row.Summary} {row.MetricsJson}");
}
ExecutionHistoryQueryMatches
JobOne job key, exactly
FiredFromFired at or after, inclusive
FiredBeforeFired before, exclusive
ResultsEffectiveResult in the set. An empty set matches nothing
  • MisfireHistoryQuery.Job narrows the misfire feed the same way, and MisfireHistoryQuery.Reasons to some MisfireReasons. An empty set matches nothing. A row a 4.2 node wrote has no reason and matches Missed.
  • A cancelled run matches FailedFinally = true.

See it in the dashboard and over HTTP

WhereWhat
History pageThe result, the summary, a chip per metric and a Manual badge on each row. Filters for results and for one job. See Execution history and misfires
Jobs pageLast run, Last success and failing ×N per job. See Job run status
Job Detail pageA Runs panel. View execution history opens that job's rows only
Execution pageThe result, the summary, a table of metrics, Manual and the fire instance id
HTTP APIThe five members on each row, the four filters, and the …/history/job-status routes. See Execution history
GET /quartz-api/schedulers/QuartzScheduler/history/executions?jobGroup=billing&jobName=release-stale&results=Failed,Cancelled
GET /quartz-api/schedulers/QuartzScheduler/history/job-status?failing=true
  • A vetoed firing is listed only when reasons names Vetoed, so a 4.3 client can read the default listing. The dashboard and AddQuartzHttpClient ask for it.
  • A scheduler in another process whose host is older than 4.4 has no statuses and cannot filter. The dashboard leaves the columns out and says so on the History page.

Alert when a job stops succeeding

The Quartz health check reports a job that has not succeeded within a window:

builder.Services.AddQuartzExecutionHistory();
builder.Services.AddHealthChecks().AddQuartz(options =>
{
    // Degraded once the nightly report has not succeeded for 26 hours.
    options.RequireSuccessWithin(new JobKey("nightly-report", "reports"), TimeSpan.FromHours(26));

    // Unhealthy, so the node leaves the rotation, once the ledger has not closed for 90 minutes.
    options.RequireSuccessWithin(new JobKey("ledger-close", "billing"), TimeSpan.FromMinutes(90), HealthStatus.Unhealthy);
});
RequiredJobOptionsDefaultWhat
Name, GroupGroup: DEFAULTThe job
SucceededWithinNone; must be positiveHow long ago its last success may have fired
StatusDegradedWhat the check reports while the job is late. Unhealthy for a job that must never be late. Healthy is refused
  • The window runs from JobRunStatus.LastSucceededAtUtc, on the scheduler's clock. A Skipped run is a success.
  • A job with no recorded success is judged from the first time the check evaluated it. A process that has just started gives each job one window.
  • The check reports the worst of the scheduler's own verdict and each late job's Status. It does not read the jobs of a scheduler that is already unhealthy. It does read them in standby.
  • The message names the gravest late job and counts the others. The data has one entry per late job, keyed <group>.<name>: lastSucceededAtUtc (or never), consecutiveFailures and succeededWithin.
  • Every check reads all the jobs' statuses in one call.
  • A job given twice keeps the later entry. RequireSuccessWithin replaces the earlier one.

The statuses come from the execution history, which must keep them: see the table under Read a job's status.

The scheduler's historyResult
NoneThe host fails at startup, naming AddQuartzExecutionHistory() and UsePersistentStore(store => store.UseExecutionHistory())
Its status read throws NotSupportedExceptionThe host fails at startup, with the store's reason
A remote host older than 4.4, through AddQuartzHttpClientThe check reports unhealthy, with the host's reason. Startup cannot tell

RequiredJobs binds from configuration. A TimeSpan is d.hh:mm:ss, so 26 hours is 1.02:00:00:

{
  "HealthChecks": {
    "Quartz": {
      "RequiredJobs": [
        { "Name": "nightly-report", "Group": "reports", "SucceededWithin": "1.02:00:00" },
        { "Name": "ledger-close", "Group": "billing", "SucceededWithin": "01:30:00", "Status": "Unhealthy" }
      ]
    }
  }
}

Bind it with services.Configure<QuartzHealthCheckOptions>(configuration.GetSection("HealthChecks:Quartz")), or under the scheduler's name for a named scheduler.

A history store of your own

  • Keep Result, Summary, MetricsJson, Manual and FireInstanceId on the rows you store. The recorder sets them.
  • Apply the four filters above, MisfireHistoryQuery.Job and MisfireHistoryQuery.Reasons.
  • Count only MisfireReason.Missed rows in CountMisfires.
  • QueryJobRunStatuses and GetJobRunStatus are default interface members. The first throws NotSupportedException; the second asks the first for one job. Implement QueryJobRunStatuses to keep a status beside the rows, updated as each row is recorded.
Help us by improving this page!
Last Updated: 9/29/26, 4:53 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
Progress and Execution Logs
Next
Multiple Triggers