From ff455b1b3ea42aa553f92283622d9f7c1eae9d1c Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Wed, 9 Apr 2025 14:12:11 +0800 Subject: [PATCH 1/7] support health check --- .../AzureAppConfigurationHealthCheck.cs | 47 ++++++++ .../AzureAppConfigurationOptions.cs | 15 ++- .../AzureAppConfigurationProvider.cs | 15 +++ ...Configuration.AzureAppConfiguration.csproj | 1 + .../HealthCheckTest.cs | 112 ++++++++++++++++++ .../RefreshTests.cs | 6 +- 6 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs create mode 100644 tests/Tests.AzureAppConfiguration/HealthCheckTest.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs new file mode 100644 index 00000000..b417c081 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; +using System.Threading.Tasks; +using System.Threading; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + /// + /// Health check for Azure App Configuration. + /// + public sealed class AzureAppConfigurationHealthCheck : IHealthCheck + { + private AzureAppConfigurationProvider _provider = null; + + internal void SetProvider(AzureAppConfigurationProvider provider) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + /// + /// Checks the health of the Azure App Configuration provider. + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (_provider == null) + { + return HealthCheckResult.Unhealthy("Configuration provider is not set."); + } + + if (!_provider.LastSuccessfulAttempt.HasValue) + { + return HealthCheckResult.Unhealthy("The initial load is not completed."); + } + + if (_provider.LastFailedAttempt.HasValue && + _provider.LastSuccessfulAttempt.Value < _provider.LastFailedAttempt.Value) + { + return HealthCheckResult.Unhealthy("The last refresh attempt failed."); + } + + return HealthCheckResult.Healthy(); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index db3b6c3d..f15471f6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -2,7 +2,7 @@ // Licensed under the MIT license. // using Azure.Core; -using Azure.Core.Pipeline; +using Azure.Core.Pipeline; using Azure.Data.AppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; @@ -11,7 +11,7 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.Http; +using System.Net.Http; using System.Threading.Tasks; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration @@ -25,7 +25,7 @@ public class AzureAppConfigurationOptions private const int MaxRetries = 2; private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(1); private static readonly TimeSpan NetworkTimeout = TimeSpan.FromSeconds(10); - private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; + private static readonly KeyValueSelector DefaultQuery = new KeyValueSelector { KeyFilter = KeyFilter.Any, LabelFilter = LabelFilter.Null }; private List _individualKvWatchers = new List(); private List _ffWatchers = new List(); @@ -39,6 +39,11 @@ public class AzureAppConfigurationOptions // Since multiple prefixes could start with the same characters, we need to trim the longest prefix first. private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase))); + /// + /// Health check for Azure App Configuration. + /// + public AzureAppConfigurationHealthCheck HealthCheck { get; set; } = new AzureAppConfigurationHealthCheck(); + /// /// Flag to indicate whether replica discovery is enabled. /// @@ -514,9 +519,9 @@ private static ConfigurationClientOptions GetDefaultClientOptions() clientOptions.Retry.Mode = RetryMode.Exponential; clientOptions.AddPolicy(new UserAgentHeaderPolicy(), HttpPipelinePosition.PerCall); clientOptions.Transport = new HttpClientTransport(new HttpClient() - { + { Timeout = NetworkTimeout - }); + }); return clientOptions; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 795aeb10..a2010f60 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -58,6 +58,9 @@ private class ConfigurationClientBackoffStatus public DateTimeOffset BackoffEndTime { get; set; } } + public DateTimeOffset? LastSuccessfulAttempt { get; private set; } = null; + public DateTimeOffset? LastFailedAttempt { get; private set; } = null; + public Uri AppConfigurationEndpoint { get @@ -116,6 +119,11 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan bool hasWatchers = watchers.Any(); TimeSpan minWatcherRefreshInterval = hasWatchers ? watchers.Min(w => w.RefreshInterval) : TimeSpan.MaxValue; + if (options.HealthCheck != null) + { + options.HealthCheck.SetProvider(this); + } + if (options.RegisterAllEnabled) { if (options.KvCollectionRefreshInterval <= TimeSpan.Zero) @@ -198,6 +206,7 @@ public override void Load() // Mark all settings have loaded at startup. _isInitialLoadComplete = true; + LastSuccessfulAttempt = DateTime.UtcNow; } public async Task RefreshAsync(CancellationToken cancellationToken) @@ -255,6 +264,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken) _logger.LogDebug(LogHelper.BuildRefreshSkippedNoClientAvailableMessage()); + LastFailedAttempt = DateTime.UtcNow; + return; } @@ -449,6 +460,8 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => // As long as adapter.NeedsRefresh is true, we will attempt to update keyvault again the next time RefreshAsync is called. SetData(await PrepareData(_mappedData, cancellationToken).ConfigureAwait(false)); } + + LastSuccessfulAttempt = DateTime.UtcNow; } finally { @@ -1143,6 +1156,7 @@ private async Task ExecuteWithFailOverPolicyAsync( if (!IsFailOverable(rfe) || !clientEnumerator.MoveNext()) { backoffAllClients = true; + LastFailedAttempt = DateTime.UtcNow; throw; } @@ -1152,6 +1166,7 @@ private async Task ExecuteWithFailOverPolicyAsync( if (!IsFailOverable(ae) || !clientEnumerator.MoveNext()) { backoffAllClients = true; + LastFailedAttempt = DateTime.UtcNow; throw; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj index 91b90bb1..00d8b767 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -19,6 +19,7 @@ + diff --git a/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs new file mode 100644 index 00000000..810be35b --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Moq; +using System.Threading; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; +using System; +using System.Linq; + +namespace Tests.AzureAppConfiguration +{ + public class HealthCheckTest + { + readonly List kvCollection = new List + { + ConfigurationModelFactory.ConfigurationSetting("TestKey1", "TestValue1", "label", + eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"), + contentType:"text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey2", "TestValue2", "label", + eTag: new ETag("31c38369-831f-4bf1-b9ad-79db56c8b989"), + contentType: "text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey3", "TestValue3", "label", + + eTag: new ETag("bb203f2b-c113-44fc-995d-b933c2143339"), + contentType: "text"), + ConfigurationModelFactory.ConfigurationSetting("TestKey4", "TestValue4", "label", + eTag: new ETag("3ca43b3e-d544-4b0c-b3a2-e7a7284217a2"), + contentType: "text"), + }; + + [Fact] + public async Task HealthCheckTests_ReturnsUnhealthyWhenInitialLoadIsNotCompleted() + { + var healthCheck = new AzureAppConfigurationHealthCheck(); + HealthCheckResult result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Unhealthy, result.Status); + + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(kvCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.HealthCheck = healthCheck; + }) + .Build(); + + Assert.True(config["TestKey1"] == "TestValue1"); + result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed() + { + IConfigurationRefresher refresher = null; + var healthCheck = new AzureAppConfigurationHealthCheck(); + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(kvCollection)) + .Throws(new RequestFailedException(503, "Request failed.")) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.MinBackoffDuration = TimeSpan.FromSeconds(2); + options.HealthCheck = healthCheck; + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.RegisterAll() + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + HealthCheckResult result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + + // Wait for the refresh interval to expire + Thread.Sleep(1000); + + await refresher.TryRefreshAsync(); + result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Unhealthy, result.Status); + + // Wait for client backoff to end + Thread.Sleep(3000); + + await refresher.RefreshAsync(); + result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + Assert.Equal(HealthStatus.Healthy, result.Status); + } + } +} diff --git a/tests/Tests.AzureAppConfiguration/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/RefreshTests.cs index e64b5184..c27a48ff 100644 --- a/tests/Tests.AzureAppConfiguration/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/RefreshTests.cs @@ -320,7 +320,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o foreach (var setting in keyValueCollection) { copy.Add(TestHelpers.CloneSetting(setting)); - }; + } return new MockAsyncPageable(copy); }); @@ -392,7 +392,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o foreach (var setting in keyValueCollection) { copy.Add(TestHelpers.CloneSetting(setting)); - }; + } return new MockAsyncPageable(copy); }); @@ -461,7 +461,7 @@ public async Task RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() foreach (var setting in keyValueCollection) { copy.Add(TestHelpers.CloneSetting(setting)); - }; + } return new MockAsyncPageable(copy, operationDelay); }); From 791bd216a7e859e9208a47224f59d810fd001017 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Wed, 9 Apr 2025 16:20:17 +0800 Subject: [PATCH 2/7] update --- .../AzureAppConfigurationHealthCheck.cs | 8 +------- .../AzureAppConfigurationOptions.cs | 15 ++++++++++----- .../AzureAppConfigurationProvider.cs | 6 ++---- .../IConfigurationHealthCheck.cs | 14 ++++++++++++++ .../HealthCheckTest.cs | 16 +++++++--------- 5 files changed, 34 insertions(+), 25 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationHealthCheck.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs index b417c081..44b60f4e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs @@ -8,10 +8,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { - /// - /// Health check for Azure App Configuration. - /// - public sealed class AzureAppConfigurationHealthCheck : IHealthCheck + internal class AzureAppConfigurationHealthCheck : IConfigurationHealthCheck { private AzureAppConfigurationProvider _provider = null; @@ -20,9 +17,6 @@ internal void SetProvider(AzureAppConfigurationProvider provider) _provider = provider ?? throw new ArgumentNullException(nameof(provider)); } - /// - /// Checks the health of the Azure App Configuration provider. - /// public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { if (_provider == null) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index f15471f6..2237c83f 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -33,17 +33,13 @@ public class AzureAppConfigurationOptions private List>> _mappers = new List>>(); private List _selectors; private IConfigurationRefresher _refresher = new AzureAppConfigurationRefresher(); + private IConfigurationHealthCheck _healthCheck = new AzureAppConfigurationHealthCheck(); private bool _selectCalled = false; // The following set is sorted in descending order. // Since multiple prefixes could start with the same characters, we need to trim the longest prefix first. private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase))); - /// - /// Health check for Azure App Configuration. - /// - public AzureAppConfigurationHealthCheck HealthCheck { get; set; } = new AzureAppConfigurationHealthCheck(); - /// /// Flag to indicate whether replica discovery is enabled. /// @@ -464,6 +460,15 @@ public IConfigurationRefresher GetRefresher() return _refresher; } + /// + /// Get an instance of that can be used to do health checks for the configuration provider. + /// + /// An instance of . + public IConfigurationHealthCheck GetHealthCheck() + { + return _healthCheck; + } + /// /// Configures the Azure App Configuration provider to use the provided Key Vault configuration to resolve key vault references. /// diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index a2010f60..9a13aa0c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -119,10 +119,8 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan bool hasWatchers = watchers.Any(); TimeSpan minWatcherRefreshInterval = hasWatchers ? watchers.Min(w => w.RefreshInterval) : TimeSpan.MaxValue; - if (options.HealthCheck != null) - { - options.HealthCheck.SetProvider(this); - } + var healthCheck = (AzureAppConfigurationHealthCheck)_options.GetHealthCheck(); + healthCheck.SetProvider(this); if (options.RegisterAllEnabled) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationHealthCheck.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationHealthCheck.cs new file mode 100644 index 00000000..e7a7c834 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationHealthCheck.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + /// + /// An interface for Azure App Configuration health check. + /// + public interface IConfigurationHealthCheck : IHealthCheck + { + } +} diff --git a/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs index 810be35b..72ed01f2 100644 --- a/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs +++ b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs @@ -36,11 +36,9 @@ public class HealthCheckTest }; [Fact] - public async Task HealthCheckTests_ReturnsUnhealthyWhenInitialLoadIsNotCompleted() + public async Task HealthCheckTests_ReturnsHealthyWhenInitialLoadIsCompleted() { - var healthCheck = new AzureAppConfigurationHealthCheck(); - HealthCheckResult result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); - Assert.Equal(HealthStatus.Unhealthy, result.Status); + IHealthCheck healthCheck = null; var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -52,12 +50,12 @@ public async Task HealthCheckTests_ReturnsUnhealthyWhenInitialLoadIsNotCompleted .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.HealthCheck = healthCheck; + healthCheck = options.GetHealthCheck(); }) .Build(); Assert.True(config["TestKey1"] == "TestValue1"); - result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Healthy, result.Status); } @@ -65,7 +63,7 @@ public async Task HealthCheckTests_ReturnsUnhealthyWhenInitialLoadIsNotCompleted public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed() { IConfigurationRefresher refresher = null; - var healthCheck = new AzureAppConfigurationHealthCheck(); + IHealthCheck healthCheck = null; var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -80,7 +78,6 @@ public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed() { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.MinBackoffDuration = TimeSpan.FromSeconds(2); - options.HealthCheck = healthCheck; options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); options.ConfigureRefresh(refreshOptions => { @@ -88,10 +85,11 @@ public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed() .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); + healthCheck = options.GetHealthCheck(); }) .Build(); - HealthCheckResult result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Healthy, result.Status); // Wait for the refresh interval to expire From b8149edddbc6c69a79d6b4537bf6b8b5f66284ba Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Thu, 10 Apr 2025 11:03:18 +0800 Subject: [PATCH 3/7] reord last successful time in ExecuteWithFailoverPolicy --- .../AzureAppConfigurationProvider.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 9a13aa0c..e6fb8f52 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -204,7 +204,6 @@ public override void Load() // Mark all settings have loaded at startup. _isInitialLoadComplete = true; - LastSuccessfulAttempt = DateTime.UtcNow; } public async Task RefreshAsync(CancellationToken cancellationToken) @@ -458,8 +457,6 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => // As long as adapter.NeedsRefresh is true, we will attempt to update keyvault again the next time RefreshAsync is called. SetData(await PrepareData(_mappedData, cancellationToken).ConfigureAwait(false)); } - - LastSuccessfulAttempt = DateTime.UtcNow; } finally { @@ -1146,6 +1143,7 @@ private async Task ExecuteWithFailOverPolicyAsync( success = true; _lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient); + LastSuccessfulAttempt = DateTime.UtcNow; return result; } From 5b3ff0bc7d564b2a3d0b645b1c103992760d0c24 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 16 Apr 2025 16:45:21 +0800 Subject: [PATCH 4/7] make health check compatible with DI --- .../AzureAppConfigurationHealthCheck.cs | 61 +++++++++++++++---- ...figurationHealthChecksBuilderExtensions.cs | 45 ++++++++++++++ .../AzureAppConfigurationOptions.cs | 10 --- .../AzureAppConfigurationProvider.cs | 3 - .../Constants/HealthCheckConstants.cs | 14 +++++ .../IConfigurationHealthCheck.cs | 14 ----- .../HealthCheckTest.cs | 40 ++++++++++-- 7 files changed, 142 insertions(+), 45 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs delete mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationHealthCheck.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs index 44b60f4e..6fe372b8 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs @@ -5,37 +5,72 @@ using System; using System.Threading.Tasks; using System.Threading; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { - internal class AzureAppConfigurationHealthCheck : IConfigurationHealthCheck + internal class AzureAppConfigurationHealthCheck : IHealthCheck { - private AzureAppConfigurationProvider _provider = null; + private static readonly PropertyInfo _propertyInfo = typeof(ChainedConfigurationProvider).GetProperty("Configuration", BindingFlags.Public | BindingFlags.Instance); + private readonly List _providers = new List(); - internal void SetProvider(AzureAppConfigurationProvider provider) + public AzureAppConfigurationHealthCheck(IConfiguration configuration) { - _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + var configurationRoot = configuration as IConfigurationRoot; + FindProviders(configurationRoot); } public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { - if (_provider == null) + if (!_providers.Any()) { - return HealthCheckResult.Unhealthy("Configuration provider is not set."); + return HealthCheckResult.Unhealthy(HealthCheckConstants.NoProviderFoundMessage); } - if (!_provider.LastSuccessfulAttempt.HasValue) + foreach (var provider in _providers) { - return HealthCheckResult.Unhealthy("The initial load is not completed."); - } + if (!provider.LastSuccessfulAttempt.HasValue) + { + return HealthCheckResult.Unhealthy(HealthCheckConstants.LoadNotCompletedMessage); + } - if (_provider.LastFailedAttempt.HasValue && - _provider.LastSuccessfulAttempt.Value < _provider.LastFailedAttempt.Value) - { - return HealthCheckResult.Unhealthy("The last refresh attempt failed."); + if (provider.LastFailedAttempt.HasValue && + provider.LastSuccessfulAttempt.Value < provider.LastFailedAttempt.Value) + { + return HealthCheckResult.Unhealthy(HealthCheckConstants.RefreshFailedMessage); + } } return HealthCheckResult.Healthy(); } + + private void FindProviders(IConfigurationRoot configurationRoot) + { + if (configurationRoot != null) + { + foreach (IConfigurationProvider provider in configurationRoot.Providers) + { + if (provider is AzureAppConfigurationProvider appConfigurationProvider) + { + _providers.Add(appConfigurationProvider); + } + else if (provider is ChainedConfigurationProvider chainedProvider) + { + if (_propertyInfo != null) + { + var chainedProviderConfigurationRoot = _propertyInfo.GetValue(chainedProvider) as IConfigurationRoot; + FindProviders(chainedProviderConfigurationRoot); + } + } + } + } + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs new file mode 100644 index 00000000..4f291190 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// Extension methods to configure . + /// + public static class AzureAppConfigurationHealthChecksBuilderExtensions + { + /// + /// Add a health check for Azure App Configuration by registering for given . + /// + /// The to add to. + /// A factory to obtain instance. + /// The health check name. + /// The that should be reported when the health check fails. + /// A list of tags that can be used to filter sets of health checks. + /// A representing the timeout of the check. + /// The provided health checks builder. + public static IHealthChecksBuilder AddAzureAppConfiguration( + this IHealthChecksBuilder builder, + Func factory = default, + string name = HealthCheckConstants.HealthCheckRegistrationName, + HealthStatus failureStatus = default, + IEnumerable tags = default, + TimeSpan? timeout = default) + { + return builder.Add(new HealthCheckRegistration( + name ?? HealthCheckConstants.HealthCheckRegistrationName, + sp => new AzureAppConfigurationHealthCheck( + factory?.Invoke(sp) ?? sp.GetRequiredService()), + failureStatus, + tags, + timeout)); + } + } +} + diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 2237c83f..67e8e993 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -33,7 +33,6 @@ public class AzureAppConfigurationOptions private List>> _mappers = new List>>(); private List _selectors; private IConfigurationRefresher _refresher = new AzureAppConfigurationRefresher(); - private IConfigurationHealthCheck _healthCheck = new AzureAppConfigurationHealthCheck(); private bool _selectCalled = false; // The following set is sorted in descending order. @@ -460,15 +459,6 @@ public IConfigurationRefresher GetRefresher() return _refresher; } - /// - /// Get an instance of that can be used to do health checks for the configuration provider. - /// - /// An instance of . - public IConfigurationHealthCheck GetHealthCheck() - { - return _healthCheck; - } - /// /// Configures the Azure App Configuration provider to use the provided Key Vault configuration to resolve key vault references. /// diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index e6fb8f52..0afe3500 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -119,9 +119,6 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan bool hasWatchers = watchers.Any(); TimeSpan minWatcherRefreshInterval = hasWatchers ? watchers.Min(w => w.RefreshInterval) : TimeSpan.MaxValue; - var healthCheck = (AzureAppConfigurationHealthCheck)_options.GetHealthCheck(); - healthCheck.SetProvider(this); - if (options.RegisterAllEnabled) { if (options.KvCollectionRefreshInterval <= TimeSpan.Zero) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs new file mode 100644 index 00000000..06939815 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/HealthCheckConstants.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class HealthCheckConstants + { + public const string HealthCheckRegistrationName = "Microsoft.Extensions.Configuration.AzureAppConfiguration"; + public const string NoProviderFoundMessage = "No configuration provider is found."; + public const string LoadNotCompletedMessage = "The initial load is not completed."; + public const string RefreshFailedMessage = "The last refresh attempt failed."; + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationHealthCheck.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationHealthCheck.cs deleted file mode 100644 index e7a7c834..00000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationHealthCheck.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using Microsoft.Extensions.Diagnostics.HealthChecks; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration -{ - /// - /// An interface for Azure App Configuration health check. - /// - public interface IConfigurationHealthCheck : IHealthCheck - { - } -} diff --git a/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs index 72ed01f2..9dce8e82 100644 --- a/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs +++ b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs @@ -13,6 +13,7 @@ using Xunit; using System; using System.Linq; +using Microsoft.Extensions.DependencyInjection; namespace Tests.AzureAppConfiguration { @@ -38,8 +39,6 @@ public class HealthCheckTest [Fact] public async Task HealthCheckTests_ReturnsHealthyWhenInitialLoadIsCompleted() { - IHealthCheck healthCheck = null; - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -50,10 +49,11 @@ public async Task HealthCheckTests_ReturnsHealthyWhenInitialLoadIsCompleted() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - healthCheck = options.GetHealthCheck(); }) .Build(); + IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config); + Assert.True(config["TestKey1"] == "TestValue1"); var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Healthy, result.Status); @@ -63,7 +63,6 @@ public async Task HealthCheckTests_ReturnsHealthyWhenInitialLoadIsCompleted() public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed() { IConfigurationRefresher refresher = null; - IHealthCheck healthCheck = null; var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); @@ -85,10 +84,11 @@ public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed() .SetRefreshInterval(TimeSpan.FromSeconds(1)); }); refresher = options.GetRefresher(); - healthCheck = options.GetHealthCheck(); }) .Build(); + IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config); + var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Healthy, result.Status); @@ -106,5 +106,35 @@ public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed() result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Healthy, result.Status); } + + [Fact] + public async Task HealthCheckTests_RegisterAzureAppConfigurationHealthCheck() + { + var mockResponse = new Mock(); + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(kvCollection)); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + }) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(config); + services.AddLogging(); // add logging for health check service + services.AddHealthChecks() + .AddAzureAppConfiguration(); + var provider = services.BuildServiceProvider(); + var healthCheckService = provider.GetRequiredService(); + + var result = await healthCheckService.CheckHealthAsync(); + Assert.Equal(HealthStatus.Healthy, result.Status); + Assert.Contains(HealthCheckConstants.HealthCheckRegistrationName, result.Entries.Keys); + Assert.Equal(HealthStatus.Healthy, result.Entries[HealthCheckConstants.HealthCheckRegistrationName].Status); + } } } From 09e5c17a5e5eed4dae98349aa0df0734a316789b Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 17 Apr 2025 15:53:15 +0800 Subject: [PATCH 5/7] add health check for each provider instance --- .../AzureAppConfigurationHealthCheck.cs | 56 ++------------ ...figurationHealthChecksBuilderExtensions.cs | 2 +- .../AzureAppConfigurationProvider.cs | 8 +- ...mpositeAzureAppConfigurationHealthCheck.cs | 75 +++++++++++++++++++ .../HealthCheckTest.cs | 4 +- 5 files changed, 92 insertions(+), 53 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/CompositeAzureAppConfigurationHealthCheck.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs index 6fe372b8..d592eeb6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs @@ -5,72 +5,32 @@ using System; using System.Threading.Tasks; using System.Threading; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { internal class AzureAppConfigurationHealthCheck : IHealthCheck { - private static readonly PropertyInfo _propertyInfo = typeof(ChainedConfigurationProvider).GetProperty("Configuration", BindingFlags.Public | BindingFlags.Instance); - private readonly List _providers = new List(); + private AzureAppConfigurationProvider _provider = null; - public AzureAppConfigurationHealthCheck(IConfiguration configuration) + public AzureAppConfigurationHealthCheck(AzureAppConfigurationProvider provider) { - if (configuration == null) - { - throw new ArgumentNullException(nameof(configuration)); - } - - var configurationRoot = configuration as IConfigurationRoot; - FindProviders(configurationRoot); + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); } public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { - if (!_providers.Any()) + if (!_provider.LastSuccessfulAttempt.HasValue) { - return HealthCheckResult.Unhealthy(HealthCheckConstants.NoProviderFoundMessage); + return HealthCheckResult.Unhealthy(HealthCheckConstants.LoadNotCompletedMessage); } - foreach (var provider in _providers) + if (_provider.LastFailedAttempt.HasValue && + _provider.LastSuccessfulAttempt.Value < _provider.LastFailedAttempt.Value) { - if (!provider.LastSuccessfulAttempt.HasValue) - { - return HealthCheckResult.Unhealthy(HealthCheckConstants.LoadNotCompletedMessage); - } - - if (provider.LastFailedAttempt.HasValue && - provider.LastSuccessfulAttempt.Value < provider.LastFailedAttempt.Value) - { - return HealthCheckResult.Unhealthy(HealthCheckConstants.RefreshFailedMessage); - } + return HealthCheckResult.Unhealthy(HealthCheckConstants.RefreshFailedMessage); } return HealthCheckResult.Healthy(); } - - private void FindProviders(IConfigurationRoot configurationRoot) - { - if (configurationRoot != null) - { - foreach (IConfigurationProvider provider in configurationRoot.Providers) - { - if (provider is AzureAppConfigurationProvider appConfigurationProvider) - { - _providers.Add(appConfigurationProvider); - } - else if (provider is ChainedConfigurationProvider chainedProvider) - { - if (_propertyInfo != null) - { - var chainedProviderConfigurationRoot = _propertyInfo.GetValue(chainedProvider) as IConfigurationRoot; - FindProviders(chainedProviderConfigurationRoot); - } - } - } - } - } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs index 4f291190..f48e59a2 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs @@ -34,7 +34,7 @@ public static IHealthChecksBuilder AddAzureAppConfiguration( { return builder.Add(new HealthCheckRegistration( name ?? HealthCheckConstants.HealthCheckRegistrationName, - sp => new AzureAppConfigurationHealthCheck( + sp => new CompositeAzureAppConfigurationHealthCheck( factory?.Invoke(sp) ?? sp.GetRequiredService()), failureStatus, tags, diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 0afe3500..028d5104 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -5,6 +5,7 @@ using Azure.Data.AppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; @@ -58,6 +59,8 @@ private class ConfigurationClientBackoffStatus public DateTimeOffset BackoffEndTime { get; set; } } + public AzureAppConfigurationHealthCheck HealthCheck { get; private set; } + public DateTimeOffset? LastSuccessfulAttempt { get; private set; } = null; public DateTimeOffset? LastFailedAttempt { get; private set; } = null; @@ -114,6 +117,8 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan _options = options ?? throw new ArgumentNullException(nameof(options)); _optional = optional; + HealthCheck = new AzureAppConfigurationHealthCheck(this); + IEnumerable watchers = options.IndividualKvWatchers.Union(options.FeatureFlagWatchers); bool hasWatchers = watchers.Any(); @@ -1149,7 +1154,6 @@ private async Task ExecuteWithFailOverPolicyAsync( if (!IsFailOverable(rfe) || !clientEnumerator.MoveNext()) { backoffAllClients = true; - LastFailedAttempt = DateTime.UtcNow; throw; } @@ -1159,7 +1163,6 @@ private async Task ExecuteWithFailOverPolicyAsync( if (!IsFailOverable(ae) || !clientEnumerator.MoveNext()) { backoffAllClients = true; - LastFailedAttempt = DateTime.UtcNow; throw; } @@ -1168,6 +1171,7 @@ private async Task ExecuteWithFailOverPolicyAsync( { if (!success && backoffAllClients) { + LastFailedAttempt = DateTime.UtcNow; _logger.LogWarning(LogHelper.BuildLastEndpointFailedMessage(previousEndpoint?.ToString())); do diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/CompositeAzureAppConfigurationHealthCheck.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/CompositeAzureAppConfigurationHealthCheck.cs new file mode 100644 index 00000000..18c2f8bd --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/CompositeAzureAppConfigurationHealthCheck.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using System.Threading; +using System.Linq; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class CompositeAzureAppConfigurationHealthCheck : IHealthCheck + { + private static readonly PropertyInfo _propertyInfo = typeof(ChainedConfigurationProvider).GetProperty("Configuration", BindingFlags.Public | BindingFlags.Instance); + private readonly IEnumerable _healthChecks; + + public CompositeAzureAppConfigurationHealthCheck(IConfiguration configuration) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + var healthChecks = new List(); + var configurationRoot = configuration as IConfigurationRoot; + FindHealthChecks(configurationRoot, healthChecks); + + _healthChecks = healthChecks; + } + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (!_healthChecks.Any()) + { + return HealthCheckResult.Unhealthy(HealthCheckConstants.NoProviderFoundMessage); + } + + foreach (IHealthCheck healthCheck in _healthChecks) + { + var result = await healthCheck.CheckHealthAsync(context, cancellationToken).ConfigureAwait(false); + + if (result.Status == HealthStatus.Unhealthy) + { + return result; + } + } + + return HealthCheckResult.Healthy(); + } + + private void FindHealthChecks(IConfigurationRoot configurationRoot, List healthChecks) + { + if (configurationRoot != null) + { + foreach (IConfigurationProvider provider in configurationRoot.Providers) + { + if (provider is AzureAppConfigurationProvider appConfigurationProvider) + { + healthChecks.Add(appConfigurationProvider.HealthCheck); + } + else if (provider is ChainedConfigurationProvider chainedProvider) + { + if (_propertyInfo != null) + { + var chainedProviderConfigurationRoot = _propertyInfo.GetValue(chainedProvider) as IConfigurationRoot; + FindHealthChecks(chainedProviderConfigurationRoot, healthChecks); + } + } + } + } + } + } +} diff --git a/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs index 9dce8e82..53385efe 100644 --- a/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs +++ b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs @@ -52,7 +52,7 @@ public async Task HealthCheckTests_ReturnsHealthyWhenInitialLoadIsCompleted() }) .Build(); - IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config); + IHealthCheck healthCheck = new CompositeAzureAppConfigurationHealthCheck(config); Assert.True(config["TestKey1"] == "TestValue1"); var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); @@ -87,7 +87,7 @@ public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed() }) .Build(); - IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config); + IHealthCheck healthCheck = new CompositeAzureAppConfigurationHealthCheck(config); var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Healthy, result.Status); From 7101c7c9210b984336b4cf1a34f3977dabec69cd Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 17 Apr 2025 15:59:57 +0800 Subject: [PATCH 6/7] update --- .../AzureAppConfigurationHealthCheck.cs | 55 ++++++++++++-- ...figurationHealthChecksBuilderExtensions.cs | 2 +- .../AzureAppConfigurationProvider.cs | 35 ++++++--- ...mpositeAzureAppConfigurationHealthCheck.cs | 75 ------------------- .../HealthCheckTest.cs | 4 +- 5 files changed, 74 insertions(+), 97 deletions(-) delete mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/CompositeAzureAppConfigurationHealthCheck.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs index d592eeb6..76b706c0 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthCheck.cs @@ -3,34 +3,73 @@ // using Microsoft.Extensions.Diagnostics.HealthChecks; using System; +using System.Collections.Generic; +using System.Reflection; using System.Threading.Tasks; using System.Threading; +using System.Linq; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { internal class AzureAppConfigurationHealthCheck : IHealthCheck { - private AzureAppConfigurationProvider _provider = null; + private static readonly PropertyInfo _propertyInfo = typeof(ChainedConfigurationProvider).GetProperty("Configuration", BindingFlags.Public | BindingFlags.Instance); + private readonly IEnumerable _healthChecks; - public AzureAppConfigurationHealthCheck(AzureAppConfigurationProvider provider) + public AzureAppConfigurationHealthCheck(IConfiguration configuration) { - _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + var healthChecks = new List(); + var configurationRoot = configuration as IConfigurationRoot; + FindHealthChecks(configurationRoot, healthChecks); + + _healthChecks = healthChecks; } public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { - if (!_provider.LastSuccessfulAttempt.HasValue) + if (!_healthChecks.Any()) { - return HealthCheckResult.Unhealthy(HealthCheckConstants.LoadNotCompletedMessage); + return HealthCheckResult.Unhealthy(HealthCheckConstants.NoProviderFoundMessage); } - if (_provider.LastFailedAttempt.HasValue && - _provider.LastSuccessfulAttempt.Value < _provider.LastFailedAttempt.Value) + foreach (IHealthCheck healthCheck in _healthChecks) { - return HealthCheckResult.Unhealthy(HealthCheckConstants.RefreshFailedMessage); + var result = await healthCheck.CheckHealthAsync(context, cancellationToken).ConfigureAwait(false); + + if (result.Status == HealthStatus.Unhealthy) + { + return result; + } } return HealthCheckResult.Healthy(); } + + private void FindHealthChecks(IConfigurationRoot configurationRoot, List healthChecks) + { + if (configurationRoot != null) + { + foreach (IConfigurationProvider provider in configurationRoot.Providers) + { + if (provider is AzureAppConfigurationProvider appConfigurationProvider) + { + healthChecks.Add(appConfigurationProvider); + } + else if (provider is ChainedConfigurationProvider chainedProvider) + { + if (_propertyInfo != null) + { + var chainedProviderConfigurationRoot = _propertyInfo.GetValue(chainedProvider) as IConfigurationRoot; + FindHealthChecks(chainedProviderConfigurationRoot, healthChecks); + } + } + } + } + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs index f48e59a2..4f291190 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs @@ -34,7 +34,7 @@ public static IHealthChecksBuilder AddAzureAppConfiguration( { return builder.Add(new HealthCheckRegistration( name ?? HealthCheckConstants.HealthCheckRegistrationName, - sp => new CompositeAzureAppConfigurationHealthCheck( + sp => new AzureAppConfigurationHealthCheck( factory?.Invoke(sp) ?? sp.GetRequiredService()), failureStatus, tags, diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 028d5104..36a57ad5 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -22,7 +22,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { - internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigurationRefresher, IDisposable + internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigurationRefresher, IHealthCheck, IDisposable { private bool _optional; private bool _isInitialLoadComplete = false; @@ -53,17 +53,16 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private Logger _logger = new Logger(); private ILoggerFactory _loggerFactory; + // For health check + private DateTimeOffset? _lastSuccessfulAttempt = null; + private DateTimeOffset? _lastFailedAttempt = null; + private class ConfigurationClientBackoffStatus { public int FailedAttempts { get; set; } public DateTimeOffset BackoffEndTime { get; set; } } - public AzureAppConfigurationHealthCheck HealthCheck { get; private set; } - - public DateTimeOffset? LastSuccessfulAttempt { get; private set; } = null; - public DateTimeOffset? LastFailedAttempt { get; private set; } = null; - public Uri AppConfigurationEndpoint { get @@ -117,8 +116,6 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan _options = options ?? throw new ArgumentNullException(nameof(options)); _optional = optional; - HealthCheck = new AzureAppConfigurationHealthCheck(this); - IEnumerable watchers = options.IndividualKvWatchers.Union(options.FeatureFlagWatchers); bool hasWatchers = watchers.Any(); @@ -263,7 +260,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken) _logger.LogDebug(LogHelper.BuildRefreshSkippedNoClientAvailableMessage()); - LastFailedAttempt = DateTime.UtcNow; + _lastFailedAttempt = DateTime.UtcNow; return; } @@ -578,6 +575,22 @@ public void ProcessPushNotification(PushNotification pushNotification, TimeSpan? } } + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + if (!_lastSuccessfulAttempt.HasValue) + { + return HealthCheckResult.Unhealthy(HealthCheckConstants.LoadNotCompletedMessage); + } + + if (_lastFailedAttempt.HasValue && + _lastSuccessfulAttempt.Value < _lastFailedAttempt.Value) + { + return HealthCheckResult.Unhealthy(HealthCheckConstants.RefreshFailedMessage); + } + + return HealthCheckResult.Healthy(); + } + private void SetDirty(TimeSpan? maxDelay) { DateTimeOffset nextRefreshTime = AddRandomDelay(DateTimeOffset.UtcNow, maxDelay ?? DefaultMaxSetDirtyDelay); @@ -1145,7 +1158,7 @@ private async Task ExecuteWithFailOverPolicyAsync( success = true; _lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient); - LastSuccessfulAttempt = DateTime.UtcNow; + _lastSuccessfulAttempt = DateTime.UtcNow; return result; } @@ -1171,7 +1184,7 @@ private async Task ExecuteWithFailOverPolicyAsync( { if (!success && backoffAllClients) { - LastFailedAttempt = DateTime.UtcNow; + _lastFailedAttempt = DateTime.UtcNow; _logger.LogWarning(LogHelper.BuildLastEndpointFailedMessage(previousEndpoint?.ToString())); do diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/CompositeAzureAppConfigurationHealthCheck.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/CompositeAzureAppConfigurationHealthCheck.cs deleted file mode 100644 index 18c2f8bd..00000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/CompositeAzureAppConfigurationHealthCheck.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using Microsoft.Extensions.Diagnostics.HealthChecks; -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Threading.Tasks; -using System.Threading; -using System.Linq; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration -{ - internal class CompositeAzureAppConfigurationHealthCheck : IHealthCheck - { - private static readonly PropertyInfo _propertyInfo = typeof(ChainedConfigurationProvider).GetProperty("Configuration", BindingFlags.Public | BindingFlags.Instance); - private readonly IEnumerable _healthChecks; - - public CompositeAzureAppConfigurationHealthCheck(IConfiguration configuration) - { - if (configuration == null) - { - throw new ArgumentNullException(nameof(configuration)); - } - - var healthChecks = new List(); - var configurationRoot = configuration as IConfigurationRoot; - FindHealthChecks(configurationRoot, healthChecks); - - _healthChecks = healthChecks; - } - - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) - { - if (!_healthChecks.Any()) - { - return HealthCheckResult.Unhealthy(HealthCheckConstants.NoProviderFoundMessage); - } - - foreach (IHealthCheck healthCheck in _healthChecks) - { - var result = await healthCheck.CheckHealthAsync(context, cancellationToken).ConfigureAwait(false); - - if (result.Status == HealthStatus.Unhealthy) - { - return result; - } - } - - return HealthCheckResult.Healthy(); - } - - private void FindHealthChecks(IConfigurationRoot configurationRoot, List healthChecks) - { - if (configurationRoot != null) - { - foreach (IConfigurationProvider provider in configurationRoot.Providers) - { - if (provider is AzureAppConfigurationProvider appConfigurationProvider) - { - healthChecks.Add(appConfigurationProvider.HealthCheck); - } - else if (provider is ChainedConfigurationProvider chainedProvider) - { - if (_propertyInfo != null) - { - var chainedProviderConfigurationRoot = _propertyInfo.GetValue(chainedProvider) as IConfigurationRoot; - FindHealthChecks(chainedProviderConfigurationRoot, healthChecks); - } - } - } - } - } - } -} diff --git a/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs index 53385efe..9dce8e82 100644 --- a/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs +++ b/tests/Tests.AzureAppConfiguration/HealthCheckTest.cs @@ -52,7 +52,7 @@ public async Task HealthCheckTests_ReturnsHealthyWhenInitialLoadIsCompleted() }) .Build(); - IHealthCheck healthCheck = new CompositeAzureAppConfigurationHealthCheck(config); + IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config); Assert.True(config["TestKey1"] == "TestValue1"); var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); @@ -87,7 +87,7 @@ public async Task HealthCheckTests_ReturnsUnhealthyWhenRefreshFailed() }) .Build(); - IHealthCheck healthCheck = new CompositeAzureAppConfigurationHealthCheck(config); + IHealthCheck healthCheck = new AzureAppConfigurationHealthCheck(config); var result = await healthCheck.CheckHealthAsync(new HealthCheckContext()); Assert.Equal(HealthStatus.Healthy, result.Status); From 58cc831c305ab92c63ec5f32b214282c6b612efa Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 22 May 2025 11:00:17 +0800 Subject: [PATCH 7/7] update comment --- .../AzureAppConfigurationHealthChecksBuilderExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs index 4f291190..f006b746 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationHealthChecksBuilderExtensions.cs @@ -15,7 +15,7 @@ namespace Microsoft.Extensions.DependencyInjection public static class AzureAppConfigurationHealthChecksBuilderExtensions { /// - /// Add a health check for Azure App Configuration by registering for given . + /// Add a health check for Azure App Configuration to given . /// /// The to add to. /// A factory to obtain instance.