Start Debugging

Quartz.NET 4.2 превращает некорректные cron-выражения в ошибки сборки

Quartz.NET 4.2.0 поставляется с анализатором Roslyn, который отклоняет непарсящиеся cron-литералы как QZ0001 на этапе компиляции, а также с генератором исходного кода, который превращает атрибуты [QuartzJob] и [CronTrigger] в регистрацию AddDeclaredJobs(). Разбираем, что он проверяет, как выглядит сгенерированный код и как от этого отказаться.

Большинство пользователей Quartz.NET хотя бы раз отправляли в продакшен cron-выражение, которое нормально выглядело при код-ревью, а при старте приложения выбрасывало FormatException. Quartz.NET 4.2.0, выпущенный 25 сентября 2026 года и дополненный патчем 4.2.1 27 сентября, переносит этот сбой на этап компиляции. Пакет Quartz теперь содержит собственный анализатор и генератор исходного кода, и для этого не нужно устанавливать ничего дополнительно.

QZ0001: парсер cron-выражений запускается во время сборки

Анализатор проверяет каждый cron-литерал или константу, переданную в WithCronSchedule, CronScheduleBuilder.Create, конструкторы CronExpression, CronCalendar и CronTriggerImpl. Он не использует отдельную грамматику: анализатор подключает исходники собственного парсера планировщика, а корпус из 128 выражений на паритетность следит за тем, чтобы оба оставались согласованными. Если компилятор принимает литерал, планировщик тоже его примет.

q.AddTrigger(t => t
    .ForJob(jobKey)
    .WithCronSchedule("0 12 * * 1-5"));
// error QZ0001, reported on the literal with the parser's own message

Этот пример - классическая ошибка: пятиполевое crontab-выражение, скопированное из Linux. Quartz читает шесть или семь полей (секунды идут первыми) и требует ? в одном из двух полей дня, поэтому версия для Quartz выглядит так: "0 0 12 ? * MON-FRI". Если вам действительно нужна Unix-грамматика, передайте CronFormat.Unix как литерал, и анализатор будет валидировать выражение по ней.

Вместе с этим правилом поставляются еще три:

Декларирование задачи прямо на классе

Вторая половина фичи - это генератор, который читает [QuartzJob] и [CronTrigger] с ваших типов IJob:

[QuartzJob(Name = "cleanup", Group = "maintenance")]
[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)
        => default;
}

services.AddQuartz(q => q.AddDeclaredJobs());
services.AddQuartzHostedService();

AddDeclaredJobs() генерируется в вашу сборку как internal-расширение для IQuartzBuilder. Он содержит именно те вызовы AddJob<T> и AddTrigger<T>, которые вы бы написали вручную, поэтому никакого сканирования сборок не происходит и нечего закреплять (root) для trimming или Native AOT. Cron-строки в атрибутах проходят через QZ0001 так же, как любой другой литерал. Установите <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>, если хотите посмотреть на сгенерированный QuartzDeclaredJobs.g.cs.

У генератора есть собственные защитные проверки: QZ1001 отклоняет атрибут на типе, который не является конкретным IJob, QZ1002 отклоняет два объявления с одинаковой идентичностью, а QZ1003 отклоняет [CronTrigger] без [QuartzJob]. Задача без триггера принудительно получает Durable = true, чтобы хранилище не удалило ее сразу же.

Заметки по обновлению

Анализатор включен по умолчанию, а значит существующий проект с некорректным литералом перестанет собираться. Это и есть цель, но также стоит следить за QZ0003 при TreatWarningsAsErrors. Чтобы полностью отключить анализатор, добавьте в файл проекта следующее:

<PropertyGroup>
  <DisableQuartzAnalyzers>true</DisableQuartzAnalyzers>
</PropertyGroup>

В заметках о выпуске отмечается, что ExcludeAssets="analyzers" в ссылке на пакет не отключает анализатор на SDK .NET 10. Отдельные уровни серьезности все еще можно настроить в .editorconfig.

Если вы используете постоянное хранилище задач (persistent job store), 4.2 также требует применения миграции database/migrations/4.2/add_continuations_<dialect>.sql перед запуском первого узла версии 4.2 - из-за новой функции продолжений триггеров (trigger continuations). А если вы включаете новую историю выполнения на основе базы данных на схеме, созданной из tables_sqlServerMOT.sql или tables_sqlServer_Below2016.sql, переходите сразу на 4.2.1, которая исправляет отсутствующий столбец RETRY_ATTEMPT.

Если вы еще решаете, подходит ли Quartz в качестве планировщика в принципе, я сравнил его с альтернативами в статье Hangfire vs Quartz.NET vs IHostedService.

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад