From 5b41eac0ef93de3714f393b74ee6344a6790c179 Mon Sep 17 00:00:00 2001 From: Vladimir Pecanac Date: Wed, 16 Sep 2026 14:52:55 +0200 Subject: [PATCH] QuartzVsHangfire: move the Quartz.NET side to 4.1.0, Hangfire to 1.8.25 Quartz.NET 4 closes the two gaps this comparison was scored on, so the sample now shows both: the retry policy that lives on the trigger, and the first-party Quartz.Dashboard package. - Quartz 3.19.1 to 4.1.0, and Quartz.Extensions.Hosting dropped (4.1.0 is an empty forwarding package; AddQuartzHostedService is in Quartz now). - Hangfire.Core and Hangfire.NetCore 1.8.24 to 1.8.25. - Newtonsoft.Json pinned to 13.0.4: the transitive 11.0.1 raises NU1903. - IJob.Execute and every IJobListener member move to ValueTask plus a CancellationToken. - StdSchedulerFactory and the NameValueCollection property bag are gone, so the standalone scheduler is built with QuartzSchedulerBuilder. - QuartzRetryJob drops the manual refire workaround; QuartzRetryPolicyScheduler carries the explicit and exponential retry policies instead. - QuartzDashboardSetup registers the dashboard and the execution history. dotnet build -c Release: 0 warnings, 0 errors. dotnet test: 17 of 17 passed. --- .../HangfireSample/HangfireMonitoring.cs | 2 +- .../QuartzSample/BackgroundJob.cs | 10 ++++--- .../QuartzSample/LoggingJobListener.cs | 19 +++++++----- .../QuartzSample/QuartzDashboardSetup.cs | 27 +++++++++++++++++ .../QuartzSample/QuartzRetryJob.cs | 20 ++++--------- .../QuartzRetryPolicyScheduler.cs | 22 ++++++++++++++ .../QuartzSample/QuartzStartup.cs | 2 +- .../QuartzSample/QuartzStorageConfig.cs | 18 ++++-------- .../QuartzSample/ReportJob.cs | 4 ++- .../QuartzVsHangfire/QuartzVsHangfire.csproj | 15 +++++++--- .../Tests/QuartzDashboardSetupTests.cs | 20 +++++++++++++ .../Tests/QuartzRetryJobTests.cs | 26 ----------------- .../Tests/QuartzRetryPolicySchedulerTests.cs | 29 +++++++++++++++++++ 13 files changed, 143 insertions(+), 71 deletions(-) create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzDashboardSetup.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzRetryPolicyScheduler.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzDashboardSetupTests.cs delete mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzRetryJobTests.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzRetryPolicySchedulerTests.cs diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireMonitoring.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireMonitoring.cs index 0841a8b31..de3bf692c 100644 --- a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireMonitoring.cs +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireMonitoring.cs @@ -2,7 +2,7 @@ namespace QuartzVsHangfire.HangfireSample; -// Hangfire ships a queryable monitoring API — the same data the drop-in +// Hangfire ships a queryable monitoring API, the same data the drop-in // dashboard renders. Wiring the dashboard itself is one line in Startup: // app.UseHangfireDashboard("/hangfire"); // needs Hangfire.AspNetCore public static class HangfireMonitoring diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/BackgroundJob.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/BackgroundJob.cs index 68243f82c..182d97762 100644 --- a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/BackgroundJob.cs +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/BackgroundJob.cs @@ -4,13 +4,15 @@ namespace QuartzVsHangfire.QuartzSample; public class BackgroundJob : IJob { - public async Task Execute(IJobExecutionContext context) + // Quartz.NET 4.x: Execute returns ValueTask and takes a CancellationToken. + public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) { var jobDataMap = context.MergedJobDataMap; - var useJobDataMapConsoleOutput = jobDataMap.GetBoolean("UseJobDataMapConsoleOutput"); - - if (useJobDataMapConsoleOutput) + // 4.x: GetBoolean throws InvalidCastException for a missing key, so an + // optional entry is read with TryGetBoolean. + if (jobDataMap.TryGetBoolean("UseJobDataMapConsoleOutput", out var useJobDataMapConsoleOutput) + && useJobDataMapConsoleOutput) { var consoleOutput = jobDataMap.GetString("ConsoleOutput"); await Console.Out.WriteLineAsync(consoleOutput); diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/LoggingJobListener.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/LoggingJobListener.cs index e502170e5..8c12435f1 100644 --- a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/LoggingJobListener.cs +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/LoggingJobListener.cs @@ -2,25 +2,28 @@ namespace QuartzVsHangfire.QuartzSample; -// Quartz.NET has no dashboard. Monitoring is a listener we attach to the -// scheduler. This one counts completed jobs — the hook a custom UI would use. +// A listener is still the seam for custom monitoring. Since 4.x Quartz.NET also +// ships its own dashboard, so this is an extension point rather than the only way. public class LoggingJobListener : IJobListener { public string Name => "logging-job-listener"; public int ExecutedCount { get; private set; } - public Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default) - => Task.CompletedTask; + // 4.x: every listener member returns ValueTask. A 3.x Task signature still + // compiles, stops implementing the interface member, and is refused at + // registration. + public ValueTask JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; - public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default) - => Task.CompletedTask; + public ValueTask JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default) + => ValueTask.CompletedTask; - public Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, + public ValueTask JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, CancellationToken cancellationToken = default) { ExecutedCount++; - return Task.CompletedTask; + return ValueTask.CompletedTask; } } diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzDashboardSetup.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzDashboardSetup.cs new file mode 100644 index 000000000..58274b1f1 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzDashboardSetup.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Quartz; + +namespace QuartzVsHangfire.QuartzSample; + +// Quartz.NET 4.x ships a dashboard of its own: a Blazor Server UI in the +// Quartz.Dashboard package, plus the execution history it reads. +public static class QuartzDashboardSetup +{ + public static IServiceCollection AddDashboard(this IServiceCollection services) + { + services.AddQuartzDashboard(options => options.ReadOnly = true); + services.AddQuartzExecutionHistory(options => options.Retention = TimeSpan.FromHours(24)); + + return services; + } + + // In a web application this is the one mapping call the dashboard needs. + public static IEndpointRouteBuilder MapDashboard(this IEndpointRouteBuilder endpoints) + { + endpoints.MapQuartzDashboard("/quartz"); + + return endpoints; + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzRetryJob.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzRetryJob.cs index bbb5ec496..4169d1897 100644 --- a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzRetryJob.cs +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzRetryJob.cs @@ -2,21 +2,13 @@ namespace QuartzVsHangfire.QuartzSample; -// Quartz.NET has no automatic retry. We opt in by catching the failure and -// throwing a JobExecutionException that asks the scheduler to refire the job. +// Since 4.x the job no longer owns the retry. It throws, and the retry policy +// on the trigger decides whether and when the occurrence runs again. public class QuartzRetryJob : IJob { - public async Task Execute(IJobExecutionContext context) - { - try - { - await DoWorkAsync(context); - } - catch (Exception ex) - { - throw new JobExecutionException(ex, refireImmediately: true); - } - } + public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) + => await DoWorkAsync(context, cancellationToken); - protected virtual Task DoWorkAsync(IJobExecutionContext context) => Task.CompletedTask; + protected virtual ValueTask DoWorkAsync(IJobExecutionContext context, CancellationToken cancellationToken) + => ValueTask.CompletedTask; } diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzRetryPolicyScheduler.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzRetryPolicyScheduler.cs new file mode 100644 index 000000000..96e669eaa --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzRetryPolicyScheduler.cs @@ -0,0 +1,22 @@ +using Quartz; + +namespace QuartzVsHangfire.QuartzSample; + +// Quartz.NET 4.x retries a failed occurrence for us: the policy lives on the +// trigger, is persisted with it, and survives a restart or a failover. +public static class QuartzRetryPolicyScheduler +{ + public static ITrigger BuildWebhookTrigger() => + TriggerBuilder.Create() + .WithIdentity("webhook") + .StartNow() + .WithRetryPolicy(RetryPolicy.Explicit([ + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(60), + TimeSpan.FromSeconds(300) + ])) + .Build(); + + public static RetryPolicy ExponentialBackoff() => + RetryPolicy.Exponential(maxAttempts: 5, initialDelay: TimeSpan.FromSeconds(30), factor: 2.0); +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStartup.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStartup.cs index b1187c56d..b104cd1a8 100644 --- a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStartup.cs +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStartup.cs @@ -13,7 +13,7 @@ public static IServiceCollection AddQuartzJobs(this IServiceCollection services) { var reportJob = new JobKey("nightly-report"); - configurator.AddJob(reportJob); + configurator.AddJob(job => job.WithIdentity(reportJob)); configurator.AddTrigger(trigger => trigger .ForJob(reportJob) .WithIdentity("nightly") diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStorageConfig.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStorageConfig.cs index fe8d17a10..0a1f91457 100644 --- a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStorageConfig.cs +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStorageConfig.cs @@ -1,22 +1,16 @@ -using System.Collections.Specialized; using Quartz; -using Quartz.Impl; namespace QuartzVsHangfire.QuartzSample; -// Quartz.NET runs fine with no database: RAMJobStore is the default. Durable -// schedules are opt-in — we swap the job store type for an ADO.NET store. +// Quartz.NET runs fine with no database: the in-memory store is the default. +// 4.x removed StdSchedulerFactory, so a scheduler outside a container is built +// with QuartzSchedulerBuilder, the same builder AddQuartz configures. public static class QuartzStorageConfig { - public static Task CreateInMemorySchedulerAsync() + public static async Task CreateInMemorySchedulerAsync() { - var properties = new NameValueCollection - { - ["quartz.jobStore.type"] = "Quartz.Simpl.RAMJobStore, Quartz" - }; + var factory = QuartzSchedulerBuilder.Create().Build(); - var factory = new StdSchedulerFactory(properties); - - return factory.GetScheduler(); + return await factory.GetScheduler(); } } diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/ReportJob.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/ReportJob.cs index ec7ead390..bcbf53b5f 100644 --- a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/ReportJob.cs +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/ReportJob.cs @@ -11,5 +11,7 @@ public class ReportJob : IJob public ReportJob(IReportBuilder reportBuilder) => _reportBuilder = reportBuilder; - public Task Execute(IJobExecutionContext context) => _reportBuilder.RunAsync(); + // 4.x signature: ValueTask plus the scheduler's CancellationToken. + public async ValueTask Execute(IJobExecutionContext context, CancellationToken cancellationToken = default) + => await _reportBuilder.RunAsync(); } diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzVsHangfire.csproj b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzVsHangfire.csproj index 72c9239a5..d34bf3dbd 100644 --- a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzVsHangfire.csproj +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzVsHangfire.csproj @@ -8,11 +8,18 @@ - + + + + + - - - + + + + + diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzDashboardSetupTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzDashboardSetupTests.cs new file mode 100644 index 000000000..7e33f87b8 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzDashboardSetupTests.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.DependencyInjection; +using Quartz.Extensibility; +using QuartzVsHangfire.QuartzSample; + +namespace Tests; + +public class QuartzDashboardSetupTests +{ + [Fact] + public void WhenAddDashboard_ThenTheExecutionHistoryStoreIsRegistered() + { + var services = new ServiceCollection(); + + services.AddDashboard(); + + using var provider = services.BuildServiceProvider(); + + Assert.NotNull(provider.GetService()); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzRetryJobTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzRetryJobTests.cs deleted file mode 100644 index 1cf7caf65..000000000 --- a/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzRetryJobTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Moq; -using Quartz; -using QuartzVsHangfire.QuartzSample; - -namespace Tests; - -public class QuartzRetryJobTests -{ - private sealed class FailingRetryJob : QuartzRetryJob - { - protected override Task DoWorkAsync(IJobExecutionContext context) - => throw new InvalidOperationException("work failed"); - } - - [Fact] - public async Task GivenTheWorkThrows_WhenExecute_ThenWrapsItInARefiringJobExecutionException() - { - var job = new FailingRetryJob(); - var context = new Mock().Object; - - var exception = await Assert.ThrowsAsync(() => job.Execute(context)); - - Assert.True(exception.RefireImmediately); - Assert.IsType(exception.InnerException); - } -} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzRetryPolicySchedulerTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzRetryPolicySchedulerTests.cs new file mode 100644 index 000000000..34c99721e --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzRetryPolicySchedulerTests.cs @@ -0,0 +1,29 @@ +using QuartzVsHangfire.QuartzSample; + +namespace Tests; + +public class QuartzRetryPolicySchedulerTests +{ + [Fact] + public void WhenBuildWebhookTrigger_ThenTheTriggerCarriesTheRetryPolicy() + { + var trigger = QuartzRetryPolicyScheduler.BuildWebhookTrigger(); + + var policy = trigger.RetryPolicy; + + Assert.NotNull(policy); + Assert.Equal(3, policy.MaxAttempts); + Assert.Equal(TimeSpan.FromSeconds(10), policy.DelayFor(1)); + Assert.Equal(TimeSpan.FromSeconds(60), policy.DelayFor(2)); + } + + [Fact] + public void WhenExponentialBackoff_ThenTheDelayDoubles() + { + var policy = QuartzRetryPolicyScheduler.ExponentialBackoff(); + + Assert.Equal(5, policy.MaxAttempts); + Assert.Equal(TimeSpan.FromSeconds(30), policy.DelayFor(1)); + Assert.Equal(TimeSpan.FromSeconds(60), policy.DelayFor(2)); + } +}