8

Microsoft's example for a forever/continous IHostedService at Implement background tasks in microservices with IHostedService and the BackgroundService class uses while+Task.Delay 'pattern'. This illustrated with a code snippet that a simplified version is just below.

public class GracePeriodManagerService : BackgroundService

(...) 

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        //Do work

        await Task.Delay(timeSpan, stoppingToken);
    }
}

This pattern suffers from a creeping shift - the work is done every timeSpan+how_long_work_took. Even when how_long_work_took is very small over a period of time it adds up.

I would like to avoid calculating timeSpan based on how long work took.

What would a robust solution be to run every fixed_amount_of_time?.

Thinking out loud: If I use a task scheduler library, like HangFire, inside ExecuteAsync does using IHostedService/BackgroundService even make sense any more?

A bonus would be to be able to run a task at a point in time (e.g. at midnight)

tymtam
  • 31,798
  • 8
  • 86
  • 126
  • I'm not sure what's wrong with calculating next schedule. Do you want the schedule to be configurable? – tia Aug 03 '18 at 07:18
  • @tia - it's not wrong but the less of my code the better. Things like "what if the work takes more than the delay" start creeping out and before I know it it's no longer short and simple :) – tymtam Aug 03 '18 at 08:19
  • Technically, you can use NCrontab to calculate the next occurrence and convert it back to `TimeSpan`. Whether it is better for you or not is quite subjective. – tia Aug 03 '18 at 08:30

2 Answers2

4

This is how I handle such thing... In my case I need to start the service on specific day, specific hour and repeat every x days. But I don't know if it's what are you looking for exactly :)

public class ScheduleHostedService: BackgroundService
{
    private readonly ILogger<ScheduleHostedService> _logger;
    private readonly DaemonSettings _settings;

    public ScheduleHostedService(IOptions<DaemonSettings> settings, ILogger<ScheduleHostedService> logger)
    {
        _logger = logger;
        _settings = settings.Value;
    }
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        DateTime? callTime=null;
        if (_settings.StartAt.HasValue)
        {

            DateTime next = DateTime.Today;
            next = next.AddHours(_settings.StartAt.Value.Hour)
                .AddMinutes(_settings.StartAt.Value.Minute)
                .AddSeconds(_settings.StartAt.Value.Second);
            if (next < DateTime.Now)
            {
                next = next.AddDays(1);
            }

            callTime = next;
        }

        if (_settings.StartDay.HasValue)
        {
            callTime = callTime ?? DateTime.Now;
            callTime = callTime.Value.AddDays(-callTime.Value.Day).AddDays(_settings.StartDay.Value);
            if (callTime < DateTime.Now)
                callTime = callTime.Value.AddMonths(1);
        }
        if(callTime.HasValue)
            await Delay(callTime.Value - DateTime.Now, stoppingToken);
        else
        {
            callTime = DateTime.Now;
        }
        while (!stoppingToken.IsCancellationRequested)
        {
            //do smth
            var nextRun = callTime.Value.Add(_settings.RepeatEvery) - DateTime.Now;

            await Delay(nextRun, stoppingToken);
        }
    }
    static async Task Delay(TimeSpan wait, CancellationToken cancellationToken)
    {
        var maxDelay = TimeSpan.FromMilliseconds(int.MaxValue);
        while (wait > TimeSpan.Zero)
        {
            if (cancellationToken.IsCancellationRequested)
                break;
            var currentDelay = wait > maxDelay ? maxDelay : wait;
            await Task.Delay(currentDelay, cancellationToken);
            wait = wait.Subtract(currentDelay);
        }
    }
}

I wrote Delay function to handle delays longer that 28 days.

Jaume
  • 744
  • 1
  • 7
  • 21
  • Thanks! This is the type of code I would like to avoid. Could you share tests for this logic? – tymtam Aug 03 '18 at 08:18
  • This is only an example, not a production code yet, I'm still working on that and have plans to write tests in near future. Unfortunately what you want to do is in fact what scheduling libraries like quartz are doing internally in similar way, you won't avoid calculating etc. You can wrap it with extensions, services or smth like that to avoid the code in your service but still it should be somewhere. But maybe someone has better, simpler idea – Jaume Aug 03 '18 at 08:31
0

You could consider using the Reactive extenstions for .NET and implement as Observables with a Timer and a Cancellation Token. With a Scheduler you can determine the best approach threading approach (refer here)

The code snippet below could be used in the ExecuteAsync method which shows an arbitrary 3 second startup time and then having a due date of 60 seconds (could be any time length. Note the Timestamp() which allows passing of the local time with the integer.

CancellationToken cancellationToken = CancellationToken.None;

Observable
  .Timer(TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(60))
  .Timestamp()
  .ObserveOn(NewThreadScheduler.Default)
  .Subscribe(  
        x =>
       {
            // do some task
       } , 
        cancellationToken);
Paul H
  • 31
  • 3