Home > Software design >  .NET 6 PeriodicTimer with top-of-the-minute timing
.NET 6 PeriodicTimer with top-of-the-minute timing

Time:06-24

.NET 6 introduced the PeriodicTimer.

I need to do something every minute, at the top of the minute. For example: 09:23:00, 09:24:00, 09:25:00, ...

But with a one minute period - new PeriodicTimer(TimeSpan.FromMinutes(1)) - and starting at 09:23:45, I will get "ticks" at: 09:24:45, 09:25:45, 09:26:45, ...

So it's dependent on the start time.

My workaround is a one-second period, and a check that the current time has seconds equal to 0. Another workaround is to wait for the next minute and then start the timer. Both approaches work but are fiddly and use too fine a resolution.

Is there a built-in or better way to trigger at the top of the minute rather than one-minute-after-start?

CodePudding user response:

For completeness, here are the workarounds mentioned in my question.

Tick every second, and wait for top-of-the-minute:

var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));

while (await timer.WaitForNextTickAsync(cancellationToken)) {
  if (DateTime.UtcNow.Second == 0)
    await DoSomething();
}

Delay till top-of-the-minute, then tick every minute:

var delay = (60 - DateTime.UtcNow.Second) * 1000; // take milliseconds into account to improve start-time accuracy
await Task.Delay(delay);
var timer = new PeriodicTimer(TimeSpan.FromMinutes(1));

while (await timer.WaitForNextTickAsync(cancellationToken)) {
  await DoSomething();
}

Some notes about accuracy:

  • This will never be perfectly accurate, as discussed in the comments above. But it's good enough in many cases.
  • Small clock drifts will occur and make the per-minute timer more and more inaccurate, and this will only be fixed when the server is restarted. But the per-second timer "self-corrects" on every tick, so although it's "heavier", it will be more accurate over time.
  • The per-second timer can sometimes lose a tick or get a double tick (see comments above). The per-minute timer won't lose ticks, but they may be inaccurate. Whether this matters is something for you to consider.

CodePudding user response:

There is nothing available in the standard .NET libraries, with this functionality. And I don't think that it's likely to be added any time soon. My suggestion is to use the third party Cronos library, that handles excellently the calculation of time intervals. You can find a usage example here, by Stephen Cleary. This library only calculates intervals, it's not doing any scheduling.

If you want to get fancy you could include the functionality of the Cronos library in a custom PeriodicTimer-like component, like the one below:

using Cronos;

public sealed class CronosPeriodicTimer : IDisposable
{
    private readonly CronExpression _cronExpression;
    private readonly CancellationTokenSource _cts;

    public CronosPeriodicTimer(string expression, CronFormat format)
    {
        _cronExpression = CronExpression.Parse(expression, format);
        _cts = new();
    }

    public async ValueTask<bool> WaitForNextTickAsync(
        CancellationToken cancellationToken = default)
    {
        if (_cts.IsCancellationRequested) return false;
        CancellationTokenSource linkedCts = null;
        CancellationToken token = _cts.Token;
        if (cancellationToken.CanBeCanceled)
        {
            cancellationToken.ThrowIfCancellationRequested();
            linkedCts = CancellationTokenSource
                .CreateLinkedTokenSource(_cts.Token, cancellationToken);
            token = linkedCts.Token;
        }
        try
        {
            DateTime utcNow = DateTime.UtcNow;
            TimeSpan delay = TimeSpan.Zero;
            for (int attempt = 1; attempt <= 2; attempt  )
            {
                DateTime? utcNext = _cronExpression.GetNextOccurrence(utcNow   delay);
                if (utcNext is null)
                    throw new InvalidOperationException("Unreachable date.");
                delay = utcNext.Value - utcNow;
                Debug.Assert(delay > TimeSpan.Zero);
                if (delay > TimeSpan.FromMilliseconds(500)) break; // Safety threshold
                // Calculate one more time if the delay is too small.
            }
            try
            {
                await Task.Delay(delay, token).ConfigureAwait(false);
                return true;
            }
            catch (OperationCanceledException)
            {
                cancellationToken.ThrowIfCancellationRequested();
                return false;
            }
        }
        finally { linkedCts?.Dispose(); }
    }

    public void Dispose() => _cts.Cancel();
}

Apart from the constructor, the CronosPeriodicTimer class has identical API with the PeriodicTimer class. You could use it like this:

var timer = new CronosPeriodicTimer("0 * * * * *", CronFormat.IncludeSeconds);
//...
await timer.WaitForNextTickAsync();

The expression 0 * * * * * means "on the 0 (zero) second of every minute, of every hour, of every day of the month, of every month, and of every day of the week."

You can find detailed documentation about the format of the Cron expressions here.


Update: I added an extra recalculation step in case the calculated delay is smaller than 500 milliseconds, in order to prevent the scenario of the Task.Delay completing earlier than the targeted DateTime. I was not able to realize this scenario experimentally, but nevertheless I added it for extra safety.

  • Related