Building a scheduled task in ASP.NET Core/Standard 2.0

In this post, we’ll look at writing a simple system for scheduling tasks in ASP.NET Core 2.0. That’s quite a big claim, so I want to add a disclaimer: this system is mainly meant to populate data in our application’s cache in the background, although it can probably be used for other things as well. It builds on the ASP.NET Core 2.0 IHostedService interface. Before we dive in, I want to give some of the background about why I thought of writing this.

Background

At JetBrains, various teams make use of a Slack bot, which we write in Kotlin. This bot performs various tasks, ranging from saying “Hi!” to managing our stand-ups to keeping track of which developer manages which part of our IDE’s. While working on the bot code, I found this little piece of code:

@Scheduled(cron = "0 0/2 * * * *")
@Synchronized fun releases() {
    releasesList.set(fetchReleases())
}

Wondering what it did, I asked around and did some research. Turns out that the @Scheduled attribute is part of the Spring framework and allows simple scheduling of background tasks. In this example, our bot uses the releasesList to return data about upcoming product releases when someone asks on Slack.

For this case, I kind of like the approach of being able to populate a list of data every 2 hours (or whetever the cron string dictates), instead of doing what we typically do in .NET which is either coming up with our own scheduling system, or coming up with a crazy approach that uses timestamps, or use ObjectCache and check whether data expired or not. While those approaches all work, they are all more complex than what we see in the above code sample. We just tell our application to refresh the list of releases every two hours, without having to do this in a request path.

This same approach is taken in various other places of the application. Twice a day, we fetch the list of employees for some other functionality. We have a few other occasions, but they all share one pattern: a simple background fetch of data, moving it outside of the request path.

Then earlier this week, I saw Steve Gordon blogged about using IHostedService in ASP.NET Core 2.0, and I noticed this was potentially the same. Except: I find it way too cumbersome. Yes, it’s a more powerful way of handling this type of background work, but look at that Kotlin sample above! It’s short, simple, clean. So I thought of working on a system that would be more similar to the Kotlin approach above. Well, Spring approach actually - except for the fun in writing code (we never heard that joke before ;-)) the above sample will work in pretty much any Spring application.

Before we dive in, please read Steve’s post about using IHostedService in ASP.NET Core 2.0. I’ll wait right here.

Building the scheduler

So now you know how to use IHostedService in ASP.NET Core 2.0, it’s time to build our scheduler. Since ASP.NET Core is heavily built around composition and dependency injection, let’s put that to use. First of all, I want all of the scheduled tasks to look like this:

public class SomeTask : IScheduledTask
{
    public string Schedule => "0 5 * * *";

    public async Task ExecuteAsync(CancellationToken cancellationToken)
    {
        // do stuff
    }
}

In other words, the IScheduledTask interface provides us with the cron schedule, and a method that can be executed when the time of execution comes.

The nice thing is that in Startup.cs, we can easily register scheduled tasks:

public void ConfigureServices(IServiceCollection services)
{
    // ...

    // Add scheduled tasks
    services.AddSingleton<IScheduledTask, SomeTask>();
    services.AddSingleton<IScheduledTask, SomeOtherTask>();
}

In our hosted service, we can then import these IScheduledTask and work with them to schedule things:

public class SchedulerHostedService : HostedService
{
    // ...
    
    public SchedulerHostedService(IEnumerable<IScheduledTask> scheduledTasks)
    {
        var referenceTime = DateTime.UtcNow;
        
        foreach (var scheduledTask in scheduledTasks)
        {
            _scheduledTasks.Add(new SchedulerTaskWrapper
            {
                Schedule = CrontabSchedule.Parse(scheduledTask.Schedule),
                Task = scheduledTask,
                NextRunTime = referenceTime
            });
        }
    }

    // ...
}

Now what is this SchedulerTaskWrapper? It’s a simple class that holds the IScheduledTask, the previous and next run time, and a parsed cron expression so we can easily check whether the task should be run or not. If you look at the example code (check the bottom of this post), the cron parsing logic comes from a library I have used long, long ago: AzureToolkit. Very unmaintained, but the cron parsing works just fine.

Perhaps another quick note: the NextRunTime is set to “now”. Reason for that is for the purpose of these self-updating pieces of data, I want them to be available ASAP. So setting the NextRunTime (or at this point, the first run time) will make sure the task is triggered as soon as possible.

Next up: deciding whether to run our schedule tasks. That’s pretty straightforward: we just need to implement Steve’s HostedService base class ExecuteAsync() method. We’ll just create an infinite loop (that does check a CancellationToken) that triggers every minute.

public class SchedulerHostedService : HostedService
{
    // ...
    
    protected override async Task ExecuteAsync(CancellationToken cancellationToken)
    {
        while (!cancellationToken.IsCancellationRequested)
        {
            await ExecuteOnceAsync(cancellationToken);
                
            await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
        }
    }
    
    // ...
}

The logic that executes every minute would check our scheduled tasks, and invoke them using TaskFactory.StartNew().

public class SchedulerHostedService : HostedService
{
    // ...

    private async Task ExecuteOnceAsync(CancellationToken cancellationToken)
    {
        var taskFactory = new TaskFactory(TaskScheduler.Current);
        var referenceTime = DateTime.UtcNow;
            
        var tasksThatShouldRun = _scheduledTasks.Where(t => t.ShouldRun(referenceTime)).ToList();

        foreach (var taskThatShouldRun in tasksThatShouldRun)
        {
            taskThatShouldRun.Increment();

            await taskFactory.StartNew(
                async () =>
                {
                    try
                    {
                        await taskThatShouldRun.Task.ExecuteAsync(cancellationToken);
                    }
                    catch (Exception ex)
                    {
                        var args = new UnobservedTaskExceptionEventArgs(
                            ex as AggregateException ?? new AggregateException(ex));
                        
                        UnobservedTaskException?.Invoke(this, args);
                        
                        if (!args.Observed)
                        {
                            throw;
                        }
                    }
                },
                cancellationToken);
        }
    }

    // ...
}

So TaskFactory.StartNew()… Why not simply await them here, you ask? Well, what if you schedule a task to run every minute but that task never returns (or returns after a couple of minutes)? Our scheduler would be useless. So instead we’re spawning tasks outside of our scheduler so at least it can keep its promises (see what I did there). And what about this UnobservedTaskException stuff? We’ll see that when we start using our little SchedulerHostedService.

Using the scheduler

As an example application, I want to display a “quote of the day” which is loaded from the TheySaidSo.com API. This API has a new quote every day, so ideally our task should only fetch this data once a day. Here’s the IScheduledTask implementation which runs every 6 hours:

public class QuoteOfTheDayTask : IScheduledTask
{
    public string Schedule => "* */6 * * *";
        
    public async Task ExecuteAsync(CancellationToken cancellationToken)
    {
        var httpClient = new HttpClient();

        var quoteJson = JObject.Parse(await httpClient.GetStringAsync("http://quotes.rest/qod.json"));

        QuoteOfTheDay.Current = JsonConvert.DeserializeObject<QuoteOfTheDay>(quoteJson["contents"]["quotes"][0].ToString());
    }
}

In this case, it’s setting the QuoteOfTheDay.Current so we can use it in our ASP.NET MVC controller. Of course it could also populate cache or use another means of setting the data. I wanted to have a simple approach (see background, so this will do.

Another thing to note: I am using HttpClient wrong for the sake of simplicity. Go read this post.

Next up, we’ll have to register our task as well as our scheduler. We can do this in Startup.cs, simply registering it with the IServiceCollection. Let’s also register the scheduler itself:

public void ConfigureServices(IServiceCollection services)
{
    // ...

    // Add scheduled tasks & scheduler
    services.AddSingleton<IScheduledTask, QuoteOfTheDayTask>();
    
    services.AddScheduler((sender, args) =>
    {
        Console.Write(args.Exception.Message);
        args.SetObserved();
    });
}

If we now start our application, it should fetch the quote of the day every 6 hours and make it available for any other part of my application to work with.

Maybe one little word about the AddScheduler above. As you can see, it takes a delegate that handles unobserved exceptions. In our scheduler code, we’ve used TaskFactory.StartNew() to run our task’s code. If we have an unhandled exception in there, we won’t see a thing… Which is why we may want to be able to do some logging. This is normally done by setting TaskScheduler.UnobservedTaskException, but I found that too global for this case so added my own to specifically catch scheduled tasks unhandled exceptions.

Give it a try! The sample code is available here. I’d love to hear your thoughts on this. But do remember: this is not a proper scheduler for complicated background tasks. There are better approaches to doing that type of work. The proposed solution may not even be a good approach to this type of problem.

Enjoy!

Leave a Comment

avatar

47 responses

  1. Avatar for khalidabuhakmeh
    khalidabuhakmeh August 1st, 2017

    Zip File!?! GitHub is probably a better place to put your code. Great post either way :)

  2. Avatar for Maarten Balliauw
    Maarten Balliauw August 1st, 2017

    What if I told you... The ZIP file is hosted on GitHub?

  3. Avatar for khalidabuhakmeh
    khalidabuhakmeh August 1st, 2017

    blog.maartenballiauw.be sure is a weird way to spell GitHub, but I don't speak or write Dutch.

    https://uploads.disquscdn.c...

  4. Avatar for Maarten Balliauw
    Maarten Balliauw August 1st, 2017

    Magic CNAME ;-)

  5. Avatar for Andon Dragomanov
    Andon Dragomanov August 7th, 2017

    Did you consider https://www.hangfire.io/ ?

  6. Avatar for Maarten Balliauw
    Maarten Balliauw August 8th, 2017

    Absolutely! But wanted to explore the `IHostedService` interface.

  7. Avatar for Nikita Danilov
    Nikita Danilov August 16th, 2017

    By my point of view, Hangfire contains too much internal magic and complexity, for simle scheduling tasks.

  8. Avatar for Constantinos L.
    Constantinos L. September 15th, 2017

    Hi Maarten,
    I am trying out your sample in a realworld scenario where I needed a DbContext "scoped" instance inside the executing task Execute method. In the sample this cannot be done so I changed it a little to resemble the way middleware "'Invoke" methods are invoked using method parameter injection and the IServiceScopeFactory.
    Here are my changes
    1. In IScheduledTask.cs I removed the execute method
    ```csharp
    public interface IScheduledTask
    {
    string Schedule { get; }
    }
    ```
    2. In SchedulerHostedService.cs I inject the IServiceScopeFactory in the constructor. Then I wrap the call to "ExecuteOneceAsync" with a scope like so:
    ```csharp
    protected override async Task ExecuteAsync(CancellationToken cancellationToken)
    {
    while (!cancellationToken.IsCancellationRequested)
    {
    using (var scope = _serviceScopeFactory.CreateScope())
    {
    await ExecuteOnceAsync(scope, cancellationToken);
    }

    await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
    }
    }
    ```
    last but not least inside the ExecuteOnceAsync I change the way I invoike the method of each task
    ```csharp
    var type = taskThatShouldRun.Task.GetType();
    var method = typt.GetMethod("Invoke", BindingFlags.Instance | BindingFlags.Public);
    var arguments = method.GetParameters().Select(a => a.ParameterType == typeof(CancellationToken) ? cancellationToken : scope.ServiceProvider.GetService(a.ParameterType)).ToArray();
    //invoke.
    if (typeof(Task).Equals(method.ReturnType))
    {
    await (Task)method.Invoke(taskThatShouldRun.Task, arguments);
    }
    else
    {
    method.Invoke(taskThatShouldRun.Task, arguments);
    }
    ```
    After that this will work like a charm.
    ```csharp
    public class SomeOtherTask : IScheduledTask
    {
    public string Schedule => "0 5 * * *";
    public async Task Invoke(MyDbContext dbContext, CancellationToken cancellationToken)
    {
    await Task.Delay(5000, cancellationToken);
    }
    }
    ```

  9. Avatar for codeceptive solutions
    codeceptive solutions September 18th, 2017

    Can you share your code, please? I am dealing with the very same real world example. Or at least, for AddScheduler..

  10. Avatar for Constantinos L.
    Constantinos L. September 19th, 2017

    Here is a gist with SchedulerHostedService https://gist.github.com/cle...

    And Here is the IScheduledTask (Notice it is missing the execute it is on purpose)
    https://gist.github.com/cle...

    Lastly here is the Quote of the day task
    https://gist.github.com/cle...

    TL;DR your tasks can now have an "Invoke" method with any number of parameters (dependencies) that will be resolved on each run instead of upfront.

  11. Avatar for codeceptive solutions
    codeceptive solutions September 19th, 2017

    Thank you very much, works awesome!

  12. Avatar for Herb
    Herb September 21st, 2017

    Nice and immediately useful. I am using this to keep configuration json's up to date from rawgit.com. Works fantastic for simple proofs and abstractions during development. I will see if I can keep it in production at scale as an alternative to hangfire.

  13. Avatar for AlexanderNaiden
    AlexanderNaiden September 25th, 2017

    Hi.Could I run background task in asp.net core 2.0 without scheduler? For example in asp.net core 2 app I called long running operation like request of large list from external api after that I save it in db and then return status of import operation.

  14. Avatar for Pete
    Pete October 26th, 2017

    For anyone wondering, cause I was :), where to get the IServiceScopeFactory from in the SchedulerExtensions method you can use this: "services.BuildServiceProvider().GetRequiredService<iservicescopefactory>()"

    This was really great info though. Thanks to everyone!!

  15. Avatar for Ronaldo Reis
    Ronaldo Reis November 19th, 2017

    Hi
    After all changes, I got: Cannot consume scoped service 'Scheduling.IMaintenanceTasksService' from singleton 'Scheduling.IScheduledTaskService'.

    I'm affraid something is missing... Any thoughts?

  16. Avatar for lunix11
    lunix11 November 30th, 2017

    I want recurring Task that will execute every 1 minute :

    public class SomeOtherTask : IScheduledTask
    {
    public string Schedule => "1 * * * *";

    public async Task ExecuteAsync(CancellationToken cancellationToken)
    {
    await Task.Delay(500, cancellationToken);
    }
    }

    The problem the task will execute only once. Did I miss something

  17. Avatar for Sietse Trommelen
    Sietse Trommelen November 30th, 2017

    Hi Maarten,

    First of all, the code is looking slick, thanks!

    For some reason I can't get it to work and I'm afraid I'm missing out on something simple. I've implemented it the same way as you did (using your sample code), but my SchedulerHostedService is never being created on application startup.

    Is this something I have to do manually somewhere?

  18. Avatar for Maarten Balliauw
    Maarten Balliauw December 1st, 2017

    Does the sample code download work on your machine?

  19. Avatar for Sietse Trommelen
    Sietse Trommelen December 5th, 2017

    Yes, that works. After comparing code I found out that it's the IHostedService interface that was causing issues. In my hurry I created my own IHostedService interface, but the trick is to use the one provided in the Microsoft Extensions. Thanks! I'm tweaking it a bit to run the check if a task needs to run every full minute (06:01:00 and 06:02:00 etc), but other than that it works perfectly.

  20. Avatar for Ahmed HABBACHI
    Ahmed HABBACHI December 7th, 2017

    Ah! man just what a lovely peace of code, I love it enjoy it and most important use it :D
    thank you a great post.

  21. Avatar for shonuff
    shonuff December 16th, 2017

    Can someone give me and example of ASP.NET Core 2 Razor pages with SignalR

  22. Avatar for Andy Chen
    Andy Chen December 18th, 2017

    `* * * * *` Every minute.

  23. Avatar for mohdakram
    mohdakram January 11th, 2018

    You seem to be using code from NCrontab. I think you should mention that and/or keep the license in the files.

  24. Avatar for Ruben Arrebola
    Ruben Arrebola January 14th, 2018

    Thank you, you solve my problem jeje :)

  25. Avatar for Dragan
    Dragan January 31st, 2018

    Given that QuoteOfTheDayTask is a Singleton, a scoped service (e.g. DbContext) can be added using the IServiceScopeFactory:

    public QuoteOfTheDayTask (IServiceProvider serviceProvider)
    {
    this.serviceProvider = serviceProvider;
    }

    var serviceScopeFactory = this.serviceProvider.GetRequiredService<IServiceScopeFactory>();

    using (var scope = serviceScopeFactory.CreateScope())
    {
    var dbContext = scope.ServiceProvider.GetService<MyDbContext>();
    ...
    }

  26. Avatar for asep surawan
    asep surawan February 1st, 2018

    hey martin thanks for the tutorial its very helpfull but i try combined with generic repository pattern

    error

    Cannot resolve 'System.Collections.Generic.IEnumerable`1[Project.Web.Codes.Scheduling.IScheduledTask]' from root provider because it requires scoped service 'App.Data.DataRepository.IRepository`1[App.Core.Domain.ScheduleFolder.schedule_task]'.

    can you help ,t hank you

  27. Avatar for Bibhu Kalyan Das
    Bibhu Kalyan Das February 5th, 2018

    Excellent article. I have already put it to use. Thank You Maarten for this great post.

  28. Avatar for Slava Borisov
    Slava Borisov February 7th, 2018

    Hello Maarten, thank you for this useful article. As you wrote: "There are better approaches to doing that type of work. The proposed solution may not even be a good approach to this type of problem." What do you mean? Could you please explain a little bit more your point of view on this issue?

  29. Avatar for diogenes ardines
    diogenes ardines March 27th, 2018

    Hi, if i need to do the crop every 15 seconds, how can i do that ?

  30. Avatar for Franck
    Franck May 17th, 2018

    Thank you very much ! That all I needed.

  31. Avatar for Simillo
    Simillo June 2nd, 2018

    Thank you Dragan, it worked greatly.

  32. Avatar for GeorgiMarokov
    GeorgiMarokov June 11th, 2018

    Great article, Marteen. Is this interface considered a good practice to send scheduled emails for example?

  33. Avatar for Sachith Ushan
    Sachith Ushan June 21st, 2018

    Is this applicable for .net core 2.1 also ?

  34. Avatar for Egor
    Egor June 24th, 2018

    Thank you for the post! It helped me

  35. Avatar for NunoFas
    NunoFas July 3rd, 2018

    Great work. I'm trying to set Cron to execute the task every 24h at 19:30.
    Setting the schedule variable to < public string Schedule => " 30 19 * * * "; > does not do the trick. It seems that the schedule time is never hit. Do you know why? Thanks, best regards.

  36. Avatar for Tiger
    Tiger July 25th, 2018

    have you got full minute (06:01:00 and 06:02:00 etc) worked?

  37. Avatar for ignj
    ignj July 31st, 2018

    I don't know if anyone else has this problem. When I shut down the server and I start it again it doesn't matter what value has the cron, the task is executed always (and I've the schedule to execute it once per month. Schedule => "0 0 1 * *").
    Does anyone know what I'm missing?

  38. Avatar for Tymek Majewski
    Tymek Majewski August 3rd, 2018

    We’ll just create an infinite loop (that does check a CancellationToken) that triggers every minute.

    The "that triggers every minute." is not entirely correct. It will run every a-little-bit-more-than-a-minute.

    Do you know of an example of IHostedService which runs task at 'exactly' the right time?

  39. Avatar for Ivan Teles
    Ivan Teles August 10th, 2018

    Congratulations. Great post, this will be very useful.

  40. Avatar for Вадим Полов'юк
    Вадим Полов'юк August 14th, 2018

    Every first minute hour

  41. Avatar for Przem
    Przem August 30th, 2018

    Why would You do this instead of

    private IMemoryCache _cache;
    public GetProductReleases()
    {
    return _cache.GetOrCreate('key', entry =>
    {
    entry.Expires = TimeSpan.FromHours(2);
    return LoadProductReleases();
    });
    }

  42. Avatar for Alvin Terible
    Alvin Terible September 18th, 2018

    thanks for this!

  43. Avatar for Martin Vengai
    Martin Vengai October 1st, 2018

    did you ever find out why?

  44. Avatar for NunoFas
    NunoFas October 1st, 2018

    Sorry Martin, unfortunately not. I had to wrap around for another alternative that involves a Windows scheduled task...

  45. Avatar for Libor Svoboda
    Libor Svoboda November 9th, 2018

    please somebody help me with server recycling? ifter server recycle the scheduller is killed, and started again only after reload some page. Can somebody help me?

  46. Avatar for Maarten Balliauw
    Maarten Balliauw November 9th, 2018

    You could use an application warmup call (e.g. a request to /) when your server starts? E.g. from within your startup, or when running on IIS, using application initialization - https://docs.microsoft.com/en-us/iis/get-started/whats-new-in-iis-8/iis-80-application-initialization

  47. Avatar for Hien Duong
    Hien Duong November 23rd, 2018

    Hi Maarten, First of all, thanks for helpful article.

    I have quick question though, for some reasons the deployed scheduled task does not wake up under IIS. I tested with IIS express it works. Are you aware of this issue?