DEV Community

Cover image for Scheduling Jobs With Quartz and Database Persistence With EF Core Migrations
Anton Martyniuk
Anton Martyniuk

Posted on β€’ Originally published at antondevtips.com on

1

Scheduling Jobs With Quartz and Database Persistence With EF Core Migrations

There are multiple options for job scheduling in .NET.

The simplest one is creating a Background Service in ASP.NET Core with a static Periodic Timer.

But if you need more customization options and job persistence, you need a library.

My personal favourite is Quartz.NET.

It's a fully-featured, open-source job scheduling system that can be used from the smallest apps to large-scale enterprise systems.
I prefer it over Hangfire because it provides more scheduling options.

In this post, I will show you:

  • How to add Quartz.NET to your ASP.NET Core application
  • How to schedule jobs with different trigger types
  • How to dynamically create jobs and triggers
  • How to use Quartz.NET with database persistence using EF Core migrations

Let's dive in.

On my website: antondevtips.com I share .NET and Architecture best practices.
Subscribe to my newsletter to improve your .NET skills.
Download the source code for this newsletter for free.

Getting Started with Quartz.NET

To get started with Quartz.NET, you need to install the following packages:

dotnet add package Quartz
dotnet add package Quartz.Extensions.Hosting
Enter fullscreen mode Exit fullscreen mode

Next, you need to register Quartz.NET in your DI container:

builder.Services.AddQuartz(q =>
{
});

builder.Services.AddQuartzHostedService(options =>
{
    // When shutting down we want jobs to complete gracefully
    options.WaitForJobsToComplete = true;
})
Enter fullscreen mode Exit fullscreen mode

Next, you need to create a class that implements the IJob interface.
Here is an example of a job that creates a report:

public record ReportCreationJob(ILogger<ReportCreationJob> Logger) : IJob
{
    private readonly ILogger<CreateReportJob> _logger;
    private readonly ReportDbContext _dbContext;

    public CreateReportJob(ILogger<CreateReportJob> logger, ReportDbContext dbContext)
    {
        _logger = logger;
        _dbContext = dbContext;
    }

    public async Task Execute(IJobExecutionContext context)
    {
        _logger.LogInformation("Starting CreateReportJob at {Time}", DateTime.UtcNow);

        // Create a new report
        var report = new Report
        {
            Title = $"Scheduled Report - {DateTime.UtcNow:yyyy-MM-dd HH:mm}",
            Content = $"This is an automatically generated report created at {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}",
            CreatedAt = DateTime.UtcNow
        };

        _dbContext.Reports.Add(report);
        await _dbContext.SaveChangesAsync();

        _logger.LogInformation("CreateReportJob completed successfully. Created report with ID: {ReportId}", report.Id);
    }
}
Enter fullscreen mode Exit fullscreen mode

And finally, register the job inside AddQuartz method:

services.AddQuartz(q =>
{
    var jobKey = new JobKey("report-job");
    q.AddJob<CreateReportJob>(opts => opts.WithIdentity(jobKey));

    q.AddTrigger(opts => opts
      .ForJob(jobKey)
      .StartNow()
      .WithSimpleSchedule(x => x
          .WithIntervalInHours(1)
          .RepeatForever()
      )
    );
});
Enter fullscreen mode Exit fullscreen mode

Here we register a Job that will create reports every hour.
StartNow method makes sure that the job will be executed immediately after the application starts.

Quartz.NET supports multiple trigger types. Let's explore them.

Trigger Types in Quartz.NET

SimpleTrigger

SimpleTrigger is the simplest trigger type.

You can schedule a job to run at a specific moment in time, with no repeats:

var trigger = TriggerBuilder.Create()
    .WithIdentity("report-job-trigger")
    // Start in 5 minutes
    .StartAt(DateBuilder.FutureDate(5, IntervalUnit.Minute))
    .Build();
Enter fullscreen mode Exit fullscreen mode

Here is how to schedule a job to run at a specific time and interval with 10 repeats:

var trigger = TriggerBuilder.Create()
    .WithIdentity("report-job-trigger")
    .StartNow()
    .WithSimpleSchedule(x => x
        .WithIntervalInMinutes(1)
        .WithRepeatCount(10))
    .Build();
Enter fullscreen mode Exit fullscreen mode

You can use RepeatForever method for infinite repeats.

Find more trigger examples in the official documentation.

DailyTimeIntervalTrigger

DailyTimeIntervalTrigger allows you to schedule a job using a daily time interval.

var trigger = TriggerBuilder.Create()
    .WithIdentity(nameof(PushNotificationsSendingJob))
    .WithDailyTimeIntervalSchedule(s =>
        s.WithIntervalInMinutes(interval)
            .OnEveryDay()
            .StartingDailyAt(TimeOfDay.HourAndMinuteOfDay(startTime.Hours, startTime.Minutes))
            .EndingDailyAt(TimeOfDay.HourAndMinuteOfDay(endTime.Hours, endTime.Minutes))
            .WithMisfireHandlingInstructionDoNothing()
    )
    .Build();
Enter fullscreen mode Exit fullscreen mode

CronTrigger

CronTrigger allows you to schedule a job using a cron expression.

var trigger = TriggerBuilder.Create()
    .WithIdentity("report-job-trigger")
    .WithCronSchedule("0 0 18 LW * ?")
    .Build();
Enter fullscreen mode Exit fullscreen mode

Examples:

0 * * * * ?      Every minute
0 0 18 LW * ?    Last business day of month 18:00
0 15 2 1,15 * ?  At 02:15 on the 1st & 15th
0/10 * 9 ? * *   Every 10 sec between 09:00‑09:59
Enter fullscreen mode Exit fullscreen mode

You can also use the following helper methods:

var trigger = TriggerBuilder.Create()
    .WithIdentity("report-job-trigger")
    .WithSchedule(CronScheduleBuilder
        .DailyAtHourAndMinute(11, 45)
        .WithMisfireHandlingInstructionFireAndProceed()
    ) // every day
    .Build();

var newTrigger = TriggerBuilder.Create()
    .WithIdentity("report-job-trigger")
    .WithSchedule(CronScheduleBuilder
        .MonthlyOnDayAndHourAndMinute(5, 23, 0)
        .WithMisfireHandlingInstructionFireAndProceed()
    ) // every 5th day of month
    .Build();
Enter fullscreen mode Exit fullscreen mode

Dynamically Creating Jobs and Triggers

You can create jobs and triggers dynamically at runtime.

Here is how you can create a job and its trigger:

var job = JobBuilder.Create<CreateReportJob>()
    .WithIdentity("report-job")
    .Build();

var trigger = TriggerBuilder.Create()
    .WithIdentity("report-job-trigger")
    .StartAt(DateBuilder.FutureDate(5, IntervalUnit.Minute))
    .Build();
Enter fullscreen mode Exit fullscreen mode

You can use ISchedulerFactory to schedule a job.
Each job can have multiple triggers.

var schedulerFactory = await app.Services.GetRequiredService<ISchedulerFactory>();

var scheduler = await schedulerFactory.GetScheduler();
await scheduler.ScheduleJob(job, new[] { trigger }, true);
Enter fullscreen mode Exit fullscreen mode

Let's explore a real-world example.
I have created a web api method to register new notifications from the frontend:

public class NotificationRequest
{
    public DateTime ScheduledTime { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
}

app.MapPost("/api/schedule-notification",
    async (NotificationRequest request, IJobSchedulerService scheduler) =>
{
    if (request.ScheduledTime <= DateTime.Now)
    {
        return Results.BadRequest("Scheduled time must be in the future");
    }

    var jobData = new Dictionary<string, object>
    {
        { NotificationJob.TitleKey, request.Title },
        { NotificationJob.ContentKey, request.Content }
    };

    var jobId = await scheduler.ScheduleJob<NotificationJob>(request.ScheduledTime, jobData);

    return Results.Ok(new { 
        JobId = jobId, 
        Message = $"Notification '{request.Title}' scheduled for {request.ScheduledTime}"
    });
});
Enter fullscreen mode Exit fullscreen mode

I have created a IJobSchedulerService that registers the job with a specific time and parameters (Title and Content):

var scheduler = await _schedulerFactory.GetScheduler();

// Generate a unique job ID
var jobId = Guid.NewGuid().ToString();

// Create the job and add job data
var jobBuilder = JobBuilder.Create<T>()
    .WithIdentity(jobId);

// Add job data 
jobBuilder.UsingJobData(new JobDataMap(jobData));

var jobDetail = jobBuilder.Build();

// Create the trigger to run at the specified time
var trigger = TriggerBuilder.Create()
    .WithIdentity($"{jobId}-trigger")
    .StartAt(new DateTimeOffset(scheduledTime))
    .Build();

// Schedule the job
await scheduler.ScheduleJob(jobDetail, trigger);
Enter fullscreen mode Exit fullscreen mode

You can download the full source code at the start and the end of the post

So far, so good, but here is a problem.

By default, Quartz.NET stores jobs and triggers data in memory.
If you restart the application, all jobs will be lost.

And this is not something you want in production.

Instead, you can persist jobs in the database.
Let's have a look.

Persisting Jobs in the Database

Quartz.NET supports multiple database providers, including SQL Server, MySQL, PostgreSQL, and SQLite.

First, you need to add a Nuget package to serialize job's data:

dotnet add package Quartz.Serialization.Json
Enter fullscreen mode Exit fullscreen mode

You need to register the database provider in your DI container:

builder.Services.AddQuartz(static options =>
{
    q.UsePersistentStore(c =>
    {
        c.RetryInterval = TimeSpan.FromMinutes(2);
        c.UseProperties = true;
        c.PerformSchemaValidation = true;
        c.UseNewtonsoftJsonSerializer();

        c.UsePostgres(postgres =>
        {
            postgres.ConnectionString = configuration.GetConnectionString("Postgres")!;
            postgres.TablePrefix = $"{DbConsts.SchemaName}.qrtz_";
            postgres.UseDriverDelegate<PostgreSQLDelegate>();
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

RetryInterval is the time to wait before scanning the database for new jobs.

Now we need to create a database schema for Quartz.NET.
We can use EF Core Migrations to simplify the process.

Using EF Core Migrations to Create Database Schema for Quartz.NET

Quartz.NET provides a database schema for each database provider.
You can use EF Core migrations to create the database schema for Quartz.NET.

First, you need to add one of the following packages depending on your database type:

dotnet add package AppAny.Quartz.EntityFrameworkCore.Migrations.PostgreSQL
dotnet add package AppAny.Quartz.EntityFrameworkCore.Migrations.MySql
dotnet add package AppAny.Quartz.EntityFrameworkCore.Migrations.SQLite
dotnet add package AppAny.Quartz.EntityFrameworkCore.Migrations.SqlServer
Enter fullscreen mode Exit fullscreen mode

These are community packages. I have used the PostgreSQL one in production without any issues.

Next, you need to add migrations to your DbContext:

public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
    : DbContext(options)
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // Adds Quartz.NET PostgreSQL schema to EntityFrameworkCore
        modelBuilder.AddQuartz(options => options.UsePostgreSql());
    }
}
Enter fullscreen mode Exit fullscreen mode

Quartz tables are created when you execute your migrations, for example, you can run them on Application startup:

using (var scope = app.Services.CreateScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<ReportDbContext>();
    await dbContext.Database.MigrateAsync();
}
Enter fullscreen mode Exit fullscreen mode

Note: for production this might not be the most suitable option for running migrations

Now, you can run your application and see that jobs are persisted in the database.

Screenshot_1

Screenshot_2

Summary

Quartz.NET is a powerful library for scheduling jobs in .NET applications.
It provides a simple API for creating and managing jobs and triggers.
You can use it to schedule jobs that run at specific times or intervals.

Quartz.NET also supports persisting jobs in the database.
This allows you to schedule jobs that run even if the application is restarted.

EF Core migrations are a great way to create the database schema for Quartz.NET.

On my website: antondevtips.com I share .NET and Architecture best practices.
Subscribe to my newsletter to improve your .NET skills.
Download the source code for this newsletter for free.

Google AI Education track image

Build Apps with Google AI Studio 🧱

This track will guide you through Google AI Studio's new "Build apps with Gemini" feature, where you can turn a simple text prompt into a fully functional, deployed web application in minutes.

Read more β†’

Top comments (1)

Collapse
 
stevsharp profile image
Spyros Ponaris β€’

Great article , clear and well-written! I remember using Quartz.NET about 10 years ago. It’s a solid library with good documentation and a large community. For some of the more advanced features, I sometimes had to dig into the original Java codebase, since it’s a port from Java.
Thanks for sharing!

Google AI Education track image

Work through these 3 parts to earn the exclusive Google AI Studio Builder badge!

This track will guide you through Google AI Studio's new "Build apps with Gemini" feature, where you can turn a simple text prompt into a fully functional, deployed web application in minutes.

Read more β†’

AWS GenAI LIVE!

GenAI LIVE! is a dynamic live-streamed show exploring how AWS and our partners are helping organizations unlock real value with generative AI.

Tune in to the full event

DEV is partnering to bring live events to the community. Join us or dismiss this billboard if you're not interested. ❀️