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

To list what is scheduled, describe what you want in a query, ask for a page of it, and render the headers that come back.

Why listings became queries

In 3.x each listing member returned everything it found: no paging, keys only, and a separate call per row for anything else. 4.x replaces them with six query members that work the same way:

QueryReturnsSelects
QueryJobs(JobQuery)PagedResult<JobHeader>jobs
QueryTriggers(TriggerQuery)PagedResult<TriggerHeader>triggers
QueryJobGroups(JobGroupQuery)PagedResult<JobGroup>job groups
QueryTriggerGroups(TriggerGroupQuery)PagedResult<TriggerGroup>trigger groups
QueryCalendarNames(CalendarQuery)PagedResult<string>calendar names
QueryFireInstances(FireInstanceQuery)PagedResult<FireInstance>firings in flight

The same six are on IJobStore, with the same shapes.

Headers, not entities

A query returns headers: enough to render a row, and nothing that needs a second read.

HeaderCarries
JobHeaderKey, Description, JobTypeName, Durable, ConcurrentExecutionDisallowed, PersistJobDataAfterExecution, RequestsRecovery
TriggerHeaderKey, JobKey, Description, TriggerType, State, StartTimeUtc, EndTimeUtc, NextFireTimeUtc, PreviousFireTimeUtc, CalendarName, Priority, ExecutionGroup

The trigger fields are explained in More About Triggers.

  • Neither header carries a JobDataMap: job data is a blob in a persistent store, and deserializing it per row would make every listing pay for it. Fetch the whole object when you need it; see from a page to full detail.
  • TriggerHeader.State is computed by the store in the same query. In 3.x you called GetTriggerState per key.
  • From 4.3, TriggerHeader.Pause is a paused trigger's reason, requester and time; see Pausing with a Reason.

Filtering

Every query is a record with init-only filter properties. A null filter matches everything; filters that are set combine with AND:

PagedResult<TriggerHeader> page = await scheduler.QueryTriggers(new TriggerQuery
{
    Group = GroupMatcher<TriggerKey>.GroupStartsWith("reporting-"),
    State = TriggerState.Error,
    Take = 50,
});
QueryFilters
JobQueryGroup (GroupMatcher<JobKey>), Name (NameMatcher<JobKey>)
TriggerQueryGroup, Name, Job (JobKey), CalendarName (string), State (TriggerState?)
JobGroupQuery / TriggerGroupQueryName (NameMatcher), Paused (bool?)
CalendarQueryName (NameMatcher)
FireInstanceQueryTriggerGroup, TriggerName, Job, SchedulerInstanceId, State

Group and Name filter on the result's own identity. A filter on something the result refers to carries that thing's name: Job, CalendarName, SchedulerInstanceId. A firing is identified by a fire instance id, not a key, so FireInstanceQuery says TriggerGroup and TriggerName.

Name filters are matchers:

MatcherMethodsMatches
GroupMatcher<TKey>GroupEquals, GroupStartsWith, GroupEndsWith, GroupContainsa key's group
NameMatcher<TKey>NameEquals, NameStartsWith, NameEndsWith, NameContainsa key's name
NameMatcherthe same foura name that belongs to no key: a calendar's, a group's
  • GroupMatcher<TKey>.AnyGroup() is for members that take a matcher and not a null, such as PauseTriggerGroups. In a query, leave the filter null.
  • Matcher text is a literal, not a pattern. GroupStartsWith("50%") selects a group named 50%; the store escapes the wildcard in SQL.

Matchers as a vocabulary

Matchers builds matchers without spelling the generic argument, and holds the combinators:

IMatcher<JobKey> notArchived = Matchers.Group<JobKey>(StringOperator.StartsWith, "archive-").Not();
IMatcher<TriggerKey> either = Matchers.Key(triggerKey).Or(Matchers.AllTriggers());
  • Matchers.AllJobs() and Matchers.AllTriggers() return EverythingMatcher<TKey>.
  • Matchers.Key(key) matches one key exactly.
  • And, Or and Not are extension methods on IMatcher<TKey>.

The combinators are for the listener matchers on IListenerManager and IQuartzBuilder, which run in memory. Query filters only take GroupMatcher<TKey> and NameMatcher<TKey>, the two shapes a job store can translate to SQL.

Paging

Results are ordered by group, then name: ordinal in RAMJobStore, the database's collation in the ADO.NET store. Skip and Take are offsets into that ordering, so page 3 is the same whichever node answers.

PagedResult<JobHeader> page = await scheduler.QueryJobs(new JobQuery
{
    Skip = (pageNumber - 1) * pageSize,
    Take = pageSize,
});
MemberValue
Take defaultPagedQuery.DefaultTake, 250
PagedQuery.Allint.MaxValue; over HTTP, ?take=all
PagedResult<T>.HasMorewhether anything was left out; exact and effectively free (the stores read one row past Take)
TotalCountnull unless IncludeTotalCount is set; costs a second query on a persistent store

Ask for everything explicitly:

JobQuery everything = new() { Take = PagedQuery.All };

Ask for a total:

PagedResult<TriggerHeader> page = await scheduler.QueryTriggers(new TriggerQuery
{
    Take = pageSize,
    IncludeTotalCount = true,
});

int total = page.TotalCount!.Value;   // non-null because IncludeTotalCount was set

Counting without rows

GetNumberOfJobs, GetNumberOfTriggers and GetNumberOfCalendars are gone. Count with a query that asks for no rows:

PagedResult<JobHeader> count = await scheduler.QueryJobs(new JobQuery
{
    Take = 0,
    IncludeTotalCount = true,
});

int jobCount = count.TotalCount!.Value;

Take = 0 is valid and returns an empty Items; with IncludeTotalCount the stores run only the count query. This counts anything a query can select: triggers in the error state, calendars whose name starts with a prefix, firings on one node.

From a page to full detail

To get the full objects for headers, fetch them by key in one round trip, not in a loop:

List<JobKey> keys = page.Items.Select(h => h.Key).ToList();
List<IJobDetail> details = await scheduler.GetJobDetails(keys);

List<ITrigger> triggers = await scheduler.GetTriggers(triggerKeys);

Keys that do not exist are absent from the result. A bulk fetch does not throw for a key deleted since the listing ran, so it is not an existence check. Use Exists(JobKey) and Exists(TriggerKey).

Changed in 4.x

CheckExists is now Exists, on both overloads.

Fire instances: what is running right now

GetCurrentlyExecutingJobs() is gone; it only described the answering node, returned whole IJobExecutionContext objects, and had no filter or paging. QueryFireInstances replaces it. It is store-backed, so on a persistent store it covers the whole cluster:

PagedResult<FireInstance> running = await scheduler.QueryFireInstances(new FireInstanceQuery
{
    TriggerGroup = GroupMatcher<TriggerKey>.GroupEquals("reporting"),
});

foreach (FireInstance fire in running.Items)
{
    Console.WriteLine($"{fire.TriggerKey} on {fire.SchedulerInstanceId} since {fire.FireTimeUtc:O}");
}

A FireInstance carries FireInstanceId, TriggerKey, JobKey, SchedulerInstanceId, State, FireTimeUtc, ScheduledFireTimeUtc and ExecutionGroup.

  • State is a FireInstanceState: Acquired or Executing. It is the only filter in the family with a non-null default: a query that says nothing about state lists executing firings. Set State = null to include firings a node has reserved but not started.
  • JobKey is nullable because an Acquired firing has not resolved its job yet. A query filtered by Job never matches a reservation, so Job with State = null still lists executing firings only.
  • Firings are ordered by trigger group, trigger name, then fire instance id, because one trigger can have several firings in flight.
FireInstanceQuery reservedAndRunning = new() { State = null };
FireInstanceQuery reservedOnly = new() { State = FireInstanceState.Acquired };

Three caveats for any UI built over this

  • A vetoed firing does not stay listed. An ITriggerListener veto completes the firing. It is listed only between the store recording it and the veto, so a "running jobs" screen cannot count vetoes.
  • Elapsed time can be negative. It is your clock minus FireTimeUtc, which the firing node's clock wrote. With skewed clocks on a cluster, clamp it.
  • ScheduledFireTimeUtc is not the missed time. After a misfire it is the rescheduled time, as the owning node recorded it. Its gap to FireTimeUtc is not misfire lateness.

To stop a single firing, use InterruptFireInstance(fireInstanceId). Interrupt(jobKey) stops every execution of the job.

Group pause state

The stores persist pause state for trigger groups and job groups, so TriggerGroup.Paused and JobGroup.Paused are accurate on both stores.

QuestionQuery
paused trigger groups (replaces GetPausedTriggerGroups())QueryTriggerGroups(new TriggerGroupQuery { Paused = true })
is one trigger group paused?new TriggerGroupQuery { Name = NameMatcher.NameEquals("reporting"), Take = 1 }
paused job groupsQueryJobGroups(new JobGroupQuery { Paused = true })
is one job group paused?new JobGroupQuery { Name = NameMatcher.NameEquals("reporting"), Take = 1 }
why is a group paused? (from 4.3)GetTriggerGroupPause("reporting"), GetJobGroupPause("reporting"); see Pausing with a Reason

The other comparisons list a tenant's or subsystem's groups: NameMatcher.NameStartsWith("tenant-42-").

4.x records paused job groups in QRTZ_PAUSED_JOB_GRPS on both stores. On 3.x the ADO store could not report them (IsJobGroupPaused answered false for every group), which is why the 4.0 schema migration is mandatory even for a database that took every optional 3.x migration.

An empty group can be paused. Paused = true lists it; the unfiltered listing does not, because it enumerates the groups jobs and triggers are in. The paused listing is the only place to find such a group to resume it.

Tips

A paused group binds what is added later, on both stores: a trigger stored into a paused trigger group, or for a job in a paused job group, is stored paused.

Pausing and resuming by matcher records the group as paused, which is what catches triggers added later. So it is named for groups and returns their names. The key-set PauseTriggers(keys) returns the keys it moved.

List<string> pausedGroups = await scheduler.PauseTriggerGroups(
    GroupMatcher<TriggerKey>.GroupStartsWith("nightly-"));

A worked example: an admin list screen

Page size, a state filter, a total for the pager, and full detail only for the row that was opened:

public sealed class TriggerListModel(IScheduler scheduler)
{
    public async Task<(IReadOnlyList<TriggerHeader> Rows, int Total)> GetPage(
        int pageNumber,
        int pageSize,
        TriggerState? state,
        string? groupPrefix,
        CancellationToken cancellationToken)
    {
        TriggerQuery query = new()
        {
            Skip = (pageNumber - 1) * pageSize,
            Take = pageSize,
            IncludeTotalCount = true,
            State = state,
            Group = groupPrefix is null
                ? null
                : GroupMatcher<TriggerKey>.GroupStartsWith(groupPrefix),
        };

        PagedResult<TriggerHeader> page = await scheduler.QueryTriggers(query, cancellationToken);
        return (page.Items, page.TotalCount ?? page.Items.Count);
    }

    public ValueTask<List<ITrigger>> Expand(
        IReadOnlyCollection<TriggerKey> keys,
        CancellationToken cancellationToken) =>
        scheduler.GetTriggers(keys, cancellationToken);
}

Nothing here loops over keys, and nothing loads a JobDataMap the list does not show.

The preset, and the mutation beside it

The Query* members take a record: a filter, a page, an optional count. The Get* conveniences answer questions that need none of that. There is no overload that only saves the new in QueryJobs(new JobQuery()).

QueryTriggersInError() is the one preset: it applies the filter for you, and pages like the member (the first PagedQuery.DefaultTake items, with HasMore reporting the rest):

PagedResult<JobHeader> jobs = await scheduler.QueryJobs(new JobQuery());
PagedResult<TriggerHeader> triggers = await scheduler.QueryTriggers(new TriggerQuery());
PagedResult<FireInstance> running = await scheduler.QueryFireInstances(new FireInstanceQuery());
PagedResult<FireInstance> runningOneJob = await scheduler.QueryFireInstances(new FireInstanceQuery { Job = jobKey });

// the one shorthand that is a preset rather than a synonym: it knows the filter
PagedResult<TriggerHeader> failed = await scheduler.QueryTriggersInError();

ResetTriggersFromErrorState(matcher) on IScheduler resets the failed triggers of a group in one call:

List<TriggerKey> reset = await scheduler.ResetTriggersFromErrorState(
    GroupMatcher<TriggerKey>.GroupEquals("imports"));

It is two calls underneath: a listing filtered by State = TriggerState.Error and the group, then ResetTriggersFromErrorState(keys). It is not atomic; a trigger that fails between them is left for the next call. What resetting does is unchanged; see Rescheduling Jobs.

Exists(name) asks the store whether a calendar is registered. GetCalendar would deserialize the stored blob.

bool haveHolidays = await scheduler.Exists("holidays");

The compatibility layer

SchedulerQueryExtensions restores eight 3.x call shapes as extension methods on IScheduler:

ExtensionBuilt on
GetJobKeys(matcher)QueryJobs
GetTriggerKeys(matcher)QueryTriggers
GetTriggersOfJob(jobKey)QueryTriggers + GetTriggers
GetJobGroupNames()QueryJobGroups
GetTriggerGroupNames()QueryTriggerGroups
GetPausedTriggerGroups()QueryTriggerGroups with Paused = true
GetCalendarNames()QueryCalendarNames
IsJobGroupPaused(name) / IsTriggerGroupPaused(name)the group listings
  • Each enumerates the entire result: they pass Take = PagedQuery.All. Fine for group names, bad for a trigger listing on a busy scheduler. Where the result can be large, or the row needs state or fire times, use the query member.
  • A null matcher throws ArgumentNullException. In 3.x GetJobKeys(null) silently listed only the DEFAULT group; pass GroupMatcher<JobKey>.AnyGroup() for every group.

Notes for job store authors

The six query members are abstract on IJobStore, with no default. To match the shipped stores:

  • Order by group then name, and add fire instance id as a third key for firings. Callers rely on a stable order for paging.
  • Read one row past Take to set HasMore. Run the count query only when IncludeTotalCount is set. Take = 0 with IncludeTotalCount must skip the row query.
  • The bulk fetches are GetJobs(keys) and GetTriggers(keys) on the store; the scheduler's GetJobDetails is the same operation.

The job store how-to covers the rest of the contract. The HTTP API exposes the same queries over the wire.

Help us by improving this page!
Last Updated: 10/6/26, 2:13 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
More About Triggers
Next
Simple Triggers