Refactor PeriodicRunner and add a test.
ADD
WalletWasabi.Tests/PeriodicRunnerTests/SerialPeriodicRunnerTests.cs
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using WalletWasabi.Bases;
+using Xunit;
+
+namespace WalletWasabi.Tests.PeriodicRunnerTests
+{
+ /// <summary>
+ /// The tests in this collection are time-sensitive, therefore this test collection is run in a special way:
+ /// Parallel-capable test collections will be run first (in parallel), followed by parallel-disabled test collections (run sequentially) like this one.
+ /// </summary>
+ /// <seealso href="https://xunit.net/docs/running-tests-in-parallel.html#parallelism-in-test-frameworks"/>
+ [Collection("Serial unit tests collection")]
+ public class SerialPeriodicRunnerTests
+ {
+ internal class TestRunner : PeriodicRunner
+ {
+ internal TestRunner(TimeSpan period, TimeSpan maxNextRoundWaitTime) : base(period)
+ {
+ MaxNextRoundWaitTime = maxNextRoundWaitTime;
+ }
+
+ public SemaphoreSlim Semaphore { get; private set; } = new SemaphoreSlim(initialCount: 0, maxCount: 1);
+
+ public int RoundCounter { get; private set; }
+ private TimeSpan MaxNextRoundWaitTime { get; }
+
+ protected override async Task ActionAsync(CancellationToken cancel)
+ {
+ // Add some delay to simulate work.
+ await Task.Delay(50, cancel).ConfigureAwait(false);
+ RoundCounter++;
+
+ Semaphore.Release();
+ }
+
+ public async Task<bool> WaitForNextRoundAsync()
+ {
+ return await Semaphore.WaitAsync(MaxNextRoundWaitTime);
+ }
+ }
+
+ [Fact]
+ public async Task PeriodicRunnerTestsAsync()
+ {
+ const double Scaler = 5.0;
+ TimeSpan leniencyThreshold = Scaler * TimeSpan.FromSeconds(0.5);
+ TimeSpan period = Scaler * TimeSpan.FromSeconds(1);
+
+ using var runner = new TestRunner(period: period, maxNextRoundWaitTime: TimeSpan.FromSeconds(Scaler * 2));
+ using CancellationTokenSource cts = new CancellationTokenSource();
+
+ var sw = new Stopwatch();
+ sw.Start();
+
+ // Round #1. This round starts immediately.
+ Task runnerTask = runner.StartAsync(cts.Token);
+
+ Assert.True(await runner.WaitForNextRoundAsync());
+ Assert.Equal(1, runner.RoundCounter);
+ Assert.InRange(sw.Elapsed, TimeSpan.Zero, 1 * runner.Period + leniencyThreshold); // Full period must elapse.
+
+ // Round #2.
+ Assert.True(await runner.WaitForNextRoundAsync());
+ Assert.Equal(2, runner.RoundCounter);
+ Assert.InRange(sw.Elapsed, 1 * runner.Period, 2 * runner.Period + leniencyThreshold); // Full period must elapse.
+
+ // Round #3.
+ Assert.True(await runner.WaitForNextRoundAsync());
+ Assert.Equal(3, runner.RoundCounter);
+ Assert.InRange(sw.Elapsed, 2 * runner.Period, 3 * runner.Period + leniencyThreshold); // Full period must elapse.
+
+ // Run immediately next round when trigger is called.
+ runner.TriggerRound();
+
+ // Repeated triggers should do nothing when not in waiting state.
+ runner.TriggerRound();
+ runner.TriggerRound();
+ runner.TriggerRound();
+ runner.TriggerRound();
+
+ // Round #4.
+ Assert.True(await runner.WaitForNextRoundAsync());
+ Assert.Equal(4, runner.RoundCounter);
+ Assert.InRange(sw.Elapsed, 2 * runner.Period, 3 * runner.Period + leniencyThreshold); // Elapsed time should not change much from the last round.
+
+ await runner.StopAsync(cts.Token);
+ await runnerTask;
+ }
+ }
+}
\ No newline at end of file

ADD
WalletWasabi.Tests/UnitTests/Bases/LastExceptionTrackerTests.cs
+using System;
+using WalletWasabi.Bases;
+using Xunit;
+
+namespace WalletWasabi.Tests.UnitTests.Bases
+{
+ public class LastExceptionTrackerTests
+ {
+ [Fact]
+ public void ProcessTest()
+ {
+ var let = new LastExceptionTracker();
+
+ // No exception was processed at this point.
+ Assert.Null(let.LastException);
+
+ // Process first exception to process.
+ {
+ ExceptionInfo prevExceptionInfo = let.Process(new ArgumentOutOfRangeException())!;
+ Assert.Null(prevExceptionInfo);
+
+ Assert.NotNull(let.LastException);
+ Assert.IsType<ArgumentOutOfRangeException>(let.LastException!.Exception);
+ Assert.Equal(1, let.LastException!.ExceptionCount);
+ }
+
+ // Same exception encountered.
+ {
+ ExceptionInfo prevExceptionInfo = let.Process(new ArgumentOutOfRangeException())!;
+ Assert.Null(prevExceptionInfo);
+
+ Assert.NotNull(let.LastException);
+ Assert.IsType<ArgumentOutOfRangeException>(let.LastException!.Exception);
+ Assert.Equal(2, let.LastException!.ExceptionCount);
+ }
+
+ // Different exception encountered.
+ {
+ ExceptionInfo prevExceptionInfo = let.Process(new NotImplementedException())!;
+ Assert.NotNull(prevExceptionInfo);
+ Assert.IsType<ArgumentOutOfRangeException>(prevExceptionInfo.Exception);
+
+ Assert.NotNull(let.LastException);
+ Assert.IsType<NotImplementedException>(let.LastException!.Exception);
+ Assert.Equal(1, let.LastException!.ExceptionCount);
+ }
+ }
+ }
+}

ADD
WalletWasabi/Bases/ExceptionInfo.cs
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace WalletWasabi.Bases
+{
+ public class ExceptionInfo
+ {
+ public ExceptionInfo(Exception exception, long exceptionCount, DateTimeOffset firstAppeared)
+ {
+ Exception = exception;
+ ExceptionCount = exceptionCount;
+ FirstAppeared = firstAppeared;
+ }
+
+ public Exception Exception { get; }
+ public long ExceptionCount { get; set; }
+ public DateTimeOffset FirstAppeared { get; }
+ }
+}

ADD
WalletWasabi/Bases/LastExceptionTracker.cs
+using System;
+
+namespace WalletWasabi.Bases
+{
+ public class LastExceptionTracker
+ {
+ public ExceptionInfo? LastException { get; set; }
+
+ /// <summary>
+ /// Process encountered exception.
+ /// </summary>
+ /// <returns>Previous exception or <c>null</c>.</returns>
+ public ExceptionInfo? Process(Exception currentException)
+ {
+ // Only log one type of exception once.
+ if (LastException is { }
+ && currentException.GetType() == LastException.Exception.GetType()
+ && currentException.Message == LastException.Exception.Message)
+ {
+ // Increment the counter.
+ LastException.ExceptionCount++;
+ return null;
+ }
+ else
+ {
+ var previousException = LastException;
+ LastException = new ExceptionInfo(currentException, exceptionCount: 1, firstAppeared: DateTimeOffset.UtcNow);
+ return previousException;
+ }
+ }
+ }
+}

MODIFY
WalletWasabi/Bases/PeriodicRunner.cs
WalletWasabi/Bases/PeriodicRunner.cs
using Microsoft.Extensions.Hosting;
using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
using System.Threading;
using System.Threading.Tasks;
using WalletWasabi.Logging;
namespace WalletWasabi.Bases
{
+ /// <summary>
+ /// <see cref="PeriodicRunner"/> is an extension of <see cref="BackgroundService"/> that is useful for tasks
+ /// that are supposed to repeat regularly.
+ /// </summary>
public abstract class PeriodicRunner : BackgroundService
{
+ private volatile TaskCompletionSource<bool>? _tcs;
+
protected PeriodicRunner(TimeSpan period)
{
- TriggeringCts = new CancellationTokenSource();
- TriggerLock = new object();
Period = period;
- ResetLastException();
+ ExceptionTracker = new LastExceptionTracker();
}
- private CancellationTokenSource TriggeringCts { get; set; }
- private object TriggerLock { get; }
public TimeSpan Period { get; }
- public Exception LastException { get; set; }
- public long LastExceptionCount { get; set; }
- public DateTimeOffset LastExceptionFirstAppeared { get; set; }
- private void ResetLastException()
- {
- LastException = null;
- LastExceptionCount = 0;
- LastExceptionFirstAppeared = DateTimeOffset.MinValue;
- }
+ private LastExceptionTracker ExceptionTracker { get; }
+ /// <summary>
+ /// Normally, <see cref="ActionAsync(CancellationToken)"/> user-action is called every time that <see cref="Period"/> elapses.
+ /// This method allows to expedite the process by interrupting the waiting process.
+ /// </summary>
+ /// <remarks>
+ /// If <see cref="ExecuteAsync(CancellationToken)"/> is not actually in waiting phase, this method call makes
+ /// sure that next waiting process will be omitted altogether.
+ /// </remarks>
public void TriggerRound()
{
- lock (TriggerLock)
- {
- TriggeringCts?.Cancel();
- }
+ // Note: All members of TaskCompletionSource<TResult> are thread-safe and may be used from multiple threads concurrently.
+ _tcs?.TrySetResult(true);
}
+ /// <summary>
+ /// Abstract method that is called every <see cref="Period"/> or sooner when <see cref="TriggerRound"/> is called.
+ /// </summary>
protected abstract Task ActionAsync(CancellationToken cancel);
/// <inheritdoc />
@@ -48,10 +48,15 @@ namespace WalletWasabi.Bases
{
while (!stoppingToken.IsCancellationRequested)
{
+ _tcs = new TaskCompletionSource<bool>();
+
try
{
+ // Do user action.
await ActionAsync(stoppingToken).ConfigureAwait(false);
- LogAndResetLastExceptionIfNotNull();
+
+ // Log previous exception if any.
+ LogLastException(ExceptionTracker.LastException);
}
catch (Exception ex) when (ex is OperationCanceledException || ex is TaskCanceledException || ex is TimeoutException)
{
@@ -59,74 +64,42 @@ namespace WalletWasabi.Bases
}
catch (Exception ex)
{
- // Only log one type of exception once.
- if (LastException is null // If the exception never came.
- || ex.GetType() != LastException.GetType() // Or the exception have different type from previous exception.
- || ex.Message != LastException.Message) // Or the exception have different message from previous exception.
- {
- // Then log and reset the last exception if another one came before.
- LogAndResetLastExceptionIfNotNull();
- // Set new exception and log it.
- LastException = ex;
- LastExceptionFirstAppeared = DateTimeOffset.UtcNow;
- LastExceptionCount = 1;
- Logger.LogError(ex);
- }
- else
- {
- // Increment the exception counter.
- LastExceptionCount++;
- }
+ // Exception encountered, process it.
+ LogLastException(ExceptionTracker.Process(ex));
+
+ Logger.LogError(ExceptionTracker.LastException!.Exception);
}
- finally
- {
+
+ // Wait for the next round.
try
{
- CancellationToken? triggeringToken = null;
- lock (TriggerLock)
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
+ var linkedTcs = _tcs; // Copy reference so it cannot change.
+
+ if (linkedTcs.Task == await Task.WhenAny(linkedTcs.Task, Task.Delay(Period, cts.Token)).ConfigureAwait(false))
{
- triggeringToken = TriggeringCts?.Token;
+ cts.Cancel(); // Ensure that the Task.Delay task is cleaned up.
}
- if (triggeringToken is { })
+ else
{
- using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, triggeringToken.Value);
- await Task.Delay(Period, linked.Token).ConfigureAwait(false);
+ linkedTcs.TrySetCanceled(); // Ensure that the tcs.Task is cleaned up.
}
}
catch (TaskCanceledException ex)
{
Logger.LogTrace(ex);
}
- finally
- {
- lock (TriggerLock)
- {
- if (TriggeringCts?.IsCancellationRequested is true)
- {
- TriggeringCts?.Dispose();
- TriggeringCts = new CancellationTokenSource();
- }
- }
- }
- }
}
}
- private void LogAndResetLastExceptionIfNotNull()
+ private void LogLastException(ExceptionInfo? info)
{
- if (LastException != null)
+ if (info != null)
{
- Logger.LogInfo($"Exception stopped coming. It came for {(DateTimeOffset.UtcNow - LastExceptionFirstAppeared).TotalSeconds} seconds, {LastExceptionCount} times: {LastException.ToTypeMessageString()}");
- ResetLastException();
- }
+ Logger.LogInfo($"Exception stopped coming. It came for " +
+ $"{(DateTimeOffset.UtcNow - info.FirstAppeared).TotalSeconds} seconds, " +
+ $"{info.ExceptionCount} times: {info.Exception.ToTypeMessageString()}");
}
-
- public override void Dispose()
- {
- TriggeringCts?.Dispose();
- TriggeringCts = null;
-
- base.Dispose();
}
}
}

Refactor PeriodicRunner and add a test.