Skip to content

Commit 6771a49

Browse files
brentschmaltzHP712
andauthored
Adjust for RefreshInterval not influencing AutomaticRefreshInterval. (#2870)
* Do not update _syncAfter unless new configuration was received. Add random % of AutomaticRefreshInterval to avoid spike in trafic in all cases * Rename method * Fixed Interlock ordering --------- Co-authored-by: id4s <[email protected]>
1 parent 7f90e34 commit 6771a49

4 files changed

Lines changed: 167 additions & 74 deletions

File tree

src/Microsoft.IdentityModel.Protocols/Configuration/ConfigurationManager.cs

Lines changed: 25 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ public class ConfigurationManager<T> : BaseConfigurationManager, IConfigurationM
2727
private readonly IConfigurationRetriever<T> _configRetriever;
2828
private readonly IConfigurationValidator<T> _configValidator;
2929
private T _currentConfiguration;
30-
private TimeSpan _bootstrapRefreshInterval = TimeSpan.FromSeconds(1);
3130

3231
// task states are used to ensure the call to 'update config' (UpdateCurrentConfiguration) is a singleton. Uses Interlocked.CompareExchange.
3332
// metadata is not being obtained
@@ -169,6 +168,7 @@ public virtual async Task<T> GetConfigurationAsync(CancellationToken cancel)
169168
return _currentConfiguration;
170169
}
171170

171+
#pragma warning disable CA1031 // Do not catch general exception types
172172
try
173173
{
174174
// Don't use the individual CT here, this is a shared operation that shouldn't be affected by an individual's cancellation.
@@ -190,65 +190,29 @@ public virtual async Task<T> GetConfigurationAsync(CancellationToken cancel)
190190
result.ErrorMessage)));
191191
}
192192

193-
// Add a random amount between 0 and 5% of AutomaticRefreshInterval jitter to avoid spike traffic to IdentityProvider.
194-
_syncAfter = DateTimeUtil.Add(DateTime.UtcNow, AutomaticRefreshInterval +
195-
TimeSpan.FromSeconds(new Random().Next((int)AutomaticRefreshInterval.TotalSeconds / 20)));
196-
197-
_currentConfiguration = configuration;
193+
UpdateConfiguration(configuration);
198194
}
199195
catch (Exception ex)
200196
{
201197
fetchMetadataFailure = ex;
202198

203-
// In this case configuration was never obtained.
204-
if (_currentConfiguration == null)
205-
{
206-
if (_bootstrapRefreshInterval < RefreshInterval)
207-
{
208-
// Adopt exponential backoff for bootstrap refresh interval with a decorrelated jitter if it is not longer than the refresh interval.
209-
TimeSpan _bootstrapRefreshIntervalWithJitter = TimeSpan.FromSeconds(new Random().Next((int)_bootstrapRefreshInterval.TotalSeconds));
210-
_bootstrapRefreshInterval += _bootstrapRefreshInterval;
211-
_syncAfter = DateTimeUtil.Add(DateTime.UtcNow, _bootstrapRefreshIntervalWithJitter);
212-
}
213-
else
214-
{
215-
_syncAfter = DateTimeUtil.Add(
216-
DateTime.UtcNow,
217-
AutomaticRefreshInterval < RefreshInterval ? AutomaticRefreshInterval : RefreshInterval);
218-
}
219-
220-
throw LogHelper.LogExceptionMessage(
221-
new InvalidOperationException(
222-
LogHelper.FormatInvariant(
223-
LogMessages.IDX20803,
224-
LogHelper.MarkAsNonPII(MetadataAddress ?? "null"),
225-
LogHelper.MarkAsNonPII(_syncAfter),
226-
LogHelper.MarkAsNonPII(ex)),
227-
ex));
228-
}
229-
else
230-
{
231-
_syncAfter = DateTimeUtil.Add(
232-
DateTime.UtcNow,
233-
AutomaticRefreshInterval < RefreshInterval ? AutomaticRefreshInterval : RefreshInterval);
234-
235-
LogHelper.LogExceptionMessage(
236-
new InvalidOperationException(
237-
LogHelper.FormatInvariant(
238-
LogMessages.IDX20806,
239-
LogHelper.MarkAsNonPII(MetadataAddress ?? "null"),
240-
LogHelper.MarkAsNonPII(ex)),
241-
ex));
242-
}
199+
LogHelper.LogExceptionMessage(
200+
new InvalidOperationException(
201+
LogHelper.FormatInvariant(
202+
LogMessages.IDX20806,
203+
LogHelper.MarkAsNonPII(MetadataAddress ?? "null"),
204+
LogHelper.MarkAsNonPII(ex)),
205+
ex));
243206
}
244207
finally
245208
{
246209
_configurationNullLock.Release();
247210
}
211+
#pragma warning restore CA1031 // Do not catch general exception types
248212
}
249213
else
250214
{
251-
if (Interlocked.CompareExchange(ref _configurationRetrieverState, ConfigurationRetrieverIdle, ConfigurationRetrieverRunning) != ConfigurationRetrieverRunning)
215+
if (Interlocked.CompareExchange(ref _configurationRetrieverState, ConfigurationRetrieverRunning, ConfigurationRetrieverIdle) == ConfigurationRetrieverIdle)
252216
{
253217
_ = Task.Run(UpdateCurrentConfiguration, CancellationToken.None);
254218
}
@@ -285,7 +249,7 @@ private void UpdateCurrentConfiguration()
285249

286250
if (_configValidator == null)
287251
{
288-
_currentConfiguration = configuration;
252+
UpdateConfiguration(configuration);
289253
}
290254
else
291255
{
@@ -298,7 +262,7 @@ private void UpdateCurrentConfiguration()
298262
LogMessages.IDX20810,
299263
result.ErrorMessage)));
300264
else
301-
_currentConfiguration = configuration;
265+
UpdateConfiguration(configuration);
302266
}
303267
}
304268
catch (Exception ex)
@@ -313,12 +277,18 @@ private void UpdateCurrentConfiguration()
313277
}
314278
finally
315279
{
316-
_syncAfter = DateTimeUtil.Add(DateTime.UtcNow, AutomaticRefreshInterval);
317280
Interlocked.Exchange(ref _configurationRetrieverState, ConfigurationRetrieverIdle);
318281
}
319282
#pragma warning restore CA1031 // Do not catch general exception types
320283
}
321284

285+
private void UpdateConfiguration(T configuration)
286+
{
287+
_currentConfiguration = configuration;
288+
_syncAfter = DateTimeUtil.Add(DateTime.UtcNow, AutomaticRefreshInterval +
289+
TimeSpan.FromSeconds(new Random().Next((int)AutomaticRefreshInterval.TotalSeconds / 20)));
290+
}
291+
322292
/// <summary>
323293
/// Obtains an updated version of Configuration.
324294
/// </summary>
@@ -332,8 +302,9 @@ public override async Task<BaseConfiguration> GetBaseConfigurationAsync(Cancella
332302
}
333303

334304
/// <summary>
335-
/// Requests that then next call to <see cref="GetConfigurationAsync()"/> obtain new configuration.
336-
/// <para>If it is a first force refresh or the last refresh was greater than <see cref="BaseConfigurationManager.RefreshInterval"/> then the next call to <see cref="GetConfigurationAsync()"/> will retrieve new configuration.</para>
305+
/// Triggers updating metadata when:
306+
/// <para>1. Called the first time.</para>
307+
/// <para>2. The time between when this method was called and DateTimeOffset.Now is greater than <see cref="BaseConfigurationManager.RefreshInterval"/>.</para>
337308
/// <para>If <see cref="BaseConfigurationManager.RefreshInterval"/> == <see cref="TimeSpan.MaxValue"/> then this method does nothing.</para>
338309
/// </summary>
339310
public override void RequestRefresh()
@@ -343,10 +314,10 @@ public override void RequestRefresh()
343314
if (now >= DateTimeUtil.Add(_lastRequestRefresh.UtcDateTime, RefreshInterval) || _isFirstRefreshRequest)
344315
{
345316
_isFirstRefreshRequest = false;
346-
_lastRequestRefresh = now;
347-
if (Interlocked.CompareExchange(ref _configurationRetrieverState, ConfigurationRetrieverIdle, ConfigurationRetrieverRunning) != ConfigurationRetrieverRunning)
317+
if (Interlocked.CompareExchange(ref _configurationRetrieverState, ConfigurationRetrieverRunning, ConfigurationRetrieverIdle) == ConfigurationRetrieverIdle)
348318
{
349319
_ = Task.Run(UpdateCurrentConfiguration, CancellationToken.None);
320+
_lastRequestRefresh = now;
350321
}
351322
}
352323
}

test/Microsoft.IdentityModel.Protocols.OpenIdConnect.Tests/ConfigurationManagerTests.cs

Lines changed: 121 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public class ConfigurationManagerTests
2626
{
2727
/// <summary>
2828
/// This test reaches out the the internet to fetch the OpenIdConnectConfiguration from the specified metadata address.
29-
/// There is no validaiton of the configuration. The validation is done in the OpenIdConnectConfigurationSerializationTests.Deserialize
29+
/// There is no validation of the configuration. The validation is done in the OpenIdConnectConfigurationSerializationTests.Deserialize
3030
/// against values obtained 2/2/2024
3131
/// </summary>
3232
/// <param name="theoryData"></param>
@@ -209,25 +209,120 @@ public async Task FetchMetadataFailureTest()
209209
TestUtilities.AssertFailIfErrors(context);
210210
}
211211

212+
[Fact]
213+
public async Task VerifyInterlockGuardForRequestRefresh()
214+
{
215+
ManualResetEvent waitEvent = new ManualResetEvent(false);
216+
ManualResetEvent signalEvent = new ManualResetEvent(false);
217+
InMemoryDocumentRetriever inMemoryDocumentRetriever = InMemoryDocumentRetrieverWithEvents(waitEvent, signalEvent);
218+
219+
var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
220+
"AADCommonV1Json",
221+
new OpenIdConnectConfigurationRetriever(),
222+
inMemoryDocumentRetriever);
223+
224+
// populate the configurationManager with AADCommonV1Config
225+
TestUtilities.SetField(configurationManager, "_currentConfiguration", OpenIdConfigData.AADCommonV1Config);
226+
227+
// InMemoryDocumentRetrieverWithEvents will block until waitEvent.Set() is called.
228+
// The first RequestRefresh will not have finished before the next RequestRefresh() is called.
229+
// The guard '_lastRequestRefresh' will not block as we set it to DateTimeOffset.MinValue.
230+
// Interlocked guard will block.
231+
// Configuration should be AADCommonV1Config
232+
signalEvent.Reset();
233+
configurationManager.RequestRefresh();
234+
235+
// InMemoryDocumentRetrieverWithEvents will signal when it is OK to change the MetadataAddress
236+
// otherwise, it may be the case that the MetadataAddress is changed before the previous Task has finished.
237+
signalEvent.WaitOne();
238+
239+
// AADCommonV1Json would have been passed to the the previous retriever, which is blocked on an event.
240+
configurationManager.MetadataAddress = "AADCommonV2Json";
241+
TestUtilities.SetField(configurationManager, "_lastRequestRefresh", DateTimeOffset.MinValue);
242+
configurationManager.RequestRefresh();
243+
244+
// Set the event to release the lock and let the previous retriever finish.
245+
waitEvent.Set();
246+
247+
// Configuration should be AADCommonV1Config
248+
var configuration = await configurationManager.GetConfigurationAsync();
249+
Assert.True(configuration.Issuer.Equals(OpenIdConfigData.AADCommonV1Config.Issuer),
250+
$"configuration.Issuer from configurationManager was not as expected," +
251+
$"configuration.Issuer: '{configuration.Issuer}' != expected '{OpenIdConfigData.AADCommonV1Config.Issuer}'.");
252+
}
253+
254+
[Fact]
255+
public async Task VerifyInterlockGuardForGetConfigurationAsync()
256+
{
257+
ManualResetEvent waitEvent = new ManualResetEvent(false);
258+
ManualResetEvent signalEvent = new ManualResetEvent(false);
259+
260+
InMemoryDocumentRetriever inMemoryDocumentRetriever = InMemoryDocumentRetrieverWithEvents(waitEvent, signalEvent);
261+
waitEvent.Set();
262+
263+
var configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
264+
"AADCommonV1Json",
265+
new OpenIdConnectConfigurationRetriever(),
266+
inMemoryDocumentRetriever);
267+
268+
OpenIdConnectConfiguration configuration = await configurationManager.GetConfigurationAsync();
269+
270+
// InMemoryDocumentRetrieverWithEvents will block until waitEvent.Set() is called.
271+
// The GetConfigurationAsync to update config will not have finished before the next GetConfigurationAsync() is called.
272+
// The guard '_syncAfter' will not block as we set it to DateTimeOffset.MinValue.
273+
// Interlocked guard should block.
274+
// Configuration should be AADCommonV1Config
275+
276+
waitEvent.Reset();
277+
signalEvent.Reset();
278+
279+
TestUtilities.SetField(configurationManager, "_syncAfter", DateTimeOffset.MinValue);
280+
await configurationManager.GetConfigurationAsync(CancellationToken.None);
281+
282+
// InMemoryDocumentRetrieverWithEvents will signal when it is OK to change the MetadataAddress
283+
// otherwise, it may be the case that the MetadataAddress is changed before the previous Task has finished.
284+
signalEvent.WaitOne();
285+
286+
// AADCommonV1Json would have been passed to the the previous retriever, which is blocked on an event.
287+
configurationManager.MetadataAddress = "AADCommonV2Json";
288+
await configurationManager.GetConfigurationAsync(CancellationToken.None);
289+
290+
// Set the event to release the lock and let the previous retriever finish.
291+
waitEvent.Set();
292+
293+
// Configuration should be AADCommonV1Config
294+
configuration = await configurationManager.GetConfigurationAsync();
295+
Assert.True(configuration.Issuer.Equals(OpenIdConfigData.AADCommonV1Config.Issuer),
296+
$"configuration.Issuer from configurationManager was not as expected," +
297+
$" configuration.Issuer: '{configuration.Issuer}' != expected: '{OpenIdConfigData.AADCommonV1Config.Issuer}'.");
298+
}
299+
212300
[Fact]
213301
public async Task BootstrapRefreshIntervalTest()
214302
{
215303
var context = new CompareContext($"{this}.BootstrapRefreshIntervalTest");
216304

217-
var documentRetriever = new HttpDocumentRetriever(HttpResponseMessageUtils.SetupHttpClientThatReturns("OpenIdConnectMetadata.json", HttpStatusCode.NotFound));
218-
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>("OpenIdConnectMetadata.json", new OpenIdConnectConfigurationRetriever(), documentRetriever) { RefreshInterval = TimeSpan.FromSeconds(2) };
305+
var documentRetriever = new HttpDocumentRetriever(
306+
HttpResponseMessageUtils.SetupHttpClientThatReturns("OpenIdConnectMetadata.json", HttpStatusCode.NotFound));
307+
308+
var configManager = new ConfigurationManager<OpenIdConnectConfiguration>(
309+
"OpenIdConnectMetadata.json",
310+
new OpenIdConnectConfigurationRetriever(),
311+
documentRetriever)
312+
{ RefreshInterval = TimeSpan.FromSeconds(2) };
219313

220-
// First time to fetch metadata.
314+
// ConfigurationManager._syncAfter is set to DateTimeOffset.MinValue on startup
315+
// If obtaining the metadata fails due to error, the value should not change
221316
try
222317
{
223318
var configuration = await configManager.GetConfigurationAsync();
224319
}
225320
catch (Exception firstFetchMetadataFailure)
226321
{
227-
// Refresh interval is BootstrapRefreshInterval
228-
var syncAfter = configManager.GetType().GetField("_syncAfter", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(configManager);
229-
if ((DateTimeOffset)syncAfter > DateTime.UtcNow + TimeSpan.FromSeconds(2))
230-
context.AddDiff($"Expected the refresh interval is longer than 2 seconds.");
322+
// _syncAfter should not have been changed, because the fetch failed.
323+
var syncAfter = TestUtilities.GetField(configManager, "_syncAfter");
324+
if ((DateTimeOffset)syncAfter != DateTimeOffset.MinValue)
325+
context.AddDiff($"ConfigurationManager._syncAfter: '{syncAfter}' should equal '{DateTimeOffset.MinValue}'.");
231326

232327
if (firstFetchMetadataFailure.InnerException == null)
233328
context.AddDiff($"Expected exception to contain inner exception for fetch metadata failure.");
@@ -243,11 +338,10 @@ public async Task BootstrapRefreshIntervalTest()
243338
if (secondFetchMetadataFailure.InnerException == null)
244339
context.AddDiff($"Expected exception to contain inner exception for fetch metadata failure.");
245340

246-
syncAfter = configManager.GetType().GetField("_syncAfter", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(configManager);
247-
248-
// Refresh interval is RefreshInterval
249-
if ((DateTimeOffset)syncAfter > DateTime.UtcNow + configManager.RefreshInterval)
250-
context.AddDiff($"Expected the refresh interval is longer than 2 seconds.");
341+
// _syncAfter should not have been changed, because the fetch failed.
342+
syncAfter = TestUtilities.GetField(configManager, "_syncAfter");
343+
if ((DateTimeOffset)syncAfter != DateTimeOffset.MinValue)
344+
context.AddDiff($"ConfigurationManager._syncAfter: '{syncAfter}' should equal '{DateTimeOffset.MinValue}'.");
251345

252346
IdentityComparer.AreEqual(firstFetchMetadataFailure, secondFetchMetadataFailure, context);
253347
}
@@ -808,6 +902,20 @@ public static TheoryData<ConfigurationManagerTheoryData<OpenIdConnectConfigurati
808902
{ "https://login.microsoftonline.com/common/discovery/v2.0/keys", OpenIdConfigData.AADCommonV2JwksString }
809903
});
810904

905+
private static InMemoryDocumentRetriever InMemoryDocumentRetrieverWithEvents(ManualResetEvent waitEvent, ManualResetEvent signalEvent)
906+
{
907+
return new InMemoryDocumentRetriever(
908+
new Dictionary<string, string>
909+
{
910+
{ "AADCommonV1Json", OpenIdConfigData.AADCommonV1Json },
911+
{ "https://login.microsoftonline.com/common/discovery/keys", OpenIdConfigData.AADCommonV1JwksString },
912+
{ "AADCommonV2Json", OpenIdConfigData.AADCommonV2Json },
913+
{ "https://login.microsoftonline.com/common/discovery/v2.0/keys", OpenIdConfigData.AADCommonV2JwksString }
914+
},
915+
waitEvent,
916+
signalEvent);
917+
}
918+
811919
public class ConfigurationManagerTheoryData<T> : TheoryDataBase where T : class
812920
{
813921
public ConfigurationManager<T> ConfigurationManager { get; set; }

test/Microsoft.IdentityModel.Protocols.OpenIdConnect.Tests/OpenIdConnectProtocolValidatorTests.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,8 @@
1212
using System.Threading.Tasks;
1313
using Microsoft.IdentityModel.TestUtils;
1414
using Microsoft.IdentityModel.Tokens;
15-
using Newtonsoft.Json;
1615
using Xunit;
1716

18-
#pragma warning disable CS3016 // Arrays as attribute arguments is not CLS-compliant
19-
2017
namespace Microsoft.IdentityModel.Protocols.OpenIdConnect.Tests
2118
{
2219
/// <summary>
@@ -1813,5 +1810,3 @@ protected override void OnEventWritten(EventWrittenEventArgs eventData)
18131810
}
18141811
}
18151812
}
1816-
1817-
#pragma warning restore CS3016 // Arrays as attribute arguments is not CLS-compliant

0 commit comments

Comments
 (0)