Version compatibility
This documentation relates to Quartz version 4.2 and later.
Quartz ships an analyzer that reports, at build time, mistakes that otherwise throw at run time:
| Mistake | Otherwise throws |
|---|---|
a cron expression that cannot parse, in AddQuartz | while the host starts |
the same, in ScheduleJob | when the scheduling code runs |
[JobTimeout("5 minutes")] | the first time the timeout middleware reflects over the job, after deployment |
[SimpleTrigger("10 minutes")] | never: the generator cannot write the schedule, so the job silently lacks it |
The analyzer is inside the package, under analyzers/dotnet/cs, so referencing Quartz is enough:
<PackageReference Include="Quartz" Version="4.2.0" />
It reads cron with the same parser Quartz uses at run time. H, wrapping ranges, L, W, #, the @ macros and the five-field Unix dialect mean the same thing, and the compiler prints the message the exception would have carried.
What it checks
| Id | Severity | What it says |
|---|---|---|
QZ0001 | Error | A cron literal or constant that the parser refuses |
QZ0002 | Error | A [JobTimeout] argument that is not a TimeSpan, or is negative |
QZ0003 | Warning | A job that persists its data map and allows concurrent firings |
QZ0004 | Info | A job body that awaits or loops without reading its cancellation token |
QZ0005 | Error | A [SimpleTrigger] interval that is not a positive TimeSpan, or a RepeatCount below -1 |
QZ0006 | Error | A [RetryPolicy] delay that is not a TimeSpan, or is negative |
QZ0007 | Warning | A trigger's cron schedule with * seconds under a restricted minute or hour, which fires every second |
QZ0008 | Error | A [CronTrigger] or [SimpleTrigger] MisfireThreshold that is not a positive TimeSpan in whole milliseconds |
Only values the compiler already knows are checked: a string literal, a const, an interpolated string with nothing interpolated. A value built at run time, read from configuration or passed in a variable is skipped silently, rather than guessed at.
To change a severity or turn a check off, see Changing a severity, or turning it off.
QZ0001 InvalidCronExpression
Reports a cron string that cannot parse, at every call that takes one: CronScheduleBuilder.Create, WithCronSchedule, the CronExpression constructors, CronExpression.Parse, TryParse, ParseWithHash, TryParseWithHash and ResolveHash, the CronCalendar constructors and CronTriggerImpl's. Also on the [CronTrigger] attribute.
// 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.
_ = TriggerBuilder.Create().WithCronSchedule("0 0 12 * *").Build();
The call decides how the literal is read:
- The dialect. A sibling
CronFormatargument is honoured:CronExpression.Parse("30 4 * * 1", CronFormat.Unix)is a valid crontab line, and the same five fields without it are not. ACronFormatthe compiler cannot evaluate skips the check, because the same digits mean different days in each dialect. - Whether
His resolved.WithCronSchedule("0 H 3 * * ?")is valid: the builder resolves the hash against the trigger key later.CronExpression.Parse("0 H 3 * * ?")is not: it has no key. The analyzer reports what each entry point would throw. TryParseis checked too. A literal that can never parse makes aTryParsethat can only returnfalse, which is a bug.- Missing expressions. A constant that is
null, empty or whitespace is reported as missing:error QZ0001: The cron expression is missing: the argument is null. Anullis allowed only where the parameter isstring?:TryParseandTryParseWithHashreturnfalsefor it; every other entry point throws.
Fix the expression, pass the CronFormat it is written in, or use an entry point that resolves H. Suppress with dotnet_diagnostic.QZ0001.severity in .editorconfig.
QZ0002 InvalidJobTimeout
Reports a [JobTimeout] argument that is not an invariant TimeSpan string, or is negative. The argument is a string because an attribute argument cannot be a TimeSpan, and nothing parses it until the timeout middleware reflects over the job type.
[JobTimeout("5 minutes")] // error QZ0002: '5 minutes' is not a TimeSpan. Spell the job's timeout the
public class ReportJob : IJob // way TimeSpan does, invariantly: "00:05:00" for five minutes.
Fix by spelling the budget as TimeSpan does: "00:05:00" for five minutes. "00:00:00" is not reported: zero means the job has no timeout, whatever the scheduler's default. Suppress with dotnet_diagnostic.QZ0002.severity in .editorconfig.
QZ0003 PersistJobDataWithoutDisallowConcurrent
Reports a job with [PersistJobDataAfterExecution] and without [DisallowConcurrentExecution]. The data map is re-stored when a firing completes; with concurrent firings, two read the same map, change it independently and store it in turn, so the first write is silently lost.
[PersistJobDataAfterExecution]
[DisallowConcurrentExecution] // without this line: warning QZ0003
public class CounterJob : IJob
Both attributes are read as Quartz reads them at run time: on the type, a base class, or any interface it implements. A job that gets [DisallowConcurrentExecution] from an interface is not reported; one that inherits [PersistJobDataAfterExecution] from one is.
Fix by adding [DisallowConcurrentExecution]. Suppress with dotnet_diagnostic.QZ0003.severity in .editorconfig.
QZ0004 CancellationTokenNotObserved
Reports a job body that awaits or loops and never reads its cancellation token: neither the parameter nor IJobExecutionContext.CancellationToken, which is the same token. The scheduler cancels it on shutdown and on IScheduler.Interrupt; a body that ignores it runs to completion regardless.
public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default)
{
await Task.Delay(TimeSpan.FromMinutes(5)); // info QZ0004
}
- The body read is the one the scheduler runs for
IJob.Execute: the implementation (explicit or not), anoverridein a job deriving from a virtual or abstract base, or the half of apartialmethod with the body. AnExecutethat hides the base job's withnewis not read. - It is information, not a warning: whether work is interruptible is a judgement no analyzer can make, and a job that returns in a millisecond is right to ignore the token. The await-or-loop condition keeps it off jobs that could not honour a cancellation anyway.
CA2016, shipped with the .NET SDK, covers the related case: a token that is not forwarded to a call that takes one.
Fix by reading the token: pass it to what the job awaits, or check it in the loop. Suppress with dotnet_diagnostic.QZ0004.severity in .editorconfig, as in the example below.
QZ0005 InvalidSimpleTriggerSchedule
Reports a [SimpleTrigger] (4.3) whose interval is not a positive invariant TimeSpan string, or whose RepeatCount is below -1. The generator writes the interval into the registration as ticks, so it cannot write a schedule it cannot read.
[QuartzJob]
[SimpleTrigger("10 minutes")] // error QZ0005: '10 minutes' is not a TimeSpan. Spell the trigger's
public class PollJob : IJob // interval the way TimeSpan does, invariantly: "00:10:00" for ten minutes.
| Written | Reported as |
|---|---|
"10 minutes", "" | not a TimeSpan |
"00:00:00", "-00:10:00" | not longer than zero |
null | missing |
RepeatCount = -2 | below -1; -1 repeats forever |
Fix by spelling the interval as TimeSpan does: "00:10:00" for ten minutes. A reported schedule is not generated, so with the severity lowered the job builds without it. Suppress with dotnet_diagnostic.QZ0005.severity in .editorconfig.
QZ0006 InvalidRetryPolicyDelay
Reports a [RetryPolicy] (4.4) delay that is not an invariant TimeSpan string, or is negative: the second argument of the fixed and exponential forms, each argument of the explicit form, and MaxDelay. Nothing parses it until the job is added to a scheduler, which then refuses the job.
[RetryPolicy(3, "5 minutes")] // error QZ0006: '5 minutes' is not a TimeSpan. Spell a retry delay the
public class ImportJob : IJob // way TimeSpan does, invariantly: "00:00:30" for thirty seconds.
Fix by spelling the delay as TimeSpan does: "00:05:00" for five minutes. The attempt count, the factor and the jitter are not checked here; the attribute refuses them when the job is added. Suppress with dotnet_diagnostic.QZ0006.severity in .editorconfig.
QZ0007 WildcardSecondsField
Reports a trigger's cron schedule (4.6) whose seconds field is * while the minute or the hour field is restricted. * matches all 60 seconds, so the trigger fires every second of each minute it matches. It is what prepending * to a five-field crontab line gives.
// warning QZ0007: '* */5 * ? * *' fires 60 times in each minute it matches, because its seconds field
// is '*'; write '0 */5 * ? * *' to fire once in each of those minutes, or '0-59' for the seconds if
// every second is intended
_ = TriggerBuilder.Create().WithCronSchedule("* */5 * ? * *").Build();
| Written | Reported |
|---|---|
* */5 * ? * *, * 0 9 * * ?, * * 9-17 * * ? | yes |
* * * * * ? | no: nothing is restricted |
0-59 */5 * ? * *, */1 */5 * ? * * | no: every second, written on purpose |
0 */5 * ? * *, or five fields with CronFormat.Unix | no: once a minute |
- Where.
CronScheduleBuilder.Create,WithCronSchedule,[CronTrigger]andCronTriggerImpl's constructors. ACronCalendaror a bareCronExpressionis not checked:* * 0-7 ? * *is how a window from midnight to eight is written. - At run time. The scheduler logs event
5112when such a trigger is scheduled or rescheduled, for an expression from configuration, a scheduling file or a database.
Fix by writing 0 in the seconds field, or by passing the five fields with CronFormat.Unix. Write 0-59 if every second is intended. Suppress with dotnet_diagnostic.QZ0007.severity in .editorconfig.
QZ0008 InvalidMisfireThreshold
Reports a [CronTrigger] or [SimpleTrigger] MisfireThreshold (4.6) that is not an invariant TimeSpan string longer than zero in whole milliseconds. The generator writes the threshold into the registration as ticks, so it cannot write one it cannot read.
[QuartzJob]
[CronTrigger("0 0 * * * ?", MisfireThreshold = "5 minutes")] // error QZ0008: '5 minutes' is not a TimeSpan.
public class ReportJob : IJob
| Written | Reported as |
|---|---|
"5 minutes", "" | not a TimeSpan |
"00:00:00", "-00:05:00" | not longer than zero |
"00:00:00.0001" | not whole milliseconds |
Fix by spelling the threshold as TimeSpan does: "00:05:00" for five minutes. A reported threshold is not generated, so with the severity lowered the trigger builds on the store's threshold. Suppress with dotnet_diagnostic.QZ0008.severity in .editorconfig.
Changing a severity, or turning it off
Each diagnostic is an ordinary compiler diagnostic, so .editorconfig sets its severity:
[*.cs]
# A cron literal that cannot parse is only a warning here
dotnet_diagnostic.QZ0001.severity = warning
# This codebase does not want the cancellation-token hint
dotnet_diagnostic.QZ0004.severity = none
To remove the analyzer from the build entirely, set one property in the project file:
<PropertyGroup>
<DisableQuartzAnalyzers>true</DisableQuartzAnalyzers>
</PropertyGroup>
Tips
The property removes the whole assembly, so the source generator and its AddDeclaredJobs() go with the diagnostics, and delegate jobs are bound by reflection; nothing else Quartz does is affected. To quiet one rule, use .editorconfig instead. ExcludeAssets="analyzers" on the package reference does not work: the .NET 10 SDK still passes the assembly to the compiler.
What it does not check
- RRULE literals (
RecurrenceScheduleBuilder.Create). The recurrence parser is not BCL-clean, so it cannot be linked into an assembly the compiler loads. - Time-zone ids (
TimeZones.FindById). The build machine's zone database is not the run-time machine's; refusing a zone your server has would be worse than no check. - Stored-form retry policies, a
[RetryPolicy]'s counts and factor, preferred nodes, key names and type-name strings. Each is rare in code, refused by name at startup, or already[RequiresUnreferencedCode].
Related
Declaring Jobs with Attributes covers the rest of the 4.2 compile-time work: attributes that declare a job's schedule on the job class, and a source generator, in the same assembly, that registers them. Its diagnostics are QZ1001 to QZ1005; the cron expression on an attribute is checked by QZ0001 and QZ0007 above, the interval by QZ0005, and the misfire threshold by QZ0008.
