Version compatibility
This documentation relates to Quartz version 4.2 and later.
A job and its schedule are two things written in two places: the class, and the AddQuartz call that registers it. [QuartzJob] and [CronTrigger] put both on the class, and the source generator that ships inside Quartz.nupkg writes the registration — the same AddJob<T> and AddTrigger<T> calls you would have written, in a file you can open and read.
Nothing here is read at run time. There is no scanning, no Type.GetType, no reflection of any kind: the attributes are read by the compiler, and what reaches the scheduler is ordinary C#. A declared job is therefore exactly as trimmable and as native-AOT clean as a hand-written registration, and the repository's trimming canary declares one of its jobs this way to keep it that way.
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 anything the attributes cannot say is written beside it as it always was:
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 appears once something in the assembly carries [QuartzJob]; a project that declares no job gets no generated file and no method to call.
This is what the generator writes for the job above — one internal class per assembly, so two assemblies that both declare jobs never collide:
// <auto-generated/>
#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;
}
}
A default is left off rather than spelled out, so what the file says is what the attributes asked for.
Tips
To read the file your own build produced rather than the one above, set <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> 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 [CronTrigger] because a non-durable job nothing points at is deleted as soon as it is stored: declaring one that vanishes cannot be what was meant. Give such a job its trigger later, from code or from a scheduling file.
What [CronTrigger] says
Write it once per schedule; a job with three of them 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 |
The first schedule a job declares is named after the job, because that is what a single trigger would have been called by hand. The second and later ones count up from it — cleanup, cleanup-2, cleanup-3 — and a Name of its own overrides that for one of them without renumbering the rest.
One scheduler out of several
AddDeclaredJobs() registers on the builder it is called on, so the simplest way to give a named scheduler its own declared jobs is to call it there — no attribute is involved at all:
builder.Services.AddQuartz("reporting", q => q.AddDeclaredJobs());
When one assembly declares jobs for several schedulers, Scheduler on the job says which one it belongs to, and 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 — the unnamed scheduler included, whose name is the empty string. A job naming none is registered on whichever builders AddDeclaredJobs() is called on.
The compiler checks the cron
The expression on [CronTrigger] is read at build time by the parser that reads it at run time, so one that cannot parse is a build error rather than an exception while the host starts:
// 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, and generated code is not analysed. H is accepted here, because the schedule is built with WithCronSchedule, which resolves H against the trigger's key. Compile-Time Checks is the rest of what the analyzer reads.
What the generator refuses
Three more build errors, all of them cases where the alternative is a job that was declared and never fires.
QZ1001 DeclaredJobTypeNotSchedulable
[QuartzJob] on a type that AddJob<T> could not 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<TInput> implementer is fine, since it is an IJob.
QZ1002 DuplicateDeclaredIdentity
Two declarations resolving to one job key, or to one trigger key. A key is an identity: the second registration does not sit beside the first, it replaces it. Keys are compared within a scheduler, so the same key on two jobs that name different Schedulers is two jobs rather than a clash.
QZ1003 CronTriggerWithoutQuartzJob
[CronTrigger] on a class carrying no [QuartzJob]. The schedule is read as part of the job the other attribute declares, so on its own it registers nothing — and a schedule that silently registers nothing is worse than a build error.
What an attribute does not say
A declared job is a starting point, not a second configuration system. Everything below is still written as a registration, beside AddDeclaredJobs():
- A start or end time, a calendar, job data, a retry policy, a preferred node.
AddTrigger<T>says all of them, and a declared job can be given further triggers by hand —ForJobwith the key the attribute declared is all it takes. - A schedule that is not cron.
[SimpleTrigger]and the other trigger families are deliberately not here: cron is the schedule an attribute can carry without becoming a builder, and the rest are better written where the other trigger settings already are. - A cron expression from configuration. An attribute argument is a constant by definition, which is what lets the compiler check it. A schedule a deployment changes belongs in a scheduling file or the
Quartz:Schedulesection. - Jobs from another assembly.
AddDeclaredJobs()is generated per assembly and registers that assembly's jobs. A library that declares jobs exposes its own registration call, or the application writes one.
Related
- Compile-Time Checks — the four diagnostics the analyzer reports,
QZ0001among them - Cron Triggers and Cron Expressions — what the expression on
[CronTrigger]may say - Using Quartz — the registration calls the generated file is written in terms of
