Skip to content

Commit e74a679

Browse files
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
1 parent 2745270 commit e74a679

File tree

6 files changed

+220
-10
lines changed

6 files changed

+220
-10
lines changed

src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,15 @@ public class AzureAppConfigurationOptions
3535
private SortedSet<string> _keyPrefixes = new SortedSet<string>(Comparer<string>.Create((k1, k2) => -string.Compare(k1, k2, StringComparison.OrdinalIgnoreCase)));
3636

3737
/// <summary>
38-
/// Flag to indicate whether enable replica discovery.
38+
/// Flag to indicate whether replica discovery is enabled.
3939
/// </summary>
4040
public bool ReplicaDiscoveryEnabled { get; set; } = true;
4141

42+
/// <summary>
43+
/// Flag to indicate whether load balancing is enabled.
44+
/// </summary>
45+
public bool LoadBalancingEnabled { get; set; }
46+
4247
/// <summary>
4348
/// The list of connection strings used to connect to an Azure App Configuration store and its replicas.
4449
/// </summary>

src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura
2929
private bool _isFeatureManagementVersionInspected;
3030
private readonly bool _requestTracingEnabled;
3131
private readonly IConfigurationClientManager _configClientManager;
32+
private Uri _lastSuccessfulEndpoint;
3233
private AzureAppConfigurationOptions _options;
3334
private Dictionary<string, ConfigurationSetting> _mappedData;
3435
private Dictionary<KeyValueIdentifier, ConfigurationSetting> _watchedSettings = new Dictionary<KeyValueIdentifier, ConfigurationSetting>();
@@ -990,6 +991,27 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
990991
Func<ConfigurationClient, Task<T>> funcToExecute,
991992
CancellationToken cancellationToken = default)
992993
{
994+
if (_options.LoadBalancingEnabled && _lastSuccessfulEndpoint != null && clients.Count() > 1)
995+
{
996+
int nextClientIndex = 0;
997+
998+
foreach (ConfigurationClient client in clients)
999+
{
1000+
nextClientIndex++;
1001+
1002+
if (_configClientManager.GetEndpointForClient(client) == _lastSuccessfulEndpoint)
1003+
{
1004+
break;
1005+
}
1006+
}
1007+
1008+
// If we found the last successful client, we'll rotate the list so that the next client is at the beginning
1009+
if (nextClientIndex < clients.Count())
1010+
{
1011+
clients = clients.Skip(nextClientIndex).Concat(clients.Take(nextClientIndex));
1012+
}
1013+
}
1014+
9931015
using IEnumerator<ConfigurationClient> clientEnumerator = clients.GetEnumerator();
9941016

9951017
clientEnumerator.MoveNext();
@@ -1010,6 +1032,8 @@ private async Task<T> ExecuteWithFailOverPolicyAsync<T>(
10101032
T result = await funcToExecute(currentClient).ConfigureAwait(false);
10111033
success = true;
10121034

1035+
_lastSuccessfulEndpoint = _configClientManager.GetEndpointForClient(currentClient);
1036+
10131037
return result;
10141038
}
10151039
catch (RequestFailedException rfe)

src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,20 @@ public IConfigurationProvider Build(IConfigurationBuilder builder)
3636
}
3737
else if (options.ConnectionStrings != null)
3838
{
39-
clientManager = new ConfigurationClientManager(options.ConnectionStrings, options.ClientOptions, options.ReplicaDiscoveryEnabled);
39+
clientManager = new ConfigurationClientManager(
40+
options.ConnectionStrings,
41+
options.ClientOptions,
42+
options.ReplicaDiscoveryEnabled,
43+
options.LoadBalancingEnabled);
4044
}
4145
else if (options.Endpoints != null && options.Credential != null)
4246
{
43-
clientManager = new ConfigurationClientManager(options.Endpoints, options.Credential, options.ClientOptions, options.ReplicaDiscoveryEnabled);
47+
clientManager = new ConfigurationClientManager(
48+
options.Endpoints,
49+
options.Credential,
50+
options.ClientOptions,
51+
options.ReplicaDiscoveryEnabled,
52+
options.LoadBalancingEnabled);
4453
}
4554
else
4655
{

src/Microsoft.Extensions.Configuration.AzureAppConfiguration/ConfigurationClientManager.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ internal class ConfigurationClientManager : IConfigurationClientManager, IDispos
5454
public ConfigurationClientManager(
5555
IEnumerable<string> connectionStrings,
5656
ConfigurationClientOptions clientOptions,
57-
bool replicaDiscoveryEnabled)
57+
bool replicaDiscoveryEnabled,
58+
bool loadBalancingEnabled)
5859
{
5960
if (connectionStrings == null || !connectionStrings.Any())
6061
{
@@ -68,6 +69,12 @@ public ConfigurationClientManager(
6869
_clientOptions = clientOptions;
6970
_replicaDiscoveryEnabled = replicaDiscoveryEnabled;
7071

72+
// If load balancing is enabled, shuffle the passed in connection strings to randomize the endpoint used on startup
73+
if (loadBalancingEnabled)
74+
{
75+
connectionStrings = connectionStrings.ToList().Shuffle();
76+
}
77+
7178
_validDomain = GetValidDomain(_endpoint);
7279
_srvLookupClient = new SrvLookupClient();
7380

@@ -84,7 +91,8 @@ public ConfigurationClientManager(
8491
IEnumerable<Uri> endpoints,
8592
TokenCredential credential,
8693
ConfigurationClientOptions clientOptions,
87-
bool replicaDiscoveryEnabled)
94+
bool replicaDiscoveryEnabled,
95+
bool loadBalancingEnabled)
8896
{
8997
if (endpoints == null || !endpoints.Any())
9098
{
@@ -101,6 +109,12 @@ public ConfigurationClientManager(
101109
_clientOptions = clientOptions;
102110
_replicaDiscoveryEnabled = replicaDiscoveryEnabled;
103111

112+
// If load balancing is enabled, shuffle the passed in endpoints to randomize the endpoint used on startup
113+
if (loadBalancingEnabled)
114+
{
115+
endpoints = endpoints.ToList().Shuffle();
116+
}
117+
104118
_validDomain = GetValidDomain(_endpoint);
105119
_srvLookupClient = new SrvLookupClient();
106120

@@ -132,6 +146,7 @@ public IEnumerable<ConfigurationClient> GetClients()
132146
_ = DiscoverFallbackClients();
133147
}
134148

149+
// Treat the passed in endpoints as the highest priority clients
135150
IEnumerable<ConfigurationClient> clients = _clients.Select(c => c.Client);
136151

137152
if (_dynamicClients != null && _dynamicClients.Any())

tests/Tests.AzureAppConfiguration/FailoverTests.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,8 @@ public void FailOverTests_ValidateEndpoints()
272272
new[] { new Uri("https://foobar.azconfig.io") },
273273
new DefaultAzureCredential(),
274274
new ConfigurationClientOptions(),
275-
true);
275+
true,
276+
false);
276277

277278
Assert.True(configClientManager.IsValidEndpoint("azure.azconfig.io"));
278279
Assert.True(configClientManager.IsValidEndpoint("appconfig.azconfig.io"));
@@ -287,7 +288,8 @@ public void FailOverTests_ValidateEndpoints()
287288
new[] { new Uri("https://foobar.appconfig.azure.com") },
288289
new DefaultAzureCredential(),
289290
new ConfigurationClientOptions(),
290-
true);
291+
true,
292+
false);
291293

292294
Assert.True(configClientManager2.IsValidEndpoint("azure.appconfig.azure.com"));
293295
Assert.True(configClientManager2.IsValidEndpoint("azure.z1.appconfig.azure.com"));
@@ -302,7 +304,8 @@ public void FailOverTests_ValidateEndpoints()
302304
new[] { new Uri("https://foobar.azconfig-test.io") },
303305
new DefaultAzureCredential(),
304306
new ConfigurationClientOptions(),
305-
true);
307+
true,
308+
false);
306309

307310
Assert.False(configClientManager3.IsValidEndpoint("azure.azconfig-test.io"));
308311
Assert.False(configClientManager3.IsValidEndpoint("azure.azconfig.io"));
@@ -311,7 +314,8 @@ public void FailOverTests_ValidateEndpoints()
311314
new[] { new Uri("https://foobar.z1.appconfig-test.azure.com") },
312315
new DefaultAzureCredential(),
313316
new ConfigurationClientOptions(),
314-
true);
317+
true,
318+
false);
315319

316320
Assert.False(configClientManager4.IsValidEndpoint("foobar.z2.appconfig-test.azure.com"));
317321
Assert.False(configClientManager4.IsValidEndpoint("foobar.appconfig-test.azure.com"));
@@ -325,7 +329,8 @@ public void FailOverTests_GetNoDynamicClient()
325329
new[] { new Uri("https://azure.azconfig.io") },
326330
new DefaultAzureCredential(),
327331
new ConfigurationClientOptions(),
328-
true);
332+
true,
333+
false);
329334

330335
var clients = configClientManager.GetClients();
331336

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
//
4+
using Azure;
5+
using Azure.Core.Testing;
6+
using Azure.Data.AppConfiguration;
7+
using Microsoft.Extensions.Configuration;
8+
using Microsoft.Extensions.Configuration.AzureAppConfiguration;
9+
using Moq;
10+
using System;
11+
using System.Collections.Generic;
12+
using System.Linq;
13+
using System.Threading;
14+
using Xunit;
15+
16+
namespace Tests.AzureAppConfiguration
17+
{
18+
public class LoadBalancingTests
19+
{
20+
readonly ConfigurationSetting kv = ConfigurationModelFactory.ConfigurationSetting(key: "TestKey1", label: "label", value: "TestValue1",
21+
eTag: new ETag("0a76e3d7-7ec1-4e37-883c-9ea6d0d89e63"),
22+
contentType: "text");
23+
24+
TimeSpan CacheExpirationTime = TimeSpan.FromSeconds(1);
25+
26+
[Fact]
27+
public void LoadBalancingTests_UsesAllEndpoints()
28+
{
29+
IConfigurationRefresher refresher = null;
30+
var mockResponse = new MockResponse(200);
31+
32+
var mockClient1 = new Mock<ConfigurationClient>(MockBehavior.Strict);
33+
mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
34+
.Returns(new MockAsyncPageable(Enumerable.Empty<ConfigurationSetting>().ToList()));
35+
mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
36+
.ReturnsAsync(Response.FromValue<ConfigurationSetting>(kv, mockResponse));
37+
mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny<ConfigurationSetting>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
38+
.ReturnsAsync(Response.FromValue(kv, mockResponse));
39+
mockClient1.Setup(c => c.Equals(mockClient1)).Returns(true);
40+
41+
var mockClient2 = new Mock<ConfigurationClient>(MockBehavior.Strict);
42+
mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
43+
.Returns(new MockAsyncPageable(Enumerable.Empty<ConfigurationSetting>().ToList()));
44+
mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
45+
.ReturnsAsync(Response.FromValue<ConfigurationSetting>(kv, mockResponse));
46+
mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny<ConfigurationSetting>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
47+
.ReturnsAsync(Response.FromValue(kv, mockResponse));
48+
mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true);
49+
50+
ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object);
51+
ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object);
52+
53+
var clientList = new List<ConfigurationClientWrapper>() { cw1, cw2 };
54+
var configClientManager = new ConfigurationClientManager(clientList);
55+
56+
var config = new ConfigurationBuilder()
57+
.AddAzureAppConfiguration(options =>
58+
{
59+
options.ClientManager = configClientManager;
60+
options.ConfigureRefresh(refreshOptions =>
61+
{
62+
refreshOptions.Register("TestKey1", "label")
63+
.SetCacheExpiration(CacheExpirationTime);
64+
});
65+
options.ReplicaDiscoveryEnabled = false;
66+
options.LoadBalancingEnabled = true;
67+
68+
refresher = options.GetRefresher();
69+
}).Build();
70+
71+
// Ensure client 1 was used for startup
72+
mockClient1.Verify(mc => mc.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()), Times.Exactly(1));
73+
74+
Thread.Sleep(CacheExpirationTime);
75+
refresher.RefreshAsync().Wait();
76+
77+
// Ensure client 2 was used for refresh
78+
mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny<ConfigurationSetting>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Exactly(0));
79+
80+
mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny<ConfigurationSetting>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Exactly(1));
81+
82+
Thread.Sleep(CacheExpirationTime);
83+
refresher.RefreshAsync().Wait();
84+
85+
// Ensure client 1 was now used for refresh
86+
mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny<ConfigurationSetting>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Exactly(1));
87+
}
88+
89+
[Fact]
90+
public void LoadBalancingTests_UsesClientAfterBackoffEnds()
91+
{
92+
IConfigurationRefresher refresher = null;
93+
var mockResponse = new MockResponse(200);
94+
95+
var mockClient1 = new Mock<ConfigurationClient>(MockBehavior.Strict);
96+
mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
97+
.Throws(new RequestFailedException(503, "Request failed."));
98+
mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
99+
.ReturnsAsync(Response.FromValue<ConfigurationSetting>(kv, mockResponse));
100+
mockClient1.Setup(c => c.GetConfigurationSettingAsync(It.IsAny<ConfigurationSetting>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
101+
.ReturnsAsync(Response.FromValue(kv, mockResponse));
102+
mockClient1.Setup(c => c.Equals(mockClient1)).Returns(true);
103+
104+
var mockClient2 = new Mock<ConfigurationClient>(MockBehavior.Strict);
105+
mockClient2.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
106+
.Returns(new MockAsyncPageable(Enumerable.Empty<ConfigurationSetting>().ToList()));
107+
mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
108+
.ReturnsAsync(Response.FromValue<ConfigurationSetting>(kv, mockResponse));
109+
mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny<ConfigurationSetting>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()))
110+
.ReturnsAsync(Response.FromValue(kv, mockResponse));
111+
mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true);
112+
113+
ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object);
114+
ConfigurationClientWrapper cw2 = new ConfigurationClientWrapper(TestHelpers.SecondaryConfigStoreEndpoint, mockClient2.Object);
115+
116+
var clientList = new List<ConfigurationClientWrapper>() { cw1, cw2 };
117+
var configClientManager = new ConfigurationClientManager(clientList);
118+
119+
var config = new ConfigurationBuilder()
120+
.AddAzureAppConfiguration(options =>
121+
{
122+
options.MinBackoffDuration = TimeSpan.FromSeconds(2);
123+
options.ClientManager = configClientManager;
124+
options.ConfigureRefresh(refreshOptions =>
125+
{
126+
refreshOptions.Register("TestKey1", "label")
127+
.SetCacheExpiration(CacheExpirationTime);
128+
});
129+
options.ReplicaDiscoveryEnabled = false;
130+
options.LoadBalancingEnabled = true;
131+
132+
refresher = options.GetRefresher();
133+
}).Build();
134+
135+
// Ensure client 2 was used for startup
136+
mockClient2.Verify(mc => mc.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()), Times.Exactly(1));
137+
138+
Thread.Sleep(TimeSpan.FromSeconds(2));
139+
refresher.RefreshAsync().Wait();
140+
141+
// Ensure client 1 has recovered and is used for refresh
142+
mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny<ConfigurationSetting>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Exactly(0));
143+
144+
mockClient1.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny<ConfigurationSetting>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Exactly(1));
145+
146+
Thread.Sleep(CacheExpirationTime);
147+
refresher.RefreshAsync().Wait();
148+
149+
mockClient2.Verify(mc => mc.GetConfigurationSettingAsync(It.IsAny<ConfigurationSetting>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Exactly(1));
150+
}
151+
}
152+
}

0 commit comments

Comments
 (0)