Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<QuartzRetryJob>()
.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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public static IServiceCollection AddQuartzJobs(this IServiceCollection services)
{
var reportJob = new JobKey("nightly-report");

configurator.AddJob<ReportJob>(reportJob);
configurator.AddJob<ReportJob>(job => job.WithIdentity(reportJob));
configurator.AddTrigger(trigger => trigger
.ForJob(reportJob)
.WithIdentity("nightly")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IScheduler> CreateInMemorySchedulerAsync()
public static async Task<IScheduler> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,18 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Hangfire.Core" Version="1.8.24"/>
<FrameworkReference Include="Microsoft.AspNetCore.App"/>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Hangfire.Core" Version="1.8.25"/>
<PackageReference Include="Hangfire.InMemory" Version="1.0.0"/>
<PackageReference Include="Hangfire.NetCore" Version="1.8.24"/>
<PackageReference Include="Quartz" Version="3.19.1"/>
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.19.1"/>
<PackageReference Include="Hangfire.NetCore" Version="1.8.25"/>
<!-- Hangfire.Core pulls Newtonsoft.Json 11.0.1, which carries a known high
severity advisory (NU1903). Pinning the current release removes it. -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.4"/>
<PackageReference Include="Quartz" Version="4.1.0"/>
<PackageReference Include="Quartz.Dashboard" Version="4.1.0"/>
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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<IExecutionHistoryStore>());
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading