diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationKeyVaultOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationKeyVaultOptions.cs
index cca16df80..0aa5fdc62 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationKeyVaultOptions.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationKeyVaultOptions.cs
@@ -31,6 +31,11 @@ public class AzureAppConfigurationKeyVaultOptions
internal TimeSpan? DefaultSecretRefreshInterval = null;
internal bool IsKeyVaultRefreshConfigured = false;
+ ///
+ /// Flag to indicate whether Key Vault references should be resolved in parallel. Disabled by default.
+ ///
+ public bool ParallelSecretResolutionEnabled { get; set; }
+
///
/// Sets the credentials used to authenticate to key vaults that have no registered .
///
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs
index e5b6e2585..bc351a01f 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs
@@ -137,12 +137,12 @@ internal IEnumerable Adapters
///
/// Flag to indicate whether Key Vault options have been configured.
///
- internal bool IsKeyVaultConfigured { get; private set; } = false;
+ internal bool IsKeyVaultConfigured { get; private set; }
///
/// Flag to indicate whether Key Vault secret values will be refreshed automatically.
///
- internal bool IsKeyVaultRefreshConfigured { get; private set; } = false;
+ internal bool IsKeyVaultRefreshConfigured { get; private set; }
///
/// Indicates all feature flag features used by the application.
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs
index fc92d1290..4c1994acf 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs
@@ -625,16 +625,19 @@ private async Task> PrepareData(Dictionary kvp in data)
+ foreach (IKeyValueAdapter adapter in _options.Adapters)
{
- IEnumerable> keyValuePairs = null;
+ await adapter.PreloadAsync(data.Values, _logger, cancellationToken).ConfigureAwait(false);
+ }
+ foreach (KeyValuePair kvp in data)
+ {
if (_requestTracingEnabled && _requestTracingOptions != null)
{
_requestTracingOptions.UpdateAiConfigurationTracing(kvp.Value.ContentType);
}
- keyValuePairs = await ProcessAdapters(kvp.Value, cancellationToken).ConfigureAwait(false);
+ IEnumerable> keyValuePairs = await ProcessAdapters(kvp.Value, cancellationToken).ConfigureAwait(false);
foreach (KeyValuePair kv in keyValuePairs)
{
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs
index 0498ba09e..38c7a5a02 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs
@@ -1,14 +1,12 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
-using Azure;
using Azure.Data.AppConfiguration;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions;
using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement;
using System;
using System.Collections.Generic;
-using System.Linq;
using System.Net.Mime;
using System.Text.Json;
using System.Threading;
@@ -18,8 +16,6 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault
{
internal class AzureKeyVaultKeyValueAdapter : IKeyValueAdapter
{
- private const string AzureIdentityAssemblyName = "Azure.Identity";
-
private readonly AzureKeyVaultSecretProvider _secretProvider;
public AzureKeyVaultKeyValueAdapter(AzureKeyVaultSecretProvider secretProvider)
@@ -37,23 +33,10 @@ public async Task>> ProcessKeyValue(Con
// Uri validation
if (string.IsNullOrEmpty(secretRefUri) || !Uri.TryCreate(secretRefUri, UriKind.Absolute, out Uri secretUri) || !KeyVaultSecretIdentifier.TryCreate(secretUri, out KeyVaultSecretIdentifier secretIdentifier))
{
- throw CreateKeyVaultReferenceException("Invalid Key vault secret identifier.", setting, null, secretRefUri);
+ throw KeyVaultReferenceException.Create("Invalid Key vault secret identifier.", setting, null, secretRefUri);
}
- string secret;
-
- try
- {
- secret = await _secretProvider.GetSecretValue(secretIdentifier, setting.Key, setting.Label, logger, cancellationToken).ConfigureAwait(false);
- }
- catch (Exception e) when (e is UnauthorizedAccessException || (e.Source?.Equals(AzureIdentityAssemblyName, StringComparison.OrdinalIgnoreCase) ?? false))
- {
- throw CreateKeyVaultReferenceException(e.Message, setting, e, secretRefUri);
- }
- catch (Exception e) when (e is RequestFailedException || ((e as AggregateException)?.InnerExceptions?.All(e => e is RequestFailedException) ?? false))
- {
- throw CreateKeyVaultReferenceException("Key vault error.", setting, e, secretRefUri);
- }
+ string secret = await _secretProvider.GetSecretValue(secretIdentifier, setting, logger, cancellationToken).ConfigureAwait(false);
return new KeyValuePair[]
{
@@ -61,18 +44,6 @@ public async Task>> ProcessKeyValue(Con
};
}
- KeyVaultReferenceException CreateKeyVaultReferenceException(string message, ConfigurationSetting setting, Exception inner, string secretRefUri = null)
- {
- return new KeyVaultReferenceException(message, inner)
- {
- Key = setting.Key,
- Label = setting.Label,
- Etag = setting.ETag.ToString(),
- ErrorCode = (inner as RequestFailedException)?.ErrorCode,
- SecretIdentifier = secretRefUri
- };
- }
-
public bool CanProcess(ConfigurationSetting setting)
{
if (setting == null ||
@@ -116,6 +87,81 @@ public bool NeedsRefresh()
return _secretProvider.ShouldRefreshKeyVaultSecrets();
}
+ public async Task PreloadAsync(IEnumerable settings, Logger logger, CancellationToken cancellationToken)
+ {
+ if (settings == null)
+ {
+ return;
+ }
+
+ var seen = new HashSet();
+ var toFetch = new List<(KeyVaultSecretIdentifier, ConfigurationSetting)>();
+
+ foreach (ConfigurationSetting setting in settings)
+ {
+ if (!CanProcess(setting))
+ {
+ continue;
+ }
+
+ string secretRefUri = ParseSecretReferenceUri(setting);
+
+ if (string.IsNullOrEmpty(secretRefUri) ||
+ !Uri.TryCreate(secretRefUri, UriKind.Absolute, out Uri secretUri) ||
+ !KeyVaultSecretIdentifier.TryCreate(secretUri, out KeyVaultSecretIdentifier secretIdentifier))
+ {
+ throw KeyVaultReferenceException.Create("Invalid Key vault secret identifier.", setting, null, secretRefUri);
+ }
+
+ if (seen.Add(secretIdentifier.SourceId))
+ {
+ toFetch.Add((secretIdentifier, setting));
+ }
+ }
+
+ if (toFetch.Count == 0)
+ {
+ return;
+ }
+
+ if (_secretProvider.IsParallelSecretResolutionEnabled)
+ {
+ using (var semaphore = new SemaphoreSlim(KeyVaultConstants.MaxParallelSecretResolution))
+ {
+ async Task ResolveSecretAsync(KeyVaultSecretIdentifier identifier, ConfigurationSetting setting)
+ {
+ await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ await _secretProvider.GetSecretValue(identifier, setting, logger, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ semaphore.Release();
+ }
+ }
+
+ Task[] tasks = new Task[toFetch.Count];
+
+ for (int i = 0; i < toFetch.Count; i++)
+ {
+ (KeyVaultSecretIdentifier identifier, ConfigurationSetting setting) = toFetch[i];
+ tasks[i] = ResolveSecretAsync(identifier, setting);
+ }
+
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+ }
+ }
+ else
+ {
+ foreach ((KeyVaultSecretIdentifier identifier, ConfigurationSetting setting) in toFetch)
+ {
+ await _secretProvider.GetSecretValue(identifier, setting, logger, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
private string ParseSecretReferenceUri(ConfigurationSetting setting)
{
string secretRefUri = null;
@@ -126,7 +172,7 @@ private string ParseSecretReferenceUri(ConfigurationSetting setting)
if (reader.Read() && reader.TokenType != JsonTokenType.StartObject)
{
- throw CreateKeyVaultReferenceException(ErrorMessages.InvalidKeyVaultReference, setting, null, null);
+ throw KeyVaultReferenceException.Create(ErrorMessages.InvalidKeyVaultReference, setting, null, null);
}
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
@@ -144,7 +190,7 @@ private string ParseSecretReferenceUri(ConfigurationSetting setting)
}
else
{
- throw CreateKeyVaultReferenceException(ErrorMessages.InvalidKeyVaultReference, setting, null, null);
+ throw KeyVaultReferenceException.Create(ErrorMessages.InvalidKeyVaultReference, setting, null, null);
}
}
else
@@ -155,7 +201,7 @@ private string ParseSecretReferenceUri(ConfigurationSetting setting)
}
catch (JsonException e)
{
- throw CreateKeyVaultReferenceException(ErrorMessages.InvalidKeyVaultReference, setting, e, null);
+ throw KeyVaultReferenceException.Create(ErrorMessages.InvalidKeyVaultReference, setting, e, null);
}
return secretRefUri;
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultSecretProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultSecretProvider.cs
index 57505ff95..8046e8a01 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultSecretProvider.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultSecretProvider.cs
@@ -1,9 +1,12 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
+using Azure;
+using Azure.Data.AppConfiguration;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions;
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -13,17 +16,19 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault
{
internal class AzureKeyVaultSecretProvider
{
+ private const string AzureIdentityAssemblyName = "Azure.Identity";
+
private readonly AzureAppConfigurationKeyVaultOptions _keyVaultOptions;
- private readonly IDictionary _secretClients;
- private readonly Dictionary _cachedKeyVaultSecrets;
- private Uri _nextRefreshSourceId;
- private DateTimeOffset? _nextRefreshTime;
+ private readonly ConcurrentDictionary _secretClients;
+ private readonly ConcurrentDictionary _cachedKeyVaultSecrets;
+
+ public bool IsParallelSecretResolutionEnabled => _keyVaultOptions.ParallelSecretResolutionEnabled;
public AzureKeyVaultSecretProvider(AzureAppConfigurationKeyVaultOptions keyVaultOptions = null)
{
_keyVaultOptions = keyVaultOptions ?? new AzureAppConfigurationKeyVaultOptions();
- _cachedKeyVaultSecrets = new Dictionary();
- _secretClients = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ _cachedKeyVaultSecrets = new ConcurrentDictionary();
+ _secretClients = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
if (_keyVaultOptions.SecretClients != null)
{
@@ -35,7 +40,7 @@ public AzureKeyVaultSecretProvider(AzureAppConfigurationKeyVaultOptions keyVault
}
}
- public async Task GetSecretValue(KeyVaultSecretIdentifier secretIdentifier, string key, string label, Logger logger, CancellationToken cancellationToken)
+ public async Task GetSecretValue(KeyVaultSecretIdentifier secretIdentifier, ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken)
{
string secretValue = null;
@@ -49,9 +54,10 @@ public async Task GetSecretValue(KeyVaultSecretIdentifier secretIdentifi
if (client == null && _keyVaultOptions.SecretResolver == null)
{
- throw new UnauthorizedAccessException("No key vault credential or secret resolver callback configured, and no matching secret client could be found.");
+ throw KeyVaultReferenceException.Create("No key vault credential or secret resolver callback configured, and no matching secret client could be found.", setting, null, secretIdentifier.SourceId.ToString());
}
+ CachedKeyVaultSecret updatedCachedSecret = null;
bool success = false;
try
@@ -59,8 +65,8 @@ public async Task GetSecretValue(KeyVaultSecretIdentifier secretIdentifi
if (client != null)
{
KeyVaultSecret secret = await client.GetSecretAsync(secretIdentifier.Name, secretIdentifier.Version, cancellationToken).ConfigureAwait(false);
- logger.LogDebug(LogHelper.BuildKeyVaultSecretReadMessage(key, label));
- logger.LogInformation(LogHelper.BuildKeyVaultSettingUpdatedMessage(key));
+ logger.LogDebug(LogHelper.BuildKeyVaultSecretReadMessage(setting.Key, setting.Label));
+ logger.LogInformation(LogHelper.BuildKeyVaultSettingUpdatedMessage(setting.Key));
secretValue = secret.Value;
}
else if (_keyVaultOptions.SecretResolver != null)
@@ -68,12 +74,20 @@ public async Task GetSecretValue(KeyVaultSecretIdentifier secretIdentifi
secretValue = await _keyVaultOptions.SecretResolver(secretIdentifier.SourceId).ConfigureAwait(false);
}
- cachedSecret = new CachedKeyVaultSecret(secretValue, secretIdentifier.SourceId);
+ updatedCachedSecret = new CachedKeyVaultSecret(secretValue, secretIdentifier.SourceId);
success = true;
}
+ catch (Exception e) when (e is UnauthorizedAccessException || (e.Source?.Equals(AzureIdentityAssemblyName, StringComparison.OrdinalIgnoreCase) ?? false))
+ {
+ throw KeyVaultReferenceException.Create(e.Message, setting, e, secretIdentifier.SourceId.ToString());
+ }
+ catch (Exception e) when (e is RequestFailedException || ((e as AggregateException)?.InnerExceptions?.All(e => e is RequestFailedException) ?? false))
+ {
+ throw KeyVaultReferenceException.Create("Key vault error.", setting, e, secretIdentifier.SourceId.ToString());
+ }
finally
{
- SetSecretInCache(secretIdentifier.SourceId, key, cachedSecret, success);
+ SetSecretInCache(secretIdentifier.SourceId, setting.Key, updatedCachedSecret, success);
}
return secretValue;
@@ -81,42 +95,31 @@ public async Task GetSecretValue(KeyVaultSecretIdentifier secretIdentifi
public bool ShouldRefreshKeyVaultSecrets()
{
- return _nextRefreshTime.HasValue && _nextRefreshTime.Value < DateTimeOffset.UtcNow;
- }
-
- public void ClearCache()
- {
- var sourceIdsToRemove = new List();
-
- var utcNow = DateTimeOffset.UtcNow;
-
foreach (KeyValuePair secret in _cachedKeyVaultSecrets)
{
- if (secret.Value.LastRefreshTime + RefreshConstants.MinimumSecretRefreshInterval < utcNow)
+ if (secret.Value.RefreshAt.HasValue && secret.Value.RefreshAt.Value < DateTimeOffset.UtcNow)
{
- sourceIdsToRemove.Add(secret.Key);
+ return true;
}
}
- foreach (Uri sourceId in sourceIdsToRemove)
- {
- _cachedKeyVaultSecrets.Remove(sourceId);
- }
+ return false;
+ }
- if (_cachedKeyVaultSecrets.Any())
+ public void ClearCache()
+ {
+ foreach (KeyValuePair secret in _cachedKeyVaultSecrets)
{
- UpdateNextRefreshableSecretFromCache();
+ if (secret.Value.LastRefreshTime + RefreshConstants.MinimumSecretRefreshInterval < DateTimeOffset.UtcNow)
+ {
+ _cachedKeyVaultSecrets.TryRemove(secret.Key, out _);
+ }
}
}
public void RemoveSecretFromCache(Uri sourceId)
{
- _cachedKeyVaultSecrets.Remove(sourceId);
-
- if (sourceId == _nextRefreshSourceId)
- {
- UpdateNextRefreshableSecretFromCache();
- }
+ _cachedKeyVaultSecrets.TryRemove(sourceId, out _);
}
private SecretClient GetSecretClient(Uri secretUri)
@@ -133,14 +136,12 @@ private SecretClient GetSecretClient(Uri secretUri)
return null;
}
- client = new SecretClient(
- new Uri(secretUri.GetLeftPart(UriPartial.Authority)),
- _keyVaultOptions.Credential,
- _keyVaultOptions.ClientOptions);
-
- _secretClients.Add(keyVaultId, client);
-
- return client;
+ return _secretClients.GetOrAdd(
+ keyVaultId,
+ _ => new SecretClient(
+ new Uri(secretUri.GetLeftPart(UriPartial.Authority)),
+ _keyVaultOptions.Credential,
+ _keyVaultOptions.ClientOptions));
}
private void SetSecretInCache(Uri sourceId, string key, CachedKeyVaultSecret cachedSecret, bool success = true)
@@ -152,37 +153,6 @@ private void SetSecretInCache(Uri sourceId, string key, CachedKeyVaultSecret cac
UpdateCacheExpirationTimeForSecret(key, cachedSecret, success);
_cachedKeyVaultSecrets[sourceId] = cachedSecret;
-
- if (sourceId == _nextRefreshSourceId)
- {
- UpdateNextRefreshableSecretFromCache();
- }
- else if ((cachedSecret.RefreshAt.HasValue && _nextRefreshTime.HasValue && cachedSecret.RefreshAt.Value < _nextRefreshTime.Value)
- || (cachedSecret.RefreshAt.HasValue && !_nextRefreshTime.HasValue))
- {
- _nextRefreshSourceId = sourceId;
- _nextRefreshTime = cachedSecret.RefreshAt.Value;
- }
- }
-
- private void UpdateNextRefreshableSecretFromCache()
- {
- _nextRefreshSourceId = null;
- _nextRefreshTime = DateTimeOffset.MaxValue;
-
- foreach (KeyValuePair secret in _cachedKeyVaultSecrets)
- {
- if (secret.Value.RefreshAt.HasValue && secret.Value.RefreshAt.Value < _nextRefreshTime)
- {
- _nextRefreshTime = secret.Value.RefreshAt;
- _nextRefreshSourceId = secret.Key;
- }
- }
-
- if (_nextRefreshTime == DateTimeOffset.MaxValue)
- {
- _nextRefreshTime = null;
- }
}
private void UpdateCacheExpirationTimeForSecret(string key, CachedKeyVaultSecret cachedSecret, bool success)
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultConstants.cs
index 1309e58cf..5cedf993e 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultConstants.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/KeyVaultConstants.cs
@@ -8,5 +8,7 @@ internal class KeyVaultConstants
public const string ContentType = "application/vnd.microsoft.appconfig.keyvaultref+json";
public const string SecretReferenceUriJsonPropertyName = "uri";
+
+ public const int MaxParallelSecretResolution = 16;
}
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Exceptions/KeyVaultReferenceException.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Exceptions/KeyVaultReferenceException.cs
index 7d549a068..6bdb980c8 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Exceptions/KeyVaultReferenceException.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Exceptions/KeyVaultReferenceException.cs
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
+using Azure;
+using Azure.Data.AppConfiguration;
using System;
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
@@ -55,5 +57,20 @@ public KeyVaultReferenceException(string message,
/// The error code, if available, describing the cause of the exception.
///
public string ErrorCode { get; set; }
+
+ ///
+ /// Creates a populated with identifying information from the setting that failed to resolve.
+ ///
+ internal static KeyVaultReferenceException Create(string message, ConfigurationSetting setting, Exception inner, string secretRefUri = null)
+ {
+ return new KeyVaultReferenceException(message, inner)
+ {
+ Key = setting.Key,
+ Label = setting.Label,
+ Etag = setting.ETag.ToString(),
+ ErrorCode = (inner as RequestFailedException)?.ErrorCode,
+ SecretIdentifier = secretRefUri
+ };
+ }
}
}
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs
index fdd7f2fdf..a3431cd7b 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs
@@ -87,6 +87,11 @@ public void OnConfigUpdated()
return;
}
+ public Task PreloadAsync(IEnumerable settings, Logger logger, CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
+
private List> ProcessDotnetSchemaFeatureFlag(FeatureFlag featureFlag, ConfigurationSetting setting, Uri endpoint)
{
var keyValues = new List>();
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs
index de13314e1..5bcb3f30e 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs
@@ -13,6 +13,8 @@ internal interface IKeyValueAdapter
{
Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken);
+ Task PreloadAsync(IEnumerable settings, Logger logger, CancellationToken cancellationToken);
+
bool CanProcess(ConfigurationSetting setting);
void OnChangeDetected(ConfigurationSetting setting = null);
diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs
index d353439fd..9d8826e78 100644
--- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs
+++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs
@@ -80,5 +80,10 @@ public bool NeedsRefresh()
{
return false;
}
+
+ public Task PreloadAsync(IEnumerable settings, Logger logger, CancellationToken cancellationToken)
+ {
+ return Task.CompletedTask;
+ }
}
}
diff --git a/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs
index b23077c2a..2ba8fe8a5 100644
--- a/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs
+++ b/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs
@@ -507,6 +507,8 @@ public void DoesNotThrowKeyVaultExceptionWhenProviderIsOptional()
var mockKeyValueAdapter = new Mock(MockBehavior.Strict);
mockKeyValueAdapter.Setup(adapter => adapter.CanProcess(It.IsAny()))
.Returns(true);
+ mockKeyValueAdapter.Setup(adapter => adapter.PreloadAsync(It.IsAny>(), It.IsAny(), It.IsAny()))
+ .Returns(Task.CompletedTask);
mockKeyValueAdapter.Setup(adapter => adapter.ProcessKeyValue(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
.Throws(new KeyVaultReferenceException("Key vault error", null));
mockKeyValueAdapter.Setup(adapter => adapter.OnChangeDetected(null));
@@ -1050,5 +1052,188 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct)
Assert.Equal(_secretValue, config[setting.Key]);
}
}
+
+ [Fact]
+ public void ParallelSecretResolution_ResolvesAllReferences()
+ {
+ // Build a collection of distinct Key Vault references.
+ const int referenceCount = 20;
+ var settings = new List();
+
+ for (int i = 0; i < referenceCount; i++)
+ {
+ settings.Add(ConfigurationModelFactory.ConfigurationSetting(
+ key: $"Key{i}",
+ value: $@"{{""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/Secret{i}""}}",
+ eTag: new ETag($"etag-{i}"),
+ contentType: KeyVaultConstants.ContentType + "; charset=utf-8"));
+ }
+
+ var mockClient = new Mock(MockBehavior.Strict);
+ mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
+ .Returns(new MockAsyncPageable(settings));
+
+ var mockSecretClient = new Mock(MockBehavior.Strict);
+ mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net"));
+ mockSecretClient.Setup(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((string name, string version, CancellationToken cancellationToken) =>
+ Task.FromResult((Response)new MockResponse(new KeyVaultSecret(name, $"value-of-{name}"))));
+
+ var configuration = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
+ options.ConfigureKeyVault(kv =>
+ {
+ kv.Register(mockSecretClient.Object);
+ kv.ParallelSecretResolutionEnabled = true;
+ });
+ })
+ .Build();
+
+ for (int i = 0; i < referenceCount; i++)
+ {
+ Assert.Equal($"value-of-Secret{i}", configuration[$"Key{i}"]);
+ }
+ }
+
+ [Fact]
+ public void ParallelSecretResolution_RunsConcurrently()
+ {
+ // Use a gated mock secret client to detect concurrent in-flight calls.
+ const int referenceCount = 10;
+ var settings = new List();
+
+ for (int i = 0; i < referenceCount; i++)
+ {
+ settings.Add(ConfigurationModelFactory.ConfigurationSetting(
+ key: $"Key{i}",
+ value: $@"{{""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/Secret{i}""}}",
+ eTag: new ETag($"etag-{i}"),
+ contentType: KeyVaultConstants.ContentType + "; charset=utf-8"));
+ }
+
+ int inFlight = 0;
+ int maxInFlight = 0;
+ var inFlightLock = new object();
+
+ var mockClient = new Mock(MockBehavior.Strict);
+ mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
+ .Returns(new MockAsyncPageable(settings));
+
+ var mockSecretClient = new Mock(MockBehavior.Strict);
+ mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net"));
+ mockSecretClient.Setup(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(async (string name, string version, CancellationToken cancellationToken) =>
+ {
+ lock (inFlightLock)
+ {
+ inFlight++;
+ if (inFlight > maxInFlight)
+ {
+ maxInFlight = inFlight;
+ }
+ }
+
+ try
+ {
+ await Task.Delay(50, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ lock (inFlightLock)
+ {
+ inFlight--;
+ }
+ }
+
+ return (Response)new MockResponse(new KeyVaultSecret(name, $"value-of-{name}"));
+ });
+
+ var configuration = new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
+ options.ConfigureKeyVault(kv =>
+ {
+ kv.Register(mockSecretClient.Object);
+ kv.ParallelSecretResolutionEnabled = true;
+ });
+ })
+ .Build();
+
+ // Verify all references resolved.
+ for (int i = 0; i < referenceCount; i++)
+ {
+ Assert.Equal($"value-of-Secret{i}", configuration[$"Key{i}"]);
+ }
+
+ // When run in parallel, more than one secret request must have been in flight at the same time.
+ Assert.True(maxInFlight > 1, $"Expected concurrent Key Vault requests, but observed max in-flight = {maxInFlight}.");
+ }
+
+ [Fact]
+ public void ParallelSecretResolution_DisabledByDefault_RunsSequentially()
+ {
+ const int referenceCount = 5;
+ var settings = new List();
+
+ for (int i = 0; i < referenceCount; i++)
+ {
+ settings.Add(ConfigurationModelFactory.ConfigurationSetting(
+ key: $"Key{i}",
+ value: $@"{{""uri"":""https://keyvault-theclassics.vault.azure.net/secrets/Secret{i}""}}",
+ eTag: new ETag($"etag-{i}"),
+ contentType: KeyVaultConstants.ContentType + "; charset=utf-8"));
+ }
+
+ int inFlight = 0;
+ int maxInFlight = 0;
+ var inFlightLock = new object();
+
+ var mockClient = new Mock(MockBehavior.Strict);
+ mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny()))
+ .Returns(new MockAsyncPageable(settings));
+
+ var mockSecretClient = new Mock(MockBehavior.Strict);
+ mockSecretClient.SetupGet(client => client.VaultUri).Returns(new Uri("https://keyvault-theclassics.vault.azure.net"));
+ mockSecretClient.Setup(client => client.GetSecretAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns(async (string name, string version, CancellationToken cancellationToken) =>
+ {
+ lock (inFlightLock)
+ {
+ inFlight++;
+ if (inFlight > maxInFlight)
+ {
+ maxInFlight = inFlight;
+ }
+ }
+
+ try
+ {
+ await Task.Delay(20, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ lock (inFlightLock)
+ {
+ inFlight--;
+ }
+ }
+
+ return (Response)new MockResponse(new KeyVaultSecret(name, $"value-of-{name}"));
+ });
+
+ new ConfigurationBuilder()
+ .AddAzureAppConfiguration(options =>
+ {
+ options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object);
+ options.ConfigureKeyVault(kv => kv.Register(mockSecretClient.Object));
+ })
+ .Build();
+
+ // Default (sequential) path should never have more than one in-flight Key Vault request.
+ Assert.Equal(1, maxInFlight);
+ }
}
}