File: HealthCheckPublisherHostedService.cs
Web Access
Project: src\src\HealthChecks\HealthChecks\src\Microsoft.Extensions.Diagnostics.HealthChecks.csproj (Microsoft.Extensions.Diagnostics.HealthChecks)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Shared;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
 
namespace Microsoft.Extensions.Diagnostics.HealthChecks;
 
internal sealed partial class HealthCheckPublisherHostedService : IHostedService
{
    private readonly HealthCheckService _healthCheckService;
    private readonly IOptions<HealthCheckServiceOptions> _healthCheckServiceOptions;
    private readonly IOptions<HealthCheckPublisherOptions> _healthCheckPublisherOptions;
    private readonly ILogger _logger;
    private readonly IHealthCheckPublisher[] _publishers;
    private List<Timer>? _timers;
 
    private readonly CancellationTokenSource _stopping;
    private CancellationTokenSource? _runTokenSource;
 
    public HealthCheckPublisherHostedService(
        HealthCheckService healthCheckService,
        IOptions<HealthCheckServiceOptions> healthCheckServiceOptions,
        IOptions<HealthCheckPublisherOptions> healthCheckPublisherOptions,
        ILogger<HealthCheckPublisherHostedService> logger,
        IEnumerable<IHealthCheckPublisher> publishers)
    {
        ArgumentNullThrowHelper.ThrowIfNull(healthCheckService);
        ArgumentNullThrowHelper.ThrowIfNull(healthCheckServiceOptions);
        ArgumentNullThrowHelper.ThrowIfNull(healthCheckPublisherOptions);
        ArgumentNullThrowHelper.ThrowIfNull(logger);
        ArgumentNullThrowHelper.ThrowIfNull(publishers);
 
        _healthCheckService = healthCheckService;
        _healthCheckServiceOptions = healthCheckServiceOptions;
        _healthCheckPublisherOptions = healthCheckPublisherOptions;
        _logger = logger;
        _publishers = publishers.ToArray();
 
        _stopping = new CancellationTokenSource();
    }
 
    private (TimeSpan Delay, TimeSpan Period) GetTimerOptions(HealthCheckRegistration registration)
    {
        return (registration?.Delay ?? _healthCheckPublisherOptions.Value.Delay, registration?.Period ?? _healthCheckPublisherOptions.Value.Period);
    }
 
    internal bool IsStopping => _stopping.IsCancellationRequested;
 
    internal bool IsTimerRunning => _timers != 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 timers can
        // fire before other code runs.
        _timers = CreateTimers();
 
        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;
        }
 
        if (_timers != null)
        {
            foreach (var timer in _timers)
            {
                timer.Dispose();
            }
 
            _timers = null;
        }
 
        return Task.CompletedTask;
    }
 
    private List<Timer> CreateTimers()
    {
        var delayPeriodGroups = new HashSet<(TimeSpan Delay, TimeSpan Period)>();
        foreach (var hc in _healthCheckServiceOptions.Value.Registrations)
        {
            var timerOptions = GetTimerOptions(hc);
            delayPeriodGroups.Add(timerOptions);
        }
 
        var timers = new List<Timer>(delayPeriodGroups.Count);
        foreach (var group in delayPeriodGroups)
        {
            var timer = CreateTimer(group);
            timers.Add(timer);
        }
 
        return timers;
    }
 
    private Timer CreateTimer((TimeSpan Delay, TimeSpan Period) timerOptions)
    {
        return
            NonCapturingTimer.Create(
            async (state) =>
            {
                await RunAsync(timerOptions).ConfigureAwait(false);
            },
            null,
            dueTime: timerOptions.Delay,
            period: timerOptions.Period);
    }
 
    // Internal for testing
    internal void CancelToken()
    {
        _runTokenSource!.Cancel();
    }
 
    // Internal for testing
    internal async Task RunAsync((TimeSpan Delay, TimeSpan Period) timerOptions)
    {
        var duration = ValueStopwatch.StartNew();
        Logger.HealthCheckPublisherProcessingBegin(_logger);
 
        CancellationTokenSource? cancellation = null;
        try
        {
            var timeout = _healthCheckPublisherOptions.Value.Timeout;
 
            cancellation = CancellationTokenSource.CreateLinkedTokenSource(_stopping.Token);
            _runTokenSource = cancellation;
            cancellation.CancelAfter(timeout);
 
            await RunAsyncCore(timerOptions, cancellation.Token).ConfigureAwait(false);
 
            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((TimeSpan Delay, TimeSpan Period) timerOptions, CancellationToken cancellationToken)
    {
        // Forcibly yield - we want to unblock the timer thread.
        await Task.Yield();
 
        // Concatenate predicates - we only run HCs at the set delay and period
        var withOptionsPredicate = (HealthCheckRegistration r) =>
        {
            // First check whether the current timer options correspond to the current registration,
            // and then check the user-defined predicate if any.
            return (GetTimerOptions(r) == timerOptions) && (_healthCheckPublisherOptions?.Value.Predicate ?? (_ => true))(r);
        };
 
        // The health checks service does it's own logging, and doesn't throw exceptions.
        var report = await _healthCheckService.CheckHealthAsync(withOptionsPredicate, cancellationToken).ConfigureAwait(false);
 
        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).ConfigureAwait(false);
    }
 
    private async Task RunPublisherAsync(IHealthCheckPublisher publisher, HealthReport report, CancellationToken cancellationToken)
    {
        var duration = ValueStopwatch.StartNew();
 
        try
        {
            Logger.HealthCheckPublisherBegin(_logger, publisher);
 
            await publisher.PublishAsync(report, cancellationToken).ConfigureAwait(false);
            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)
        {
            Logger.HealthCheckPublisherTimeout(_logger, publisher, duration.GetElapsedTime());
            throw;
        }
        catch (Exception ex)
        {
            Logger.HealthCheckPublisherError(_logger, publisher, duration.GetElapsedTime(), ex);
            throw;
        }
    }
 
    internal static class EventIds
    {
        public const int HealthCheckPublisherProcessingBeginId = 100;
        public const int HealthCheckPublisherProcessingEndId = 101;
        public const int HealthCheckPublisherBeginId = 102;
        public const int HealthCheckPublisherEndId = 103;
        public const int HealthCheckPublisherErrorId = 104;
        public const int HealthCheckPublisherTimeoutId = 104;
 
        // Hard code the event names to avoid breaking changes. Even if the methods are renamed, these hard-coded names shouldn't change.
        public const string HealthCheckPublisherProcessingBeginName = "HealthCheckPublisherProcessingBegin";
        public const string HealthCheckPublisherProcessingEndName = "HealthCheckPublisherProcessingEnd";
        public const string HealthCheckPublisherBeginName = "HealthCheckPublisherBegin";
        public const string HealthCheckPublisherEndName = "HealthCheckPublisherEnd";
        public const string HealthCheckPublisherErrorName = "HealthCheckPublisherError";
        public const string HealthCheckPublisherTimeoutName = "HealthCheckPublisherTimeout";
    }
 
    private static partial class Logger
    {
        [LoggerMessage(EventIds.HealthCheckPublisherProcessingBeginId, LogLevel.Debug, "Running health check publishers", EventName = EventIds.HealthCheckPublisherProcessingBeginName)]
        public static partial void HealthCheckPublisherProcessingBegin(ILogger logger);
 
        public static void HealthCheckPublisherProcessingEnd(ILogger logger, TimeSpan duration, Exception? exception = null) =>
            HealthCheckPublisherProcessingEnd(logger, duration.TotalMilliseconds, exception);
 
        [LoggerMessage(EventIds.HealthCheckPublisherProcessingEndId, LogLevel.Debug, "Health check publisher processing completed after {ElapsedMilliseconds}ms", EventName = EventIds.HealthCheckPublisherProcessingEndName)]
        private static partial void HealthCheckPublisherProcessingEnd(ILogger logger, double ElapsedMilliseconds, Exception? exception = null);
 
        [LoggerMessage(EventIds.HealthCheckPublisherBeginId, LogLevel.Debug, "Running health check publisher '{HealthCheckPublisher}'", EventName = EventIds.HealthCheckPublisherBeginName)]
        public static partial void HealthCheckPublisherBegin(ILogger logger, IHealthCheckPublisher HealthCheckPublisher);
 
        public static void HealthCheckPublisherEnd(ILogger logger, IHealthCheckPublisher HealthCheckPublisher, TimeSpan duration) =>
            HealthCheckPublisherEnd(logger, HealthCheckPublisher, duration.TotalMilliseconds);
 
        [LoggerMessage(EventIds.HealthCheckPublisherEndId, LogLevel.Debug, "Health check '{HealthCheckPublisher}' completed after {ElapsedMilliseconds}ms", EventName = EventIds.HealthCheckPublisherEndName)]
        private static partial void HealthCheckPublisherEnd(ILogger logger, IHealthCheckPublisher HealthCheckPublisher, double ElapsedMilliseconds);
 
        public static void HealthCheckPublisherError(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration, Exception exception) =>
            HealthCheckPublisherError(logger, publisher, duration.TotalMilliseconds, exception);
 
#pragma warning disable SYSLIB1006
        [LoggerMessage(EventIds.HealthCheckPublisherErrorId, LogLevel.Error, "Health check {HealthCheckPublisher} threw an unhandled exception after {ElapsedMilliseconds}ms", EventName = EventIds.HealthCheckPublisherErrorName)]
        private static partial void HealthCheckPublisherError(ILogger logger, IHealthCheckPublisher HealthCheckPublisher, double ElapsedMilliseconds, Exception exception);
 
        public static void HealthCheckPublisherTimeout(ILogger logger, IHealthCheckPublisher publisher, TimeSpan duration) =>
            HealthCheckPublisherTimeout(logger, publisher, duration.TotalMilliseconds);
 
        [LoggerMessage(EventIds.HealthCheckPublisherTimeoutId, LogLevel.Error, "Health check {HealthCheckPublisher} was canceled after {ElapsedMilliseconds}ms", EventName = EventIds.HealthCheckPublisherTimeoutName)]
        private static partial void HealthCheckPublisherTimeout(ILogger logger, IHealthCheckPublisher HealthCheckPublisher, double ElapsedMilliseconds);
#pragma warning restore SYSLIB1006
    }
}