Version compatibility
This documentation relates to Quartz version 4.2 and later.
[QuartzJob] declares a job on its class; [CronTrigger] and [SimpleTrigger] (4.3) declare its schedules. The source generator inside Quartz.nupkg writes the registration: the same AddJob and AddTrigger calls you would have written, in a file you can open and read.
Nothing is read at run time: no scanning, no Type.GetType, no reflection. The compiler reads the attributes and the scheduler gets ordinary C#, so a declared job is as trimmable and native-AOT clean as a hand-written registration. The repository's trimming canary declares one of its jobs this way to keep it so.
Declaring a job
[QuartzJob(Name = "cleanup", Group = "maintenance", Description = "removes rows nobody reads")]
[CronTrigger("0 0 0/6 * * ?")]
[CronTrigger("0 0 12 ? * MON-FRI", Name = "cleanup-weekday-noon", TimeZone = "Europe/Helsinki")]
public sealed class CleanupJob : IJob
{
public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
return default;
}
}
Registering what was declared
AddDeclaredJobs() adds every job the current assembly declares. It is an ordinary registration call, so write anything the attributes cannot say beside it:
services.AddQuartz(q =>
{
// Every job in this assembly that carries [QuartzJob], with the schedules it declares.
q.AddDeclaredJobs();
// Anything an attribute cannot say is still written here, beside it.
q.AddTrigger<CleanupJob>(trigger => trigger
.WithIdentity("cleanup-on-start")
.ForJob("cleanup", "maintenance")
.StartNow());
});
services.AddQuartzHostedService();
The method exists once something in the assembly carries [QuartzJob]; a project that declares no job gets no generated file and no method.
The generator writes one internal class per assembly, so nothing reaches the assembly's public surface. Two assemblies that declare jobs each get their own; only InternalsVisibleTo puts both in scope at once (see QZ1004). For the job above it writes:
//
#nullable enable
namespace Quartz
{
internal static class QuartzDeclaredJobs
{
public static global::Quartz.IQuartzBuilder AddDeclaredJobs(this global::Quartz.IQuartzBuilder builder)
{
builder.AddJob<global::MyApp.CleanupJob>(job => job
.WithIdentity("cleanup", "maintenance")
.WithDescription("removes rows nobody reads"));
builder.AddTrigger<global::MyApp.CleanupJob>(trigger => trigger
.WithIdentity("cleanup", "maintenance")
.ForJob("cleanup", "maintenance")
.WithCronSchedule("0 0 0/6 * * ?"));
builder.AddTrigger<global::MyApp.CleanupJob>(trigger => trigger
.WithIdentity("cleanup-weekday-noon", "maintenance")
.ForJob("cleanup", "maintenance")
.WithCronSchedule("0 0 12 ? * MON-FRI", cron => cron
.InTimeZone(global::Quartz.TimeZones.FindById("Europe/Helsinki"))));
return builder;
}
public static global::Quartz.IQuartzBuilder AddDeclaredJobsFromMyApp(this global::Quartz.IQuartzBuilder builder)
{
return AddDeclaredJobs(builder);
}
}
}
- Defaults are left out, so the file says only what the attributes asked for.
AddDeclaredJobsFrom(4.3) is the same registration under the assembly's own name, which binds however many other assemblies' registrations are visible; see() QZ1004.- The file is plain C# 8 (a block namespace, not file-scoped), so a project pinned to an older
LangVersioncompiles it. The attributes' named properties areinit-only, so setting one needs C# 9: a C# 8 project can write[QuartzJob]and[CronTrigger("…")]with no named properties.
Tips
To read the file your own build produced, set and look in obj/…/generated/Quartz.Analyzers/Quartz.Analyzers.DeclaredJobsGenerator/QuartzDeclaredJobs.g.cs.
What [QuartzJob] says
| Property | Default | What it sets |
|---|---|---|
Name | the class's own name | the job key's name |
Group | DEFAULT | the job key's group |
Description | none | the description carried on the job detail |
Durable | false, and forced true for a job that declares no schedule | whether the job stays in the store when no trigger points at it |
RequestRecovery | false | whether a firing interrupted by a hard shutdown is re-fired on recovery |
Scheduler | every scheduler | the one scheduler this job belongs to — see One scheduler out of several |
Durable is forced on for a job with no schedule, because a non-durable job with no trigger is deleted as soon as it is stored. Give such a job its trigger later, from code or a scheduling file.
What [CronTrigger] says
Write one per schedule; a job with three gets three triggers.
| Property | Default | What it sets |
|---|---|---|
| the constructor argument | — | the cron expression, in Quartz's six- or seven-field form |
Name | the job's name, then -2, -3 … | the trigger key's name |
Group | the job's group | the trigger key's group |
TimeZone | the scheduler's local zone | the zone the schedule is read in, by the id TimeZones.FindById resolves — IANA or Windows |
MisfireInstruction | SmartPolicy | what the trigger does about a firing it missed |
Priority | 5 | who wins when two triggers want the same moment and one worker is free |
Description | none | the description carried on the trigger |
ExecutionGroup | none | the execution group the firing counts against |
ConfigurationKey (4.3) | none | a configuration key whose value replaces the expression — see A schedule from configuration |
The first schedule is named after the job, as a single hand-written trigger would be. Later ones count up: cleanup, cleanup-2, cleanup-3. A Name of its own overrides one without renumbering the rest. [CronTrigger] and [SimpleTrigger] count together, in the order they are written.
What [SimpleTrigger] says
[SimpleTrigger] (4.3) is a fixed interval: a simple trigger with no start time. Write one per schedule, beside any [CronTrigger]:
// Fires as the scheduler starts, then every ten minutes. Jobs:Inbox:Interval, when it is set, replaces
// the ten minutes; the warm-up polls four times, five seconds apart, and stops.
[QuartzJob(Name = "poll-inbox")]
[SimpleTrigger("00:10:00", ConfigurationKey = "Jobs:Inbox:Interval")]
[SimpleTrigger("00:00:05", Name = "poll-inbox-warm-up", RepeatCount = 3)]
public sealed class PollInboxJob : IJob
{
public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
return default;
}
}
| Property | Default | What it sets |
|---|---|---|
| the constructor argument | — | the interval, as an invariant TimeSpan string: "00:10:00" is ten minutes, "1.00:00:00" a day |
RepeatCount | -1, forever | how many times it repeats after the first firing |
Name | the job's name, then -2, -3 … | the trigger key's name |
Group | the job's group | the trigger key's group |
MisfireInstruction | SmartPolicy | a SimpleTriggerMisfireInstruction: what the trigger does about a firing it missed |
Priority | 5 | who wins when two triggers want the same moment and one worker is free |
Description | none | the description carried on the trigger |
ExecutionGroup | none | the execution group the firing counts against |
ConfigurationKey | none | a configuration key whose value replaces the interval — see A schedule from configuration |
- It fires as the scheduler starts. Like an
AddTriggerwithoutStartAt, the trigger starts when the scheduler is built.RepeatCount = 0is that one firing. - The interval is parsed at build time and written into the registration as ticks, so nothing parses it at run time. One that is not a positive
TimeSpan, and aRepeatCountbelow-1, areQZ0005.
A schedule from configuration
ConfigurationKey (4.3) reads the expression, or on [SimpleTrigger] the interval, from the container's IConfiguration as the scheduler is built. The constructor's value is the fallback:
// Jobs:Report:Cron in appsettings.json, or Jobs__Report__Cron in the environment, replaces the
// expression. Without it, the report runs at 06:00.
[QuartzJob(Name = "report")]
[CronTrigger("0 0 6 * * ?", ConfigurationKey = "Jobs:Report:Cron")]
public sealed class DailyReportJob : IJob
{
public ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
return default;
}
}
{
"Jobs": {
"Report": {
"Cron": "0 30 5 * * ?"
}
}
}
| Key | Schedule |
|---|---|
| set | the configured value |
not set, or no IConfiguration registered | the attribute's expression or interval |
| set to a value the parser refuses, empty included | none: building the scheduler throws FormatException, so the host does not start |
- A configured interval is an invariant
TimeSpanstring, as the attribute's is:"00:05:00". One that does not parse, or is not positive, throws aFormatExceptionnaming the key. - The attribute's own value is still required and still checked, by
QZ0001orQZ0005. - A configured value is checked only at run time, when the scheduler is built.
- Only the expression or interval comes from configuration;
Name,TimeZone,RepeatCountand the rest stay the attribute's. - The generated registration reads it through
(services, trigger) => …, the same lookup a hand-written one makes. Nothing is reflected, so it stays trimming- and AOT-safe. - An assembly that does not reference
Microsoft.Extensions.Configuration.Abstractionscannot read the key:QZ1005.
One scheduler out of several
AddDeclaredJobs() registers on the builder it is called on. To give a named scheduler its declared jobs, call it there; no attribute is needed:
builder.Services.AddQuartz("reporting", q => q.AddDeclaredJobs());
When one assembly declares jobs for several schedulers, set Scheduler on the job. The generated registration is wrapped in a check on the builder's name:
[QuartzJob(Name = "nightly-report", Scheduler = "reporting")]
[CronTrigger("0 0 6 * * ?")]
public sealed class ReportJob : IJob { /* … */ }
// generated
if (builder.SchedulerName == "reporting")
{
builder.AddJob<global::MyApp.ReportJob>(job => job.WithIdentity("nightly-report"));
// …
}
- A job naming a scheduler is skipped by every other one, including the unnamed scheduler, whose name is the empty string.
- A job naming none is registered on every builder
AddDeclaredJobs()is called on.
The compiler checks the cron
The expression on [CronTrigger] is read at build time by the run-time parser, so one that cannot parse is a build error, not an exception at host start:
// error QZ0001: '0 0 12 * *' is not a valid cron expression: ... has 5 fields, but 6 or 7 are
// required: seconds, minutes, hours, day-of-month, month, day-of-week, and optionally year.
[QuartzJob]
[CronTrigger("0 0 12 * *")]
public sealed class CleanupJob : IJob { /* … */ }
- It is reported once, on the attribute. The generated file carries the same literal, but generated code is not analysed.
- A missing expression,
[CronTrigger(null!)]or[CronTrigger("")], is reported the same way, and the generator writes no schedule for it. His accepted, because the schedule is built withWithCronSchedule, which resolvesHagainst the trigger's key.
[SimpleTrigger]'s interval and RepeatCount are checked the same way, by QZ0005, and a schedule it reports is not generated either.
Compile-Time Checks has the rest of what the analyzer checks.
What the generator reports
Four build errors, each for a job or schedule that would otherwise be declared and never used, and one note about a name. DisableQuartzAnalyzers removes the generator and these diagnostics with it; see Changing a severity, or turning it off.
QZ1001 DeclaredJobTypeNotSchedulable
Reports [QuartzJob] on a type AddJob cannot take: one that does not implement IJob, is abstract, is generic, or cannot be named from another file in the assembly (a private nested class, or a file-local one). An IJob implementer is fine; it is an IJob.
Fix the type so it qualifies, or remove the attribute.
QZ1002 DuplicateDeclaredIdentity
Reports two declarations that resolve to one job key or one trigger key. A key is an identity: the second registration replaces the first. Keys are compared within a scheduler, so the same key on two jobs naming different Schedulers is two jobs, not a clash.
Fix by giving one of them its own Name or Group.
QZ1003 CronTriggerWithoutQuartzJob
Reports [CronTrigger] or [SimpleTrigger] on a class with no [QuartzJob], once per class. The schedule is read as part of the job [QuartzJob] declares, so on its own it would silently register nothing.
Fix by adding [QuartzJob] to the class.
QZ1004 DeclaredJobsRegistrationRenamed
Reports, as information (a warning before 4.3), an assembly whose registration was renamed. When an assembly that declares jobs grants InternalsVisibleTo to another that declares jobs, both generated QuartzDeclaredJobs classes are in scope in the second. AddDeclaredJobs() there would be ambiguous, and naming the class would be too. So the second assembly's registration is named after the assembly: in MyApp.Worker it is QuartzDeclaredJobs_MyApp_Worker.AddDeclaredJobsFromMyApp_Worker(). Characters an identifier cannot hold become _, and a name starting with a digit gets a leading _.
AddDeclaredJobs() in that assembly still means the other assembly's jobs. The note, on the assembly's first [QuartzJob], says so:
info QZ1004: AddDeclaredJobs() in this assembly resolves to 'MyApp.Jobs''s declared jobs, which are
visible through InternalsVisibleTo; call AddDeclaredJobsFromMyApp_Worker() for this assembly's own
From 4.3 every registration also has the method named after its assembly, so each set of declared jobs has a spelling that binds:
| Visible in the application | Call |
|---|---|
| its own registration only | AddDeclaredJobs(), or AddDeclaredJobsFrom |
| its own and one library's | AddDeclaredJobsFrom for its own; AddDeclaredJobs() or AddDeclaredJobsFrom for the library's |
| two libraries' | AddDeclaredJobsFrom for each; AddDeclaredJobs() is ambiguous |
Fix: nothing is broken. Where more than one registration is visible, call each by its assembly's name. The note is reported whatever the calls say; dotnet_diagnostic.QZ1004.severity = none hides it. An assembly no InternalsVisibleTo names sees no other registration and keeps QuartzDeclaredJobs.AddDeclaredJobs().
QZ1005 ConfigurationKeyWithoutConfiguration
Reports a [CronTrigger] or [SimpleTrigger] with a ConfigurationKey in an assembly that does not reference Microsoft.Extensions.Configuration.Abstractions. The generated registration reads the key through IConfiguration, so without the type the key could never be read, and the schedule would silently be the attribute's.
Fix by referencing the package (the Quartz package brings it, unless its assets are excluded), or by removing ConfigurationKey.
What an attribute does not say
Write these as registrations beside AddDeclaredJobs():
- A start or end time, a calendar, job data, a preferred node. Use
AddTrigger. A retry policy is[RetryPolicy]on the class. To give a declared job more triggers, useForJobwith the key the attribute declared. - A calendar-interval, daily-time-interval or recurrence schedule. Cron and a fixed interval are the schedules an attribute can carry without becoming a builder.
- Anything but the expression or interval from configuration.
ConfigurationKeyreplaces that alone. Put a whole schedule a deployment changes in a scheduling file or theQuartz:Schedulesection. - Jobs from another assembly.
AddDeclaredJobs()is generated per assembly, registers that assembly's jobs, and isinternal. WithoutInternalsVisibleTo, an application cannot see a library's generated method, so the library exposes a registration call of its own, or the application writes one. WithInternalsVisibleTo, the application calls the library'sAddDeclaredJobsFrom;() QZ1004has the table.
Related
- Compile-Time Checks — the five diagnostics the analyzer reports,
QZ0001andQZ0005among them - Cron Triggers and Cron Expressions — what the expression on
[CronTrigger]may say - Simple Triggers — the trigger
[SimpleTrigger]declares - Using Quartz — the registration calls the generated file is written in terms of
