Skip to main content

C# class to encapsulate and run cancelable action in the background.

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace DotnetCommon.Utils
{
    public class PeriodicAction : IDisposable
    {
        private const int waitHandleThresholdCount = 258;
        private readonly AutoResetEvent _updateAsap = new AutoResetEvent(false);
        private readonly AutoResetEvent _intervalChanged = new AutoResetEvent(false);
        private readonly Action<CancellationToken> _action;
        private CancellationTokenSource _ownCancellationSource;
        private CancellationTokenSource _cancellationSource;
        private Task _task;
        private TimeSpan _interval;

        /// <summary>
        /// Encapsulate and run cancelable action in the background.
        /// </summary>
        /// <param name="action">The action to trigger periodically.</param>
        /// <param name="cancellationTokenSource">A <see cref="CancellationTokenSource"/> object that will be called if the action in cancelled.</param>
        public PeriodicAction(Action<CancellationToken> action, CancellationTokenSource cancellationTokenSource = null)
        {
            _action = action;
            _ownCancellationSource = new CancellationTokenSource();
            _cancellationSource = cancellationTokenSource;
            _task = Task.Factory.StartNew(Run, _ownCancellationSource.Token);
            _interval = TimeSpan.FromMinutes(1.0);
        }

        public void Dispose()
        {
            if (_task == null)
            {
                return;
            }

            _ownCancellationSource?.Cancel();
            try
            {
                _task.Wait();
            }
            catch (AggregateException ex)
            {
                if (ex.InnerExceptions.Count <= 1 && ex.InnerException is TaskCanceledException)
                {
                    return;
                }
                throw;
            }
            finally
            {
                _task = null;

                if (_ownCancellationSource != null)
                {
                    _ownCancellationSource.Dispose();
                    _ownCancellationSource = null;
                }

                _cancellationSource = null;

                _updateAsap.Close();
                _updateAsap.Dispose();
                _intervalChanged.Close();
                _intervalChanged.Dispose();
            }
        }

        /// <summary>
        /// The <see cref="TimeSpan"/> interval in which the action will be triggered.
        /// </summary>
        public TimeSpan Interval
        {
            get
            {
                return _interval;
            }
            set
            {
                if (_interval == value)
                {
                    return;
                }

                _interval = value;
                _intervalChanged.Set();
            }
        }

        /// <summary>
        /// Whether or not the action will trigger.
        ///
        /// <c>true</c> indicates the action will trigger, <c>false</c> indicates
        /// the action will not trigger.
        /// </summary>
        public bool IsEnabled { get; set; }

        /// <summary>
        /// Whether or not to perform the action asap; without waiting
        /// for the specified <see cref="Interval"/> to elapse.
        /// </summary>
        public void PerformAsap()
        {
            _updateAsap.Set();
        }

        private void Run()
        {
            try
            {
                var waitHandles = new WaitHandle[3]
                {
                    _ownCancellationSource.Token.WaitHandle,
                    _intervalChanged,
                    _updateAsap
                };

                if (_cancellationSource != null)
                {
                    waitHandles = waitHandles.Concat(
                        new WaitHandle[1]
                        {
                            _cancellationSource.Token.WaitHandle
                        }).ToArray();
                }

                int index;
                while ((index = WaitHandle.WaitAny(waitHandles, Interval)) == waitHandleThresholdCount ||
                       waitHandles[index] == _intervalChanged || waitHandles[index] == _updateAsap)
                {
                    if ((index == waitHandleThresholdCount || waitHandles[index] != _intervalChanged) && IsEnabled)
                    {
                        _action((_cancellationSource ?? _ownCancellationSource).Token);
                    }
                }
            }
            catch
            {
                // absorb exception...
            }
        }
    }
}

// ------

using System;
using System.Diagnostics;
using System.Threading;
using DotnetCommon.Utils;
using Xunit;

namespace DotnetCommon.Tests.Utils
{
    public class PeriodicActionTests
    {
        private static int _periodicActionTriggeredCount;

        [Fact]
        public void TestPeriodicActionIsTriggeredOnInterval()
        {
            const int expectedActionTriggeredCount = 4;

            _periodicActionTriggeredCount = 0;

            using (var cancellationToken = new CancellationTokenSource())
            {
                cancellationToken.Token.Register(() => { Debug.WriteLine("[{0}] Periodic action cancelled.", DateTime.Now); });
                using (var periodicAction = new PeriodicAction(LogToConsole, cancellationToken) { Interval = TimeSpan.FromSeconds(2) })
                {
                    periodicAction.IsEnabled = true;
                    periodicAction.PerformAsap();

                    Thread.Sleep(TimeSpan.FromSeconds(8));
                }
            }

            Assert.Equal(expectedActionTriggeredCount, _periodicActionTriggeredCount);
        }

        [Fact]
        public void TestPeriodicActionDoesNotTriggeredIfDisabled()
        {
            const int expectedActionTriggeredCount = 0;

            _periodicActionTriggeredCount = 0;

            using (var cancellationToken = new CancellationTokenSource())
            {
                cancellationToken.Token.Register(() => { Debug.WriteLine("[{0}] Periodic action cancelled.", DateTime.Now); });
                using (var periodicAction = new PeriodicAction(LogToConsole, cancellationToken) { Interval = TimeSpan.FromSeconds(2) })
                {
                    periodicAction.IsEnabled = false;
                    periodicAction.PerformAsap();

                    Thread.Sleep(TimeSpan.FromSeconds(8));
                }
            }

            Assert.Equal(expectedActionTriggeredCount, _periodicActionTriggeredCount);
        }

        [Fact]
        public void TestPeriodicActionDoesNotTriggeredWhenDisabledToggled()
        {
            const int expectedActionTriggeredCount = 2;

            _periodicActionTriggeredCount = 0;

            using (var cancellationToken = new CancellationTokenSource())
            {
                cancellationToken.Token.Register(() => { Debug.WriteLine("[{0}] Periodic action cancelled.", DateTime.Now); });
                using (var periodicAction = new PeriodicAction(LogToConsole, cancellationToken) { Interval = TimeSpan.FromSeconds(2) })
                {
                    periodicAction.IsEnabled = true;
                    periodicAction.PerformAsap();

                    Thread.Sleep(TimeSpan.FromSeconds(2));
                    periodicAction.IsEnabled = false;
                    Thread.Sleep(TimeSpan.FromSeconds(2));
                    periodicAction.IsEnabled = true;
                    Thread.Sleep(TimeSpan.FromSeconds(2));
                }
            }

            Assert.Equal(expectedActionTriggeredCount, _periodicActionTriggeredCount);
        }

        [Fact]
        public void EnsureCancelledActionIsNotTriggered()
        {
            const int expectedActionTriggeredCount = 1;

            _periodicActionTriggeredCount = 0;

            using (var cancellationToken = new CancellationTokenSource())
            {
                cancellationToken.Token.Register(() => { Debug.WriteLine("[{0}] Periodic action cancelled.", DateTime.Now); });
                using (var periodicAction = new PeriodicAction(LogToConsole, cancellationToken) { Interval = TimeSpan.FromSeconds(2) })
                {
                    periodicAction.IsEnabled = true;
                    periodicAction.PerformAsap();

                    Thread.Sleep(TimeSpan.FromSeconds(2));
                    periodicAction.IsEnabled = false;
                    Thread.Sleep(TimeSpan.FromSeconds(2));
                    periodicAction.IsEnabled = true;
                    cancellationToken.Cancel();
                    Thread.Sleep(TimeSpan.FromSeconds(4));
                }
            }

            Assert.Equal(expectedActionTriggeredCount, _periodicActionTriggeredCount);
        }

        private static void LogToConsole(CancellationToken cancellationToken)
        {
            if (!cancellationToken.IsCancellationRequested)
            {
                Interlocked.Increment(ref _periodicActionTriggeredCount);

                Debug.WriteLine(
                    "[{0}] Periodic action triggered {1} time{2}.",
                    DateTime.Now,
                    _periodicActionTriggeredCount,
                    _periodicActionTriggeredCount > 1 ? "s" : "");
            }
            else
            {
                cancellationToken.ThrowIfCancellationRequested();
            }
        }
    }
}