A poor person’s scheduler using .NET Background service

scheduler service feature image

Running a job on a schedule is a common and essential requirement in programming. All the major technologies and programming languages give developers a way to run a scheduler service, and .NET is no different. 

.NET has popular libraries such as TopSelf, Quartz, Hangfire etc. that you can use to run your scheduled jobs. If you are on the cloud, you can leverage serverless solutions such as Azure Function, AWS Lamda and Google Cloud Functions to schedule your jobs.

However, there are occasions where we may want to keep things simple and avoid any external dependency.

This post explains how you can create a scheduler service using .NET BackgroundService.

Source Code

You can follow this GitHub repository for the source code of the sample application. I’m using the .NET 5 SDK for the sample, but the solution should also work with previous versions of the .NET Core.

Let us first start with creating a Worker project with the default template that contains a BackgroundService, as shown below:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace SchedulerJobSample.Worker
{
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
public Worker(ILogger<Worker> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
await Task.Delay(1000, stoppingToken);
}
}
}
}
view raw Worker.cs hosted with ❤ by GitHub

Running the application would return an output similar to below:

Default background Service

Next, we will create a recurring task using the CRON expression. Here, I have used the popular open-source library CRONOS to parse the CRON expression. In the below code, we have scheduled our background service to run every one minute.

using System;
using System.Threading;
using System.Threading.Tasks;
using Cronos;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace SchedulerJobSample.Worker
{
public class SchedulerService : BackgroundService
{
private readonly ILogger<SchedulerService> _logger;
public SchedulerService(ILogger<SchedulerService> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Schedule the job every minute.
await WaitForNextSchedule("* * * * *");
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
}
}
private async Task WaitForNextSchedule(string cronExpression)
{
var parsedExp = CronExpression.Parse(cronExpression);
var currentUtcTime = DateTimeOffset.UtcNow.UtcDateTime;
var occurenceTime = parsedExp.GetNextOccurrence(currentUtcTime);
var delay = occurenceTime.GetValueOrDefault() currentUtcTime;
_logger.LogInformation("The run is delayed for {delay}. Current time: {time}", delay, DateTimeOffset.Now);
await Task.Delay(delay);
}
}
}
view raw Worker.cs hosted with ❤ by GitHub

Here is the console output after the change:

A background service running every one min.

Running Scheduler as a Scoped Service

The BackgroundService is a Singleton service. However, it may not be an ideal place to execute our recurring job with a scheduler since we may have some scoped dependencies, such as a database repository. Injecting a scoped or transient dependency in Singleton service could lead to Captive Dependency. To fix this, we can invoke a scoped service within a BackgroundService.

To create a scoped scheduler service, we first need to create a scoped service and our scheduler job logic.

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace SchedulerJobSample.Worker
{
public interface IScopedSchedulerService
{
Task ExecuteAsync(CancellationToken cancellationToken);
}
public class ScopedSchedulerService : IScopedSchedulerService
{
private readonly ILogger<ScopedSchedulerService> _logger;
public ScopedSchedulerService(ILogger<ScopedSchedulerService> logger)
{
_logger = logger;
}
public Task ExecuteAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
return Task.CompletedTask;
}
}
}

Next, we update BackgroundService to inject IServiceProvider and resolve the scoped service created in the previous step.

using System;
using System.Threading;
using System.Threading.Tasks;
using Cronos;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace SchedulerJobSample.Worker
{
public class SchedulerService : BackgroundService
{
private readonly ILogger<SchedulerService> _logger;
private readonly IServiceProvider _serviceProvider;
public SchedulerService(ILogger<SchedulerService> logger, IServiceProvider serviceProvider)
{
_logger = logger;
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Schedule the job every minute.
await WaitForNextSchedule("* * * * *");
using var scope = _serviceProvider.CreateScope();
var scopedSchedulerService = scope.ServiceProvider.GetRequiredService<IScopedSchedulerService>();
await scopedSchedulerService.ExecuteAsync(stoppingToken);
}
}
private async Task WaitForNextSchedule(string cronExpression)
{
var parsedExp = CronExpression.Parse(cronExpression);
var currentUtcTime = DateTimeOffset.UtcNow.UtcDateTime;
var occurenceTime = parsedExp.GetNextOccurrence(currentUtcTime);
var delay = occurenceTime.GetValueOrDefault() currentUtcTime;
_logger.LogInformation("The run is delayed for {delay}. Current time: {time}", delay, DateTimeOffset.Now);
await Task.Delay(delay);
}
}
}
view raw SchedulerService.cs hosted with ❤ by GitHub

Last but not least, we have to register our newly created scoped service in Program.cs.

services.AddHostedService<SchedulerService>();
services.AddScoped<IScopedSchedulerService, ScopedSchedulerService>();
view raw Program.cs hosted with ❤ by GitHub

That’s it! We now have the flexibility to invoke a recurring job within a scope.

A word of caution: The above code works well as long as you only have a single instance of your worker. However, if your worker has more than one instances deployed, it would run the job multiple times. To avoid this, you could use techniques like a distributed lock. I will talk more about this in a separate post.

Wrapping up

This post explains how you can create a simple scheduler service without using an external library or serverless functions. I hope you find it useful. 🙂

Feature Photo by Fabrizio Verrecchia on Unsplash