From 1f31e0556d7cfb7fc99c6c8650173d0cc4ba0717 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Mon, 8 Oct 2018 17:58:45 -0700 Subject: [PATCH] Add IHealthCheckPublisher for push-based checks (#498) IHealthCheckPublisher allows you to configure and run health checks regularly inside an application, and push the notifications elsewhere. All publishers are part of a single queue with a configurable period and timeout. --- build/dependencies.props | 2 + samples/WelcomePageSample/web.config | 9 - .../HealthReport.cs | 0 .../HealthReportEntry.cs | 0 .../IHealthCheckPublisher.cs | 39 ++ .../DefaultHealthCheckService.cs | 20 +- .../HealthCheckServiceCollectionExtensions.cs | 4 +- .../HealthCheckPublisherHostedService.cs | 262 +++++++++ .../HealthCheckPublisherOptions.cs | 84 +++ ...Extensions.Diagnostics.HealthChecks.csproj | 4 +- ...Core.Diagnostics.HealthChecks.Tests.csproj | 3 +- .../ServiceCollectionExtensionsTest.cs | 12 +- .../HealthCheckPublisherHostedServiceTest.cs | 528 ++++++++++++++++++ 13 files changed, 943 insertions(+), 24 deletions(-) delete mode 100644 samples/WelcomePageSample/web.config rename src/{Microsoft.Extensions.Diagnostics.HealthChecks => Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions}/HealthReport.cs (100%) rename src/{Microsoft.Extensions.Diagnostics.HealthChecks => Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions}/HealthReportEntry.cs (100%) create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/IHealthCheckPublisher.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherHostedService.cs create mode 100644 src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherOptions.cs create mode 100644 test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckPublisherHostedServiceTest.cs diff --git a/build/dependencies.props b/build/dependencies.props index 93387f8f..d382aa61 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -27,6 +27,8 @@ 2.2.0-preview3-35359 2.2.0-preview3-35359 2.2.0-preview3-35359 + 2.2.0-preview3-35359 + 2.2.0-preview3-35359 2.2.0-preview3-35359 2.2.0-preview3-35359 2.2.0-preview3-35359 diff --git a/samples/WelcomePageSample/web.config b/samples/WelcomePageSample/web.config deleted file mode 100644 index f7ac6793..00000000 --- a/samples/WelcomePageSample/web.config +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthReport.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthReport.cs similarity index 100% rename from src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthReport.cs rename to src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthReport.cs diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthReportEntry.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthReportEntry.cs similarity index 100% rename from src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthReportEntry.cs rename to src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/HealthReportEntry.cs diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/IHealthCheckPublisher.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/IHealthCheckPublisher.cs new file mode 100644 index 00000000..f1809c4b --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions/IHealthCheckPublisher.cs @@ -0,0 +1,39 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Represents a publisher of information. + /// + /// + /// + /// The default health checks implementation provided an IHostedService implementation that can + /// be used to execute health checks at regular intervals and provide the resulting + /// data to all registered instances. + /// + /// + /// To provide an implementation, register an instance or type as a singleton + /// service in the dependency injection container. + /// + /// + /// instances are provided with a after executing + /// health checks in a background thread. The use of depend on hosting in + /// an application using IWebHost or generic host (IHost). Execution of + /// instance is not related to execution of health checks via a middleware. + /// + /// + public interface IHealthCheckPublisher + { + /// + /// Publishes the provided . + /// + /// The . The result of executing a set of health checks. + /// The . + /// A which will complete when publishing is complete. + Task PublishAsync(HealthReport report, CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/DefaultHealthCheckService.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/DefaultHealthCheckService.cs index 0e907d78..aa12d974 100644 --- a/src/Microsoft.Extensions.Diagnostics.HealthChecks/DefaultHealthCheckService.cs +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/DefaultHealthCheckService.cs @@ -126,19 +126,19 @@ private static void ValidateRegistrations(IEnumerable r } } - private static class Log + internal static class EventIds { - public static class EventIds - { - public static readonly EventId HealthCheckProcessingBegin = new EventId(100, "HealthCheckProcessingBegin"); - public static readonly EventId HealthCheckProcessingEnd = new EventId(101, "HealthCheckProcessingEnd"); + public static readonly EventId HealthCheckProcessingBegin = new EventId(100, "HealthCheckProcessingBegin"); + public static readonly EventId HealthCheckProcessingEnd = new EventId(101, "HealthCheckProcessingEnd"); - public static readonly EventId HealthCheckBegin = new EventId(102, "HealthCheckBegin"); - public static readonly EventId HealthCheckEnd = new EventId(103, "HealthCheckEnd"); - public static readonly EventId HealthCheckError = new EventId(104, "HealthCheckError"); - public static readonly EventId HealthCheckData = new EventId(105, "HealthCheckData"); - } + public static readonly EventId HealthCheckBegin = new EventId(102, "HealthCheckBegin"); + public static readonly EventId HealthCheckEnd = new EventId(103, "HealthCheckEnd"); + public static readonly EventId HealthCheckError = new EventId(104, "HealthCheckError"); + public static readonly EventId HealthCheckData = new EventId(105, "HealthCheckData"); + } + private static class Log + { private static readonly Action _healthCheckProcessingBegin = LoggerMessage.Define( LogLevel.Debug, EventIds.HealthCheckProcessingBegin, diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/DependencyInjection/HealthCheckServiceCollectionExtensions.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/DependencyInjection/HealthCheckServiceCollectionExtensions.cs index 76b0dc45..d6df03d2 100644 --- a/src/Microsoft.Extensions.Diagnostics.HealthChecks/DependencyInjection/HealthCheckServiceCollectionExtensions.cs +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/DependencyInjection/HealthCheckServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; namespace Microsoft.Extensions.DependencyInjection { @@ -24,7 +25,8 @@ public static class HealthCheckServiceCollectionExtensions /// An instance of from which health checks can be registered. public static IHealthChecksBuilder AddHealthChecks(this IServiceCollection services) { - services.TryAdd(ServiceDescriptor.Singleton()); + services.TryAddSingleton(); + services.TryAddSingleton(); return new HealthChecksBuilder(services); } } diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherHostedService.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherHostedService.cs new file mode 100644 index 00000000..d124ffa2 --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherHostedService.cs @@ -0,0 +1,262 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + internal sealed class HealthCheckPublisherHostedService : IHostedService + { + private readonly HealthCheckService _healthCheckService; + private readonly IOptions _options; + private readonly ILogger _logger; + private readonly IHealthCheckPublisher[] _publishers; + + private CancellationTokenSource _stopping; + private Timer _timer; + + public HealthCheckPublisherHostedService( + HealthCheckService healthCheckService, + IOptions options, + ILogger logger, + IEnumerable publishers) + { + if (healthCheckService == null) + { + throw new ArgumentNullException(nameof(healthCheckService)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + if (logger == null) + { + throw new ArgumentNullException(nameof(logger)); + } + + if (publishers == null) + { + throw new ArgumentNullException(nameof(publishers)); + } + + _healthCheckService = healthCheckService; + _options = options; + _logger = logger; + _publishers = publishers.ToArray(); + + _stopping = new CancellationTokenSource(); + } + + internal bool IsStopping => _stopping.IsCancellationRequested; + + internal bool IsTimerRunning => _timer != null; + + public Task StartAsync(CancellationToken cancellationToken = default) + { + if (_publishers.Length == 0) + { + return Task.CompletedTask; + } + + // IMPORTANT - make sure this is the last thing that happens in this method. The timer can + // fire before other code runs. + _timer = NonCapturingTimer.Create(Timer_Tick, null, dueTime: _options.Value.Delay, period: _options.Value.Period); + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken = default) + { + try + { + _stopping.Cancel(); + } + catch + { + // Ignore exceptions thrown as a result of a cancellation. + } + + if (_publishers.Length == 0) + { + return Task.CompletedTask; + } + + _timer?.Dispose(); + _timer = null; + + + return Task.CompletedTask; + } + + // Yes, async void. We need to be async. We need to be void. We handle the exceptions in RunAsync + private async void Timer_Tick(object state) + { + await RunAsync(); + } + + // Internal for testing + internal async Task RunAsync() + { + var duration = ValueStopwatch.StartNew(); + Logger.HealthCheckPublisherProcessingBegin(_logger); + + CancellationTokenSource cancellation = null; + try + { + var timeout = _options.Value.Timeout; + + cancellation = CancellationTokenSource.CreateLinkedTokenSource(_stopping.Token); + cancellation.CancelAfter(timeout); + + await RunAsyncCore(cancellation.Token); + + Logger.HealthCheckPublisherProcessingEnd(_logger, duration.GetElapsedTime()); + } + catch (OperationCanceledException) when (IsStopping) + { + // This is a cancellation - if the app is shutting down we want to ignore it. Otherwise, it's + // a timeout and we want to log it. + } + catch (Exception ex) + { + // This is an error, publishing failed. + Logger.HealthCheckPublisherProcessingEnd(_logger, duration.GetElapsedTime(), ex); + } + finally + { + cancellation.Dispose(); + } + } + + private async Task RunAsyncCore(CancellationToken cancellationToken) + { + // Forcibly yield - we want to unblock the timer thread. + await Task.Yield(); + + // The health checks service does it's own logging, and doesn't throw exceptions. + var report = await _healthCheckService.CheckHealthAsync(_options.Value.Predicate, cancellationToken); + + var publishers = _publishers; + var tasks = new Task[publishers.Length]; + for (var i = 0; i < publishers.Length; i++) + { + tasks[i] = RunPublisherAsync(publishers[i], report, cancellationToken); + } + + await Task.WhenAll(tasks); + } + + private async Task RunPublisherAsync(IHealthCheckPublisher publisher, HealthReport report, CancellationToken cancellationToken) + { + var duration = ValueStopwatch.StartNew(); + + try + { + Logger.HealthCheckPublisherBegin(_logger, publisher); + + await publisher.PublishAsync(report, cancellationToken); + Logger.HealthCheckPublisherEnd(_logger, publisher, duration.GetElapsedTime()); + } + catch (OperationCanceledException) when (IsStopping) + { + // This is a cancellation - if the app is shutting down we want to ignore it. Otherwise, it's + // a timeout and we want to log it. + } + catch (OperationCanceledException ocex) + { + Logger.HealthCheckPublisherTimeout(_logger, publisher, duration.GetElapsedTime()); + throw ocex; + } + catch (Exception ex) + { + Logger.HealthCheckPublisherError(_logger, publisher, duration.GetElapsedTime(), ex); + throw ex; + } + } + + internal static class EventIds + { + public static readonly EventId HealthCheckPublisherProcessingBegin = new EventId(100, "HealthCheckPublisherProcessingBegin"); + public static readonly EventId HealthCheckPublisherProcessingEnd = new EventId(101, "HealthCheckPublisherProcessingEnd"); + public static readonly EventId HealthCheckPublisherProcessingError = new EventId(101, "HealthCheckPublisherProcessingError"); + + public static readonly EventId HealthCheckPublisherBegin = new EventId(102, "HealthCheckPublisherBegin"); + public static readonly EventId HealthCheckPublisherEnd = new EventId(103, "HealthCheckPublisherEnd"); + public static readonly EventId HealthCheckPublisherError = new EventId(104, "HealthCheckPublisherError"); + public static readonly EventId HealthCheckPublisherTimeout = new EventId(104, "HealthCheckPublisherTimeout"); + } + + private static class Logger + { + private static readonly Action _healthCheckPublisherProcessingBegin = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckPublisherProcessingBegin, + "Running health check publishers"); + + private static readonly Action _healthCheckPublisherProcessingEnd = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckPublisherProcessingEnd, + "Health check publisher processing completed after {ElapsedMilliseconds}ms"); + + private static readonly Action _healthCheckPublisherBegin = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckPublisherBegin, + "Running health check publisher '{HealthCheckPublisher}'"); + + private static readonly Action _healthCheckPublisherEnd = LoggerMessage.Define( + LogLevel.Debug, + EventIds.HealthCheckPublisherEnd, + "Health check '{HealthCheckPublisher}' completed after {ElapsedMilliseconds}ms"); + + private static readonly Action _healthCheckPublisherError = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckPublisherError, + "Health check {HealthCheckPublisher} threw an unhandled exception after {ElapsedMilliseconds}ms"); + + private static readonly Action _healthCheckPublisherTimeout = LoggerMessage.Define( + LogLevel.Error, + EventIds.HealthCheckPublisherTimeout, + "Health check {HealthCheckPublisher} was canceled after {ElapsedMilliseconds}ms"); + + public static void HealthCheckPublisherProcessingBegin(ILogger logger) + { + _healthCheckPublisherProcessingBegin(logger, null); + } + + public static void HealthCheckPublisherProcessingEnd(ILogger logger, TimeSpan duration, Exception exception = null) + { + _healthCheckPublisherProcessingEnd(logger, duration.TotalMilliseconds, exception); + } + + public static void HealthCheckPublisherBegin(ILogger logger, IHealthCheckPublisher publisher) + { + _healthCheckPublisherBegin(logger, publisher, null); + } + + public static void HealthCheckPublisherEnd(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration) + { + _healthCheckPublisherEnd(logger, publisher, duration.TotalMilliseconds, null); + } + + public static void HealthCheckPublisherError(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration, Exception exception) + { + _healthCheckPublisherError(logger, publisher, duration.TotalMilliseconds, exception); + } + + public static void HealthCheckPublisherTimeout(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration) + { + _healthCheckPublisherTimeout(logger, publisher, duration.TotalMilliseconds, null); + } + } + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherOptions.cs b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherOptions.cs new file mode 100644 index 00000000..1313718a --- /dev/null +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/HealthCheckPublisherOptions.cs @@ -0,0 +1,84 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + /// + /// Options for the default service that executes instances. + /// + public sealed class HealthCheckPublisherOptions + { + private TimeSpan _delay; + private TimeSpan _period; + + public HealthCheckPublisherOptions() + { + _delay = TimeSpan.FromSeconds(5); + _period = TimeSpan.FromSeconds(30); + } + + /// + /// Gets or sets the initial delay applied after the application starts before executing + /// instances. The delay is applied once at startup, and does + /// not apply to subsequent iterations. The default value is 5 seconds. + /// + public TimeSpan Delay + { + get => _delay; + set + { + if (value == System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentException($"The {nameof(Delay)} must not be infinite.", nameof(value)); + } + + _delay = value; + } + } + + /// + /// Gets or sets the period of execution. The default value is + /// 30 seconds. + /// + /// + /// The cannot be set to a value lower than 1 second. + /// + public TimeSpan Period + { + get => _period; + set + { + if (value < TimeSpan.FromSeconds(1)) + { + throw new ArgumentException($"The {nameof(Period)} must be greater than or equal to one second.", nameof(value)); + } + + if (value == System.Threading.Timeout.InfiniteTimeSpan) + { + throw new ArgumentException($"The {nameof(Period)} must not be infinite.", nameof(value)); + } + + _delay = value; + } + } + + /// + /// Gets or sets a predicate that is used to filter the set of health checks executed. + /// + /// + /// If is null, the health check publisher service will run all + /// registered health checks - this is the default behavior. To run a subset of health checks, + /// provide a function that filters the set of checks. The predicate will be evaluated each period. + /// + public Func Predicate { get; set; } + + /// + /// Gets or sets the timeout for executing the health checks an all + /// instances. Use to execute with no timeout. + /// The default value is 30 seconds. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + } +} diff --git a/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj b/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj index 72522602..52380390 100644 --- a/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj +++ b/src/Microsoft.Extensions.Diagnostics.HealthChecks/Microsoft.Extensions.Diagnostics.HealthChecks.csproj @@ -12,8 +12,8 @@ Microsoft.Extensions.Diagnostics.HealthChecks.IHealthChecksBuilder diagnostics;healthchecks - - + + diff --git a/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj index 8ac9320a..8f7cd5e9 100644 --- a/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj +++ b/test/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests/Microsoft.AspNetCore.Diagnostics.HealthChecks.Tests.csproj @@ -1,4 +1,4 @@ - + $(StandardTestTfms) @@ -9,6 +9,7 @@ + diff --git a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/DependencyInjection/ServiceCollectionExtensionsTest.cs b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/DependencyInjection/ServiceCollectionExtensionsTest.cs index 34b92b7b..694a9762 100644 --- a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/DependencyInjection/ServiceCollectionExtensionsTest.cs +++ b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/DependencyInjection/ServiceCollectionExtensionsTest.cs @@ -1,7 +1,9 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Linq; using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; using Xunit; namespace Microsoft.Extensions.DependencyInjection @@ -19,7 +21,7 @@ public void AddHealthChecks_RegistersSingletonHealthCheckServiceIdempotently() services.AddHealthChecks(); // Assert - Assert.Collection(services, + Assert.Collection(services.OrderBy(s => s.ServiceType.FullName), actual => { Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime); @@ -27,6 +29,14 @@ public void AddHealthChecks_RegistersSingletonHealthCheckServiceIdempotently() Assert.Equal(typeof(DefaultHealthCheckService), actual.ImplementationType); Assert.Null(actual.ImplementationInstance); Assert.Null(actual.ImplementationFactory); + }, + actual => + { + Assert.Equal(ServiceLifetime.Singleton, actual.Lifetime); + Assert.Equal(typeof(IHostedService), actual.ServiceType); + Assert.Equal(typeof(HealthCheckPublisherHostedService), actual.ImplementationType); + Assert.Null(actual.ImplementationInstance); + Assert.Null(actual.ImplementationFactory); }); } } diff --git a/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckPublisherHostedServiceTest.cs b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckPublisherHostedServiceTest.cs new file mode 100644 index 00000000..49d57916 --- /dev/null +++ b/test/Microsoft.Extensions.Diagnostics.HealthChecks.Tests/HealthCheckPublisherHostedServiceTest.cs @@ -0,0 +1,528 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Testing; +using Xunit; + +namespace Microsoft.Extensions.Diagnostics.HealthChecks +{ + public class HealthCheckPublisherHostedServiceTest + { + [Fact] + public async Task StartAsync_WithoutPublishers_DoesNotStartTimer() + { + // Arrange + var publishers = new IHealthCheckPublisher[] + { + }; + + var service = CreateService(publishers); + + try + { + // Act + await service.StartAsync(); + + // Assert + Assert.False(service.IsTimerRunning); + Assert.False(service.IsStopping); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task StartAsync_WithPublishers_StartsTimer() + { + // Arrange + var publishers = new IHealthCheckPublisher[] + { + new TestPublisher(), + }; + + var service = CreateService(publishers); + + try + { + // Act + await service.StartAsync(); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task StartAsync_WithPublishers_StartsTimer_RunsPublishers() + { + // Arrange + var unblock0 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock0.Task, }, + new TestPublisher() { Wait = unblock1.Task, }, + new TestPublisher() { Wait = unblock2.Task, }, + }; + + var service = CreateService(publishers, configure: (options) => + { + options.Delay = TimeSpan.FromMilliseconds(0); + }); + + try + { + // Act + await service.StartAsync(); + + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + await publishers[1].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + await publishers[2].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + + unblock0.SetResult(null); + unblock1.SetResult(null); + unblock2.SetResult(null); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task StopAsync_CancelsExecution() + { + // Arrange + var unblock = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock.Task, } + }; + + var service = CreateService(publishers); + + try + { + await service.StartAsync(); + + // Start execution + var running = service.RunAsync(); + + // Wait for the publisher to see the cancellation token + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + Assert.Single(publishers[0].Entries); + + // Act + await service.StopAsync(); // Trigger cancellation + + // Assert + await AssertCancelledAsync(publishers[0].Entries[0].cancellationToken); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + + unblock.SetResult(null); + + await running.TimeoutAfter(TimeSpan.FromSeconds(10)); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task RunAsync_WaitsForCompletion_Single() + { + // Arrange + var sink = new TestSink(); + + var unblock = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock.Task, }, + }; + + var service = CreateService(publishers, sink: sink); + + try + { + await service.StartAsync(); + + // Act + var running = service.RunAsync(); + + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + + unblock.SetResult(null); + + await running.TimeoutAfter(TimeSpan.FromSeconds(10)); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + + for (var i = 0; i < publishers.Length; i++) + { + var report = Assert.Single(publishers[i].Entries).report; + Assert.Equal(new[] { "one", "two", }, report.Entries.Keys.OrderBy(k => k)); + } + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + + Assert.Collection( + sink.Writes, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingEnd, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherBegin, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherEnd, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingEnd, entry.EventId); }); + } + + // Not testing logs here to avoid differences in logging order + [Fact] + public async Task RunAsync_WaitsForCompletion_Multiple() + { + // Arrange + var unblock0 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock1 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var unblock2 = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock0.Task, }, + new TestPublisher() { Wait = unblock1.Task, }, + new TestPublisher() { Wait = unblock2.Task, }, + }; + + var service = CreateService(publishers); + + try + { + await service.StartAsync(); + + // Act + var running = service.RunAsync(); + + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + await publishers[1].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + await publishers[2].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + + unblock0.SetResult(null); + unblock1.SetResult(null); + unblock2.SetResult(null); + + await running.TimeoutAfter(TimeSpan.FromSeconds(10)); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + + for (var i = 0; i < publishers.Length; i++) + { + var report = Assert.Single(publishers[i].Entries).report; + Assert.Equal(new[] { "one", "two", }, report.Entries.Keys.OrderBy(k => k)); + } + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task RunAsync_PublishersCanTimeout() + { + // Arrange + var sink = new TestSink(); + var unblock = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + var publishers = new TestPublisher[] + { + new TestPublisher() { Wait = unblock.Task, }, + }; + + var service = CreateService(publishers, sink: sink, configure: (options) => + { + options.Timeout = TimeSpan.FromMilliseconds(50); + }); + + try + { + await service.StartAsync(); + + // Act + var running = service.RunAsync(); + + await publishers[0].Started.TimeoutAfter(TimeSpan.FromSeconds(10)); + + await AssertCancelledAsync(publishers[0].Entries[0].cancellationToken); + + unblock.SetResult(null); + + await running.TimeoutAfter(TimeSpan.FromSeconds(10)); + + // Assert + Assert.True(service.IsTimerRunning); + Assert.False(service.IsStopping); + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + + Assert.Collection( + sink.Writes, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingEnd, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherBegin, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherTimeout, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingEnd, entry.EventId); }); + } + + [Fact] + public async Task RunAsync_CanFilterHealthChecks() + { + // Arrange + var publishers = new TestPublisher[] + { + new TestPublisher(), + new TestPublisher(), + }; + + var service = CreateService(publishers, configure: (options) => + { + options.Predicate = (r) => r.Name == "one"; + }); + + try + { + await service.StartAsync(); + + // Act + await service.RunAsync().TimeoutAfter(TimeSpan.FromSeconds(10)); + + // Assert + for (var i = 0; i < publishers.Length; i++) + { + var report = Assert.Single(publishers[i].Entries).report; + Assert.Equal(new[] { "one", }, report.Entries.Keys.OrderBy(k => k)); + } + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + [Fact] + public async Task RunAsync_HandlesExceptions() + { + // Arrange + var sink = new TestSink(); + var publishers = new TestPublisher[] + { + new TestPublisher() { Exception = new InvalidTimeZoneException(), }, + }; + + var service = CreateService(publishers, sink: sink); + + try + { + await service.StartAsync(); + + // Act + await service.RunAsync().TimeoutAfter(TimeSpan.FromSeconds(10)); + + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + + Assert.Collection( + sink.Writes, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckBegin, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckEnd, entry.EventId); }, + entry => { Assert.Equal(DefaultHealthCheckService.EventIds.HealthCheckProcessingEnd, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherBegin, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherError, entry.EventId); }, + entry => { Assert.Equal(HealthCheckPublisherHostedService.EventIds.HealthCheckPublisherProcessingEnd, entry.EventId); }); + } + + // Not testing logging here to avoid flaky ordering issues + [Fact] + public async Task RunAsync_HandlesExceptions_Multiple() + { + // Arrange + var sink = new TestSink(); + var publishers = new TestPublisher[] + { + new TestPublisher() { Exception = new InvalidTimeZoneException(), }, + new TestPublisher(), + new TestPublisher() { Exception = new InvalidTimeZoneException(), }, + }; + + var service = CreateService(publishers, sink: sink); + + try + { + await service.StartAsync(); + + // Act + await service.RunAsync().TimeoutAfter(TimeSpan.FromSeconds(10)); + + } + finally + { + await service.StopAsync(); + Assert.False(service.IsTimerRunning); + Assert.True(service.IsStopping); + } + } + + private HealthCheckPublisherHostedService CreateService( + IHealthCheckPublisher[] publishers, + Action configure = null, + TestSink sink = null) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddOptions(); + serviceCollection.AddLogging(); + serviceCollection.AddHealthChecks() + .AddCheck("one", () => { return HealthCheckResult.Passed(); }) + .AddCheck("two", () => { return HealthCheckResult.Passed(); }); + + // Choosing big values for tests to make sure that we're not dependent on the defaults. + // All of the tests that rely on the timer will set their own values for speed. + serviceCollection.Configure(options => + { + options.Delay = TimeSpan.FromMinutes(5); + options.Period = TimeSpan.FromMinutes(5); + options.Timeout = TimeSpan.FromMinutes(5); + }); + + if (publishers != null) + { + for (var i = 0; i < publishers.Length; i++) + { + serviceCollection.AddSingleton(publishers[i]); + } + } + + if (configure != null) + { + serviceCollection.Configure(configure); + } + + if (sink != null) + { + serviceCollection.AddSingleton(new TestLoggerFactory(sink, enabled: true)); + } + + var services = serviceCollection.BuildServiceProvider(); + return services.GetServices().OfType< HealthCheckPublisherHostedService>().Single(); + } + + private static async Task AssertCancelledAsync(CancellationToken cancellationToken) + { + await Assert.ThrowsAsync(() => Task.Delay(TimeSpan.FromSeconds(10), cancellationToken)); + } + + private class TestPublisher : IHealthCheckPublisher + { + private TaskCompletionSource _started; + + public TestPublisher() + { + _started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public List<(HealthReport report, CancellationToken cancellationToken)> Entries { get; } = new List<(HealthReport report, CancellationToken cancellationToken)>(); + + public Exception Exception { get; set; } + + public Task Started => _started.Task; + + public Task Wait { get; set; } + + public async Task PublishAsync(HealthReport report, CancellationToken cancellationToken) + { + Entries.Add((report, cancellationToken)); + + // Signal that we've started + _started.SetResult(null); + + if (Wait != null) + { + await Wait; + } + + if (Exception != null) + { + throw Exception; + } + + cancellationToken.ThrowIfCancellationRequested(); + } + } + } +}