DEV Community

Mirco Benthien
Mirco Benthien

Posted on

No Headache Periodic Background Jobs in ASP.NET Core

Scenario

You're building a small to medium sized aspnet core web project. An API or a Blazor project most likely.
You find yourself needing to execute code once a day instead of on a request. Examples include

  • Take all coupons from your database and set Active = false, if they're older than 7 days.
  • Getting today's data from an external system and adding it to your database.
  • Building a daily report and sending it out via mail
  • Archive or purge old logs or database entries

The code itself is often trivial. However, this is the first time any periodic execution comes up on the project.
Doing something once a day or once every 5 minutes are common tasks. There are solutions for that.

The most common in the .NET space would probably be Azure Functions or the HangFire library.

However, they come with overhead and questions.

  • Do I use an azure function?
  • How do I get my dependencies in there?
  • How do I integrate it into my CI/CD setup?
  • Does the person implementing this even have access to our azure account?
  • Do I create 3 functions? One for Develop, Staging and Production?
  • Last time I checked, each Azure function added about 2 minutes to our pipeline.
  • How do I test it? Where do logs end up? Can I run it locally?
  • Do I use HangFire?
  • HangFire is deceptively simple. Developers unfamiliar with it could start using BackgroundJob.Enqueue(job) everywhere. Without knowing that the job gets serialized into a database. With all the implications that come with it.
  • HangFire adds like 8 tables to your database by the way.
  • Should I add these tables to my database or add a new one? Does this affect ef core's migration in any way?
  • HangFire comes with a nice admin dashboard for errors. Who makes sure to check it regularly?
  • A colleague once told me about a third one that (used to?) exists in an azure environment. In a WebApp you can add a certain folder named a certain way to your solution. It executes the code in there periodically based on a cron string.
  • and more... probably

These questions stand in absolutely no proportion to the actual solution to the business problem. The solution for the first example is:

var coupons = await dbContext.Set<Coupon>()
    .Where(x => x.CreatedAt <= DateTime.Now.AddDays(-7))
    .ToListAsync();

foreach (var coupon in coupons)
    coupon.IsActive = false;

await dbContext.SaveChangesAsync();
Enter fullscreen mode Exit fullscreen mode

The most junior person on your team can solve this. Why should they have to deal with all these other questions and implications, simply because this code gets executed at 2 am instead of when someone sends a request?

Goals

Having had this experience a couple of times, my goal was to find a solution that checks multiple of these boxes.

  • No required knowledge (or access) to azure, azure functions, Hangfire, or other libraries
  • Dependency injection the familiar way
  • Little to no impact on the main application or the deployment process
  • Schedule work via CronStrings
  • A solution that just feels more fitting to the size of the problem

The Result

What I ended up with will end up enabling you and anyone else on the project to write client code like this:

    public class CouponJob : ICronJob
    {
        private readonly MyContext dbContext;

        public CouponJob(MyContext dbContext)
        {
            this.dbContext = dbContext;
        }

        public async Task DoWorkAsync()
        {
            var coupons = await dbContext.Set<Coupon>()
                .Where(x => x.CreatedAt <= DateTime.Now.AddDays(7))
                .ToListAsync();

            foreach (var coupon in coupons)
                coupon.IsActive = false;

            await dbContext.SaveChangesAsync();
        }
    }
Enter fullscreen mode Exit fullscreen mode

You can register this background job like this:

builder.Services.AddCronJob<CouponJob>("0 2 * * *");
Enter fullscreen mode Exit fullscreen mode

General Idea

ASP.NET Core already gives you a way to do long running background tasks. It's called IHostedService.

The conceptual idea is simple. Inside the long running background service do the following.

  1. Figure out how long till the next execution of your CronString
  2. Create a standard Timer, that will execute your code on time
  3. Wait till the code is done executing
  4. Go back to step 1

Things to note

  • An IHostedServiceis a background service that starts with your main application and executes the code in it's StartAsync() method. Important to notice is that DependencyInjection does not work the usual way. You are in a different scope than your main application and need to inject the IServiceProvider directly and create your own scope.
  • I did not want my CronJobs to inject the IServiceProvider directly. I wanted them to work just like anywhere else, so I'm doing the scope creation for them.
  • I also did not want the CronJobs to implement the CronString directly as it changes between environments. You don't want random background code to fire every minute or every hour in a development scenario. You do not want code to start executing on your production database, simply because you're debugging something completely different at 2 am.

Complete Code

using Cronos;
using System.Timers;
using Timer = System.Timers.Timer;

    public class CronJobService<T> : IHostedService, IDisposable where T : ICronJob
    {
        private Timer timer;
        private readonly CronExpression? cronExpression;
        private readonly TimeZoneInfo timeZoneInfo;
        private readonly IServiceProvider serviceProvider;

        private void ScheduleJob()
        {
            if (cronExpression == null)
                return;

            // Figure out when the next execution is supposed to happen. (1.)
            var next = cronExpression.GetNextOccurrence(DateTimeOffset.Now, timeZoneInfo);
            if (!next.HasValue)
                return;

            var delay = next.Value - DateTimeOffset.Now;

            // Set up a timer to wait for said time. (2.)
            timer = new Timer(delay.TotalMilliseconds);
            timer.Elapsed += DoWorkAndReschedule;
            timer.Start();
        }

        private async void DoWorkAndReschedule(object sender, ElapsedEventArgs e)
        {
            // Make sure to not execute while a current execution is still running.
            // For example: A job is supposed to be executed every minute, but takes 90 seconds.
            // This is probably making a little too sure...
            timer.Stop();
            timer.Dispose();
            timer = null;

            // Actually do the work (3.)
            await TryDoWorkAsync();

            // Once finished, figure out when the next execution is happening again. (4.)
            ScheduleJob();
        }

        private async Task TryDoWorkAsync()
        {
            // Resolve services within their own scope, as there is no other way in an IHostedService.
            using var scope = serviceProvider.CreateScope();

            // Make sure not to crash, as this would stop the scheduling of the next execution.
            try
            {
                var cronJob = scope.ServiceProvider.GetRequiredService<T>();
                await cronJob.DoWorkAsync();
            }
            catch (Exception ex)
            {
                var logger = scope.ServiceProvider.GetRequiredService<ILogger<CronJobService<T>>>();
                logger.LogError(ex, $"Exception while executing CronJob {typeof(T).FullName}");
            }
        }

        public CronJobService(ScheduleConfig<T> config, IServiceProvider serviceProvider)
        {
            if (string.IsNullOrWhiteSpace(config.CronString))
            {
                using var scope = serviceProvider.CreateScope();
                var logger = scope.ServiceProvider.GetService<ILogger<CronJobService<T>>>();
                logger.LogWarning($"No CronString provided for {typeof(T).FullName}");
                return;
            }

            cronExpression = CronExpression.Parse(config.CronString);
            timeZoneInfo = config.TimeZoneInfo;
            this.serviceProvider = serviceProvider;
        }


        // IHostedService
        public Task StartAsync(CancellationToken cancellationToken)
        {
            ScheduleJob();
            return Task.CompletedTask;
        }

        // IHostedService
        public Task StopAsync(CancellationToken cancellationToken)
        {
            timer?.Stop();
            return Task.CompletedTask;
        }

        // IDisposable
        public void Dispose()
        {
            timer?.Dispose();
        }
    }

    public static class ScheduledServiceExtensions
    {
        public static IServiceCollection AddCronJob<T>(this IServiceCollection services, string cronString)
            where T : class, ICronJob
        {
            // This extension provides some very pretty syntactic sugar.

            // Create a ScheduleConfig<T> that holds the cron string and register it as a singleton
            // Register the actual ICronJob
            // Register the CronJobService<T> (this class) as an IHostedService
            // All 3 components are linked up by T. The concrete Type of the ICronJob implementation

            var scheduleConfig = new ScheduleConfig<T>(cronString, TimeZoneInfo.FindSystemTimeZoneById("Central European Standard Time"));

            services.AddSingleton(scheduleConfig);
            services.AddTransient<T>();
            services.AddHostedService<CronJobService<T>>();

            return services;
        }
    }

    public class ScheduleConfig<T>
    {
        public string CronString { get; set; }
        public TimeZoneInfo TimeZoneInfo { get; set; }

        public ScheduleConfig(string cronString, TimeZoneInfo timeZoneInfo)
        {
            CronString = cronString;
            TimeZoneInfo = timeZoneInfo;
        }
    }

    public interface ICronJob
    {
        Task DoWorkAsync();
    }
Enter fullscreen mode Exit fullscreen mode

Final Thoughts and Use Case

I've deliberately not turned this code into a library or a package. I copy-paste the entire file into every new project as soon as periodic background tasks come up.

It's the smallest possible intrusion on the project.
It uses familiar DI, familiar logging and using it does not require any special knowledge or access.

Any developer on the team capable of writing the code to be executed, can now implement it to "run every day at 2 am" too.

Often all it takes to retroactively turn some code to now run periodically is adding the ICronJob interface and renaming a method.

As soon as you start thinking about

  • Retries
  • Performance implications
  • More detailed information on logging or errors
  • Any of the questions at the top

you should remove this file and migrate your background jobs to the appropriately sized solution.

Consider this the smallest possible solution and use it only as long as there are no problems.

Top comments (0)