1

How do I run an async task in a Kestrel process with a very long time interval (say daily or perhaps even longer)? The task needs to run in the memory space of the web server process to update some global variables that slowly go out of date.

Bad answers:

  • Trying to use an OS scheduler is a poor plan.
  • Calling await from a controller is not acceptable. The task is slow.
  • The delay is too long for Task.Delay() (about 16 hours or so and Task.Delay will throw).
  • HangFire, etc. make no sense here. It's an in-memory job that doesn't care about anything in the database. Also, we can't call the database without a user context (from a logged-in user hitting some controller) anyway.
  • System.Threading.Timer. It's reentrant.

Bonus:

  • The task is idempotent. Old runs are completely irrelevant.
  • It doesn't matter if a particular page render misses the change; the next one will get it soon enough.
  • As this is a Kestrel server we're not really worried about stopping the background task. It'll stop when the server process goes down anyway.
  • The task should run once immediately on startup. This should make coordination easier.

Some people are missing this. The method is async. If it wasn't async the problem wouldn't be difficult.

Joshua
  • 40,822
  • 8
  • 72
  • 132
  • @GuruStron: Looks rather like it, but timer can't be async. Also, timer will fault on the same number of milliseconds that task.delay will. – Joshua Sep 01 '20 at 22:49
  • What about some config setting that tells the process when to update i.e. time of day or whatever your need is. Then inside your HostedService, instead of `Task.Delay` for x amount of hours, have it sleep for a shorter period that won't throw, when it wakes up, check your config setting, check the time of day/do calc to see if it should update or not. You can build in some tolerance for if it wakes up too early/late. – JohanP Sep 01 '20 at 23:43
  • You can set the initial Task.Delay quite low and readjust it based on how much time is needed before update, some sort of exponential backoff. You can try out Quartz.net https://andrewlock.net/creating-a-quartz-net-hosted-service-with-asp-net-core/ – JohanP Sep 01 '20 at 23:47
  • Timers can be async: https://stackoverflow.com/a/38918443/1204153 This is a job for an `IHostedService` running a timer. There is no other logical way to do it. – Andy Sep 02 '20 at 00:59
  • Also, the maximum interval of timer is 2,147,483,647 milliseconds... about 25 days. It also has an option to fire immediately when started. Even if you needed it to fire every 40 days, you could just do a countdown on a value in your service. Set your interval to 1 second. @GuruStron 's suggestion is the correct one. – Andy Sep 02 '20 at 01:12
  • @Andy: Last time I tried it, timers blew up when given a number of milliseconds equivalent to a whole day. There's a hidden multiply by 1000 because the actual OS call takes microseconds. But besides that, you *can't* pass an async method to the first argument of timer. – Joshua Sep 02 '20 at 01:17
  • I run timers on intervals where they fire off every 1 day, 5 days, etc. and had zero problems. If you can find an article about it, i'd love to read it. Even if that was the case, you could simply set a variable to `int countdown = 24*60*60` and have it decrement every 1 second interval. When `countdown` hits 0, execute your method and reset the `countdown`. – Andy Sep 02 '20 at 01:20
  • And yes you *can* pass `async` to the first method of timer. Please read the link i posted. `new Timer(async x => await DoSomethingAsync(), null, 0, 1000);` – Andy Sep 02 '20 at 01:22
  • @Andy: Timers and `async void` don't mix. So you "know" the job finishes before it starts another right. Well, maybe. You haven't battled clock instability yet. – Joshua Sep 02 '20 at 01:27
  • How do they not mix? `async void` because it's intended for event handlers. Event handlers are the *only* thing allowed to be `async void` because the framework handles event handlers differently. https://stackoverflow.com/a/19415703/1204153 – Andy Sep 02 '20 at 01:28
  • @Andy: Now I know you're making stuff up. `async void` means run this task asynchronously when nobody cares when it finishes. But in this case, the timer needs to care when the job finishes so it can start listening again. – Joshua Sep 02 '20 at 01:34
  • eh... I wouldn't make anything up. Maybe if someone who has more rep chimes in here to confirm what I am saying, you'll believe them. I know I am a lowly 5K, but I wouldn't waste my time if I wasn't confident in what I am saying. – Andy Sep 02 '20 at 01:40
  • @Andy: Actually I just found in the documentation, that Timer tasks are simply reentrant outright, despite the fact the examples imply otherwise. This makes it unusable here. – Joshua Sep 02 '20 at 01:45

1 Answers1

2

I am going to add an answer to this, because this is the only logical way to accomplish such a thing in ASP.NET Core: an IHostedService implementation.

This is a non-reentrant timer background service that implements IHostedService.

public sealed class MyTimedBackgroundService : IHostedService
{
    private const int TimerInterval = 5000; // change this to 24*60*60 to fire off every 24 hours
    private Timer _t;

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        // Requirement: "fire" timer method immediatly.
        await OnTimerFiredAsync();

        // set up a timer to be non-reentrant, fire in 5 seconds
        _t = new Timer(async _ => await OnTimerFiredAsync(),
            null, TimerInterval, Timeout.Infinite);
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _t?.Dispose();
        return Task.CompletedTask;
    }

    private async Task OnTimerFiredAsync()
    {
        try
        {
            // do your work here
            Debug.WriteLine($"{TimerInterval / 1000} second tick. Simulating heavy I/O bound work");
            await Task.Delay(2000);
        }
        finally
        {
            // set timer to fire off again
            _t?.Change(TimerInterval, Timeout.Infinite);
        }
    }
}

So, I know we discussed this in comments, but System.Threading.Timer callback method is considered a Event Handler. It is perfectly acceptable to use async void in this case since an exception escaping the method will be raised on a thread pool thread, just the same as if the method was synchronous. You probably should throw a catch in there anyway to log any exceptions.

You brought up timers not being safe at some interval boundary. I looked high and low for that information and could not find it. I have used timers on 24 hour intervals, 2 day intervals, 2 week intervals... I have never had them fail. I have a lot of them running in ASP.NET Core in production servers for years, too. We would have seen it happen by now.

OK, so you still don't trust System.Threading.Timer...

Let's say that, no... There is just no fricken way you are going to use a timer. OK, that's fine... Let's go another route. Let's move from IHostedService to BackgroundService (which is an implementation of IHostedService) and simply count down.

This will alleviate any fears of the timer boundary, and you don't have to worry about async void event handlers. This is also a non-reentrant for free.

public sealed class MyTimedBackgroundService : BackgroundService
{
    private const long TimerIntervalSeconds = 5; // change this to 24*60 to fire off every 24 hours

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Requirement: "fire" timer method immediatly.
        await OnTimerFiredAsync(stoppingToken);

        var countdown = TimerIntervalSeconds;

        while (!stoppingToken.IsCancellationRequested)
        {
            if (countdown-- <= 0)
            {
                try
                {
                    await OnTimerFiredAsync(stoppingToken);
                }
                catch(Exception ex)
                {
                    // TODO: log exception
                }
                finally
                {
                    countdown = TimerIntervalSeconds;
                }
            }
            await Task.Delay(1000, stoppingToken);
        }
    }

    private async Task OnTimerFiredAsync(CancellationToken stoppingToken)
    {
        // do your work here
        Debug.WriteLine($"{TimerIntervalSeconds} second tick. Simulating heavy I/O bound work");
        await Task.Delay(2000);
    }
}

A bonus side-effect is you can use long as your interval, allowing you more than 25 days for the event to fire as opposed to Timer which is capped at 25 days.

You would inject either of these as so:

services.AddHostedService<MyTimedBackgroundService>();
Andy
  • 12,859
  • 5
  • 41
  • 56