From e74a679dca0fbd66d7b0c7aa2baf18e39a442778 Mon Sep 17 00:00:00 2001 From: Amer Jusupovic <32405726+amerjusupovic@users.noreply.github.com> Date: Mon, 22 Apr 2024 12:14:58 -0700 Subject: [PATCH] Add an option to enable load balancing between replicas (#535) * in progress shuffle clients * first draft load balancing, need tests * WIP logic for client shuffling - unsure how to incorporate priority * WIP * shuffle all clients together, fix logic for order of clients used * WIP * WIP store shuffle order for combined list * WIP shuffle logic * WIP new design * clean up logic/leftover code * move tests, check if dynamic clients are available in getclients * remove unused code * fix syntax issues, extend test * fix logic to increment client index * add clarifying comment * remove tests for now * WIP tests * add some tests, will add more * add to last test * remove unused usings * add extra verify statement to check client isnt used * edit logic to treat passed in clients as highest priority * PR comment revisions * check for more than one client in load balancing logic * set clients equal to new copied list before finding next available client * remove convert list to clients --- .../AzureAppConfigurationOptions.cs | 7 +- .../AzureAppConfigurationProvider.cs | 24 +++ .../AzureAppConfigurationSource.cs | 13 +- .../ConfigurationClientManager.cs | 19 ++- .../FailoverTests.cs | 15 +- .../LoadBalancingTests.cs | 152 ++++++++++++++++++ 6 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index a14fba48..b715f656 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -35,10 +35,15 @@ public class AzureAppConfigurationOptions private SortedSet _keyPrefixes = new SortedSet(Comparer.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase))); /// - /// Flag to indicate whether enable replica discovery. + /// Flag to indicate whether replica discovery is enabled. /// public bool ReplicaDiscoveryEnabled { get; set; } = true; + /// + /// Flag to indicate whether load balancing is enabled. + /// + public bool LoadBalancingEnabled { get; set; } + /// /// The list of connection strings used to connect to an Azure App Configuration store and its replicas. /// diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 031495b2..e5a8ac42 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -29,6 +29,7 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private bool _isFeatureManagementVersionInspected; private readonly bool _requestTracingEnabled; private readonly IConfigurationClientManager _configClientManager; + private Uri _lastSuccessfulEndpoint; private AzureAppConfigurationOptions _options; private Dictionary _mappedData; private Dictionary _watchedSettings = new Dictionary(); @@ -990,6 +991,27 @@ private async Task ExecuteWithFailOverPolicyAsync( Func> funcToExecute, CancellationToken cancellationToken = default) { + if (_options.LoadBalancingEnabled && _lastSuccessfulEndpoint != null && clients.Count() > 1) + { + int nextClientIndex = 0; + + foreach (ConfigurationClient client in clients) + { + nextClientIndex++; + + if (_configClientManager.GetEndpointForClient(client) == _lastSuccessfulEndpoint) + { + break; + } + } + + // If we found the last successful client, we'll rotate the list so that the next client is at the beginning + if (nextClientIndex < clients.Count()) + { + clients = clients.Skip(nextClientIndex).Concat(clients.Take(nextClientIndex)); + } + } + using IEnumerator clientEnumerator = clients.GetEnumerator(); clientEnumerator.MoveNext(); @@ -1010,6 +1032,8 @@ private async Task ExecuteWithFailOverPolicyAsync( T result = await funcToExecute(currentClient).ConfigureAwait(false); success = true; + _lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient); + return result; } catch (RequestFailedException rfe) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs index 71a5f480..446fa714 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -36,11 +36,20 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) } else if (options.ConnectionStrings != null) { - clientManager = new ConfigurationClientManager(options.ConnectionStrings, options.ClientOptions, options.ReplicaDiscoveryEnabled); + clientManager = new ConfigurationClientManager( + options.ConnectionStrings, + options.ClientOptions, + options.ReplicaDiscoveryEnabled, + options.LoadBalancingEnabled); } else if (options.Endpoints != null && options.Credential != null) { - clientManager = new ConfigurationClientManager(options.Endpoints, options.Credential, options.ClientOptions, options.ReplicaDiscoveryEnabled); + clientManager = new ConfigurationClientManager( + options.Endpoints, + options.Credential, + options.ClientOptions, + options.ReplicaDiscoveryEnabled, + options.LoadBalancingEnabled); } else { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs index 9ae71d2a..0a80932c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs @@ -54,7 +54,8 @@ internal class ConfigurationClientManager : IConfigurationClientManager, IDispos public ConfigurationClientManager( IEnumerable connectionStrings, ConfigurationClientOptions clientOptions, - bool replicaDiscoveryEnabled) + bool replicaDiscoveryEnabled, + bool loadBalancingEnabled) { if (connectionStrings == null || !connectionStrings.Any()) { @@ -68,6 +69,12 @@ public ConfigurationClientManager( _clientOptions = clientOptions; _replicaDiscoveryEnabled = replicaDiscoveryEnabled; + // If load balancing is enabled, shuffle the passed in connection strings to randomize the endpoint used on startup + if (loadBalancingEnabled) + { + connectionStrings = connectionStrings.ToList().Shuffle(); + } + _validDomain = GetValidDomain(_endpoint); _srvLookupClient = new SrvLookupClient(); @@ -84,7 +91,8 @@ public ConfigurationClientManager( IEnumerable endpoints, TokenCredential credential, ConfigurationClientOptions clientOptions, - bool replicaDiscoveryEnabled) + bool replicaDiscoveryEnabled, + bool loadBalancingEnabled) { if (endpoints == null || !endpoints.Any()) { @@ -101,6 +109,12 @@ public ConfigurationClientManager( _clientOptions = clientOptions; _replicaDiscoveryEnabled = replicaDiscoveryEnabled; + // If load balancing is enabled, shuffle the passed in endpoints to randomize the endpoint used on startup + if (loadBalancingEnabled) + { + endpoints = endpoints.ToList().Shuffle(); + } + _validDomain = GetValidDomain(_endpoint); _srvLookupClient = new SrvLookupClient(); @@ -132,6 +146,7 @@ public IEnumerable GetClients() _ = DiscoverFallbackClients(); } + // Treat the passed in endpoints as the highest priority clients IEnumerable clients = _clients.Select(c => c.Client); if (_dynamicClients != null && _dynamicClients.Any()) diff --git a/tests/Tests.AzureAppConfiguration/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/FailoverTests.cs index 603975b9..8df25e56 100644 --- a/tests/Tests.AzureAppConfiguration/FailoverTests.cs +++ b/tests/Tests.AzureAppConfiguration/FailoverTests.cs @@ -272,7 +272,8 @@ public void FailOverTests_ValidateEndpoints() new[] { new Uri("https://foobar.azconfig.io") }, new DefaultAzureCredential(), new ConfigurationClientOptions(), - true); + true, + false); Assert.True(configClientManager.IsValidEndpoint("azure.azconfig.io")); Assert.True(configClientManager.IsValidEndpoint("appconfig.azconfig.io")); @@ -287,7 +288,8 @@ public void FailOverTests_ValidateEndpoints() new[] { new Uri("https://foobar.appconfig.azure.com") }, new DefaultAzureCredential(), new ConfigurationClientOptions(), - true); + true, + false); Assert.True(configClientManager2.IsValidEndpoint("azure.appconfig.azure.com")); Assert.True(configClientManager2.IsValidEndpoint("azure.z1.appconfig.azure.com")); @@ -302,7 +304,8 @@ public void FailOverTests_ValidateEndpoints() new[] { new Uri("https://foobar.azconfig-test.io") }, new DefaultAzureCredential(), new ConfigurationClientOptions(), - true); + true, + false); Assert.False(configClientManager3.IsValidEndpoint("azure.azconfig-test.io")); Assert.False(configClientManager3.IsValidEndpoint("azure.azconfig.io")); @@ -311,7 +314,8 @@ public void FailOverTests_ValidateEndpoints() new[] { new Uri("https://foobar.z1.appconfig-test.azure.com") }, new DefaultAzureCredential(), new ConfigurationClientOptions(), - true); + true, + false); Assert.False(configClientManager4.IsValidEndpoint("foobar.z2.appconfig-test.azure.com")); Assert.False(configClientManager4.IsValidEndpoint("foobar.appconfig-test.azure.com")); @@ -325,7 +329,8 @@ public void FailOverTests_GetNoDynamicClient() new[] { new Uri("https://azure.azconfig.io") }, new DefaultAzureCredential(), new ConfigurationClientOptions(), - true); + true, + false); var clients = configClientManager.GetClients(); diff --git a/tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs b/tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs new file mode 100644 index 00000000..6a5ff417 --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/LoadBalancingTests.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Core.Testing; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Moq; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Xunit; + +namespace Tests.AzureAppConfiguration +{ + public class LoadBalancingTests + { + readonly ConfigurationSetting kv = ConfigurationModelFactory.ConfigurationSetting(key: "TestKey1", label: "label", value: "TestValue1", + eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"), + contentType: "text"); + + TimeSpan CacheExpirationTime = TimeSpan.FromSeconds(1); + + [Fact] + public void LoadBalancingTests_UsesAllEndpoints() + { + IConfigurationRefresher refresher = null; + var mockResponse = new MockResponse(200); + + var mockClient1 = new Mock(MockBehavior.Strict); + mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); + mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient1.Setup(c => c.Equals(mockClient1)).Returns(true); + + var mockClient2 = new Mock(MockBehavior.Strict); + mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); + mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); + + ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); + ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + + var clientList = new List() { cw1, cw2 }; + var configClientManager = new ConfigurationClientManager(clientList); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = configClientManager; + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label") + .SetCacheExpiration(CacheExpirationTime); + }); + options.ReplicaDiscoveryEnabled = false; + options.LoadBalancingEnabled = true; + + refresher = options.GetRefresher(); + }).Build(); + + // Ensure client 1 was used for startup + mockClient1.Verify(mc => mc.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + + Thread.Sleep(CacheExpirationTime); + refresher.RefreshAsync().Wait(); + + // Ensure client 2 was used for refresh + mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(0)); + + mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + + Thread.Sleep(CacheExpirationTime); + refresher.RefreshAsync().Wait(); + + // Ensure client 1 was now used for refresh + mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + } + + [Fact] + public void LoadBalancingTests_UsesClientAfterBackoffEnds() + { + IConfigurationRefresher refresher = null; + var mockResponse = new MockResponse(200); + + var mockClient1 = new Mock(MockBehavior.Strict); + mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Throws(new RequestFailedException(503, "Request failed.")); + mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient1.Setup(c => c.Equals(mockClient1)).Returns(true); + + var mockClient2 = new Mock(MockBehavior.Strict); + mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); + mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(kv, mockResponse)); + mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); + + ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); + ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object); + + var clientList = new List() { cw1, cw2 }; + var configClientManager = new ConfigurationClientManager(clientList); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.MinBackoffDuration = TimeSpan.FromSeconds(2); + options.ClientManager = configClientManager; + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label") + .SetCacheExpiration(CacheExpirationTime); + }); + options.ReplicaDiscoveryEnabled = false; + options.LoadBalancingEnabled = true; + + refresher = options.GetRefresher(); + }).Build(); + + // Ensure client 2 was used for startup + mockClient2.Verify(mc => mc.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + + Thread.Sleep(TimeSpan.FromSeconds(2)); + refresher.RefreshAsync().Wait(); + + // Ensure client 1 has recovered and is used for refresh + mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(0)); + + mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + + Thread.Sleep(CacheExpirationTime); + refresher.RefreshAsync().Wait(); + + mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + } + } +}