Search code examples
c#.nettimer.net-6.0periodictimer

.NET 6 PeriodicTimer with top-of-the-minute timing


.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?


Solution

  • AFAIK there is nothing like this available in the standard .NET libraries. 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 does a good job at calculating time intervals¹. You can find a usage example here, by Stephen Cleary. What this library does is to take a DateTime and a Cron expression as input, and calculate the next DateTime that satisfies this expression. It is just a DateTime calculator, not a scheduler.

    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; // Also used as the locker
        private PeriodicTimer _activeTimer;
        private bool _disposed;
    
        public CronosPeriodicTimer(string expression, CronFormat format)
        {
            _cronExpression = CronExpression.Parse(expression, format);
        }
    
        public async ValueTask<bool> WaitForNextTickAsync(
            CancellationToken cancellationToken = default)
        {
            cancellationToken.ThrowIfCancellationRequested();
            PeriodicTimer timer;
            lock (_cronExpression)
            {
                if (_disposed) return false;
                if (_activeTimer is not null)
                    throw new InvalidOperationException("One consumer at a time.");
                DateTime utcNow = DateTime.UtcNow;
                TimeSpan minDelay = TimeSpan.FromMilliseconds(500);
                DateTime? utcNext = _cronExpression.GetNextOccurrence(utcNow + minDelay);
                if (utcNext is null)
                    throw new InvalidOperationException("Unreachable date.");
                TimeSpan delay = utcNext.Value - utcNow;
                Debug.Assert(delay > _minDelay);
                timer = _activeTimer = new(delay);
            }
            try
            {
                // Dispose the timer after the first tick.
                using (timer)
                    return await timer.WaitForNextTickAsync(cancellationToken)
                        .ConfigureAwait(false);
            }
            finally { Volatile.Write(ref _activeTimer, null); }
        }
    
        public void Dispose()
        {
            PeriodicTimer activeTimer;
            lock (_cronExpression)
            {
                if (_disposed) return;
                _disposed = true;
                activeTimer = _activeTimer;
            }
            activeTimer?.Dispose();
        }
    }
    

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

    private CronosPeriodicTimer _timer = new("0 * * * * *", CronFormat.IncludeSeconds);
    
    async Task DoSomethingPeriodicAsync()
    {
        while (await _timer.WaitForNextTickAsync()) // Dispose the _timer to stop
        {
            // DoSomething
        }
    }
    

    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.

    Caution: The CronosPeriodicTimer.WaitForNextTickAsync method calculates the interval until the next tick at the time it is invoked. In case the system clock is adjusted while a WaitForNextTickAsync operation is in-flight, the tick will not happen at the correct time according to the adjusted system time.

    The 500 milliseconds minDelay has the intention to prevent the remote possibility of the timer ticking twice by mistake. Also because the PeriodicTimer class has a minimum period of 1 millisecond.

    The TimeSpan period parameter in the PeriodicTimer's constructor has an upper limit of approximately 49 days and 17 hours. In case the _cronExpression.GetNextOccurrence call produces a DateTime further in the future than this, the CronosPeriodicTimer.WaitForNextTickAsync call will fail with an ArgumentOutOfRangeException exception.

    For an implementation that uses the Task.Delay method instead of the PeriodicTimer class, and so it can be used by .NET versions previous than 6.0, you can look at the 3rd revision of this answer.

    ¹ With the caveat that the Cronos library is currently capped to the year 2099 (version 0.7.1).