ASP.NET Core scheduling with Quartz.NET and SignalR monitoring

This article shows how scheduled tasks can be implemented in ASP.NET Core using Quartz.NET and then displays the job info in an ASP.NET Core Razor page using SignalR. A concurrent job and a non concurrent job are implemented using a simple trigger to show the difference in how the jobs are run. Quartz.NET provides lots of scheduling features and has an easy to use API for implementing scheduled jobs.

Code: https://github.com/damienbod/AspNetCoreQuartz

History

  • 2024-01-07 Updated to .NET 8
  • 2023-01-01 Updated to .NET 7

A simple ASP.NET Core Razor Page web application is used to implement the scheduler and the SignalR messaging. The Quartz Nuget package and the Quartz.Extensions.Hosting Nuget package are used to implement the scheduling service. The Microsoft.AspNetCore.SignalR.Client package is used to send messages to all listening web socket clients.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="7.0.1" />
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.0" />
    <PackageReference Include="Quartz" Version="3.5.0" />
    <PackageReference Include="Quartz.Extensions.Hosting" Version="3.5.0" />
  </ItemGroup>

</Project>

The .NET 7 templates no longer use a Startup class, all this logic can now be implemented directly in the Program.cs file with no static main. The ConfigureServices logic can be implemented using a WebApplicationBuilder instance. The AddQuartz method is used to add the scheduling services. Two jobs are added, a concurrent job and a non concurrent job. Both jobs are triggered with a simple trigger every five seconds which runs forever. The AddQuartzHostedService method adds the service as a hosted service. The AddSignalR adds the SignalR services.

using AspNetCoreQuartz;
using AspNetCoreQuartz.QuartzServices;
using Quartz;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();

builder.Services.AddSignalR();

builder.Services.AddQuartz(q =>
{
    var conconcurrentJobKey = new JobKey("ConconcurrentJob");
    q.AddJob<ConconcurrentJob>(opts => opts.WithIdentity(conconcurrentJobKey));
    q.AddTrigger(opts => opts
        .ForJob(conconcurrentJobKey)
        .WithIdentity("ConconcurrentJob-trigger")
        .WithSimpleSchedule(x => x
            .WithIntervalInSeconds(5)
            .RepeatForever()));

    var nonConconcurrentJobKey = new JobKey("NonConconcurrentJob");
    q.AddJob<NonConconcurrentJob>(opts => opts.WithIdentity(nonConconcurrentJobKey));
    q.AddTrigger(opts => opts
        .ForJob(nonConconcurrentJobKey)
        .WithIdentity("NonConconcurrentJob-trigger")
        .WithSimpleSchedule(x => x
            .WithIntervalInSeconds(5)
            .RepeatForever()));

});

builder.Services.AddQuartzHostedService(
    q => q.WaitForJobsToComplete = true);

The WebApplication instance is used to add the middleware like the Startup Configure method. The SignalR JobsHub endpoint is added to send the live messages of the running jobs to the UI in the client browser..

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthorization();

app.UseEndpoints(endpoints =>
{
    endpoints.MapHub<JobsHub>("/jobshub");
});

app.MapRazorPages();

app.Run();

The ConconcurrentJob implements the IJob interface and logs messages before and after a time delay. A SignalR client is used to send all the job information to any listening clients. A seven second sleep was added to simulate a slow running job. The jobs are triggered every 5 seconds, so this should result in no change in behavior as the jobs can run in parallel.

using Microsoft.AspNetCore.SignalR;
using Quartz;

namespace AspNetCoreQuartz.QuartzServices
{
    public class ConconcurrentJob : IJob
    {
        private readonly ILogger<ConconcurrentJob> _logger;
        private static int _counter = 0;
        private readonly IHubContext<JobsHub> _hubContext;

        public ConconcurrentJob(ILogger<ConconcurrentJob> logger, 
            IHubContext<JobsHub> hubContext)
        {
            _logger = logger;
            _hubContext = hubContext;
        }

        public async Task Execute(IJobExecutionContext context)
        {
            var count = _counter++;

            var beginMessage = $"Conconcurrent Job BEGIN {count} {DateTime.UtcNow}";
            await _hubContext.Clients.All.SendAsync("ConcurrentJobs", beginMessage);
            _logger.LogInformation(beginMessage);

            Thread.Sleep(7000);

            var endMessage = $"Conconcurrent Job END {count} {DateTime.UtcNow}";
            await _hubContext.Clients.All.SendAsync("ConcurrentJobs", endMessage);
            _logger.LogInformation(endMessage);
        }
    }
}

The NonConconcurrentJob class is almost like the previous job, except the DisallowConcurrentExecution attribute is used to prevent concurrent running of the job. This means that even though the trigger is set to five seconds, each job must wait until the previous job finishes.

[DisallowConcurrentExecution]
public class NonConconcurrentJob : IJob
{
	private readonly ILogger<NonConconcurrentJob> _logger;
	private static int _counter = 0;
	private readonly IHubContext<JobsHub> _hubContext;

	public NonConconcurrentJob(ILogger<NonConconcurrentJob> logger,
		   IHubContext<JobsHub> hubContext)
	{
		_logger = logger;
		_hubContext = hubContext;
	}

	public async Task Execute(IJobExecutionContext context)
	{
		var count = _counter++;

		var beginMessage = $"NonConconcurrentJob Job BEGIN {count} {DateTime.UtcNow}";
		await _hubContext.Clients.All.SendAsync("NonConcurrentJobs", beginMessage);
		_logger.LogInformation(beginMessage);

		Thread.Sleep(7000);

		var endMessage = $"NonConconcurrentJob Job END {count} {DateTime.UtcNow}";
		await _hubContext.Clients.All.SendAsync("NonConcurrentJobs", endMessage);
		_logger.LogInformation(endMessage);
	}
}

The JobsHub class implements the SignalR Hub and define methods for sending SignalR messages. Two messages are used, one for the concurrent job messages and one for the non concurrent job messages.

public class JobsHub : Hub
{
	public Task SendConcurrentJobsMessage(string message)
	{
		return Clients.All.SendAsync("ConcurrentJobs",  message);
	}

	public Task SendNonConcurrentJobsMessage(string message)
	{
		return Clients.All.SendAsync("NonConcurrentJobs", message);
	}

}

The microsoft signalr Javascript package is used to implement the client which listens for messages.

{
  "version": "1.0",
  "defaultProvider": "cdnjs",
  "libraries": [
    {
      "library": "microsoft-signalr@5.0.11",
      "destination": "wwwroot/lib/microsoft-signalr/"
    }
  ]
}

The Index Razor Page view uses the SignalR Javascript file and displays messages by adding html elements.

@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="container">
    <div class="row">
        <div class="col-6">
            <ul id="concurrentJobs"></ul>
        </div>
         <div class="col-6">
            <ul id="nonConcurrentJobs"></ul>
        </div>
    </div>
</div>

<script src="~/lib/microsoft-signalr/signalr.js"></script>

The SignalR client adds the two methods to listen to messages sent from the Quartz jobs.

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/jobshub")
    .configureLogging(signalR.LogLevel.Information)
    .build();

async function start() {
    try {
        await connection.start();
        console.log("SignalR Connected.");
    } catch (err) {
        console.log(err);
        setTimeout(start, 5000);
    }
};

connection.onclose(async () => {
    await start();
});

start();

connection.on("ConcurrentJobs", function (message) {
    var li = document.createElement("li");
    document.getElementById("concurrentJobs").appendChild(li);
    li.textContent = `${message}`;
});

connection.on("NonConcurrentJobs", function (message) {
    var li = document.createElement("li");
    document.getElementById("nonConcurrentJobs").appendChild(li);
    li.textContent = `${message}`;
});


When the application is run and the hosted Quartz service runs the scheduled jobs, the concurrent jobs starts every five seconds as required and the non concurrent job runs every seven seconds due to the thread sleep. Running concurrent or non concurrent jobs by using a single attribute definition is a really powerful feature of Quartz.NET.

Quartz.NET provides great documentation and has a really simple API. By using SignalR, it would be really easy to implement a good monitoring UI.

Links:

https://www.quartz-scheduler.net/

https://andrewlock.net/using-quartz-net-with-asp-net-core-and-worker-services/

https://docs.microsoft.com/en-us/aspnet/core/signalr/introduction

6 comments

  1. What is your deployment model, app service in azure with always on? or Azure Fn host with durable features (host with Always on)?

    1. Hi ozbob, yes I use this with an azure app service, always on. azure Durable functions would be another way of solving this.

      Greetings Damien

  2. […] ASP.NET Core scheduling with Quartz.NET and SignalR monitoring (Damien Bowden) […]

  3. […] ASP.NET Core scheduling with Quartz.NET and SignalR monitoring (Damien Bowden) […]

  4. […] Really Advanced Typescript Types (Rick Weber) Azure CLI Microsoft Ignite Highlights (Chase Wilson) ASP.NET Core scheduling with Quartz.NET and SignalR monitoring (Damien Bowden) Converting JavaScript to TypeScript (Jason Gaylord) AWS CLI – Basics (Jawad Hasan […]

  5. […] ASP.NET Core scheduling with Quartz.NET and SignalR monitoring – Damien Bowden […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.