From 3a2a8336ed2497b8e18039241428bae5a1694dc5 Mon Sep 17 00:00:00 2001 From: Vladimir Pecanac Date: Sat, 15 Aug 2026 18:56:28 +0200 Subject: [PATCH 1/2] Add Hangfire vs Quartz.NET comparison sample New dotnet-client-libraries/QuartzVsHangfire sample backing the Hangfire vs Quartz.NET article: the Hangfire side enqueues a fire-and-forget job and registers a recurring job against in-memory storage, and the Quartz.NET side builds a cron trigger and runs a job. Targets net10.0 with xunit tests. --- .../QuartzVsHangfire/QuartzVsHangfire.sln | 31 ++++++++++ .../HangfireSample/HangfireJobScheduler.cs | 16 +++++ .../QuartzVsHangfire/Program.cs | 19 ++++++ .../QuartzSample/BackgroundJob.cs | 23 ++++++++ .../QuartzSample/QuartzTriggerScheduler.cs | 15 +++++ .../QuartzVsHangfire/QuartzVsHangfire.csproj | 16 +++++ .../QuartzVsHangfire/Services/EmailSender.cs | 11 ++++ .../QuartzVsHangfire/Services/IEmailSender.cs | 6 ++ .../Services/IReportBuilder.cs | 6 ++ .../Services/ReportBuilder.cs | 11 ++++ .../Tests/BackgroundJobTests.cs | 59 +++++++++++++++++++ .../Tests/HangfireJobSchedulerTests.cs | 40 +++++++++++++ .../Tests/QuartzTriggerSchedulerTests.cs | 25 ++++++++ .../QuartzVsHangfire/Tests/Tests.csproj | 28 +++++++++ dotnet-client-libraries/README.md | 1 + 15 files changed, 307 insertions(+) create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire.sln create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireJobScheduler.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Program.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/BackgroundJob.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzTriggerScheduler.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzVsHangfire.csproj create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/EmailSender.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/IEmailSender.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/IReportBuilder.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/ReportBuilder.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/BackgroundJobTests.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireJobSchedulerTests.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzTriggerSchedulerTests.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/Tests.csproj diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire.sln b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire.sln new file mode 100644 index 000000000..72f9acab8 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33110.190 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuartzVsHangfire", "QuartzVsHangfire\QuartzVsHangfire.csproj", "{FC8EF8A5-F261-45EE-89D1-C803C0229725}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{0C10125C-AEBF-4FA6-9CEF-4F4400ADE3C7}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FC8EF8A5-F261-45EE-89D1-C803C0229725}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC8EF8A5-F261-45EE-89D1-C803C0229725}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC8EF8A5-F261-45EE-89D1-C803C0229725}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC8EF8A5-F261-45EE-89D1-C803C0229725}.Release|Any CPU.Build.0 = Release|Any CPU + {0C10125C-AEBF-4FA6-9CEF-4F4400ADE3C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C10125C-AEBF-4FA6-9CEF-4F4400ADE3C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C10125C-AEBF-4FA6-9CEF-4F4400ADE3C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C10125C-AEBF-4FA6-9CEF-4F4400ADE3C7}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {936474A8-A312-4C71-8420-9D4762C8A024} + EndGlobalSection +EndGlobal diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireJobScheduler.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireJobScheduler.cs new file mode 100644 index 000000000..3c2e4bed8 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireJobScheduler.cs @@ -0,0 +1,16 @@ +using Hangfire; +using QuartzVsHangfire.Services; + +namespace QuartzVsHangfire.HangfireSample; + +// Hangfire is built around a persistent job queue. We hand it work and it +// stores, runs, and retries that work for us. These two calls are the model. +public static class HangfireJobScheduler +{ + // Hangfire: enqueue now, retry automatically, watch it in the dashboard + public static string EnqueueWelcomeEmail(int userId) => + BackgroundJob.Enqueue(x => x.SendWelcomeAsync(userId)); + + public static void ScheduleNightlyReport() => + RecurringJob.AddOrUpdate("nightly", x => x.RunAsync(), Cron.Daily); +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Program.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Program.cs new file mode 100644 index 000000000..8071ac512 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Program.cs @@ -0,0 +1,19 @@ +using Hangfire; +using Hangfire.InMemory; +using Quartz; +using QuartzVsHangfire.HangfireSample; +using QuartzVsHangfire.QuartzSample; + +// Hangfire needs storage because the queue is the product. An in-memory store +// keeps this comparison sample self-contained (no SQL Server or Redis required). +GlobalConfiguration.Configuration.UseInMemoryStorage(); + +var jobId = HangfireJobScheduler.EnqueueWelcomeEmail(userId: 42); +HangfireJobScheduler.ScheduleNightlyReport(); + +Console.WriteLine($"Hangfire enqueued the welcome email as job '{jobId}' and scheduled the 'nightly' report."); + +// Quartz.NET needs no storage to describe a schedule: the trigger is the product. +var trigger = (ICronTrigger)QuartzTriggerScheduler.BuildNightlyTrigger(); + +Console.WriteLine($"Quartz.NET built trigger '{trigger.Key.Name}' with cron expression '{trigger.CronExpressionString}'."); diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/BackgroundJob.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/BackgroundJob.cs new file mode 100644 index 000000000..68243f82c --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/BackgroundJob.cs @@ -0,0 +1,23 @@ +using Quartz; + +namespace QuartzVsHangfire.QuartzSample; + +public class BackgroundJob : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + var jobDataMap = context.MergedJobDataMap; + + var useJobDataMapConsoleOutput = jobDataMap.GetBoolean("UseJobDataMapConsoleOutput"); + + if (useJobDataMapConsoleOutput) + { + var consoleOutput = jobDataMap.GetString("ConsoleOutput"); + await Console.Out.WriteLineAsync(consoleOutput); + } + else + { + await Console.Out.WriteLineAsync("Executing background job without JobDataMap"); + } + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzTriggerScheduler.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzTriggerScheduler.cs new file mode 100644 index 000000000..bcc578329 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzTriggerScheduler.cs @@ -0,0 +1,15 @@ +using Quartz; + +namespace QuartzVsHangfire.QuartzSample; + +// Quartz.NET is built around the trigger. The schedule itself is the unit of +// work: we describe when a job fires and Quartz.NET owns the firing. +public static class QuartzTriggerScheduler +{ + // Quartz.NET: the trigger is the unit of work + public static ITrigger BuildNightlyTrigger() => + TriggerBuilder.Create() + .WithIdentity("nightly") + .WithCronSchedule("0 0 2 * * ?") + .Build(); +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzVsHangfire.csproj b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzVsHangfire.csproj new file mode 100644 index 000000000..c8f34bd6c --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzVsHangfire.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/EmailSender.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/EmailSender.cs new file mode 100644 index 000000000..5b176cb0b --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/EmailSender.cs @@ -0,0 +1,11 @@ +namespace QuartzVsHangfire.Services; + +public class EmailSender : IEmailSender +{ + public Task SendWelcomeAsync(int userId) + { + Console.WriteLine($"Sending welcome email to user {userId}."); + + return Task.CompletedTask; + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/IEmailSender.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/IEmailSender.cs new file mode 100644 index 000000000..31525157d --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/IEmailSender.cs @@ -0,0 +1,6 @@ +namespace QuartzVsHangfire.Services; + +public interface IEmailSender +{ + Task SendWelcomeAsync(int userId); +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/IReportBuilder.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/IReportBuilder.cs new file mode 100644 index 000000000..0f64d5dfc --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/IReportBuilder.cs @@ -0,0 +1,6 @@ +namespace QuartzVsHangfire.Services; + +public interface IReportBuilder +{ + Task RunAsync(); +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/ReportBuilder.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/ReportBuilder.cs new file mode 100644 index 000000000..7001b7e70 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/Services/ReportBuilder.cs @@ -0,0 +1,11 @@ +namespace QuartzVsHangfire.Services; + +public class ReportBuilder : IReportBuilder +{ + public Task RunAsync() + { + Console.WriteLine("Building the nightly report."); + + return Task.CompletedTask; + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/BackgroundJobTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/BackgroundJobTests.cs new file mode 100644 index 000000000..cf57224dd --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/BackgroundJobTests.cs @@ -0,0 +1,59 @@ +using Moq; +using Quartz; +using QuartzVsHangfire.QuartzSample; + +namespace Tests; + +public class BackgroundJobTests +{ + private readonly Mock _jobExecutionContextMock; + private readonly BackgroundJob _backgroundJob = new BackgroundJob(); + + public BackgroundJobTests() + { + _jobExecutionContextMock = new Mock(); + } + + [Fact] + public async Task GivenAValidJobDataMap_WhenUsingJobDataMapForOuputIsTrue_ThenUsesJobDataMapConsoleOutput() + { + const string consoleOutput = "test console output"; + var expectedOutput = $"{consoleOutput}{Environment.NewLine}"; + // Arrange + var jobDataMap = new JobDataMap + { + { "UseJobDataMapConsoleOutput", true }, + { "ConsoleOutput", consoleOutput } + }; + _jobExecutionContextMock.SetupGet(j => j.MergedJobDataMap).Returns(jobDataMap); + using var stringWriter = new StringWriter(); + Console.SetOut(stringWriter); + + // Act + await _backgroundJob.Execute(_jobExecutionContextMock.Object); + + // Assert + Assert.Equal(expectedOutput, stringWriter.ToString()); + } + + [Fact] + public async Task GivenAValidJobDataMap_WhenUsingJobDataMapForOuputIsFalse_ThenUsesDefaultConsoleOutput() + { + const string defaultOutput = "Executing background job without JobDataMap"; + var expectedOutput = $"{defaultOutput}{Environment.NewLine}"; + // Arrange + var jobDataMap = new JobDataMap + { + { "UseJobDataMapConsoleOutput", false } + }; + _jobExecutionContextMock.SetupGet(j => j.MergedJobDataMap).Returns(jobDataMap); + using var stringWriter = new StringWriter(); + Console.SetOut(stringWriter); + + // Act + await _backgroundJob.Execute(_jobExecutionContextMock.Object); + + // Assert + Assert.Equal(expectedOutput, stringWriter.ToString()); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireJobSchedulerTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireJobSchedulerTests.cs new file mode 100644 index 000000000..c4030ecd7 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireJobSchedulerTests.cs @@ -0,0 +1,40 @@ +using Hangfire; +using Hangfire.InMemory; +using Hangfire.Storage; +using QuartzVsHangfire.HangfireSample; +using QuartzVsHangfire.Services; + +namespace Tests; + +public class HangfireJobSchedulerTests +{ + static HangfireJobSchedulerTests() + { + // A single in-memory store backs every assertion, so the tests need no + // SQL Server or Redis: Hangfire only needs storage to write to. + GlobalConfiguration.Configuration.UseInMemoryStorage(); + } + + [Fact] + public void GivenAUserId_WhenEnqueueWelcomeEmail_ThenEnqueuesTheSendWelcomeJob() + { + var jobId = HangfireJobScheduler.EnqueueWelcomeEmail(42); + + Assert.False(string.IsNullOrWhiteSpace(jobId)); + + var details = JobStorage.Current.GetMonitoringApi().JobDetails(jobId); + Assert.NotNull(details); + Assert.Equal(nameof(IEmailSender.SendWelcomeAsync), details.Job.Method.Name); + } + + [Fact] + public void WhenScheduleNightlyReport_ThenRegistersTheNightlyRecurringJob() + { + HangfireJobScheduler.ScheduleNightlyReport(); + + using var connection = JobStorage.Current.GetConnection(); + var recurringJobs = connection.GetRecurringJobs(); + + Assert.Contains(recurringJobs, job => job.Id == "nightly"); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzTriggerSchedulerTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzTriggerSchedulerTests.cs new file mode 100644 index 000000000..77f3be9f8 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzTriggerSchedulerTests.cs @@ -0,0 +1,25 @@ +using Quartz; +using Quartz.Impl.Triggers; +using QuartzVsHangfire.QuartzSample; + +namespace Tests; + +public class QuartzTriggerSchedulerTests +{ + [Fact] + public void WhenBuildNightlyTrigger_ThenTriggerHasNightlyIdentity() + { + var trigger = QuartzTriggerScheduler.BuildNightlyTrigger(); + + Assert.Equal("nightly", trigger.Key.Name); + } + + [Fact] + public void WhenBuildNightlyTrigger_ThenTriggerUsesTheExpectedCronExpression() + { + var trigger = QuartzTriggerScheduler.BuildNightlyTrigger(); + + var cronTrigger = Assert.IsType(trigger); + Assert.Equal("0 0 2 * * ?", cronTrigger.CronExpressionString); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/Tests.csproj b/dotnet-client-libraries/QuartzVsHangfire/Tests/Tests.csproj new file mode 100644 index 000000000..3dc0c8736 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/dotnet-client-libraries/README.md b/dotnet-client-libraries/README.md index 63bde6975..ada6c460e 100644 --- a/dotnet-client-libraries/README.md +++ b/dotnet-client-libraries/README.md @@ -30,6 +30,7 @@ This section contains the topics about the client libraries in .NET. - [Vendor-Agnostic Telemetry Using OpenTelemetry Collector in .NET](https://code-maze.com/vendor-agnostic-telemetry-opentelemetry-collector-dotnet/) - [MediatR Publish and Send Methods](https://code-maze.com/csharp-mediatr-publish-and-send-methods/) - [Schedule Jobs with Quartz.NET](https://code-maze.com/schedule-jobs-with-quartz-net/) +- [Hangfire vs Quartz.NET: Which Scheduler to Choose?](https://code-maze.com/chsarp-the-differences-between-quartz-net-and-hangfire/) - [Rebus in .NET – Service Bus Implementation](https://code-maze.com/rebus-dotnet/) - [Easy Sorting, Filtering and Pagination in .NET With Sieve Package](https://code-maze.com/dotnet-sorting-filtering-pagination-sieve-package/) - [Using Refit to Consume APIs in C#](https://code-maze.com/using-refit-to-consume-apis-in-csharp/) From 9660fa253a69b4a629e068ca71328a8fb9b705b6 Mon Sep 17 00:00:00 2001 From: Vladimir Pecanac Date: Sat, 15 Aug 2026 21:06:46 +0200 Subject: [PATCH 2/2] Add per-criterion Hangfire vs Quartz.NET code examples Cover each comparison criterion with both sides: DI registration and startup, fire-and-forget vs immediate trigger, recurring cron jobs, storage configuration, automatic retries, and dashboard/monitoring. --- .../HangfireSample/HangfireMonitoring.cs | 16 +++++++++++ .../HangfireSample/HangfireRetryPolicy.cs | 16 +++++++++++ .../HangfireSample/HangfireStartup.cs | 17 ++++++++++++ .../HangfireSample/HangfireStorageConfig.cs | 19 +++++++++++++ .../QuartzSample/LoggingJobListener.cs | 26 ++++++++++++++++++ .../QuartzSample/QuartzRetryJob.cs | 22 +++++++++++++++ .../QuartzSample/QuartzStartup.cs | 27 +++++++++++++++++++ .../QuartzSample/QuartzStorageConfig.cs | 22 +++++++++++++++ .../QuartzSample/QuartzTriggerScheduler.cs | 8 ++++++ .../QuartzSample/ReportJob.cs | 15 +++++++++++ .../QuartzVsHangfire/QuartzVsHangfire.csproj | 2 ++ .../Tests/HangfireJobSchedulerTests.cs | 1 + .../Tests/HangfireMonitoringTests.cs | 21 +++++++++++++++ .../Tests/HangfireRetryPolicyTests.cs | 19 +++++++++++++ .../Tests/HangfireStartupTests.cs | 22 +++++++++++++++ .../Tests/HangfireStorageCollection.cs | 6 +++++ .../Tests/HangfireStorageConfigTests.cs | 17 ++++++++++++ .../Tests/LoggingJobListenerTests.cs | 19 +++++++++++++ .../Tests/QuartzImmediateTriggerTests.cs | 14 ++++++++++ .../Tests/QuartzRetryJobTests.cs | 26 ++++++++++++++++++ .../Tests/QuartzStartupTests.cs | 21 +++++++++++++++ .../Tests/QuartzStorageConfigTests.cs | 14 ++++++++++ .../QuartzVsHangfire/Tests/Tests.csproj | 1 + 23 files changed, 371 insertions(+) create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireMonitoring.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireRetryPolicy.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireStartup.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireStorageConfig.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/LoggingJobListener.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzRetryJob.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStartup.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStorageConfig.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/ReportJob.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireMonitoringTests.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireRetryPolicyTests.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireStartupTests.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireStorageCollection.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireStorageConfigTests.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/LoggingJobListenerTests.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzImmediateTriggerTests.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzRetryJobTests.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzStartupTests.cs create mode 100644 dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzStorageConfigTests.cs diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireMonitoring.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireMonitoring.cs new file mode 100644 index 000000000..0841a8b31 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireMonitoring.cs @@ -0,0 +1,16 @@ +using Hangfire; + +namespace QuartzVsHangfire.HangfireSample; + +// 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 +{ + public static long ScheduledJobCount() + { + var monitoringApi = JobStorage.Current.GetMonitoringApi(); + + return monitoringApi.GetStatistics().Scheduled; + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireRetryPolicy.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireRetryPolicy.cs new file mode 100644 index 000000000..a44fa1f4b --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireRetryPolicy.cs @@ -0,0 +1,16 @@ +using Hangfire; + +namespace QuartzVsHangfire.HangfireSample; + +// Hangfire retries failed jobs for us. We do not write a retry loop; we declare +// the policy with an attribute and Hangfire re-runs the job on the schedule below. +public class HangfireRetryPolicy +{ + [AutomaticRetry(Attempts = 5, DelaysInSeconds = new[] { 10, 60, 300 })] + public Task SendWebhookAsync(string url) + { + Console.WriteLine($"Posting to {url}. If this throws, Hangfire retries it."); + + return Task.CompletedTask; + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireStartup.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireStartup.cs new file mode 100644 index 000000000..ecc5410f6 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireStartup.cs @@ -0,0 +1,17 @@ +using Hangfire; +using Microsoft.Extensions.DependencyInjection; + +namespace QuartzVsHangfire.HangfireSample; + +// One registration block wires the client, the storage, and the worker that +// drains the queue. After this, we inject IBackgroundJobClient and enqueue. +public static class HangfireStartup +{ + public static IServiceCollection AddHangfireJobs(this IServiceCollection services) + { + services.AddHangfire(config => config.UseInMemoryStorage()); + services.AddHangfireServer(); + + return services; + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireStorageConfig.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireStorageConfig.cs new file mode 100644 index 000000000..375ecb3cf --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/HangfireSample/HangfireStorageConfig.cs @@ -0,0 +1,19 @@ +using Hangfire; + +namespace QuartzVsHangfire.HangfireSample; + +// Hangfire always needs a storage backend: the persisted queue is the product. +// This sample uses the in-memory store to stay self-contained. +public static class HangfireStorageConfig +{ + public static JobStorage UseInMemory() + { + GlobalConfiguration.Configuration.UseInMemoryStorage(); + + // In production we swap this single line for a durable provider, e.g. + // config.UseSqlServerStorage(connectionString); + // config.UsePostgreSqlStorage(connectionString); + // (Redis storage lives in the paid Hangfire Pro package.) + return JobStorage.Current; + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/LoggingJobListener.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/LoggingJobListener.cs new file mode 100644 index 000000000..e502170e5 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/LoggingJobListener.cs @@ -0,0 +1,26 @@ +using Quartz; + +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. +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; + + public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task JobWasExecuted(IJobExecutionContext context, JobExecutionException? jobException, + CancellationToken cancellationToken = default) + { + ExecutedCount++; + + return Task.CompletedTask; + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzRetryJob.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzRetryJob.cs new file mode 100644 index 000000000..bbb5ec496 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzRetryJob.cs @@ -0,0 +1,22 @@ +using Quartz; + +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. +public class QuartzRetryJob : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + try + { + await DoWorkAsync(context); + } + catch (Exception ex) + { + throw new JobExecutionException(ex, refireImmediately: true); + } + } + + protected virtual Task DoWorkAsync(IJobExecutionContext context) => Task.CompletedTask; +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStartup.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStartup.cs new file mode 100644 index 000000000..b1187c56d --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStartup.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using Quartz; + +namespace QuartzVsHangfire.QuartzSample; + +// Quartz.NET registers the scheduler, its jobs, and their triggers together. +// The job and the trigger are declared side by side, wired by the job key. +public static class QuartzStartup +{ + public static IServiceCollection AddQuartzJobs(this IServiceCollection services) + { + services.AddQuartz(configurator => + { + var reportJob = new JobKey("nightly-report"); + + configurator.AddJob(reportJob); + configurator.AddTrigger(trigger => trigger + .ForJob(reportJob) + .WithIdentity("nightly") + .WithCronSchedule("0 0 2 * * ?")); + }); + + services.AddQuartzHostedService(options => options.WaitForJobsToComplete = true); + + return services; + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStorageConfig.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStorageConfig.cs new file mode 100644 index 000000000..fe8d17a10 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzStorageConfig.cs @@ -0,0 +1,22 @@ +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. +public static class QuartzStorageConfig +{ + public static Task CreateInMemorySchedulerAsync() + { + var properties = new NameValueCollection + { + ["quartz.jobStore.type"] = "Quartz.Simpl.RAMJobStore, Quartz" + }; + + var factory = new StdSchedulerFactory(properties); + + return factory.GetScheduler(); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzTriggerScheduler.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzTriggerScheduler.cs index bcc578329..e390dd22e 100644 --- a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzTriggerScheduler.cs +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/QuartzTriggerScheduler.cs @@ -12,4 +12,12 @@ public static ITrigger BuildNightlyTrigger() => .WithIdentity("nightly") .WithCronSchedule("0 0 2 * * ?") .Build(); + + // Quartz.NET has no fire-and-forget queue. The closest equivalent is a + // trigger that starts immediately instead of firing on a schedule. + public static ITrigger BuildImmediateTrigger() => + TriggerBuilder.Create() + .WithIdentity("welcome-email") + .StartNow() + .Build(); } diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/ReportJob.cs b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/ReportJob.cs new file mode 100644 index 000000000..ec7ead390 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzSample/ReportJob.cs @@ -0,0 +1,15 @@ +using Quartz; +using QuartzVsHangfire.Services; + +namespace QuartzVsHangfire.QuartzSample; + +// A Quartz.NET job is a class implementing IJob. The scheduler resolves it from +// DI, so constructor-injected services (here IReportBuilder) just work. +public class ReportJob : IJob +{ + private readonly IReportBuilder _reportBuilder; + + public ReportJob(IReportBuilder reportBuilder) => _reportBuilder = reportBuilder; + + public Task Execute(IJobExecutionContext context) => _reportBuilder.RunAsync(); +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzVsHangfire.csproj b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzVsHangfire.csproj index c8f34bd6c..72c9239a5 100644 --- a/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzVsHangfire.csproj +++ b/dotnet-client-libraries/QuartzVsHangfire/QuartzVsHangfire/QuartzVsHangfire.csproj @@ -10,7 +10,9 @@ + + diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireJobSchedulerTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireJobSchedulerTests.cs index c4030ecd7..5656bdc70 100644 --- a/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireJobSchedulerTests.cs +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireJobSchedulerTests.cs @@ -6,6 +6,7 @@ namespace Tests; +[Collection("HangfireStorage")] public class HangfireJobSchedulerTests { static HangfireJobSchedulerTests() diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireMonitoringTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireMonitoringTests.cs new file mode 100644 index 000000000..9b992921d --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireMonitoringTests.cs @@ -0,0 +1,21 @@ +using Hangfire; +using QuartzVsHangfire.HangfireSample; + +namespace Tests; + +[Collection("HangfireStorage")] +public class HangfireMonitoringTests +{ + static HangfireMonitoringTests() + { + GlobalConfiguration.Configuration.UseInMemoryStorage(); + } + + [Fact] + public void WhenScheduledJobCount_ThenReadsFromTheMonitoringApi() + { + var count = HangfireMonitoring.ScheduledJobCount(); + + Assert.True(count >= 0); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireRetryPolicyTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireRetryPolicyTests.cs new file mode 100644 index 000000000..6e81391a1 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireRetryPolicyTests.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using Hangfire; +using QuartzVsHangfire.HangfireSample; + +namespace Tests; + +public class HangfireRetryPolicyTests +{ + [Fact] + public void GivenTheSendWebhookMethod_WhenReadingItsAttributes_ThenAutomaticRetryIsConfigured() + { + var method = typeof(HangfireRetryPolicy).GetMethod(nameof(HangfireRetryPolicy.SendWebhookAsync)); + + var retry = method!.GetCustomAttribute(); + + Assert.NotNull(retry); + Assert.Equal(5, retry!.Attempts); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireStartupTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireStartupTests.cs new file mode 100644 index 000000000..7aec05606 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireStartupTests.cs @@ -0,0 +1,22 @@ +using Hangfire; +using Microsoft.Extensions.DependencyInjection; +using QuartzVsHangfire.HangfireSample; + +namespace Tests; + +[Collection("HangfireStorage")] +public class HangfireStartupTests +{ + [Fact] + public void WhenAddHangfireJobs_ThenTheBackgroundJobClientIsRegistered() + { + var services = new ServiceCollection(); + + services.AddHangfireJobs(); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetService(); + + Assert.NotNull(client); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireStorageCollection.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireStorageCollection.cs new file mode 100644 index 000000000..22c1bf90d --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireStorageCollection.cs @@ -0,0 +1,6 @@ +namespace Tests; + +// Hangfire exposes a single process-wide JobStorage.Current. These tests each +// configure or read it, so they must not run in parallel with one another. +[CollectionDefinition("HangfireStorage", DisableParallelization = true)] +public class HangfireStorageCollection; diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireStorageConfigTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireStorageConfigTests.cs new file mode 100644 index 000000000..851f23c5d --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/HangfireStorageConfigTests.cs @@ -0,0 +1,17 @@ +using Hangfire; +using QuartzVsHangfire.HangfireSample; + +namespace Tests; + +[Collection("HangfireStorage")] +public class HangfireStorageConfigTests +{ + [Fact] + public void WhenUseInMemory_ThenReturnsAConfiguredJobStorage() + { + var storage = HangfireStorageConfig.UseInMemory(); + + Assert.NotNull(storage); + Assert.Same(JobStorage.Current, storage); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/LoggingJobListenerTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/LoggingJobListenerTests.cs new file mode 100644 index 000000000..3dfd4c381 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/LoggingJobListenerTests.cs @@ -0,0 +1,19 @@ +using Moq; +using Quartz; +using QuartzVsHangfire.QuartzSample; + +namespace Tests; + +public class LoggingJobListenerTests +{ + [Fact] + public async Task WhenJobWasExecuted_ThenTheListenerCountsIt() + { + var listener = new LoggingJobListener(); + var context = new Mock().Object; + + await listener.JobWasExecuted(context, jobException: null); + + Assert.Equal(1, listener.ExecutedCount); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzImmediateTriggerTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzImmediateTriggerTests.cs new file mode 100644 index 000000000..53d6974ed --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzImmediateTriggerTests.cs @@ -0,0 +1,14 @@ +using QuartzVsHangfire.QuartzSample; + +namespace Tests; + +public class QuartzImmediateTriggerTests +{ + [Fact] + public void WhenBuildImmediateTrigger_ThenTriggerHasTheWelcomeEmailIdentity() + { + var trigger = QuartzTriggerScheduler.BuildImmediateTrigger(); + + Assert.Equal("welcome-email", trigger.Key.Name); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzRetryJobTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzRetryJobTests.cs new file mode 100644 index 000000000..1cf7caf65 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzRetryJobTests.cs @@ -0,0 +1,26 @@ +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/QuartzStartupTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzStartupTests.cs new file mode 100644 index 000000000..3a1276df2 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzStartupTests.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using Quartz; +using QuartzVsHangfire.QuartzSample; + +namespace Tests; + +public class QuartzStartupTests +{ + [Fact] + public void WhenAddQuartzJobs_ThenTheSchedulerFactoryIsRegistered() + { + var services = new ServiceCollection(); + + services.AddQuartzJobs(); + + using var provider = services.BuildServiceProvider(); + var schedulerFactory = provider.GetService(); + + Assert.NotNull(schedulerFactory); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzStorageConfigTests.cs b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzStorageConfigTests.cs new file mode 100644 index 000000000..4937bb726 --- /dev/null +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/QuartzStorageConfigTests.cs @@ -0,0 +1,14 @@ +using QuartzVsHangfire.QuartzSample; + +namespace Tests; + +public class QuartzStorageConfigTests +{ + [Fact] + public async Task WhenCreateInMemoryScheduler_ThenReturnsAUsableScheduler() + { + var scheduler = await QuartzStorageConfig.CreateInMemorySchedulerAsync(); + + Assert.NotNull(scheduler); + } +} diff --git a/dotnet-client-libraries/QuartzVsHangfire/Tests/Tests.csproj b/dotnet-client-libraries/QuartzVsHangfire/Tests/Tests.csproj index 3dc0c8736..772872a6f 100644 --- a/dotnet-client-libraries/QuartzVsHangfire/Tests/Tests.csproj +++ b/dotnet-client-libraries/QuartzVsHangfire/Tests/Tests.csproj @@ -11,6 +11,7 @@ +