From c21932619b72e318e84819aa276d1ee205e619de Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Tue, 14 Oct 2025 21:47:11 +0800 Subject: [PATCH 01/20] wip --- .../Afd/AfdConfigurationClientManager.cs | 56 +++++++++++++++++++ .../Afd/AfdPolicy.cs | 44 +++++++++++++++ .../Afd/EmptyTokenCredential.cs | 38 +++++++++++++ .../Afd/RequestHeaders.cs | 13 +++++ .../AzureAppConfigurationOptions.cs | 49 +++++++++++++++- .../AzureAppConfigurationSource.cs | 31 +++++++++- .../Constants/ErrorMessages.cs | 3 + .../Constants/RequestTracingConstants.cs | 1 + .../ConfigurationClientExtensions.cs | 10 ++-- .../RequestTracingOptions.cs | 18 +++++- 10 files changed, 252 insertions(+), 11 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdPolicy.cs create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/EmptyTokenCredential.cs create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/RequestHeaders.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs new file mode 100644 index 000000000..81a94f79d --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Azure; +using System; +using System.Collections.Generic; +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd +{ + internal class AfdConfigurationClientManager : IConfigurationClientManager + { + private readonly ConfigurationClientWrapper _clientWrapper; + + public AfdConfigurationClientManager( + IAzureClientFactory clientFactory, + Uri endpoint) + { + if (clientFactory == null) + { + throw new ArgumentNullException(nameof(clientFactory)); + } + + if (endpoint == null) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + _clientWrapper = new ConfigurationClientWrapper(endpoint, clientFactory.CreateClient(endpoint.AbsoluteUri)); + } + + public IEnumerable GetClients() + { + return new List { _clientWrapper.Client }; + } + + public void RefreshClients() + { + return; + } + + public bool UpdateSyncToken(Uri endpoint, string syncToken) + { + return false; + } + + public Uri GetEndpointForClient(ConfigurationClient client) + { + if (client == null) + { + throw new ArgumentNullException(nameof(client)); + } + + return _clientWrapper.Client == client ? _clientWrapper.Endpoint : null; + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdPolicy.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdPolicy.cs new file mode 100644 index 000000000..35afe2343 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdPolicy.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Core; +using Azure.Core.Pipeline; +using System; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd +{ + /// + /// HTTP pipeline policy that removes Authorization and Sync-Token headers from outgoing requests. + /// + internal class AfdPolicy : HttpPipelinePolicy + { + /// + /// Processes the HTTP message and removes Authorization and Sync-Token headers. + /// + /// The HTTP message. + /// The pipeline. + public override void Process(HttpMessage message, ReadOnlyMemory pipeline) + { + message.Request.Headers.Remove(RequestHeaders.Authorization); + + message.Request.Headers.Remove(RequestHeaders.SyncToken); + + ProcessNext(message, pipeline); + } + + /// + /// Processes the HTTP message and removes Authorization and Sync-Token headers. + /// + /// The HTTP message. + /// The pipeline. + /// A task representing the asynchronous operation. + public override async System.Threading.Tasks.ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory pipeline) + { + message.Request.Headers.Remove(RequestHeaders.Authorization); + + message.Request.Headers.Remove(RequestHeaders.SyncToken); + + await ProcessNextAsync(message, pipeline).ConfigureAwait(false); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/EmptyTokenCredential.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/EmptyTokenCredential.cs new file mode 100644 index 000000000..1239676f7 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/EmptyTokenCredential.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Core; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd +{ + /// + /// A token credential that provides an empty token. + /// + internal class EmptyTokenCredential : TokenCredential + { + /// + /// Gets an empty token. + /// + /// The context of the token request. + /// A cancellation token to cancel the operation. + /// An empty access token. + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken(string.Empty, DateTimeOffset.MaxValue); + } + + /// + /// Asynchronously gets an empty token. + /// + /// The context of the token request. + /// A cancellation token to cancel the operation. + /// A task that represents the asynchronous operation. The task result contains an empty access token. + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(new AccessToken(string.Empty, DateTimeOffset.MaxValue)); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/RequestHeaders.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/RequestHeaders.cs new file mode 100644 index 000000000..dcca3c1f8 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/RequestHeaders.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd +{ + internal static class RequestHeaders + { + public const string Authorization = "Authorization"; + public const string SyncToken = "Sync-Token"; + public const string TimeStamp = "x-ms-date"; + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index e5b6e2585..42dff3aab 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -4,6 +4,7 @@ using Azure.Core; using Azure.Data.AppConfiguration; using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd; using Microsoft.Extensions.Configuration.AzureAppConfiguration.AzureKeyVault; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; @@ -132,7 +133,7 @@ internal IEnumerable Adapters /// /// Options used to configure the client used to communicate with Azure App Configuration. /// - internal ConfigurationClientOptions ClientOptions { get; } = GetDefaultClientOptions(); + internal ConfigurationClientOptions ClientOptions { get; private set; } = GetDefaultClientOptions(); /// /// Flag to indicate whether Key Vault options have been configured. @@ -154,6 +155,11 @@ internal IEnumerable Adapters /// internal StartupOptions Startup { get; set; } = new StartupOptions(); + /// + /// Gets a value indicating whether Azure Front Door is used. + /// + internal bool IsAfdUsed { get; private set; } + /// /// Client factory that is responsible for creating instances of ConfigurationClient. /// @@ -186,11 +192,12 @@ public AzureAppConfigurationOptions() public AzureAppConfigurationOptions SetClientFactory(IAzureClientFactory factory) { ClientFactory = factory ?? throw new ArgumentNullException(nameof(factory)); + return this; } /// - /// Specify what key-values to include in the configuration provider. + /// Specifies what key-values to include in the configuration provider. /// can be called multiple times to include multiple sets of key-values. /// /// @@ -262,7 +269,7 @@ public AzureAppConfigurationOptions Select(string keyFilter, string labelFilter } /// - /// Specify a snapshot and include its contained key-values in the configuration provider. + /// Specifies a snapshot and include its contained key-values in the configuration provider. /// can be called multiple times to include key-values from multiple snapshots. /// /// The name of the snapshot in Azure App Configuration. @@ -362,6 +369,11 @@ public AzureAppConfigurationOptions Connect(string connectionString) /// public AzureAppConfigurationOptions Connect(IEnumerable connectionStrings) { + if (IsAfdUsed) + { + throw new InvalidOperationException(ErrorMessages.ConnectionConflict); + } + if (connectionStrings == null || !connectionStrings.Any()) { throw new ArgumentNullException(nameof(connectionStrings)); @@ -405,6 +417,11 @@ public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential creden /// Token credential to use to connect. public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCredential credential) { + if (IsAfdUsed) + { + throw new InvalidOperationException(ErrorMessages.ConnectionConflict); + } + if (endpoints == null || !endpoints.Any()) { throw new ArgumentNullException(nameof(endpoints)); @@ -422,6 +439,32 @@ public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCre return this; } + /// + /// Connect the provider to Azure Front Door endpoint. + /// + /// The endpoint of the Azure Front Door instance to connect to. + public AzureAppConfigurationOptions ConnectAzureFrontDoor(Uri endpoint) + { + if ((Credential != null && !(Credential is EmptyTokenCredential)) || (ConnectionStrings?.Any() ?? false)) + { + throw new InvalidOperationException(ErrorMessages.ConnectionConflict); + } + + if (endpoint == null) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + Credential ??= new EmptyTokenCredential(); + + Endpoints = new List() { endpoint }; + ConnectionStrings = null; + + IsAfdUsed = true; + + return this; + } + /// /// Trims the provided prefix from the keys of all key-values retrieved from Azure App Configuration. /// diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs index 83d20e2fb..e68a3abaf 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // +using Azure.Core; using Azure.Data.AppConfiguration; using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd; using System; using System.Collections.Generic; using System.Linq; @@ -34,13 +36,29 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) { AzureAppConfigurationOptions options = _optionsProvider(); + IAzureClientFactory clientFactory = options.ClientFactory; + + if (options.IsAfdUsed) + { + if (options.LoadBalancingEnabled) + { + throw new InvalidOperationException(ErrorMessages.AfdLoadBalancingUnsupported); + } + + if (clientFactory != null) + { + throw new InvalidOperationException(ErrorMessages.AfdCustomClientOptionsUnsupported); + } + + options.ClientOptions.AddPolicy(new AfdPolicy(), HttpPipelinePosition.PerRetry); + } + if (options.ClientManager != null) { return new AzureAppConfigurationProvider(options.ClientManager, options, _optional); } IEnumerable endpoints; - IAzureClientFactory clientFactory = options.ClientFactory; if (options.ConnectionStrings != null) { @@ -56,10 +74,17 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) } else { - throw new ArgumentException($"Please call {nameof(AzureAppConfigurationOptions)}.{nameof(AzureAppConfigurationOptions.Connect)} to specify how to connect to Azure App Configuration."); + throw new ArgumentException($"Please call {nameof(AzureAppConfigurationOptions)}.{nameof(AzureAppConfigurationOptions.Connect)} or {nameof(AzureAppConfigurationOptions)}.{nameof(AzureAppConfigurationOptions.ConnectAzureFrontDoor)} to specify how to connect to Azure App Configuration."); } - provider = new AzureAppConfigurationProvider(new ConfigurationClientManager(clientFactory, endpoints, options.ReplicaDiscoveryEnabled, options.LoadBalancingEnabled), options, _optional); + if (options.IsAfdUsed) + { + provider = new AzureAppConfigurationProvider(new AfdConfigurationClientManager(clientFactory, endpoints.First()), options, _optional); + } + else + { + provider = new AzureAppConfigurationProvider(new ConfigurationClientManager(clientFactory, endpoints, options.ReplicaDiscoveryEnabled, options.LoadBalancingEnabled), options, _optional); + } } catch (InvalidOperationException ex) // InvalidOperationException is thrown when any problems are found while configuring AzureAppConfigurationOptions or when SDK fails to create a configurationClient. { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs index 7bc0e84f9..65268ffe0 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs @@ -14,5 +14,8 @@ internal class ErrorMessages public const string SnapshotReferenceInvalidJsonProperty = "Invalid snapshot reference format for key '{0}' (label: '{1}'). The '{2}' property must be a string value, but found {3}."; public const string SnapshotReferencePropertyMissing = "Invalid snapshot reference format for key '{0}' (label: '{1}'). The '{2}' property is required."; public const string SnapshotInvalidComposition = "{0} for the selected snapshot with name '{1}' must be 'key', found '{2}'."; + public const string ConnectionConflict = "Cannot connect to both Azure App Configuration and Azure Front Door at the same time."; + public const string AfdLoadBalancingUnsupported = "Load balancing is not supported when connecting to Azure Front Door."; + public const string AfdCustomClientOptionsUnsupported = "Custom client options are not supported when connecting to Azure Front Door."; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs index 5c4df33ec..e3e7f6160 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/RequestTracingConstants.cs @@ -37,6 +37,7 @@ internal class RequestTracingConstants public const string SignalRUsedTag = "SignalR"; public const string FailoverRequestTag = "Failover"; public const string PushRefreshTag = "PushRefresh"; + public const string AfdTag = "AFD"; public const string FeatureFlagFilterTypeKey = "Filter"; public const string CustomFilter = "CSTM"; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index c4edfb0ee..b4ae0bc31 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -14,7 +14,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { internal static class ConfigurationClientExtensions { - public static async Task GetKeyValueChange(this ConfigurationClient client, ConfigurationSetting setting, CancellationToken cancellationToken) + public static async Task GetKeyValueChange(this ConfigurationClient client, ConfigurationSetting setting, bool makeConditionalRequest, CancellationToken cancellationToken) { if (setting == null) { @@ -28,7 +28,7 @@ public static async Task GetKeyValueChange(this ConfigurationCli try { - Response response = await client.GetConfigurationSettingAsync(setting, onlyIfChanged: true, cancellationToken).ConfigureAwait(false); + Response response = await client.GetConfigurationSettingAsync(setting, onlyIfChanged: makeConditionalRequest, cancellationToken).ConfigureAwait(false); if (response.GetRawResponse().Status == (int)HttpStatusCode.OK && !response.Value.ETag.Equals(setting.ETag)) { @@ -64,7 +64,7 @@ public static async Task GetKeyValueChange(this ConfigurationCli }; } - public static async Task HaveCollectionsChanged(this ConfigurationClient client, KeyValueSelector keyValueSelector, IEnumerable matchConditions, IConfigurationSettingPageIterator pageIterator, CancellationToken cancellationToken) + public static async Task HaveCollectionsChanged(this ConfigurationClient client, KeyValueSelector keyValueSelector, IEnumerable matchConditions, IConfigurationSettingPageIterator pageIterator, bool makeConditionalRequest, CancellationToken cancellationToken) { if (matchConditions == null) { @@ -91,7 +91,9 @@ public static async Task HaveCollectionsChanged(this ConfigurationClient c using IEnumerator existingMatchConditionsEnumerator = matchConditions.GetEnumerator(); - await foreach (Page page in pageable.AsPages(pageIterator, matchConditions).ConfigureAwait(false)) + IAsyncEnumerable> pages = makeConditionalRequest ? pageable.AsPages(pageIterator, matchConditions) : pageable.AsPages(pageIterator); + + await foreach (Page page in pages.ConfigureAwait(false)) { using Response response = page.GetRawResponse(); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs index 2f50a3856..7c673ee71 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs @@ -76,6 +76,11 @@ internal class RequestTracingOptions /// public bool IsPushRefreshUsed { get; set; } = false; + /// + /// Flag to indicate wether the request is sent to a AFD. + /// + public bool IsAfdUsed { get; set; } = false; + /// /// Flag to indicate whether any key-value uses the json content type and contains /// a parameter indicating an AI profile. @@ -132,7 +137,8 @@ public bool UsesAnyTracingFeature() IsSignalRUsed || UsesAIConfiguration || UsesAIChatCompletionConfiguration || - UsesSnapshotReference; + UsesSnapshotReference || + IsAfdUsed; } /// @@ -193,6 +199,16 @@ public string CreateFeaturesString() sb.Append(RequestTracingConstants.SnapshotReferenceTag); } + if (IsAfdUsed) + { + if (sb.Length > 0) + { + sb.Append(RequestTracingConstants.Delimiter); + } + + sb.Append(RequestTracingConstants.AfdTag); + } + return sb.ToString(); } } From 2ebf5a85bca5b1a84294ffbf5da00e1aab7762c3 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Wed, 15 Oct 2025 23:08:12 +0800 Subject: [PATCH 02/20] wip --- .../Afd/AfdPolicy.cs | 11 +- .../Afd/RequestHeaders.cs | 13 - .../AzureAppConfigurationProvider.cs | 359 +++++++++++++----- .../Constants/LoggingConstants.cs | 1 + .../ConfigurationClientExtensions.cs | 63 +-- .../Extensions/ResponseExtensions.cs | 13 + .../KeyValueChange.cs | 3 + .../PageWatcher.cs | 14 + 8 files changed, 336 insertions(+), 141 deletions(-) delete mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/RequestHeaders.cs create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/PageWatcher.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdPolicy.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdPolicy.cs index 35afe2343..da461b7bf 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdPolicy.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdPolicy.cs @@ -12,6 +12,9 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd /// internal class AfdPolicy : HttpPipelinePolicy { + private const string AuthorizationHeader = "Authorization"; + private const string SyncTokenHeader = "Sync-Token"; + /// /// Processes the HTTP message and removes Authorization and Sync-Token headers. /// @@ -19,9 +22,9 @@ internal class AfdPolicy : HttpPipelinePolicy /// The pipeline. public override void Process(HttpMessage message, ReadOnlyMemory pipeline) { - message.Request.Headers.Remove(RequestHeaders.Authorization); + message.Request.Headers.Remove(AuthorizationHeader); - message.Request.Headers.Remove(RequestHeaders.SyncToken); + message.Request.Headers.Remove(SyncTokenHeader); ProcessNext(message, pipeline); } @@ -34,9 +37,9 @@ public override void Process(HttpMessage message, ReadOnlyMemoryA task representing the asynchronous operation. public override async System.Threading.Tasks.ValueTask ProcessAsync(HttpMessage message, ReadOnlyMemory pipeline) { - message.Request.Headers.Remove(RequestHeaders.Authorization); + message.Request.Headers.Remove(AuthorizationHeader); - message.Request.Headers.Remove(RequestHeaders.SyncToken); + message.Request.Headers.Remove(SyncTokenHeader); await ProcessNextAsync(message, pipeline).ConfigureAwait(false); } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/RequestHeaders.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/RequestHeaders.cs deleted file mode 100644 index dcca3c1f8..000000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/RequestHeaders.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd -{ - internal static class RequestHeaders - { - public const string Authorization = "Authorization"; - public const string SyncToken = "Sync-Token"; - public const string TimeStamp = "x-ms-date"; - } -} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index aa0656d68..0b232b6d6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -35,12 +35,18 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private Dictionary _mappedData; private Dictionary _watchedIndividualKvs = new Dictionary(); private HashSet _ffKeys = new HashSet(); - private Dictionary> _kvEtags = new Dictionary>(); - private Dictionary> _ffEtags = new Dictionary>(); + private Dictionary> _kvEtags = new Dictionary>(); + private Dictionary> _ffEtags = new Dictionary>(); private RequestTracingOptions _requestTracingOptions; private Dictionary _configClientBackoffs = new Dictionary(); private DateTimeOffset _nextCollectionRefreshTime; + #region Afd + private Dictionary _watchedIndividualKvChangeDetectedTime = new Dictionary(); + private DateTimeOffset _lastChangeDetectedTime = default; + private bool _isLastRefreshAborted = false; + #endregion + private readonly TimeSpan MinRefreshInterval; // The most-recent time when the refresh operation attempted to load the initial configuration @@ -58,6 +64,12 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private DateTimeOffset? _lastSuccessfulAttempt = null; private DateTimeOffset? _lastFailedAttempt = null; + private class CollectionChange + { + public bool Found { get; set; } + public DateTimeOffset Time { get; set; } + } + private class ConfigurationClientBackoffStatus { public int FailedAttempts { get; set; } @@ -220,7 +232,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken) !refreshableIndividualKvWatchers.Any() && !refreshableFfWatchers.Any() && !isRefreshDue && - !_options.Adapters.Any(adapter => adapter.NeedsRefresh())) + !_options.Adapters.Any(adapter => adapter.NeedsRefresh()) && + !_isLastRefreshAborted) { return; } @@ -276,14 +289,15 @@ public async Task RefreshAsync(CancellationToken cancellationToken) // // Avoid instance state modification - Dictionary> kvEtags = null; - Dictionary> ffEtags = null; + Dictionary> kvEtags = null; + Dictionary> ffEtags = null; HashSet ffKeys = null; Dictionary watchedIndividualKvs = null; - List keyValueChanges = null; + Dictionary watchedIndividualKvChangeDetectedTime = null; + List watchedIndividualKvChanges = null; Dictionary data = null; Dictionary ffCollectionData = null; - bool ffCollectionUpdated = false; + bool refreshFf = false; bool refreshAll = false; StringBuilder logInfoBuilder = new StringBuilder(); StringBuilder logDebugBuilder = new StringBuilder(); @@ -294,10 +308,10 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => ffEtags = null; ffKeys = null; watchedIndividualKvs = null; - keyValueChanges = new List(); + watchedIndividualKvChanges = null; data = null; ffCollectionData = null; - ffCollectionUpdated = false; + refreshFf = false; refreshAll = false; logDebugBuilder.Clear(); logInfoBuilder.Clear(); @@ -305,69 +319,111 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => if (_options.RegisterAllEnabled) { - // Get key value collection changes if RegisterAll was called - if (isRefreshDue) + if (_isLastRefreshAborted) { - refreshAll = await HaveCollectionsChanged( + refreshAll = true; + } + else if (isRefreshDue) + { + CollectionChange kvCollectionChange = await GetCollectionChange( _options.Selectors.Where(selector => !selector.IsFeatureFlagSelector), _kvEtags, client, cancellationToken).ConfigureAwait(false); + + refreshAll = kvCollectionChange.Found; + if (refreshAll) + { + _lastChangeDetectedTime = kvCollectionChange.Time; + } } } else { - refreshAll = await RefreshIndividualKvWatchers( - client, - keyValueChanges, - refreshableIndividualKvWatchers, - endpoint, - logDebugBuilder, - logInfoBuilder, - cancellationToken).ConfigureAwait(false); + if (_isLastRefreshAborted) + { + if (_options.IndividualKvWatchers.Any(w => w.RefreshAll)) + { + refreshAll = true; + } + } + else + { + watchedIndividualKvChanges = new List(); + refreshAll = await RefreshIndividualKvWatchers( + client, + watchedIndividualKvChanges, + refreshableIndividualKvWatchers, + endpoint, + logDebugBuilder, + logInfoBuilder, + cancellationToken).ConfigureAwait(false); + } } if (refreshAll) { // Trigger a single load-all operation if a change was detected in one or more key-values with refreshAll: true, // or if any key-value collection change was detected. - kvEtags = new Dictionary>(); - ffEtags = new Dictionary>(); + kvEtags = new Dictionary>(); + ffEtags = new Dictionary>(); ffKeys = new HashSet(); + watchedIndividualKvChangeDetectedTime = new Dictionary(); + + data = await LoadSelected(client, kvEtags, ffEtags, _options.Selectors, ffKeys, false, cancellationToken).ConfigureAwait(false); + watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh(client, data, watchedIndividualKvChangeDetectedTime, cancellationToken).ConfigureAwait(false); + if (data != null && watchedIndividualKvs != null && !_isLastRefreshAborted) + { + logInfoBuilder.AppendLine(LogHelper.BuildConfigurationUpdatedMessage()); + } - data = await LoadSelected(client, kvEtags, ffEtags, _options.Selectors, ffKeys, cancellationToken).ConfigureAwait(false); - watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh(client, data, cancellationToken).ConfigureAwait(false); - logInfoBuilder.AppendLine(LogHelper.BuildConfigurationUpdatedMessage()); return; } - // Get feature flag changes - ffCollectionUpdated = await HaveCollectionsChanged( - refreshableFfWatchers.Select(watcher => new KeyValueSelector + if (_isLastRefreshAborted) + { + refreshFf = true; + } + else + { + // Get feature flag changes + CollectionChange ffCollectionChange = await GetCollectionChange( + refreshableFfWatchers.Select(watcher => new KeyValueSelector + { + KeyFilter = watcher.Key, + LabelFilter = watcher.Label, + TagFilters = watcher.Tags, + IsFeatureFlagSelector = true + }), + _ffEtags, + client, + cancellationToken).ConfigureAwait(false); + + refreshFf = ffCollectionChange.Found; + if (refreshFf) { - KeyFilter = watcher.Key, - LabelFilter = watcher.Label, - TagFilters = watcher.Tags, - IsFeatureFlagSelector = true - }), - _ffEtags, - client, - cancellationToken).ConfigureAwait(false); - - if (ffCollectionUpdated) + _lastChangeDetectedTime = ffCollectionChange.Time; + } + } + + if (refreshFf) { - ffEtags = new Dictionary>(); + ffEtags = new Dictionary>(); ffKeys = new HashSet(); ffCollectionData = await LoadSelected( client, - new Dictionary>(), + new Dictionary>(), ffEtags, _options.Selectors.Where(selector => selector.IsFeatureFlagSelector), ffKeys, + true, cancellationToken).ConfigureAwait(false); - logInfoBuilder.Append(LogHelper.BuildFeatureFlagsUpdatedMessage()); + if (ffCollectionData != null && !_isLastRefreshAborted) + { + logInfoBuilder.Append(LogHelper.BuildFeatureFlagsUpdatedMessage()); + } } else { @@ -377,49 +433,57 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => cancellationToken) .ConfigureAwait(false); - if (refreshAll) + if (!_isLastRefreshAborted) { - _mappedData = await MapConfigurationSettings(data).ConfigureAwait(false); - - // Invalidate all the cached KeyVault secrets - foreach (IKeyValueAdapter adapter in _options.Adapters) - { - adapter.OnChangeDetected(); - } - - // Update the next refresh time for all refresh registered settings and feature flags - foreach (KeyValueWatcher changeWatcher in _options.IndividualKvWatchers.Concat(_options.FeatureFlagWatchers)) + if (refreshAll) { - UpdateNextRefreshTime(changeWatcher); - } - } - else - { - watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); + _mappedData = await MapConfigurationSettings(data).ConfigureAwait(false); - await ProcessKeyValueChangesAsync(keyValueChanges, _mappedData, watchedIndividualKvs).ConfigureAwait(false); + // Invalidate all the cached KeyVault secrets + foreach (IKeyValueAdapter adapter in _options.Adapters) + { + adapter.OnChangeDetected(); + } - if (ffCollectionUpdated) - { - // Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously - foreach (string key in _ffKeys.Except(ffKeys)) + // Update the next refresh time for all refresh registered settings and feature flags + foreach (KeyValueWatcher changeWatcher in _options.IndividualKvWatchers.Concat(_options.FeatureFlagWatchers)) { - _mappedData.Remove(key); + UpdateNextRefreshTime(changeWatcher); } + } + else + { + watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); + watchedIndividualKvChangeDetectedTime = new Dictionary(_watchedIndividualKvChangeDetectedTime); - Dictionary mappedFfData = await MapConfigurationSettings(ffCollectionData).ConfigureAwait(false); + await ProcessKeyValueChangesAsync( + watchedIndividualKvChanges, + _mappedData, + watchedIndividualKvs, + watchedIndividualKvChangeDetectedTime).ConfigureAwait(false); - foreach (KeyValuePair kvp in mappedFfData) + if (refreshFf) { - _mappedData[kvp.Key] = kvp.Value; + // Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously + foreach (string key in _ffKeys.Except(ffKeys)) + { + _mappedData.Remove(key); + } + + Dictionary mappedFfData = await MapConfigurationSettings(ffCollectionData).ConfigureAwait(false); + + foreach (KeyValuePair kvp in mappedFfData) + { + _mappedData[kvp.Key] = kvp.Value; + } } - } - // - // update the next refresh time for all refresh registered settings and feature flags - foreach (KeyValueWatcher changeWatcher in refreshableIndividualKvWatchers.Concat(refreshableFfWatchers)) - { - UpdateNextRefreshTime(changeWatcher); + // + // update the next refresh time for all refresh registered settings and feature flags + foreach (KeyValueWatcher changeWatcher in refreshableIndividualKvWatchers.Concat(refreshableFfWatchers)) + { + UpdateNextRefreshTime(changeWatcher); + } } } @@ -428,10 +492,13 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => _nextCollectionRefreshTime = DateTimeOffset.UtcNow.Add(_options.KvCollectionRefreshInterval); } - if (_options.Adapters.Any(adapter => adapter.NeedsRefresh()) || keyValueChanges.Any() || refreshAll || ffCollectionUpdated) + if (!_isLastRefreshAborted && + (_options.Adapters.Any(adapter => adapter.NeedsRefresh()) || watchedIndividualKvChanges.Any() || refreshAll || refreshFf)) { _watchedIndividualKvs = watchedIndividualKvs ?? _watchedIndividualKvs; + _watchedIndividualKvChangeDetectedTime = watchedIndividualKvChangeDetectedTime ?? _watchedIndividualKvChangeDetectedTime; + _ffEtags = ffEtags ?? _ffEtags; _kvEtags = kvEtags ?? _kvEtags; @@ -768,9 +835,10 @@ private async Task TryInitializeAsync(IEnumerable cli private async Task InitializeAsync(IEnumerable clients, CancellationToken cancellationToken = default) { Dictionary data = null; - Dictionary> kvEtags = new Dictionary>(); - Dictionary> ffEtags = new Dictionary>(); + Dictionary> kvEtags = new Dictionary>(); + Dictionary> ffEtags = new Dictionary>(); Dictionary watchedIndividualKvs = null; + Dictionary watchedIndividualKvChangeDetectedTime = new Dictionary(); HashSet ffKeys = new HashSet(); await ExecuteWithFailOverPolicyAsync( @@ -783,18 +851,22 @@ await ExecuteWithFailOverPolicyAsync( ffEtags, _options.Selectors, ffKeys, + false, cancellationToken) .ConfigureAwait(false); watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh( client, data, + watchedIndividualKvChangeDetectedTime, cancellationToken) .ConfigureAwait(false); }, cancellationToken) .ConfigureAwait(false); + Debug.Assert(watchedIndividualKvs != null); + // Update the next refresh time for all refresh registered settings and feature flags foreach (KeyValueWatcher changeWatcher in _options.IndividualKvWatchers.Concat(_options.FeatureFlagWatchers)) { @@ -828,10 +900,11 @@ await ExecuteWithFailOverPolicyAsync( private async Task> LoadSelected( ConfigurationClient client, - Dictionary> kvEtags, - Dictionary> ffEtags, + Dictionary> kvEtags, + Dictionary> ffEtags, IEnumerable selectors, HashSet ffKeys, + bool loadFeatureFlagOnly, CancellationToken cancellationToken) { Dictionary data = new Dictionary(); @@ -854,7 +927,7 @@ private async Task> LoadSelected( } } - var matchConditions = new List(); + var pageWatchers = new List(); await CallWithRequestTracing(async () => { @@ -863,6 +936,12 @@ await CallWithRequestTracing(async () => await foreach (Page page in pageableSettings.AsPages(_options.ConfigurationSettingPageIterator).ConfigureAwait(false)) { using Response response = page.GetRawResponse(); + DateTimeOffset responseDate = response.GetDate(); + _isLastRefreshAborted = responseDate < _lastChangeDetectedTime; + if (_isLastRefreshAborted) + { + return; + } foreach (ConfigurationSetting setting in page.Values) { @@ -901,17 +980,26 @@ await CallWithRequestTracing(async () => // The ETag will never be null here because it's not a conditional request // Each successful response should have 200 status code and an ETag - matchConditions.Add(new MatchConditions { IfNoneMatch = response.Headers.ETag }); + pageWatchers.Add(new PageWatcher() + { + Etag = new MatchConditions { IfNoneMatch = response.Headers.ETag }, + LastUpdateTime = responseDate + }); } }).ConfigureAwait(false); + if (_isLastRefreshAborted) + { + return null; + } + if (loadOption.IsFeatureFlagSelector) { - ffEtags[loadOption] = matchConditions; + ffEtags[loadOption] = pageWatchers; } else { - kvEtags[loadOption] = matchConditions; + kvEtags[loadOption] = pageWatchers; } } else @@ -969,6 +1057,7 @@ await CallWithRequestTracing(async () => private async Task> LoadKeyValuesRegisteredForRefresh( ConfigurationClient client, IDictionary existingSettings, + Dictionary watchedIndividualKvChangeDetectedTime, CancellationToken cancellationToken) { var watchedIndividualKvs = new Dictionary(); @@ -992,11 +1081,61 @@ private async Task> LoadKey ConfigurationSetting watchedKv = null; try { - await CallWithRequestTracing(async () => watchedKv = await client.GetConfigurationSettingAsync(watchedKey, watchedLabel, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + Response response = null; + await CallWithRequestTracing(async () => + { + response = await client.GetConfigurationSettingAsync(watchedKey, watchedLabel, cancellationToken).ConfigureAwait(false); + }).ConfigureAwait(false); + + using Response rawResponse = response.GetRawResponse(); + DateTimeOffset responseDate = rawResponse.GetDate(); + if (_watchedIndividualKvChangeDetectedTime.TryGetValue(watchedKeyLabel, out DateTimeOffset lastChangeDetectedTime)) + { + if (responseDate >= lastChangeDetectedTime) + { + watchedKv = response.Value; + watchedIndividualKvChangeDetectedTime[watchedKeyLabel] = responseDate; + _isLastRefreshAborted = false; + } + else + { + _isLastRefreshAborted = true; + return null; + } + } + else + { + // initial load + watchedKv = response.Value; + watchedIndividualKvChangeDetectedTime[watchedKeyLabel] = responseDate; + _isLastRefreshAborted = false; + } } catch (RequestFailedException e) when (e.Status == (int)HttpStatusCode.NotFound) { - watchedKv = null; + using Response rawResponse = e.GetRawResponse(); + DateTimeOffset responseDate = rawResponse.GetDate(); + if (_watchedIndividualKvChangeDetectedTime.TryGetValue(watchedKeyLabel, out DateTimeOffset lastChangeDetectedTime)) + { + if (responseDate >= lastChangeDetectedTime) + { + watchedKv = null; + watchedIndividualKvChangeDetectedTime[watchedKeyLabel] = responseDate; + _isLastRefreshAborted = false; + } + else + { + _isLastRefreshAborted = true; + return null; + } + } + else + { + // initial load + watchedKey = null; + watchedIndividualKvChangeDetectedTime[watchedKeyLabel] = responseDate; + _isLastRefreshAborted = false; + } } // If the key-value was found, store it for updating the settings @@ -1054,17 +1193,24 @@ private async Task RefreshIndividualKvWatchers( KeyValueChange change = default; + Debug.Assert(_watchedIndividualKvChangeDetectedTime.ContainsKey(watchedKeyLabel)); + _watchedIndividualKvChangeDetectedTime.TryGetValue(watchedKeyLabel, out DateTimeOffset lastChangeDetectedTime); + // // Find if there is a change associated with watcher if (_watchedIndividualKvs.TryGetValue(watchedKeyLabel, out ConfigurationSetting watchedKv)) { await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions, - async () => change = await client.GetKeyValueChange(watchedKv, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + async () => change = await client.GetKeyValueChange( + watchedKv, + makeConditionalRequest: !_options.IsAfdUsed, + lastChangeDetectedTime, + cancellationToken).ConfigureAwait(false) + ).ConfigureAwait(false); } else { // Load the key-value in case the previous load attempts had failed - try { await CallWithRequestTracing( @@ -1097,6 +1243,7 @@ await CallWithRequestTracing( // If the watcher is set to refresh all, or the content type matches the snapshot reference content type then refresh all if (kvWatcher.RefreshAll || watchedKv.ContentType == SnapshotReferenceConstants.ContentType) { + _lastChangeDetectedTime = change.DetectedTime; return true; } } @@ -1427,50 +1574,62 @@ private void UpdateClientBackoffStatus(Uri endpoint, bool successful) _configClientBackoffs[endpoint] = clientBackoffStatus; } - private async Task HaveCollectionsChanged( + private async Task GetCollectionChange( IEnumerable selectors, - Dictionary> pageEtags, + Dictionary> pageWatchers, ConfigurationClient client, CancellationToken cancellationToken) { - bool haveCollectionsChanged = false; + Page pageChanged = null; foreach (KeyValueSelector selector in selectors) { - if (pageEtags.TryGetValue(selector, out IEnumerable matchConditions)) + Debug.Assert(pageWatchers.ContainsKey(selector)); + + if (pageWatchers.TryGetValue(selector, out IEnumerable watchers)) { await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions, - async () => haveCollectionsChanged = await client.HaveCollectionsChanged( + async () => pageChanged = await client.GetPageChange( selector, - matchConditions, + watchers, _options.ConfigurationSettingPageIterator, + makeConditionalRequest: !_options.IsAfdUsed, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); - } - if (haveCollectionsChanged) - { - return true; + if (pageChanged != null) + { + return new CollectionChange + { + Found = true, + Time = pageChanged.GetRawResponse().GetDate() + }; + } } } - return haveCollectionsChanged; + return new CollectionChange + { + Found = false + }; } private async Task ProcessKeyValueChangesAsync( IEnumerable keyValueChanges, Dictionary mappedData, - Dictionary watchedIndividualKvs) + Dictionary watchedIndividualKvs, + Dictionary watchedIndividualKvChangeDetectedTime) { foreach (KeyValueChange change in keyValueChanges) { KeyValueIdentifier changeIdentifier = new KeyValueIdentifier(change.Key, change.Label); + watchedIndividualKvChangeDetectedTime[changeIdentifier] = change.DetectedTime; + if (change.ChangeType == KeyValueChangeType.Modified) { ConfigurationSetting setting = change.Current; - ConfigurationSetting settingCopy = new ConfigurationSetting(setting.Key, setting.Value, setting.Label, setting.ETag); - watchedIndividualKvs[changeIdentifier] = settingCopy; + watchedIndividualKvs[changeIdentifier] = setting; foreach (Func> func in _options.Mappers) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs index 3bdcaecd5..3209c7a7f 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs @@ -34,5 +34,6 @@ internal class LoggingConstants public const string RefreshSkippedNoClientAvailable = "Refresh skipped because no endpoint is accessible."; public const string RefreshFailedToGetSettingsFromEndpoint = "Failed to get configuration settings from endpoint"; public const string FailingOverToEndpoint = "Failing over to endpoint"; + public const string RefreshAborted = "Configuration refresh aborted."; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index b4ae0bc31..30b4ae49c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -3,9 +3,11 @@ // using Azure; using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Threading; using System.Threading.Tasks; @@ -14,7 +16,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { internal static class ConfigurationClientExtensions { - public static async Task GetKeyValueChange(this ConfigurationClient client, ConfigurationSetting setting, bool makeConditionalRequest, CancellationToken cancellationToken) + public static async Task GetKeyValueChange(this ConfigurationClient client, ConfigurationSetting setting, bool makeConditionalRequest, DateTimeOffset lastChangeDetectedTime, CancellationToken cancellationToken) { if (setting == null) { @@ -29,8 +31,10 @@ public static async Task GetKeyValueChange(this ConfigurationCli try { Response response = await client.GetConfigurationSettingAsync(setting, onlyIfChanged: makeConditionalRequest, cancellationToken).ConfigureAwait(false); - if (response.GetRawResponse().Status == (int)HttpStatusCode.OK && - !response.Value.ETag.Equals(setting.ETag)) + using Response rawResponse = response.GetRawResponse(); + if (rawResponse.Status == (int)HttpStatusCode.OK && + !response.Value.ETag.Equals(setting.ETag) && + rawResponse.GetDate() >= lastChangeDetectedTime) { return new KeyValueChange { @@ -38,20 +42,26 @@ public static async Task GetKeyValueChange(this ConfigurationCli Previous = setting, Current = response.Value, Key = setting.Key, - Label = setting.Label + Label = setting.Label, + DetectedTime = rawResponse.GetDate() }; } } catch (RequestFailedException e) when (e.Status == (int)HttpStatusCode.NotFound && setting.ETag != default) { - return new KeyValueChange + using Response rawResponse = e.GetRawResponse(); + if (rawResponse.GetDate() >= lastChangeDetectedTime) { - ChangeType = KeyValueChangeType.Deleted, - Previous = setting, - Current = null, - Key = setting.Key, - Label = setting.Label - }; + return new KeyValueChange + { + ChangeType = KeyValueChangeType.Deleted, + Previous = setting, + Current = null, + Key = setting.Key, + Label = setting.Label, + DetectedTime = rawResponse.GetDate() + }; + } } return new KeyValueChange @@ -60,15 +70,16 @@ public static async Task GetKeyValueChange(this ConfigurationCli Previous = setting, Current = setting, Key = setting.Key, - Label = setting.Label + Label = setting.Label, + DetectedTime = lastChangeDetectedTime }; } - public static async Task HaveCollectionsChanged(this ConfigurationClient client, KeyValueSelector keyValueSelector, IEnumerable matchConditions, IConfigurationSettingPageIterator pageIterator, bool makeConditionalRequest, CancellationToken cancellationToken) + public static async Task> GetPageChange(this ConfigurationClient client, KeyValueSelector keyValueSelector, IEnumerable pageWatchers, IConfigurationSettingPageIterator pageIterator, bool makeConditionalRequest, CancellationToken cancellationToken) { - if (matchConditions == null) + if (pageWatchers == null) { - throw new ArgumentNullException(nameof(matchConditions)); + throw new ArgumentNullException(nameof(pageWatchers)); } if (keyValueSelector == null) @@ -89,25 +100,29 @@ public static async Task HaveCollectionsChanged(this ConfigurationClient c AsyncPageable pageable = client.GetConfigurationSettingsAsync(selector, cancellationToken); - using IEnumerator existingMatchConditionsEnumerator = matchConditions.GetEnumerator(); + IAsyncEnumerable> pages = makeConditionalRequest + ? pageable.AsPages(pageIterator, pageWatchers.Select(p => p.Etag)) + : pageable.AsPages(pageIterator); + + List existingWatcherList = pageWatchers.ToList(); - IAsyncEnumerable> pages = makeConditionalRequest ? pageable.AsPages(pageIterator, matchConditions) : pageable.AsPages(pageIterator); + int i = 0; await foreach (Page page in pages.ConfigureAwait(false)) { using Response response = page.GetRawResponse(); + DateTimeOffset timestamp = response.GetDate(); - // Return true if the lists of etags are different - if ((!existingMatchConditionsEnumerator.MoveNext() || - !existingMatchConditionsEnumerator.Current.IfNoneMatch.Equals(response.Headers.ETag)) && - response.Status == (int)HttpStatusCode.OK) + if (i >= existingWatcherList.Count || + (response.Status == (int)HttpStatusCode.OK && + timestamp >= existingWatcherList[i].LastUpdateTime && + !existingWatcherList[i].Etag.IfNoneMatch.Equals(response.Headers.ETag))) { - return true; + return page; } } - // Need to check if pages were deleted and no change was found within the new shorter list of match conditions - return existingMatchConditionsEnumerator.MoveNext(); + return null; } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs new file mode 100644 index 000000000..3b43644b6 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs @@ -0,0 +1,13 @@ +using Azure; +using System; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions +{ + internal static class ResponseExtensions + { + public static DateTimeOffset GetDate(this Response response) + { + return response.Headers.Date.HasValue ? response.Headers.Date.Value : DateTimeOffset.UtcNow; + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/KeyValueChange.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/KeyValueChange.cs index 2286016d7..ab3fdb0be 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/KeyValueChange.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/KeyValueChange.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // using Azure.Data.AppConfiguration; +using System; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { @@ -23,5 +24,7 @@ internal struct KeyValueChange public ConfigurationSetting Current { get; set; } public ConfigurationSetting Previous { get; set; } + + public DateTimeOffset DetectedTime { get; set; } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/PageWatcher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/PageWatcher.cs new file mode 100644 index 000000000..470cd0e54 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/PageWatcher.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using System; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class PageWatcher + { + public MatchConditions Etag { get; set; } + public DateTimeOffset LastUpdateTime { get; set; } + } +} From a9dd87cb3bbeaeb0e4e471d284f7a0b7d3d02b75 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Wed, 15 Oct 2025 23:09:22 +0800 Subject: [PATCH 03/20] wip --- .../AzureAppConfigurationProvider.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 0b232b6d6..dce27638c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -232,8 +232,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken) !refreshableIndividualKvWatchers.Any() && !refreshableFfWatchers.Any() && !isRefreshDue && - !_options.Adapters.Any(adapter => adapter.NeedsRefresh()) && - !_isLastRefreshAborted) + !_options.Adapters.Any(adapter => adapter.NeedsRefresh())) { return; } From 85f0306dde95df4867ee67d7552a9988ce6f6c19 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 16 Oct 2025 11:21:36 +0800 Subject: [PATCH 04/20] wip --- .../Extensions/ResponseExtensions.cs | 2 +- .../Unit/FailoverTests.cs | 42 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs index 3b43644b6..597adfa71 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs @@ -7,7 +7,7 @@ internal static class ResponseExtensions { public static DateTimeOffset GetDate(this Response response) { - return response.Headers.Date.HasValue ? response.Headers.Date.Value : DateTimeOffset.UtcNow; + return response.Headers.Date ?? DateTimeOffset.UtcNow; } } } diff --git a/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs index 57735f379..28fe795b3 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs @@ -2,6 +2,8 @@ // Licensed under the MIT license. // using Azure; +using Azure.Core; +using Azure.Core.Testing; using Azure.Data.AppConfiguration; using Azure.Identity; using Microsoft.Extensions.Configuration; @@ -27,7 +29,6 @@ public async Task FailOverTests_ReturnsAllClientsIfAllBackedOff() { // Arrange IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); var mockClient1 = new Mock(); mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -96,7 +97,6 @@ public void FailOverTests_PropagatesNonFailOverableExceptions() { // Arrange IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); var mockClient1 = new Mock(); mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -148,7 +148,7 @@ public async Task FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest() { // Arrange IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient1 = new Mock(); mockClient1.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -156,11 +156,11 @@ public async Task FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest() .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())) .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); mockClient1.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient1.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient1.Setup(c => c.Equals(mockClient1)).Returns(true); var mockClient2 = new Mock(); @@ -168,11 +168,11 @@ public async Task FailOverTests_BackoffStateIsUpdatedOnSuccessfulRequest() .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())) .Returns(new MockAsyncPageable(Enumerable.Empty().ToList())); mockClient2.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); @@ -222,7 +222,7 @@ public void FailOverTests_AutoFailover() { // Arrange IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient1 = new Mock(); mockClient1.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -237,9 +237,9 @@ public void FailOverTests_AutoFailover() 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())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); @@ -342,7 +342,7 @@ public void FailOverTests_NetworkTimeout() { // Arrange IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var client1 = new ConfigurationClient(TestHelpers.CreateMockEndpointString(), new ConfigurationClientOptions() @@ -357,9 +357,9 @@ public void FailOverTests_NetworkTimeout() 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())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, client1); @@ -420,7 +420,7 @@ ae.InnerException is AggregateException ae2 && public async Task FailOverTests_AllClientsBackedOffAfterNonFailoverableException() { IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); // Setup first client - succeeds on startup, fails with 404 (non-failoverable) on first refresh var mockClient1 = new Mock(); @@ -429,7 +429,7 @@ public async Task FailOverTests_AllClientsBackedOffAfterNonFailoverableException .Throws(new RequestFailedException(412, "Request failed.")) .Throws(new RequestFailedException(412, "Request failed.")); mockClient1.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))) + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))) .Throws(new RequestFailedException(412, "Request failed.")) .Throws(new RequestFailedException(412, "Request failed.")); mockClient1.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) @@ -442,9 +442,9 @@ public async Task FailOverTests_AllClientsBackedOffAfterNonFailoverableException 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())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(kv, mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(kv, mockResponse))); mockClient2.Setup(c => c.Equals(mockClient2)).Returns(true); ConfigurationClientWrapper cw1 = new ConfigurationClientWrapper(TestHelpers.PrimaryConfigStoreEndpoint, mockClient1.Object); From 0ad2a50008cc2e3705d4d76004e0d0e9c65bf5da Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 16 Oct 2025 17:33:13 +0800 Subject: [PATCH 05/20] fix test --- .../AzureAppConfigurationProvider.cs | 14 +-- .../Unit/FeatureManagementTests.cs | 2 +- .../Unit/KeyVaultReferenceTests.cs | 30 +---- .../Unit/LoggingTests.cs | 19 +++- .../Unit/MapTests.cs | 4 +- .../Unit/PushRefreshTests.cs | 4 +- .../Unit/RefreshTests.cs | 103 ++++++++++++++---- .../Unit/SnapshotReferenceTests.cs | 28 ++--- 8 files changed, 121 insertions(+), 83 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index dce27638c..4b765c288 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -307,7 +307,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => ffEtags = null; ffKeys = null; watchedIndividualKvs = null; - watchedIndividualKvChanges = null; + watchedIndividualKvChanges = new List(); data = null; ffCollectionData = null; refreshFf = false; @@ -339,6 +339,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => } else { + watchedIndividualKvChanges = new List(); if (_isLastRefreshAborted) { if (_options.IndividualKvWatchers.Any(w => w.RefreshAll)) @@ -348,7 +349,6 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => } else { - watchedIndividualKvChanges = new List(); refreshAll = await RefreshIndividualKvWatchers( client, watchedIndividualKvChanges, @@ -1068,14 +1068,6 @@ private async Task> LoadKey KeyValueIdentifier watchedKeyLabel = new KeyValueIdentifier(watchedKey, watchedLabel); - // Skip the loading for the key-value in case it has already been loaded - if (existingSettings.TryGetValue(watchedKey, out ConfigurationSetting loadedKv) - && watchedKeyLabel.Equals(new KeyValueIdentifier(loadedKv.Key, loadedKv.Label))) - { - watchedIndividualKvs[watchedKeyLabel] = new ConfigurationSetting(loadedKv.Key, loadedKv.Value, loadedKv.Label, loadedKv.ETag); - continue; - } - // Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing ConfigurationSetting watchedKv = null; try @@ -1192,7 +1184,7 @@ private async Task RefreshIndividualKvWatchers( KeyValueChange change = default; - Debug.Assert(_watchedIndividualKvChangeDetectedTime.ContainsKey(watchedKeyLabel)); + // if fail to get, the default DateTimeOffset.MinValue will be used _watchedIndividualKvChangeDetectedTime.TryGetValue(watchedKeyLabel, out DateTimeOffset lastChangeDetectedTime); // diff --git a/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs index a668b3a15..1e9508426 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs @@ -2367,7 +2367,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(TestHelpers.CloneSetting(FirstKeyValue), new Mock().Object); + return Response.FromValue(TestHelpers.CloneSetting(FirstKeyValue), new MockResponse(200)); } private ConfigurationSetting CreateFeatureFlag(string featureId, diff --git a/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs index 3e856a1b0..a671300eb 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/KeyVaultReferenceTests.cs @@ -171,7 +171,6 @@ public class KeyVaultReferenceTests [Fact] public void NotSecretIdentifierURI() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kvNoUrl })); @@ -198,7 +197,6 @@ public void NotSecretIdentifierURI() [Fact] public void UseSecret() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -223,7 +221,6 @@ public void UseSecret() [Fact] public void UseCertificate() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kvCertRef })); @@ -248,7 +245,6 @@ public void UseCertificate() [Fact] public void ThrowsWhenSecretNotFound() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -273,7 +269,6 @@ public void ThrowsWhenSecretNotFound() [Fact] public void DisabledSecretIdentifier() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -298,7 +293,6 @@ public void DisabledSecretIdentifier() [Fact] public void WrongContentType() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kvWrongContentType })); @@ -320,7 +314,6 @@ public void WrongContentType() [Fact] public void MultipleKeys() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollectionPageOne)); @@ -346,7 +339,6 @@ public void MultipleKeys() [Fact] public void CancellationToken() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollectionPageOne)); @@ -374,7 +366,6 @@ public void CancellationToken() [Fact] public void HasNoAccessToKeyVault() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -400,7 +391,6 @@ public void HasNoAccessToKeyVault() [Fact] public void RegisterMultipleClients() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -431,7 +421,6 @@ public void RegisterMultipleClients() [Fact] public void ServerRequestIsMadeWhenDefaultCredentialIsSet() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -452,7 +441,6 @@ public void ServerRequestIsMadeWhenDefaultCredentialIsSet() [Fact] public void ThrowsWhenNoMatchingSecretClientIsFound() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -481,7 +469,6 @@ public void ThrowsWhenNoMatchingSecretClientIsFound() [Fact] public void ThrowsWhenConfigureKeyVaultIsMissing() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -524,7 +511,6 @@ public void DoesNotThrowKeyVaultExceptionWhenProviderIsOptional() [Fact] public void CallsSecretResolverCallbackWhenNoMatchingSecretClientIsFound() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -549,7 +535,6 @@ public void CallsSecretResolverCallbackWhenNoMatchingSecretClientIsFound() [Fact] public void ThrowsWhenBothDefaultCredentialAndSecretResolverCallbackAreSet() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -579,7 +564,6 @@ public void ThrowsWhenBothDefaultCredentialAndSecretResolverCallbackAreSet() [Fact] public void ThrowsWhenSecretResolverIsNull() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -601,7 +585,6 @@ public void ThrowsWhenSecretResolverIsNull() [Fact] public void LastKeyVaultOptionsWinWithMultipleConfigureKeyVaultCalls() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -630,7 +613,6 @@ public void LastKeyVaultOptionsWinWithMultipleConfigureKeyVaultCalls() [Fact] public void DontUseSecretResolverCallbackWhenMatchingSecretClientIsPresent() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -663,7 +645,6 @@ public void DontUseSecretResolverCallbackWhenMatchingSecretClientIsPresent() [Fact] public void ThrowsWhenSecretRefreshIntervalIsTooShort() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -688,12 +669,12 @@ public async Task SecretIsReturnedFromCacheIfSecretCacheHasNotExpired() IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(1); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -761,12 +742,12 @@ public async Task CachedSecretIsInvalidatedWhenRefreshAllIsTrue() IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(60); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -833,7 +814,6 @@ public async Task SecretIsReloadedFromKeyVaultWhenCacheExpires() IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(60); - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(new List { _kv })); @@ -965,7 +945,6 @@ public async Task SecretsWithDifferentRefreshIntervals() [Fact] public void ThrowsWhenInvalidKeyVaultSecretReferenceJson() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); var cacheExpiration = TimeSpan.FromSeconds(1); @@ -1008,7 +987,6 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) [Fact] public void AlternateValidKeyVaultSecretReferenceJsons() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); var cacheExpiration = TimeSpan.FromSeconds(1); diff --git a/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs b/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs index 6fcec29fd..9fd87b887 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/LoggingTests.cs @@ -187,12 +187,12 @@ public async Task ValidateKeyVaultExceptionLoggedDuringRefresh() IConfigurationRefresher refresher = null; // Mock ConfigurationClient - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(sentinelKv), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -602,17 +602,24 @@ public async Task ValidateCorrectKeyVaultSecretLoggedDuringRefresh() private Mock GetMockConfigurationClient() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); - Response GetTestKey(string key, string label, CancellationToken cancellationToken) + Response GetSetting(string key, string label, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { cancellationToken.ThrowIfCancellationRequested(); } - return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse.Object); + if (label.Equals(LabelFilter.Null)) + { + label = null; + } + + ConfigurationSetting setting = _kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label); + + return Response.FromValue(TestHelpers.CloneSetting(setting), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -636,7 +643,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o }); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((Func>)GetTestKey); + .ReturnsAsync((Func>)GetSetting); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((Func>)GetIfChanged); diff --git a/tests/Tests.AzureAppConfiguration/Unit/MapTests.cs b/tests/Tests.AzureAppConfiguration/Unit/MapTests.cs index cdf15f4e6..a82aa54fb 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/MapTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/MapTests.cs @@ -537,7 +537,7 @@ public async Task MapTransformSettingKeyWithLogAndRefresh() private Mock GetMockConfigurationClient() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) @@ -547,7 +547,7 @@ Response GetTestKey(string key, string label, Cancellation cancellationToken.ThrowIfCancellationRequested(); } - return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) diff --git a/tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs b/tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs index 41e265d20..db0b7de5b 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/PushRefreshTests.cs @@ -357,12 +357,12 @@ public async Task RefreshAsyncUpdatesConfig() private Mock GetMockConfigurationClient() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) diff --git a/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs b/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs index 08a414780..25d57baab 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/RefreshTests.cs @@ -5,6 +5,7 @@ using Azure.Core.Testing; using Azure.Data.AppConfiguration; using Azure.Identity; +using Azure.ResourceManager.KeyVault; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; @@ -66,12 +67,12 @@ public class RefreshTests public void RefreshTests_RefreshRegisteredKeysAreLoadedOnStartup_DefaultUseQuery() { var keyValueCollection = new List(_kvCollection); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == key && s.Label == label), mockResponse.Object); + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == key && s.Label == label), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -298,12 +299,12 @@ public async Task RefreshTests_RefreshAllTrueUpdatesEntireConfiguration() public async Task RefreshTests_RefreshAllTrueRemovesDeletedConfiguration() { var keyValueCollection = new List(_kvCollection); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetSettingFromService(string k, string l, CancellationToken ct) { - return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse.Object); + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -370,12 +371,17 @@ Response GetIfChanged(ConfigurationSetting setting, bool o public async Task RefreshTests_RefreshAllForNonExistentSentinelDoesNothing() { var keyValueCollection = new List(_kvCollection); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); - Response GetSettingFromService(string k, string l, CancellationToken ct) + Response GetSetting(string k, string l, CancellationToken ct) { - return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse.Object); + if (l.Equals(LabelFilter.Null)) + { + l = null; + } + + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -399,7 +405,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o }); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((Func>)GetSettingFromService); + .ReturnsAsync((Func>)GetSetting); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((Func>)GetIfChanged); @@ -448,7 +454,7 @@ public async Task RefreshTests_SingleServerCallOnSimultaneousMultipleRefresh() { var keyValueCollection = new List(_kvCollection); var requestCount = 0; - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); // Define delay for async operations @@ -478,9 +484,22 @@ async Task> GetIfChanged(ConfigurationSetting set return Response.FromValue(newSetting, response); } + Response GetSetting(string k, string l, CancellationToken ct) + { + if (l.Equals(LabelFilter.Null)) + { + l = null; + } + + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse); + } + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .Returns((Func>>)GetIfChanged); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetSetting); + IConfigurationRefresher refresher = null; var config = new ConfigurationBuilder() @@ -622,18 +641,32 @@ public async Task RefreshTests_TryRefreshAsyncUpdatesConfigurationAndReturnsTrue public async Task RefreshTests_TryRefreshAsyncReturnsFalseForAuthenticationFailedException() { IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); + var keyValueCollection = new List(_kvCollection); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); + Response GetSetting(string k, string l, CancellationToken ct) + { + if (l.Equals(LabelFilter.Null)) + { + l = null; + } + + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse); + } + mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollection.Select(setting => TestHelpers.CloneSetting(setting)).ToList())); var innerException = new AuthenticationFailedException("Authentication failed.") { Source = "Azure.Identity" }; mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(_kvCollection.FirstOrDefault(s => s.Key == "TestKey1"), mockResponse.Object))) + .Returns(Task.FromResult(Response.FromValue(_kvCollection.FirstOrDefault(s => s.Key == "TestKey1"), mockResponse))) .Throws(new KeyVaultReferenceException(innerException.Message, innerException)); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetSetting); + var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { @@ -669,9 +702,20 @@ public async Task RefreshTests_TryRefreshAsyncReturnsFalseForAuthenticationFaile public async Task RefreshTests_RefreshAsyncThrowsOnExceptionWhenOptionalIsTrueForInitialLoad() { IConfigurationRefresher refresher = null; - var mockResponse = new Mock(); + var keyValueCollection = new List(_kvCollection); + var mockResponse = new MockResponse(200); var mockClient = new Mock() { CallBase = true }; + Response GetSetting(string k, string l, CancellationToken ct) + { + if (l.Equals(LabelFilter.Null)) + { + l = null; + } + + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse); + } + Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) { var newSetting = _kvCollection.FirstOrDefault(s => s.Key == setting.Key && s.Label == setting.Label); @@ -684,6 +728,9 @@ Response GetIfChanged(ConfigurationSetting setting, bool o .Returns(new MockAsyncPageable(_kvCollection.Select(setting => TestHelpers.CloneSetting(setting)).ToList())) .Throws(new RequestFailedException("Request failed.")); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetSetting); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((Func>)GetIfChanged); @@ -716,7 +763,7 @@ await refresher.RefreshAsync() [Fact] public async Task RefreshTests_UpdatesAllSettingsIfInitialLoadFails() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock() { CallBase = true }; mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -725,7 +772,7 @@ public async Task RefreshTests_UpdatesAllSettingsIfInitialLoadFails() .Returns(new MockAsyncPageable(_kvCollection)); mockClient.SetupSequence(c => c.GetConfigurationSettingAsync("TestKey1", It.IsAny(), It.IsAny())) - .Returns(Task.FromResult(Response.FromValue(_kvCollection.FirstOrDefault(s => s.Key == "TestKey1" && s.Label == "label"), mockResponse.Object))); + .Returns(Task.FromResult(Response.FromValue(_kvCollection.FirstOrDefault(s => s.Key == "TestKey1" && s.Label == "label"), mockResponse))); IConfigurationRefresher refresher = null; IConfiguration configuration = new ConfigurationBuilder() @@ -779,9 +826,19 @@ await Assert.ThrowsAsync(async () => public async Task RefreshTests_SentinelKeyNotUpdatedOnRefreshAllFailure() { var keyValueCollection = new List(_kvCollection); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock() { CallBase = true }; + Response GetSetting(string k, string l, CancellationToken ct) + { + if (l.Equals(LabelFilter.Null)) + { + l = null; + } + + return Response.FromValue(keyValueCollection.FirstOrDefault(s => s.Key == k && s.Label == l), mockResponse); + } + Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) { var newSetting = keyValueCollection.FirstOrDefault(s => s.Key == setting.Key); @@ -795,13 +852,17 @@ Response GetIfChanged(ConfigurationSetting setting, bool o .Throws(new RequestFailedException(429, "Too many requests")) .Returns(new MockAsyncPageable(keyValueCollection.Select(setting => { - setting.Value = "newValue"; - return TestHelpers.CloneSetting(setting); + var newSetting = TestHelpers.CloneSetting(setting); + newSetting.Value = "newValue"; + return newSetting; }).ToList())); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((Func>)GetIfChanged); + mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Func>)GetSetting); + IConfigurationRefresher refresher = null; var config = new ConfigurationBuilder() @@ -1323,7 +1384,7 @@ private void WaitAndRefresh(IConfigurationRefresher refresher, int millisecondsD private Mock GetMockConfigurationClient() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); Response GetTestKey(string key, string label, CancellationToken cancellationToken) @@ -1333,7 +1394,7 @@ Response GetTestKey(string key, string label, Cancellation cancellationToken.ThrowIfCancellationRequested(); } - return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) @@ -1367,7 +1428,7 @@ Response GetIfChanged(ConfigurationSetting setting, bool o private Mock GetMockConfigurationClientSelectKeyLabel() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) @@ -1381,7 +1442,7 @@ MockAsyncPageable GetTestKeys(SettingSelector selector, CancellationToken ct) Response GetTestKey(string key, string label, CancellationToken cancellationToken) { - return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse.Object); + return Response.FromValue(TestHelpers.CloneSetting(_kvCollection.FirstOrDefault(s => s.Key == key && s.Label == label)), mockResponse); } Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) diff --git a/tests/Tests.AzureAppConfiguration/Unit/SnapshotReferenceTests.cs b/tests/Tests.AzureAppConfiguration/Unit/SnapshotReferenceTests.cs index 830a92f28..c49858e3e 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/SnapshotReferenceTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/SnapshotReferenceTests.cs @@ -366,7 +366,7 @@ public async Task SnapshotReferenceRegisteredWithRefreshAllFalse() IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(1); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); bool refreshAllTriggered = false; @@ -378,7 +378,7 @@ public async Task SnapshotReferenceRegisteredWithRefreshAllFalse() var realSnapshot = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) - .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny())) .Returns(new MockAsyncPageable(new List { _settingInSnapshot1 })); @@ -387,7 +387,7 @@ public async Task SnapshotReferenceRegisteredWithRefreshAllFalse() var realSnapshot2 = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; mockClient.Setup(c => c.GetSnapshotAsync("snapshot2", It.IsAny>(), It.IsAny())) - .ReturnsAsync(Response.FromValue(realSnapshot2, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(realSnapshot2, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot2", It.IsAny())) .Returns(new MockAsyncPageable(new List { _settingInSnapshot2 })); @@ -395,7 +395,7 @@ public async Task SnapshotReferenceRegisteredWithRefreshAllFalse() mockClient.Setup(c => c.GetConfigurationSettingAsync("SnapshotRef1", It.IsAny(), It.IsAny())) .ReturnsAsync(() => { - return Response.FromValue(updatedSnapshotRef1, mockResponse.Object); + return Response.FromValue(updatedSnapshotRef1, mockResponse); }); // Setup refresh check - simulate change detected @@ -439,7 +439,7 @@ public async Task SnapshotReferenceRegisteredWithRefreshAllTrue_TriggersRefreshA IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(1); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); bool refreshAllTriggered = false; @@ -451,13 +451,13 @@ public async Task SnapshotReferenceRegisteredWithRefreshAllTrue_TriggersRefreshA var realSnapshot = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) - .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny())) .Returns(new MockAsyncPageable(new List { _settingInSnapshot1 })); mockClient.Setup(c => c.GetConfigurationSettingAsync("SnapshotRef1", It.IsAny(), It.IsAny())) - .ReturnsAsync(() => Response.FromValue(_snapshotReference1, mockResponse.Object)); + .ReturnsAsync(() => Response.FromValue(_snapshotReference1, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((ConfigurationSetting setting, bool onlyIfChanged, CancellationToken token) => @@ -497,7 +497,7 @@ public async Task SnapshotReferenceRegisteredWithoutRefreshAllParameter_StillTri IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(1); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); bool refreshAllTriggered = false; @@ -509,13 +509,13 @@ public async Task SnapshotReferenceRegisteredWithoutRefreshAllParameter_StillTri var realSnapshot = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) - .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny())) .Returns(new MockAsyncPageable(new List { _settingInSnapshot1 })); mockClient.Setup(c => c.GetConfigurationSettingAsync("SnapshotRef1", It.IsAny(), It.IsAny())) - .ReturnsAsync(() => Response.FromValue(_snapshotReference1, mockResponse.Object)); + .ReturnsAsync(() => Response.FromValue(_snapshotReference1, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((ConfigurationSetting setting, bool onlyIfChanged, CancellationToken token) => @@ -555,7 +555,7 @@ public void SnapshotReferenceRegisteredForRefreshButNotInSelect() IConfigurationRefresher refresher = null; TimeSpan refreshInterval = TimeSpan.FromSeconds(1); - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); // Only return regular key-value in initial load (snapshot reference not selected) @@ -567,14 +567,14 @@ public void SnapshotReferenceRegisteredForRefreshButNotInSelect() var realSnapshot = new ConfigurationSnapshot(settingsToInclude) { SnapshotComposition = SnapshotComposition.Key }; mockClient.Setup(c => c.GetSnapshotAsync("snapshot1", It.IsAny>(), It.IsAny())) - .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(realSnapshot, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingsForSnapshotAsync("snapshot1", It.IsAny())) .Returns(new MockAsyncPageable(new List { _settingInSnapshot1 })); // Mock the GetConfigurationSettingAsync call for the registered snapshot reference mockClient.Setup(c => c.GetConfigurationSettingAsync("SnapshotRef1", It.IsAny(), It.IsAny())) - .ReturnsAsync(() => Response.FromValue(_snapshotReference1, mockResponse.Object)); + .ReturnsAsync(() => Response.FromValue(_snapshotReference1, mockResponse)); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((ConfigurationSetting setting, bool onlyIfChanged, CancellationToken token) => @@ -612,4 +612,4 @@ public void SnapshotReferenceRegisteredForRefreshButNotInSelect() mockClient.Verify(c => c.GetConfigurationSettingAsync("SnapshotRef1", It.IsAny(), It.IsAny()), Times.Once); } } -} \ No newline at end of file +} From d39d46817da2a9725427ada471b0f6d7b2951212 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Fri, 17 Oct 2025 11:45:11 +0800 Subject: [PATCH 06/20] wip --- .../AzureAppConfigurationProvider.cs | 6 +- .../AzureAppConfigurationSource.cs | 2 +- .../Constants/ErrorMessages.cs | 2 +- .../Unit/AfdTests.cs | 144 ++++++++++++++++++ .../Tests.AzureAppConfiguration/Unit/Tests.cs | 1 - 5 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 4b765c288..cb0d2fa6b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -1184,7 +1184,8 @@ private async Task RefreshIndividualKvWatchers( KeyValueChange change = default; - // if fail to get, the default DateTimeOffset.MinValue will be used + // Unless initial load failed, _watchedIndividualKvChangeDetectedTime should always have an entry for the watched key-label + // If fail to get, lastChangeDetectedTime will be DateTimeOffset.MinValue by default _watchedIndividualKvChangeDetectedTime.TryGetValue(watchedKeyLabel, out DateTimeOffset lastChangeDetectedTime); // @@ -1300,7 +1301,8 @@ private void SetRequestTracingOptions() IsKeyVaultConfigured = _options.IsKeyVaultConfigured, IsKeyVaultRefreshConfigured = _options.IsKeyVaultRefreshConfigured, FeatureFlagTracing = _options.FeatureFlagTracing, - IsLoadBalancingEnabled = _options.LoadBalancingEnabled + IsLoadBalancingEnabled = _options.LoadBalancingEnabled, + IsAfdUsed = _options.IsAfdUsed }; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs index e68a3abaf..4dd1148f3 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -47,7 +47,7 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) if (clientFactory != null) { - throw new InvalidOperationException(ErrorMessages.AfdCustomClientOptionsUnsupported); + throw new InvalidOperationException(ErrorMessages.AfdCustomClientFactoryUnsupported); } options.ClientOptions.AddPolicy(new AfdPolicy(), HttpPipelinePosition.PerRetry); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs index 65268ffe0..1d571724e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs @@ -16,6 +16,6 @@ internal class ErrorMessages public const string SnapshotInvalidComposition = "{0} for the selected snapshot with name '{1}' must be 'key', found '{2}'."; public const string ConnectionConflict = "Cannot connect to both Azure App Configuration and Azure Front Door at the same time."; public const string AfdLoadBalancingUnsupported = "Load balancing is not supported when connecting to Azure Front Door."; - public const string AfdCustomClientOptionsUnsupported = "Custom client options are not supported when connecting to Azure Front Door."; + public const string AfdCustomClientFactoryUnsupported = "Custom client factory is not supported when connecting to Azure Front Door."; } } diff --git a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs new file mode 100644 index 000000000..153f0e100 --- /dev/null +++ b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure; +using Azure.Core; +using Azure.Identity; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Azure; +using System; +using System.Linq; +using Xunit; + +namespace Tests.AzureAppConfiguration +{ + public class AfdTests + { + private class TestClientFactory : IAzureClientFactory + { + public ConfigurationClient CreateClient(string name) + { + throw new NotImplementedException(); + } + } + + [Fact] + public void AfdTests_ConnectThrowsAfterConnectAzureFrontDoor() + { + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + var endpoint = new Uri("https://fake-endpoint.azconfig.io"); + var connectionString = "Endpoint=https://fake-endpoint.azconfig.io;Id=test;Secret=123456"; + var builder = new ConfigurationBuilder(); + var exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.Connect(endpoint, new DefaultAzureCredential()); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.ConnectionConflict, exception.InnerException.Message); + + exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.Connect(connectionString); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.ConnectionConflict, exception.InnerException.Message); + } + + [Fact] + public void AfdTests_ConnectAzureFrontDoorThrowsAfterConnect() + { + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + var endpoint = new Uri("https://fake-endpoint.azconfig.io"); + var connectionString = "Endpoint=https://fake-endpoint.azconfig.io;Id=test;Secret=123456"; + var builder = new ConfigurationBuilder(); + var exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.Connect(endpoint, new DefaultAzureCredential()); + options.ConnectAzureFrontDoor(afdEndpoint); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.ConnectionConflict, exception.InnerException.Message); + + exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.Connect(connectionString); + options.ConnectAzureFrontDoor(afdEndpoint); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.ConnectionConflict, exception.InnerException.Message); + } + + [Fact] + public void AfdTests_LoadbalancingIsUnsupportedWhenConnectAzureFrontDoor() + { + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + var builder = new ConfigurationBuilder(); + var exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.LoadBalancingEnabled = true; + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.AfdLoadBalancingUnsupported, exception.InnerException.Message); + } + + [Fact] + public void AfdTests_CustomClientOptionsIsUnsupportedWhenConnectAzureFrontDoor() + { + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + var builder = new ConfigurationBuilder(); + var exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.SetClientFactory(new TestClientFactory()); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.AfdCustomClientFactoryUnsupported, exception.InnerException.Message); + } + + [Fact] + public void AfdTests_LoadsConfiguration() + { + } + } +} diff --git a/tests/Tests.AzureAppConfiguration/Unit/Tests.cs b/tests/Tests.AzureAppConfiguration/Unit/Tests.cs index eb08bba26..7f06a73a4 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/Tests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/Tests.cs @@ -13,7 +13,6 @@ using System.Diagnostics; using System.Linq; using System.Threading; -using System.Threading.Tasks; using Xunit; namespace Tests.AzureAppConfiguration From 10e36a908c223b88517466e5e45c310dfe65bfaf Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Fri, 17 Oct 2025 22:34:30 +0800 Subject: [PATCH 07/20] wip --- .../Afd/AfdConfigurationClientManager.cs | 1 + .../AzureAppConfigurationOptions.cs | 8 +- .../AzureAppConfigurationProvider.cs | 1 + .../Constants/ErrorMessages.cs | 1 + .../ConfigurationClientExtensions.cs | 1 - .../Azure.Core.Testing/MockResponse.cs | 6 +- .../Unit/AfdTests.cs | 121 +++++++++++++++++- .../Unit/TestHelper.cs | 2 + .../Tests.AzureAppConfiguration/Unit/Tests.cs | 16 +-- 9 files changed, 136 insertions(+), 21 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs index 81a94f79d..fbf057c1b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Afd/AfdConfigurationClientManager.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Azure; using System; using System.Collections.Generic; + namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Afd { internal class AfdConfigurationClientManager : IConfigurationClientManager diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 42dff3aab..16a94719e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -433,7 +433,6 @@ public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCre } Credential = credential ?? throw new ArgumentNullException(nameof(credential)); - Endpoints = endpoints; ConnectionStrings = null; return this; @@ -450,6 +449,11 @@ public AzureAppConfigurationOptions ConnectAzureFrontDoor(Uri endpoint) throw new InvalidOperationException(ErrorMessages.ConnectionConflict); } + if (IsAfdUsed) + { + throw new InvalidOperationException(ErrorMessages.AfdConnectionConflict); + } + if (endpoint == null) { throw new ArgumentNullException(nameof(endpoint)); @@ -459,9 +463,7 @@ public AzureAppConfigurationOptions ConnectAzureFrontDoor(Uri endpoint) Endpoints = new List() { endpoint }; ConnectionStrings = null; - IsAfdUsed = true; - return this; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index cb0d2fa6b..79ed1f7e2 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -893,6 +893,7 @@ await ExecuteWithFailOverPolicyAsync( _kvEtags = kvEtags; _ffEtags = ffEtags; _watchedIndividualKvs = watchedIndividualKvs; + _watchedIndividualKvChangeDetectedTime = watchedIndividualKvChangeDetectedTime; _ffKeys = ffKeys; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs index 1d571724e..0d52aec31 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs @@ -15,6 +15,7 @@ internal class ErrorMessages public const string SnapshotReferencePropertyMissing = "Invalid snapshot reference format for key '{0}' (label: '{1}'). The '{2}' property is required."; public const string SnapshotInvalidComposition = "{0} for the selected snapshot with name '{1}' must be 'key', found '{2}'."; public const string ConnectionConflict = "Cannot connect to both Azure App Configuration and Azure Front Door at the same time."; + public const string AfdConnectionConflict = "Cannot connect to multiple Azure Front Doors."; public const string AfdLoadBalancingUnsupported = "Load balancing is not supported when connecting to Azure Front Door."; public const string AfdCustomClientFactoryUnsupported = "Custom client factory is not supported when connecting to Azure Front Door."; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index 30b4ae49c..dbc7bfde2 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -3,7 +3,6 @@ // using Azure; using Azure.Data.AppConfiguration; -using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; using System; using System.Collections.Generic; diff --git a/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs b/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs index c60c2a255..26fdc9107 100644 --- a/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs +++ b/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs @@ -13,15 +13,17 @@ public class MockResponse : Response { private readonly Dictionary> _headers = new Dictionary>(StringComparer.OrdinalIgnoreCase); - public MockResponse(int status, string reasonPhrase = null) + public MockResponse(int status, string etag = null, DateTimeOffset? date = null, string reasonPhrase = null) { Status = status; ReasonPhrase = reasonPhrase; if (status == 200) { - AddHeader(new HttpHeader(HttpHeader.Names.ETag, "\"" + Guid.NewGuid().ToString() + "\"")); + AddHeader(new HttpHeader(HttpHeader.Names.ETag, "\"" + etag ?? Guid.NewGuid().ToString() + "\"")); } + + AddHeader(new HttpHeader(HttpHeader.Names.XMsDate, date?.ToString() ?? DateTimeOffset.UtcNow.ToString())); } public override int Status { get; } diff --git a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs index 153f0e100..8eb6a9ec7 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs @@ -2,20 +2,41 @@ // Licensed under the MIT license. // using Azure; -using Azure.Core; +using Azure.Core.Testing; +using Azure.Data.AppConfiguration; +using Azure.Data.AppConfiguration.Tests; using Azure.Identity; +using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; -using Azure.Data.AppConfiguration; -using Microsoft.Extensions.Azure; +using Moq; using System; +using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using Xunit; namespace Tests.AzureAppConfiguration { public class AfdTests { + 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") + }; + private class TestClientFactory : IAzureClientFactory { public ConfigurationClient CreateClient(string name) @@ -96,6 +117,27 @@ public void AfdTests_ConnectAzureFrontDoorThrowsAfterConnect() Assert.Equal(ErrorMessages.ConnectionConflict, exception.InnerException.Message); } + [Fact] + public void AfdTests_ThrowsWhenConnectMultipleAzureFrontDoor() + { + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + var afdEndpoint2 = new Uri("https://test.b02.azurefd.net"); + var builder = new ConfigurationBuilder(); + var exception = Record.Exception(() => + { + builder.AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.ConnectAzureFrontDoor(afdEndpoint2); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.AfdConnectionConflict, exception.InnerException.Message); + } + [Fact] public void AfdTests_LoadbalancingIsUnsupportedWhenConnectAzureFrontDoor() { @@ -117,7 +159,7 @@ public void AfdTests_LoadbalancingIsUnsupportedWhenConnectAzureFrontDoor() } [Fact] - public void AfdTests_CustomClientOptionsIsUnsupportedWhenConnectAzureFrontDoor() + public void AfdTests_CustomClientOptionsNotSupported() { var afdEndpoint = new Uri("https://test.b01.azurefd.net"); var builder = new ConfigurationBuilder(); @@ -137,8 +179,77 @@ public void AfdTests_CustomClientOptionsIsUnsupportedWhenConnectAzureFrontDoor() } [Fact] - public void AfdTests_LoadsConfiguration() + public async Task AfdTests_RefreshSingleWatchedSetting() { + var mockClient = new Mock(MockBehavior.Strict); + + var keyValueCollection = new List(_kvCollection); + var mockAsyncPageable = new MockAsyncPageable(keyValueCollection); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(mockAsyncPageable); + + string etag1 = Guid.NewGuid().ToString(); + var setting = ConfigurationModelFactory.ConfigurationSetting( + "Sentinel", + "sentinel-value", + "label", + eTag: new ETag(etag1), + contentType: "text"); + + string etag2 = Guid.NewGuid().ToString(); + var oldSetting = ConfigurationModelFactory.ConfigurationSetting( + "Sentinel", + "old-value", + "label", + eTag: new ETag(etag2), + contentType: "text"); + + string etag3 = Guid.NewGuid().ToString(); + var newSetting = ConfigurationModelFactory.ConfigurationSetting( + "Sentinel", + "new-value", + "label", + eTag: new ETag(etag3), + contentType: "text"); + + mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(setting, new MockResponse(200, etag1, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")))); + + mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(oldSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T08:59:59+08:00")))) + .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag3, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")))); + + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + IConfigurationRefresher refresher = null; + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select("TestKey*", "label"); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("Sentinel", "label", false) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("sentinel-value", config["Sentinel"]); + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("sentinel-value", config["Sentinel"]); // should not refresh, because of the response is out of date + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("new-value", config["Sentinel"]); } } } diff --git a/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs b/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs index 9fd3f388e..acd8bf519 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs @@ -164,6 +164,8 @@ class MockAsyncPageable : AsyncPageable { private readonly List _collection = new List(); private int _status; + private string _etag; + private DateTimeOffset _date; private readonly TimeSpan? _delay; public MockAsyncPageable(List collection, TimeSpan? delay = null) diff --git a/tests/Tests.AzureAppConfiguration/Unit/Tests.cs b/tests/Tests.AzureAppConfiguration/Unit/Tests.cs index 7f06a73a4..052ef8b64 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/Tests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/Tests.cs @@ -32,7 +32,6 @@ public class Tests 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", @@ -46,14 +45,14 @@ public class Tests [Fact] public void AddsConfigurationValues() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollectionPageOne)); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(_kv, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(_kv, mockResponse)); var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object)) @@ -109,7 +108,6 @@ public void AddsInvalidConfigurationStore_MalformedSecret() [Fact] public void LoadConfigurationStore_OnFailure() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -128,7 +126,6 @@ public void LoadConfigurationStore_OnFailure() [Fact] public void LoadOptionalConfigurationStore_OnFailure() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) @@ -143,14 +140,14 @@ public void LoadOptionalConfigurationStore_OnFailure() [Fact] public void TrimKeyPrefix_TestCase1() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollectionPageOne)); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(_kv, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(_kv, mockResponse)); // Trim following prefixes from all keys in the configuration. var keyPrefix1 = "T"; @@ -175,14 +172,14 @@ public void TrimKeyPrefix_TestCase1() [Fact] public void TrimKeyPrefix_TestCase2() { - var mockResponse = new Mock(); + var mockResponse = new MockResponse(200); var mockClient = new Mock(MockBehavior.Strict); mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(new MockAsyncPageable(_kvCollectionPageOne)); mockClient.Setup(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(_kv, mockResponse.Object)); + .ReturnsAsync(Response.FromValue(_kv, mockResponse)); // Trim following prefixes from all keys in the configuration. var keyPrefix1 = "T"; @@ -320,7 +317,6 @@ public void TestTurnOffRequestTracing() [Fact] public void TestKeepSelectorPrecedenceAfterDedup() { - var mockResponse = new Mock(); var mockClient = new Mock(MockBehavior.Strict); var kvOfDevLabel = new List { From d5b1b647fdeca5fe27ae6bba0a72afafce5199de Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Sat, 18 Oct 2025 01:31:44 +0800 Subject: [PATCH 08/20] wip --- .../AzureAppConfigurationProvider.cs | 11 +- .../Azure.Core.Testing/MockResponse.cs | 2 +- .../Unit/AfdTests.cs | 106 +++++++++++++++++- .../Unit/TestHelper.cs | 89 ++++++++++----- 4 files changed, 171 insertions(+), 37 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 79ed1f7e2..a15e1cdfd 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -370,8 +370,13 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => watchedIndividualKvChangeDetectedTime = new Dictionary(); data = await LoadSelected(client, kvEtags, ffEtags, _options.Selectors, ffKeys, false, cancellationToken).ConfigureAwait(false); - watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh(client, data, watchedIndividualKvChangeDetectedTime, cancellationToken).ConfigureAwait(false); - if (data != null && watchedIndividualKvs != null && !_isLastRefreshAborted) + + if (!_isLastRefreshAborted) + { + watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh(client, data, watchedIndividualKvChangeDetectedTime, cancellationToken).ConfigureAwait(false); + } + + if (!_isLastRefreshAborted) { logInfoBuilder.AppendLine(LogHelper.BuildConfigurationUpdatedMessage()); } @@ -1131,7 +1136,7 @@ await CallWithRequestTracing(async () => } // If the key-value was found, store it for updating the settings - if (watchedKv != null) + if (watchedKv != null && existingSettings != null) { watchedIndividualKvs[watchedKeyLabel] = new ConfigurationSetting(watchedKv.Key, watchedKv.Value, watchedKv.Label, watchedKv.ETag); diff --git a/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs b/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs index 26fdc9107..a205f0888 100644 --- a/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs +++ b/tests/Tests.AzureAppConfiguration/Azure.Core.Testing/MockResponse.cs @@ -20,7 +20,7 @@ public MockResponse(int status, string etag = null, DateTimeOffset? date = null, if (status == 200) { - AddHeader(new HttpHeader(HttpHeader.Names.ETag, "\"" + etag ?? Guid.NewGuid().ToString() + "\"")); + AddHeader(new HttpHeader(HttpHeader.Names.ETag, "\"" + (etag ?? Guid.NewGuid().ToString()) + "\"")); } AddHeader(new HttpHeader(HttpHeader.Names.XMsDate, date?.ToString() ?? DateTimeOffset.UtcNow.ToString())); diff --git a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs index 8eb6a9ec7..2b5dc1fe2 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs @@ -6,6 +6,7 @@ using Azure.Data.AppConfiguration; using Azure.Data.AppConfiguration.Tests; using Azure.Identity; +using Azure.ResourceManager.Resources.Models; using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; @@ -217,8 +218,8 @@ public async Task AfdTests_RefreshSingleWatchedSetting() .ReturnsAsync(Response.FromValue(setting, new MockResponse(200, etag1, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")))); mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(oldSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T08:59:59+08:00")))) - .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag3, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")))); + .ReturnsAsync(Response.FromValue(oldSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T08:59:59+08:00")))) // stale + .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag3, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")))); // up-to-date var afdEndpoint = new Uri("https://test.b01.azurefd.net"); IConfigurationRefresher refresher = null; @@ -243,7 +244,7 @@ public async Task AfdTests_RefreshSingleWatchedSetting() await refresher.RefreshAsync(); - Assert.Equal("sentinel-value", config["Sentinel"]); // should not refresh, because of the response is out of date + Assert.Equal("sentinel-value", config["Sentinel"]); // should not refresh, because the response is out of date await Task.Delay(1500); @@ -251,5 +252,104 @@ public async Task AfdTests_RefreshSingleWatchedSetting() Assert.Equal("new-value", config["Sentinel"]); } + + [Fact] + public async Task AfdTests_WatchedSettingRefreshAll() + { + var mockClient = new Mock(MockBehavior.Strict); + + var keyValueCollection1 = new List(_kvCollection); + string page1_etag = Guid.NewGuid().ToString(); + string page2_etag = Guid.NewGuid().ToString(); + var responses = new List() + { + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), + new MockResponse(200, page2_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")) + }; + var mockAsyncPageable1 = new MockAsyncPageable(keyValueCollection1, null, 3, responses); + + var keyValueCollection2 = new List(_kvCollection); + keyValueCollection2[3].Value = "old-value"; + string page2_etag2 = Guid.NewGuid().ToString(); + var responses2 = new List() + { + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), + new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T00:00:00+08:00")) // stale + }; + var mockAsyncPageable2 = new MockAsyncPageable(keyValueCollection2, null, 3, responses2); + + var keyValueCollection3 = new List(_kvCollection); + keyValueCollection3[3].Value = "new-value"; + string page2_etag3 = Guid.NewGuid().ToString(); + var responses3 = new List() + { + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")), + new MockResponse(200, page2_etag3, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")) // up-to-date + }; + var mockAsyncPageable3 = new MockAsyncPageable(keyValueCollection3, null, 3, responses3); + + mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(mockAsyncPageable1) + .Returns(mockAsyncPageable2) + .Returns(mockAsyncPageable3); + + string etag1 = Guid.NewGuid().ToString(); + var setting = ConfigurationModelFactory.ConfigurationSetting( + "Sentinel", + "sentinel-value", + "label", + eTag: new ETag(etag1), + contentType: "text"); + + string etag2 = Guid.NewGuid().ToString(); + var newSetting = ConfigurationModelFactory.ConfigurationSetting( + "Sentinel", + "new-value", + "label", + eTag: new ETag(etag2), + contentType: "text"); + + mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(setting, new MockResponse(200, etag1, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")))) + .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")))) + .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")))); + + mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")))) + .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")))); + + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + IConfigurationRefresher refresher = null; + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Select("TestKey*", "label"); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("Sentinel", "label", true) + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("sentinel-value", config["Sentinel"]); + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("TestValue4", config["TestKey4"]); // should not refresh, because page 2 is out of date + //Assert.Equal("new-value", config["Sentinel"]); + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("new-value", config["TestKey4"]); + Assert.Equal("new-value", config["Sentinel"]); + } } } diff --git a/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs b/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs index acd8bf519..437e1171d 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/TestHelper.cs @@ -16,6 +16,8 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using System.Globalization; +using System.Diagnostics; namespace Tests.AzureAppConfiguration { @@ -162,14 +164,18 @@ public static bool ValidateLog(Mock logger, string expectedMessage, Log class MockAsyncPageable : AsyncPageable { - private readonly List _collection = new List(); - private int _status; - private string _etag; - private DateTimeOffset _date; + private List> _pages; + private List _responses; + private int _status = 200; + private readonly int _itemsPerPage; + private List _collection = new List(); private readonly TimeSpan? _delay; - public MockAsyncPageable(List collection, TimeSpan? delay = null) + public MockAsyncPageable(List collection, TimeSpan? delay = null, int itemsPerPage = 100, List responses = null) { + _itemsPerPage = itemsPerPage; + _delay = delay; + foreach (ConfigurationSetting setting in collection) { var newSetting = new ConfigurationSetting(setting.Key, setting.Value, setting.Label, setting.ETag); @@ -179,18 +185,35 @@ public MockAsyncPageable(List collection, TimeSpan? delay _collection.Add(newSetting); } - _status = 200; - _delay = delay; + if (responses != null) + { + _responses = responses; + } + + SlicePages(); + } + + private void SlicePages() + { + int pageCount = (_collection.Count + _itemsPerPage - 1) / _itemsPerPage; + + _pages = new List>(); + for (int i = 0; i < pageCount; i++) + { + _pages.Add(_collection.Skip(i * _itemsPerPage).Take(_itemsPerPage).ToList()); + } } - public void UpdateCollection(List newCollection) + public void UpdateCollection(List newCollection, List responses = null) { - if (_collection.Count() == newCollection.Count() && - _collection.All(setting => newCollection.Any(newSetting => - setting.Key == newSetting.Key && - setting.Value == newSetting.Value && - setting.Label == newSetting.Label && - setting.ETag == newSetting.ETag))) + bool isUnchanged = _collection.Count == newCollection.Count && + _collection.All(setting => newCollection.Any(newSetting => + setting.Key == newSetting.Key && + setting.Value == newSetting.Value && + setting.Label == newSetting.Label && + setting.ETag == newSetting.ETag)); + + if (isUnchanged) { _status = 304; } @@ -208,6 +231,8 @@ public void UpdateCollection(List newCollection) _collection.Add(newSetting); } + + SlicePages(); } } @@ -218,7 +243,26 @@ public override async IAsyncEnumerable> AsPages(strin await Task.Delay(_delay.Value); } - yield return Page.FromValues(_collection, null, new MockResponse(_status)); + int pageIndex = 0; + + while (pageIndex < _pages.Count) + { + List pageItems = _pages[pageIndex]; + + MockResponse response; + + if (_responses == null) + { + response = new MockResponse(_status); + } + else + { + response = _responses[pageIndex]; + } + + yield return Page.FromValues(pageItems, null, response); + pageIndex++; + } } } @@ -234,19 +278,4 @@ public IAsyncEnumerable> IteratePages(AsyncPageable - { - private readonly List _collection; - - public MockPageable(List collection) - { - _collection = collection; - } - - public override IEnumerable> AsPages(string continuationToken = null, int? pageSizeHint = null) - { - yield return Page.FromValues(_collection, null, new MockResponse(200)); - } - } } From 3049eb3dcf85b0017563a8f73efcd805990bbf29 Mon Sep 17 00:00:00 2001 From: zhiyuanliang Date: Sat, 18 Oct 2025 02:24:02 +0800 Subject: [PATCH 09/20] add test --- .../ConfigurationClientExtensions.cs | 2 + .../Unit/AfdTests.cs | 84 ++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index dbc7bfde2..303891620 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -119,6 +119,8 @@ public static async Task> GetPageChange(this Configur { return page; } + + i++; } return null; diff --git a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs index 2b5dc1fe2..9c853a497 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs @@ -226,6 +226,7 @@ public async Task AfdTests_RefreshSingleWatchedSetting() var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { + options.ConnectAzureFrontDoor(afdEndpoint); options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.Select("TestKey*", "label"); options.ConfigureRefresh(refreshOptions => @@ -274,7 +275,7 @@ public async Task AfdTests_WatchedSettingRefreshAll() var responses2 = new List() { new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), - new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T00:00:00+08:00")) // stale + new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")) // stale }; var mockAsyncPageable2 = new MockAsyncPageable(keyValueCollection2, null, 3, responses2); @@ -323,6 +324,7 @@ public async Task AfdTests_WatchedSettingRefreshAll() var config = new ConfigurationBuilder() .AddAzureAppConfiguration(options => { + options.ConnectAzureFrontDoor(afdEndpoint); options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); options.Select("TestKey*", "label"); options.ConfigureRefresh(refreshOptions => @@ -342,7 +344,7 @@ public async Task AfdTests_WatchedSettingRefreshAll() await refresher.RefreshAsync(); Assert.Equal("TestValue4", config["TestKey4"]); // should not refresh, because page 2 is out of date - //Assert.Equal("new-value", config["Sentinel"]); + Assert.Equal("sentinel-value", config["Sentinel"]); await Task.Delay(1500); @@ -351,5 +353,83 @@ public async Task AfdTests_WatchedSettingRefreshAll() Assert.Equal("new-value", config["TestKey4"]); Assert.Equal("new-value", config["Sentinel"]); } + + [Fact] + public async Task AfdTests_RegisterAllRefresh() + { + var mockClient = new Mock(MockBehavior.Strict); + + var keyValueCollection1 = new List(_kvCollection); + string page1_etag = Guid.NewGuid().ToString(); + string page2_etag = Guid.NewGuid().ToString(); + var responses = new List() + { + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), + new MockResponse(200, page2_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")) + }; + var mockAsyncPageable1 = new MockAsyncPageable(keyValueCollection1, null, 3, responses); + + var keyValueCollection2 = new List(_kvCollection); + keyValueCollection2[3].Value = "new-value"; + string page2_etag2 = Guid.NewGuid().ToString(); + var responses2 = new List() + { + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), // stale + new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T09:00:02+08:00")) + }; + var mockAsyncPageable2 = new MockAsyncPageable(keyValueCollection2, null, 3, responses2); + + var responses3 = new List() + { + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), // stale + new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T09:00:02+08:00")) + }; + var mockAsyncPageable3 = new MockAsyncPageable(keyValueCollection2, null, 3, responses2); + + var responses4 = new List() + { + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")), // up-to-date + new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")) + }; + var mockAsyncPageable4 = new MockAsyncPageable(keyValueCollection2, null, 3, responses4); + + mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(mockAsyncPageable1) + .Returns(mockAsyncPageable2) + .Returns(mockAsyncPageable3) + .Returns(mockAsyncPageable4); + + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + IConfigurationRefresher refresher = null; + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.Select("TestKey*", "label"); + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.RegisterAll() + .SetRefreshInterval(TimeSpan.FromSeconds(1)); + }); + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("TestValue4", config["TestKey4"]); + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("TestValue4", config["TestKey4"]); // should not refresh, because page 1 is stale + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("new-value", config["TestKey4"]); + } } } From 5d3d2c2f32d0aa62372791a47dc2f0867fd04bfb Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Tue, 4 Nov 2025 13:18:29 +0800 Subject: [PATCH 10/20] update correlation context --- .../RequestTracingOptions.cs | 10 ---------- .../TracingUtils.cs | 5 +++++ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs index 7c673ee71..9a3a26d99 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs @@ -199,16 +199,6 @@ public string CreateFeaturesString() sb.Append(RequestTracingConstants.SnapshotReferenceTag); } - if (IsAfdUsed) - { - if (sb.Length > 0) - { - sb.Append(RequestTracingConstants.Delimiter); - } - - sb.Append(RequestTracingConstants.AfdTag); - } - return sb.ToString(); } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs index 33050b5b8..b0d937233 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs @@ -211,6 +211,11 @@ private static string CreateCorrelationContextHeader(RequestType requestType, Re correlationContextTags.Add(RequestTracingConstants.PushRefreshTag); } + if (requestTracingOptions.IsAfdUsed) + { + correlationContextTags.Add(RequestTracingConstants.AfdTag); + } + var sb = new StringBuilder(); foreach (KeyValuePair kvp in correlationContextKeyValues) From 9363ea8913c244a9838d923190af2acc471d7662 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Tue, 4 Nov 2025 23:12:12 +0800 Subject: [PATCH 11/20] update --- .../AzureAppConfigurationProvider.cs | 155 ++++++++++-------- .../ConfigurationClientExtensions.cs | 6 +- .../KeyValueChange.cs | 2 +- 3 files changed, 89 insertions(+), 74 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index a15e1cdfd..567d3782c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -33,7 +33,7 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private Uri _lastSuccessfulEndpoint; private AzureAppConfigurationOptions _options; private Dictionary _mappedData; - private Dictionary _watchedIndividualKvs = new Dictionary(); + private Dictionary _watchedIndividualKvs = new Dictionary(); private HashSet _ffKeys = new HashSet(); private Dictionary> _kvEtags = new Dictionary>(); private Dictionary> _ffEtags = new Dictionary>(); @@ -42,7 +42,6 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private DateTimeOffset _nextCollectionRefreshTime; #region Afd - private Dictionary _watchedIndividualKvChangeDetectedTime = new Dictionary(); private DateTimeOffset _lastChangeDetectedTime = default; private bool _isLastRefreshAborted = false; #endregion @@ -64,6 +63,12 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private DateTimeOffset? _lastSuccessfulAttempt = null; private DateTimeOffset? _lastFailedAttempt = null; + private class WatchedSetting + { + public ConfigurationSetting setting { get; set; } + public DateTimeOffset lastServerResponseTime { get; set; } + } + private class CollectionChange { public bool Found { get; set; } @@ -154,6 +159,24 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan MinRefreshInterval = RefreshConstants.DefaultRefreshInterval; } + if (options.IndividualKvWatchers.Any()) + { + foreach (KeyValueWatcher kvWatcher in options.IndividualKvWatchers) + { + _watchedIndividualKvs.Add( + new KeyValueIdentifier + { + Key = kvWatcher.Key, + Label = kvWatcher.Label + }, + new WatchedSetting + { + setting = null, + lastServerResponseTime = DateTimeOffset.MinValue + }); + } + } + _requestTracingEnabled = !EnvironmentVariableHelper.GetBoolOrDefault(EnvironmentVariableNames.RequestTracingDisabled); if (_requestTracingEnabled) @@ -291,8 +314,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken) Dictionary> kvEtags = null; Dictionary> ffEtags = null; HashSet ffKeys = null; - Dictionary watchedIndividualKvs = null; - Dictionary watchedIndividualKvChangeDetectedTime = null; + Dictionary watchedIndividualKvs = null; List watchedIndividualKvChanges = null; Dictionary data = null; Dictionary ffCollectionData = null; @@ -367,13 +389,12 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => kvEtags = new Dictionary>(); ffEtags = new Dictionary>(); ffKeys = new HashSet(); - watchedIndividualKvChangeDetectedTime = new Dictionary(); data = await LoadSelected(client, kvEtags, ffEtags, _options.Selectors, ffKeys, false, cancellationToken).ConfigureAwait(false); if (!_isLastRefreshAborted) { - watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh(client, data, watchedIndividualKvChangeDetectedTime, cancellationToken).ConfigureAwait(false); + watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh(client, data, cancellationToken).ConfigureAwait(false); } if (!_isLastRefreshAborted) @@ -457,14 +478,12 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => } else { - watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); - watchedIndividualKvChangeDetectedTime = new Dictionary(_watchedIndividualKvChangeDetectedTime); + watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); await ProcessKeyValueChangesAsync( watchedIndividualKvChanges, _mappedData, - watchedIndividualKvs, - watchedIndividualKvChangeDetectedTime).ConfigureAwait(false); + watchedIndividualKvs).ConfigureAwait(false); if (refreshFf) { @@ -501,8 +520,6 @@ await ProcessKeyValueChangesAsync( { _watchedIndividualKvs = watchedIndividualKvs ?? _watchedIndividualKvs; - _watchedIndividualKvChangeDetectedTime = watchedIndividualKvChangeDetectedTime ?? _watchedIndividualKvChangeDetectedTime; - _ffEtags = ffEtags ?? _ffEtags; _kvEtags = kvEtags ?? _kvEtags; @@ -841,8 +858,7 @@ private async Task InitializeAsync(IEnumerable clients, Can Dictionary data = null; Dictionary> kvEtags = new Dictionary>(); Dictionary> ffEtags = new Dictionary>(); - Dictionary watchedIndividualKvs = null; - Dictionary watchedIndividualKvChangeDetectedTime = new Dictionary(); + Dictionary watchedIndividualKvs = null; HashSet ffKeys = new HashSet(); await ExecuteWithFailOverPolicyAsync( @@ -862,7 +878,6 @@ await ExecuteWithFailOverPolicyAsync( watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh( client, data, - watchedIndividualKvChangeDetectedTime, cancellationToken) .ConfigureAwait(false); }, @@ -898,7 +913,6 @@ await ExecuteWithFailOverPolicyAsync( _kvEtags = kvEtags; _ffEtags = ffEtags; _watchedIndividualKvs = watchedIndividualKvs; - _watchedIndividualKvChangeDetectedTime = watchedIndividualKvChangeDetectedTime; _ffKeys = ffKeys; } } @@ -1059,20 +1073,21 @@ await CallWithRequestTracing(async () => return resolvedSettings; } - private async Task> LoadKeyValuesRegisteredForRefresh( + private async Task> LoadKeyValuesRegisteredForRefresh( ConfigurationClient client, IDictionary existingSettings, - Dictionary watchedIndividualKvChangeDetectedTime, CancellationToken cancellationToken) { - var watchedIndividualKvs = new Dictionary(); + var watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); foreach (KeyValueWatcher kvWatcher in _options.IndividualKvWatchers) { string watchedKey = kvWatcher.Key; string watchedLabel = kvWatcher.Label; - KeyValueIdentifier watchedKeyLabel = new KeyValueIdentifier(watchedKey, watchedLabel); + var watchedKeyLabel = new KeyValueIdentifier(watchedKey, watchedLabel); + Debug.Assert(_watchedIndividualKvs.ContainsKey(watchedKeyLabel)); + DateTimeOffset lastServerResponseTime = _watchedIndividualKvs[watchedKeyLabel].lastServerResponseTime; // Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing ConfigurationSetting watchedKv = null; @@ -1086,60 +1101,42 @@ await CallWithRequestTracing(async () => using Response rawResponse = response.GetRawResponse(); DateTimeOffset responseDate = rawResponse.GetDate(); - if (_watchedIndividualKvChangeDetectedTime.TryGetValue(watchedKeyLabel, out DateTimeOffset lastChangeDetectedTime)) + + if (responseDate >= lastServerResponseTime) { - if (responseDate >= lastChangeDetectedTime) - { - watchedKv = response.Value; - watchedIndividualKvChangeDetectedTime[watchedKeyLabel] = responseDate; - _isLastRefreshAborted = false; - } - else - { - _isLastRefreshAborted = true; - return null; - } + watchedKv = response.Value; + watchedIndividualKvs[watchedKeyLabel].setting = watchedKv; + watchedIndividualKvs[watchedKeyLabel].lastServerResponseTime = responseDate; + _isLastRefreshAborted = false; } else { - // initial load - watchedKv = response.Value; - watchedIndividualKvChangeDetectedTime[watchedKeyLabel] = responseDate; - _isLastRefreshAborted = false; + _isLastRefreshAborted = true; + return null; } } catch (RequestFailedException e) when (e.Status == (int)HttpStatusCode.NotFound) { using Response rawResponse = e.GetRawResponse(); DateTimeOffset responseDate = rawResponse.GetDate(); - if (_watchedIndividualKvChangeDetectedTime.TryGetValue(watchedKeyLabel, out DateTimeOffset lastChangeDetectedTime)) + + if (responseDate >= lastServerResponseTime) { - if (responseDate >= lastChangeDetectedTime) - { - watchedKv = null; - watchedIndividualKvChangeDetectedTime[watchedKeyLabel] = responseDate; - _isLastRefreshAborted = false; - } - else - { - _isLastRefreshAborted = true; - return null; - } + watchedKv = null; + watchedIndividualKvs[watchedKeyLabel].setting = watchedKv; + watchedIndividualKvs[watchedKeyLabel].lastServerResponseTime = responseDate; + _isLastRefreshAborted = false; } else { - // initial load - watchedKey = null; - watchedIndividualKvChangeDetectedTime[watchedKeyLabel] = responseDate; - _isLastRefreshAborted = false; + _isLastRefreshAborted = true; + return null; } } // If the key-value was found, store it for updating the settings if (watchedKv != null && existingSettings != null) { - watchedIndividualKvs[watchedKeyLabel] = new ConfigurationSetting(watchedKv.Key, watchedKv.Value, watchedKv.Label, watchedKv.ETag); - if (watchedKv.ContentType == SnapshotReferenceConstants.ContentType) { // Track snapshot reference usage for telemetry @@ -1190,29 +1187,37 @@ private async Task RefreshIndividualKvWatchers( KeyValueChange change = default; - // Unless initial load failed, _watchedIndividualKvChangeDetectedTime should always have an entry for the watched key-label - // If fail to get, lastChangeDetectedTime will be DateTimeOffset.MinValue by default - _watchedIndividualKvChangeDetectedTime.TryGetValue(watchedKeyLabel, out DateTimeOffset lastChangeDetectedTime); + Debug.Assert(_watchedIndividualKvs.ContainsKey(watchedKeyLabel)); + ConfigurationSetting watchedKv = _watchedIndividualKvs[watchedKeyLabel].setting; + DateTimeOffset lastServerResponseTime = _watchedIndividualKvs[watchedKeyLabel].lastServerResponseTime; // // Find if there is a change associated with watcher - if (_watchedIndividualKvs.TryGetValue(watchedKeyLabel, out ConfigurationSetting watchedKv)) + if (watchedKv != null) { - await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions, - async () => change = await client.GetKeyValueChange( + await CallWithRequestTracing(async () => change = await client.GetKeyValueChange( watchedKv, makeConditionalRequest: !_options.IsAfdUsed, - lastChangeDetectedTime, + lastServerResponseTime, cancellationToken).ConfigureAwait(false) ).ConfigureAwait(false); } else { + DateTimeOffset responseDate = lastServerResponseTime; // Load the key-value in case the previous load attempts had failed try { - await CallWithRequestTracing( - async () => watchedKv = await client.GetConfigurationSettingAsync(watchedKey, watchedLabel, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); + await CallWithRequestTracing(async () => + { + Response response = await client.GetConfigurationSettingAsync(watchedKey, watchedLabel, cancellationToken).ConfigureAwait(false); + using Response rawResponse = response.GetRawResponse(); + responseDate = rawResponse.GetDate(); + if (responseDate >= lastServerResponseTime) + { + watchedKv = response.Value; + } + }).ConfigureAwait(false); } catch (RequestFailedException e) when (e.Status == (int)HttpStatusCode.NotFound) { @@ -1226,7 +1231,18 @@ await CallWithRequestTracing( Key = watchedKv.Key, Label = watchedKv.Label.NormalizeNull(), Current = watchedKv, - ChangeType = KeyValueChangeType.Modified + ChangeType = KeyValueChangeType.Modified, + ServerResponseTime = responseDate + }; + } + else + { + change = new KeyValueChange() + { + Key = watchedKey, + Label = watchedLabel.NormalizeNull(), + ChangeType = KeyValueChangeType.None, + ServerResponseTime = responseDate }; } } @@ -1241,7 +1257,7 @@ await CallWithRequestTracing( // If the watcher is set to refresh all, or the content type matches the snapshot reference content type then refresh all if (kvWatcher.RefreshAll || watchedKv.ContentType == SnapshotReferenceConstants.ContentType) { - _lastChangeDetectedTime = change.DetectedTime; + _lastChangeDetectedTime = change.ServerResponseTime; return true; } } @@ -1615,20 +1631,19 @@ await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Wa private async Task ProcessKeyValueChangesAsync( IEnumerable keyValueChanges, Dictionary mappedData, - Dictionary watchedIndividualKvs, - Dictionary watchedIndividualKvChangeDetectedTime) + Dictionary watchedIndividualKvs) { foreach (KeyValueChange change in keyValueChanges) { KeyValueIdentifier changeIdentifier = new KeyValueIdentifier(change.Key, change.Label); - - watchedIndividualKvChangeDetectedTime[changeIdentifier] = change.DetectedTime; + Debug.Assert(watchedIndividualKvs.ContainsKey(changeIdentifier)); if (change.ChangeType == KeyValueChangeType.Modified) { ConfigurationSetting setting = change.Current; - watchedIndividualKvs[changeIdentifier] = setting; + watchedIndividualKvs[changeIdentifier].setting = setting; + watchedIndividualKvs[changeIdentifier].lastServerResponseTime = change.ServerResponseTime; foreach (Func> func in _options.Mappers) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index 303891620..27480a955 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -42,7 +42,7 @@ public static async Task GetKeyValueChange(this ConfigurationCli Current = response.Value, Key = setting.Key, Label = setting.Label, - DetectedTime = rawResponse.GetDate() + ServerResponseTime = rawResponse.GetDate() }; } } @@ -58,7 +58,7 @@ public static async Task GetKeyValueChange(this ConfigurationCli Current = null, Key = setting.Key, Label = setting.Label, - DetectedTime = rawResponse.GetDate() + ServerResponseTime = rawResponse.GetDate() }; } } @@ -70,7 +70,7 @@ public static async Task GetKeyValueChange(this ConfigurationCli Current = setting, Key = setting.Key, Label = setting.Label, - DetectedTime = lastChangeDetectedTime + ServerResponseTime = lastChangeDetectedTime }; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/KeyValueChange.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/KeyValueChange.cs index ab3fdb0be..fa39707e1 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/KeyValueChange.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/KeyValueChange.cs @@ -25,6 +25,6 @@ internal struct KeyValueChange public ConfigurationSetting Previous { get; set; } - public DateTimeOffset DetectedTime { get; set; } + public DateTimeOffset ServerResponseTime { get; set; } } } From 1e2d081428b22390420e350e999844f6ce30c7e4 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 5 Nov 2025 01:27:04 +0800 Subject: [PATCH 12/20] only check response time for refresh trigger engine --- .../AzureAppConfigurationProvider.cs | 318 ++++++------------ .../ConfigurationClientExtensions.cs | 22 +- .../PageWatcher.cs | 2 +- .../RequestTracingOptions.cs | 1 - .../WatchedSetting.cs | 14 + .../Unit/AfdTests.cs | 56 ++- 6 files changed, 146 insertions(+), 267 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedSetting.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 567d3782c..02740dc1f 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -35,17 +35,12 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private Dictionary _mappedData; private Dictionary _watchedIndividualKvs = new Dictionary(); private HashSet _ffKeys = new HashSet(); - private Dictionary> _kvEtags = new Dictionary>(); - private Dictionary> _ffEtags = new Dictionary>(); + private Dictionary> _kvPageWatchers = new Dictionary>(); + private Dictionary> _ffPageWatchers = new Dictionary>(); private RequestTracingOptions _requestTracingOptions; private Dictionary _configClientBackoffs = new Dictionary(); private DateTimeOffset _nextCollectionRefreshTime; - #region Afd - private DateTimeOffset _lastChangeDetectedTime = default; - private bool _isLastRefreshAborted = false; - #endregion - private readonly TimeSpan MinRefreshInterval; // The most-recent time when the refresh operation attempted to load the initial configuration @@ -63,18 +58,6 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private DateTimeOffset? _lastSuccessfulAttempt = null; private DateTimeOffset? _lastFailedAttempt = null; - private class WatchedSetting - { - public ConfigurationSetting setting { get; set; } - public DateTimeOffset lastServerResponseTime { get; set; } - } - - private class CollectionChange - { - public bool Found { get; set; } - public DateTimeOffset Time { get; set; } - } - private class ConfigurationClientBackoffStatus { public int FailedAttempts { get; set; } @@ -163,17 +146,7 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan { foreach (KeyValueWatcher kvWatcher in options.IndividualKvWatchers) { - _watchedIndividualKvs.Add( - new KeyValueIdentifier - { - Key = kvWatcher.Key, - Label = kvWatcher.Label - }, - new WatchedSetting - { - setting = null, - lastServerResponseTime = DateTimeOffset.MinValue - }); + _watchedIndividualKvs.Add(new KeyValueIdentifier(kvWatcher.Key, kvWatcher.Label), new WatchedSetting()); } } @@ -340,46 +313,25 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => if (_options.RegisterAllEnabled) { - if (_isLastRefreshAborted) + if (isRefreshDue) { - refreshAll = true; - } - else if (isRefreshDue) - { - CollectionChange kvCollectionChange = await GetCollectionChange( + refreshAll = await HavePageChange( _options.Selectors.Where(selector => !selector.IsFeatureFlagSelector), - _kvEtags, + _kvPageWatchers, client, cancellationToken).ConfigureAwait(false); - - refreshAll = kvCollectionChange.Found; - if (refreshAll) - { - _lastChangeDetectedTime = kvCollectionChange.Time; - } } } else { - watchedIndividualKvChanges = new List(); - if (_isLastRefreshAborted) - { - if (_options.IndividualKvWatchers.Any(w => w.RefreshAll)) - { - refreshAll = true; - } - } - else - { - refreshAll = await RefreshIndividualKvWatchers( - client, - watchedIndividualKvChanges, - refreshableIndividualKvWatchers, - endpoint, - logDebugBuilder, - logInfoBuilder, - cancellationToken).ConfigureAwait(false); - } + refreshAll = await RefreshIndividualKvWatchers( + client, + watchedIndividualKvChanges, + refreshableIndividualKvWatchers, + endpoint, + logDebugBuilder, + logInfoBuilder, + cancellationToken).ConfigureAwait(false); } if (refreshAll) @@ -390,46 +342,27 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => ffEtags = new Dictionary>(); ffKeys = new HashSet(); - data = await LoadSelected(client, kvEtags, ffEtags, _options.Selectors, ffKeys, false, cancellationToken).ConfigureAwait(false); + data = await LoadSelected(client, kvEtags, ffEtags, _options.Selectors, ffKeys, cancellationToken).ConfigureAwait(false); - if (!_isLastRefreshAborted) - { - watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh(client, data, cancellationToken).ConfigureAwait(false); - } + watchedIndividualKvs = await LoadIndividualWatchedSettings(client, data, cancellationToken).ConfigureAwait(false); - if (!_isLastRefreshAborted) - { - logInfoBuilder.AppendLine(LogHelper.BuildConfigurationUpdatedMessage()); - } + logInfoBuilder.AppendLine(LogHelper.BuildConfigurationUpdatedMessage()); return; } - if (_isLastRefreshAborted) - { - refreshFf = true; - } - else - { - // Get feature flag changes - CollectionChange ffCollectionChange = await GetCollectionChange( - refreshableFfWatchers.Select(watcher => new KeyValueSelector - { - KeyFilter = watcher.Key, - LabelFilter = watcher.Label, - TagFilters = watcher.Tags, - IsFeatureFlagSelector = true - }), - _ffEtags, - client, - cancellationToken).ConfigureAwait(false); - - refreshFf = ffCollectionChange.Found; - if (refreshFf) + // Get feature flag changes + refreshFf = await HavePageChange( + refreshableFfWatchers.Select(watcher => new KeyValueSelector { - _lastChangeDetectedTime = ffCollectionChange.Time; - } - } + KeyFilter = watcher.Key, + LabelFilter = watcher.Label, + TagFilters = watcher.Tags, + IsFeatureFlagSelector = true + }), + _ffPageWatchers, + client, + cancellationToken).ConfigureAwait(false); if (refreshFf) { @@ -442,13 +375,9 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => ffEtags, _options.Selectors.Where(selector => selector.IsFeatureFlagSelector), ffKeys, - true, cancellationToken).ConfigureAwait(false); - if (ffCollectionData != null && !_isLastRefreshAborted) - { - logInfoBuilder.Append(LogHelper.BuildFeatureFlagsUpdatedMessage()); - } + logInfoBuilder.Append(LogHelper.BuildFeatureFlagsUpdatedMessage()); } else { @@ -458,56 +387,50 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => cancellationToken) .ConfigureAwait(false); - if (!_isLastRefreshAborted) + if (refreshAll) { - if (refreshAll) - { - _mappedData = await MapConfigurationSettings(data).ConfigureAwait(false); - - // Invalidate all the cached KeyVault secrets - foreach (IKeyValueAdapter adapter in _options.Adapters) - { - adapter.OnChangeDetected(); - } + _mappedData = await MapConfigurationSettings(data).ConfigureAwait(false); - // Update the next refresh time for all refresh registered settings and feature flags - foreach (KeyValueWatcher changeWatcher in _options.IndividualKvWatchers.Concat(_options.FeatureFlagWatchers)) - { - UpdateNextRefreshTime(changeWatcher); - } + // Invalidate all the cached KeyVault secrets + foreach (IKeyValueAdapter adapter in _options.Adapters) + { + adapter.OnChangeDetected(); } - else + + // Update the next refresh time for all refresh registered settings and feature flags + foreach (KeyValueWatcher changeWatcher in _options.IndividualKvWatchers.Concat(_options.FeatureFlagWatchers)) { - watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); + UpdateNextRefreshTime(changeWatcher); + } + } + else + { + watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); - await ProcessKeyValueChangesAsync( - watchedIndividualKvChanges, - _mappedData, - watchedIndividualKvs).ConfigureAwait(false); + await ProcessKeyValueChangesAsync(watchedIndividualKvChanges, _mappedData, watchedIndividualKvs).ConfigureAwait(false); - if (refreshFf) + if (refreshFf) + { + // Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously + foreach (string key in _ffKeys.Except(ffKeys)) { - // Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously - foreach (string key in _ffKeys.Except(ffKeys)) - { - _mappedData.Remove(key); - } - - Dictionary mappedFfData = await MapConfigurationSettings(ffCollectionData).ConfigureAwait(false); - - foreach (KeyValuePair kvp in mappedFfData) - { - _mappedData[kvp.Key] = kvp.Value; - } + _mappedData.Remove(key); } - // - // update the next refresh time for all refresh registered settings and feature flags - foreach (KeyValueWatcher changeWatcher in refreshableIndividualKvWatchers.Concat(refreshableFfWatchers)) + Dictionary mappedFfData = await MapConfigurationSettings(ffCollectionData).ConfigureAwait(false); + + foreach (KeyValuePair kvp in mappedFfData) { - UpdateNextRefreshTime(changeWatcher); + _mappedData[kvp.Key] = kvp.Value; } } + + // + // update the next refresh time for all refresh registered settings and feature flags + foreach (KeyValueWatcher changeWatcher in refreshableIndividualKvWatchers.Concat(refreshableFfWatchers)) + { + UpdateNextRefreshTime(changeWatcher); + } } if (_options.RegisterAllEnabled && isRefreshDue) @@ -515,14 +438,13 @@ await ProcessKeyValueChangesAsync( _nextCollectionRefreshTime = DateTimeOffset.UtcNow.Add(_options.KvCollectionRefreshInterval); } - if (!_isLastRefreshAborted && - (_options.Adapters.Any(adapter => adapter.NeedsRefresh()) || watchedIndividualKvChanges.Any() || refreshAll || refreshFf)) + if (_options.Adapters.Any(adapter => adapter.NeedsRefresh()) || watchedIndividualKvChanges.Any() || refreshAll || refreshFf) { _watchedIndividualKvs = watchedIndividualKvs ?? _watchedIndividualKvs; - _ffEtags = ffEtags ?? _ffEtags; + _ffPageWatchers = ffEtags ?? _ffPageWatchers; - _kvEtags = kvEtags ?? _kvEtags; + _kvPageWatchers = kvEtags ?? _kvPageWatchers; _ffKeys = ffKeys ?? _ffKeys; @@ -871,11 +793,10 @@ await ExecuteWithFailOverPolicyAsync( ffEtags, _options.Selectors, ffKeys, - false, cancellationToken) .ConfigureAwait(false); - watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh( + watchedIndividualKvs = await LoadIndividualWatchedSettings( client, data, cancellationToken) @@ -884,8 +805,6 @@ await ExecuteWithFailOverPolicyAsync( cancellationToken) .ConfigureAwait(false); - Debug.Assert(watchedIndividualKvs != null); - // Update the next refresh time for all refresh registered settings and feature flags foreach (KeyValueWatcher changeWatcher in _options.IndividualKvWatchers.Concat(_options.FeatureFlagWatchers)) { @@ -910,8 +829,8 @@ await ExecuteWithFailOverPolicyAsync( SetData(await PrepareData(mappedData, cancellationToken).ConfigureAwait(false)); _mappedData = mappedData; - _kvEtags = kvEtags; - _ffEtags = ffEtags; + _kvPageWatchers = kvEtags; + _ffPageWatchers = ffEtags; _watchedIndividualKvs = watchedIndividualKvs; _ffKeys = ffKeys; } @@ -923,7 +842,6 @@ private async Task> LoadSelected( Dictionary> ffEtags, IEnumerable selectors, HashSet ffKeys, - bool loadFeatureFlagOnly, CancellationToken cancellationToken) { Dictionary data = new Dictionary(); @@ -956,11 +874,6 @@ await CallWithRequestTracing(async () => { using Response response = page.GetRawResponse(); DateTimeOffset responseDate = response.GetDate(); - _isLastRefreshAborted = responseDate < _lastChangeDetectedTime; - if (_isLastRefreshAborted) - { - return; - } foreach (ConfigurationSetting setting in page.Values) { @@ -1002,16 +915,11 @@ await CallWithRequestTracing(async () => pageWatchers.Add(new PageWatcher() { Etag = new MatchConditions { IfNoneMatch = response.Headers.ETag }, - LastUpdateTime = responseDate + LastServerResponseTime = responseDate }); } }).ConfigureAwait(false); - if (_isLastRefreshAborted) - { - return null; - } - if (loadOption.IsFeatureFlagSelector) { ffEtags[loadOption] = pageWatchers; @@ -1073,7 +981,7 @@ await CallWithRequestTracing(async () => return resolvedSettings; } - private async Task> LoadKeyValuesRegisteredForRefresh( + private async Task> LoadIndividualWatchedSettings( ConfigurationClient client, IDictionary existingSettings, CancellationToken cancellationToken) @@ -1087,7 +995,7 @@ private async Task> LoadKeyValues var watchedKeyLabel = new KeyValueIdentifier(watchedKey, watchedLabel); Debug.Assert(_watchedIndividualKvs.ContainsKey(watchedKeyLabel)); - DateTimeOffset lastServerResponseTime = _watchedIndividualKvs[watchedKeyLabel].lastServerResponseTime; + DateTimeOffset lastServerResponseTime = _watchedIndividualKvs[watchedKeyLabel].LastServerResponseTime; // Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing ConfigurationSetting watchedKv = null; @@ -1102,36 +1010,18 @@ await CallWithRequestTracing(async () => using Response rawResponse = response.GetRawResponse(); DateTimeOffset responseDate = rawResponse.GetDate(); - if (responseDate >= lastServerResponseTime) - { - watchedKv = response.Value; - watchedIndividualKvs[watchedKeyLabel].setting = watchedKv; - watchedIndividualKvs[watchedKeyLabel].lastServerResponseTime = responseDate; - _isLastRefreshAborted = false; - } - else - { - _isLastRefreshAborted = true; - return null; - } + watchedKv = response.Value; + watchedIndividualKvs[watchedKeyLabel].Setting = watchedKv; + watchedIndividualKvs[watchedKeyLabel].LastServerResponseTime = responseDate; } catch (RequestFailedException e) when (e.Status == (int)HttpStatusCode.NotFound) { using Response rawResponse = e.GetRawResponse(); DateTimeOffset responseDate = rawResponse.GetDate(); - if (responseDate >= lastServerResponseTime) - { - watchedKv = null; - watchedIndividualKvs[watchedKeyLabel].setting = watchedKv; - watchedIndividualKvs[watchedKeyLabel].lastServerResponseTime = responseDate; - _isLastRefreshAborted = false; - } - else - { - _isLastRefreshAborted = true; - return null; - } + watchedKv = null; + watchedIndividualKvs[watchedKeyLabel].Setting = watchedKv; + watchedIndividualKvs[watchedKeyLabel].LastServerResponseTime = responseDate; } // If the key-value was found, store it for updating the settings @@ -1188,8 +1078,8 @@ private async Task RefreshIndividualKvWatchers( KeyValueChange change = default; Debug.Assert(_watchedIndividualKvs.ContainsKey(watchedKeyLabel)); - ConfigurationSetting watchedKv = _watchedIndividualKvs[watchedKeyLabel].setting; - DateTimeOffset lastServerResponseTime = _watchedIndividualKvs[watchedKeyLabel].lastServerResponseTime; + ConfigurationSetting watchedKv = _watchedIndividualKvs[watchedKeyLabel].Setting; + DateTimeOffset lastServerResponseTime = _watchedIndividualKvs[watchedKeyLabel].LastServerResponseTime; // // Find if there is a change associated with watcher @@ -1221,30 +1111,18 @@ await CallWithRequestTracing(async () => } catch (RequestFailedException e) when (e.Status == (int)HttpStatusCode.NotFound) { + using Response rawResponse = e.GetRawResponse(); + responseDate = rawResponse.GetDate(); watchedKv = null; } - if (watchedKv != null) - { - change = new KeyValueChange() - { - Key = watchedKv.Key, - Label = watchedKv.Label.NormalizeNull(), - Current = watchedKv, - ChangeType = KeyValueChangeType.Modified, - ServerResponseTime = responseDate - }; - } - else + change = new KeyValueChange() { - change = new KeyValueChange() - { - Key = watchedKey, - Label = watchedLabel.NormalizeNull(), - ChangeType = KeyValueChangeType.None, - ServerResponseTime = responseDate - }; - } + Key = watchedKey, + Label = watchedLabel.NormalizeNull(), + ChangeType = watchedKv != null ? KeyValueChangeType.Modified : KeyValueChangeType.None, + ServerResponseTime = responseDate + }; } // Check if a change has been detected in the key-value registered for refresh @@ -1257,7 +1135,6 @@ await CallWithRequestTracing(async () => // If the watcher is set to refresh all, or the content type matches the snapshot reference content type then refresh all if (kvWatcher.RefreshAll || watchedKv.ContentType == SnapshotReferenceConstants.ContentType) { - _lastChangeDetectedTime = change.ServerResponseTime; return true; } } @@ -1589,13 +1466,13 @@ private void UpdateClientBackoffStatus(Uri endpoint, bool successful) _configClientBackoffs[endpoint] = clientBackoffStatus; } - private async Task GetCollectionChange( + private async Task HavePageChange( IEnumerable selectors, Dictionary> pageWatchers, ConfigurationClient client, CancellationToken cancellationToken) { - Page pageChanged = null; + bool havePageChanged = false; foreach (KeyValueSelector selector in selectors) { @@ -1604,28 +1481,21 @@ private async Task GetCollectionChange( if (pageWatchers.TryGetValue(selector, out IEnumerable watchers)) { await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions, - async () => pageChanged = await client.GetPageChange( + async () => havePageChanged = await client.HavePageChange( selector, watchers, _options.ConfigurationSettingPageIterator, makeConditionalRequest: !_options.IsAfdUsed, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); - if (pageChanged != null) + if (havePageChanged) { - return new CollectionChange - { - Found = true, - Time = pageChanged.GetRawResponse().GetDate() - }; + return true; } } } - return new CollectionChange - { - Found = false - }; + return havePageChanged; } private async Task ProcessKeyValueChangesAsync( @@ -1642,8 +1512,8 @@ private async Task ProcessKeyValueChangesAsync( { ConfigurationSetting setting = change.Current; - watchedIndividualKvs[changeIdentifier].setting = setting; - watchedIndividualKvs[changeIdentifier].lastServerResponseTime = change.ServerResponseTime; + watchedIndividualKvs[changeIdentifier].Setting = setting; + watchedIndividualKvs[changeIdentifier].LastServerResponseTime = change.ServerResponseTime; foreach (Func> func in _options.Mappers) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index 27480a955..046f4ef7c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -74,7 +74,7 @@ public static async Task GetKeyValueChange(this ConfigurationCli }; } - public static async Task> GetPageChange(this ConfigurationClient client, KeyValueSelector keyValueSelector, IEnumerable pageWatchers, IConfigurationSettingPageIterator pageIterator, bool makeConditionalRequest, CancellationToken cancellationToken) + public static async Task HavePageChange(this ConfigurationClient client, KeyValueSelector keyValueSelector, IEnumerable pageWatchers, IConfigurationSettingPageIterator pageIterator, bool makeConditionalRequest, CancellationToken cancellationToken) { if (pageWatchers == null) { @@ -99,31 +99,29 @@ public static async Task> GetPageChange(this Configur AsyncPageable pageable = client.GetConfigurationSettingsAsync(selector, cancellationToken); + using IEnumerator existingPageWatcherEnumerator = pageWatchers.GetEnumerator(); + IAsyncEnumerable> pages = makeConditionalRequest ? pageable.AsPages(pageIterator, pageWatchers.Select(p => p.Etag)) : pageable.AsPages(pageIterator); - List existingWatcherList = pageWatchers.ToList(); - - int i = 0; - await foreach (Page page in pages.ConfigureAwait(false)) { using Response response = page.GetRawResponse(); DateTimeOffset timestamp = response.GetDate(); - if (i >= existingWatcherList.Count || + if (!existingPageWatcherEnumerator.MoveNext() || (response.Status == (int)HttpStatusCode.OK && - timestamp >= existingWatcherList[i].LastUpdateTime && - !existingWatcherList[i].Etag.IfNoneMatch.Equals(response.Headers.ETag))) + // if the server response time is later than last server response time, the change is considered detected + timestamp >= existingPageWatcherEnumerator.Current.LastServerResponseTime && + !existingPageWatcherEnumerator.Current.Etag.IfNoneMatch.Equals(response.Headers.ETag))) { - return page; + return true; } - - i++; } - return null; + // Need to check if pages were deleted and no change was found within the new shorter list of page + return existingPageWatcherEnumerator.MoveNext(); } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/PageWatcher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/PageWatcher.cs index 470cd0e54..7158ba5de 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/PageWatcher.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/PageWatcher.cs @@ -9,6 +9,6 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration internal class PageWatcher { public MatchConditions Etag { get; set; } - public DateTimeOffset LastUpdateTime { get; set; } + public DateTimeOffset LastServerResponseTime { get; set; } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs index 9a3a26d99..8914a0ee5 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs @@ -3,7 +3,6 @@ // using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.SnapshotReference; using System.Net.Mime; using System.Text; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedSetting.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedSetting.cs new file mode 100644 index 000000000..872846e69 --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedSetting.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Azure.Data.AppConfiguration; +using System; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration +{ + internal class WatchedSetting + { + public ConfigurationSetting Setting { get; set; } + public DateTimeOffset LastServerResponseTime { get; set; } + } +} diff --git a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs index 9c853a497..1a0f35970 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs @@ -270,29 +270,18 @@ public async Task AfdTests_WatchedSettingRefreshAll() var mockAsyncPageable1 = new MockAsyncPageable(keyValueCollection1, null, 3, responses); var keyValueCollection2 = new List(_kvCollection); - keyValueCollection2[3].Value = "old-value"; + keyValueCollection2[3].Value = "updated-value"; string page2_etag2 = Guid.NewGuid().ToString(); var responses2 = new List() { - new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), - new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")) // stale + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")), + new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")) }; var mockAsyncPageable2 = new MockAsyncPageable(keyValueCollection2, null, 3, responses2); - var keyValueCollection3 = new List(_kvCollection); - keyValueCollection3[3].Value = "new-value"; - string page2_etag3 = Guid.NewGuid().ToString(); - var responses3 = new List() - { - new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")), - new MockResponse(200, page2_etag3, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")) // up-to-date - }; - var mockAsyncPageable3 = new MockAsyncPageable(keyValueCollection3, null, 3, responses3); - mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(mockAsyncPageable1) - .Returns(mockAsyncPageable2) - .Returns(mockAsyncPageable3); + .Returns(mockAsyncPageable2); string etag1 = Guid.NewGuid().ToString(); var setting = ConfigurationModelFactory.ConfigurationSetting( @@ -303,21 +292,28 @@ public async Task AfdTests_WatchedSettingRefreshAll() contentType: "text"); string etag2 = Guid.NewGuid().ToString(); + var oldSetting = ConfigurationModelFactory.ConfigurationSetting( + "Sentinel", + "old-value", + "label", + eTag: new ETag(etag2), + contentType: "text"); + + string etag3 = Guid.NewGuid().ToString(); var newSetting = ConfigurationModelFactory.ConfigurationSetting( "Sentinel", "new-value", "label", - eTag: new ETag(etag2), + eTag: new ETag(etag3), contentType: "text"); mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(Response.FromValue(setting, new MockResponse(200, etag1, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")))) - .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")))) - .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")))); + .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag3, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")))); mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")))) - .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")))); + .ReturnsAsync(Response.FromValue(oldSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T08:59:59+08:00")))) // stale, should not refresh + .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag3, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")))); var afdEndpoint = new Uri("https://test.b01.azurefd.net"); IConfigurationRefresher refresher = null; @@ -343,14 +339,14 @@ public async Task AfdTests_WatchedSettingRefreshAll() await refresher.RefreshAsync(); - Assert.Equal("TestValue4", config["TestKey4"]); // should not refresh, because page 2 is out of date + Assert.Equal("TestValue4", config["TestKey4"]); // should not refresh, because sentinel is stale Assert.Equal("sentinel-value", config["Sentinel"]); await Task.Delay(1500); await refresher.RefreshAsync(); - Assert.Equal("new-value", config["TestKey4"]); + Assert.Equal("updated-value", config["TestKey4"]); Assert.Equal("new-value", config["Sentinel"]); } @@ -370,28 +366,30 @@ public async Task AfdTests_RegisterAllRefresh() var mockAsyncPageable1 = new MockAsyncPageable(keyValueCollection1, null, 3, responses); var keyValueCollection2 = new List(_kvCollection); - keyValueCollection2[3].Value = "new-value"; + keyValueCollection2[3].Value = "old-value"; string page2_etag2 = Guid.NewGuid().ToString(); var responses2 = new List() { - new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), // stale - new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T09:00:02+08:00")) + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), + new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T08:59:59+08:00")) // stale, should not refresh }; var mockAsyncPageable2 = new MockAsyncPageable(keyValueCollection2, null, 3, responses2); + var keyValueCollection3 = new List(_kvCollection); + keyValueCollection3[3].Value = "new-value"; var responses3 = new List() { - new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), // stale + new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), // up-to-date, should refresh new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T09:00:02+08:00")) }; - var mockAsyncPageable3 = new MockAsyncPageable(keyValueCollection2, null, 3, responses2); + var mockAsyncPageable3 = new MockAsyncPageable(keyValueCollection3, null, 3, responses3); var responses4 = new List() { new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")), // up-to-date new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")) }; - var mockAsyncPageable4 = new MockAsyncPageable(keyValueCollection2, null, 3, responses4); + var mockAsyncPageable4 = new MockAsyncPageable(keyValueCollection3, null, 3, responses4); mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) .Returns(mockAsyncPageable1) @@ -423,7 +421,7 @@ public async Task AfdTests_RegisterAllRefresh() await refresher.RefreshAsync(); - Assert.Equal("TestValue4", config["TestKey4"]); // should not refresh, because page 1 is stale + Assert.Equal("TestValue4", config["TestKey4"]); // should not refresh, because page 2 is stale await Task.Delay(1500); From f0e52bfdac4c428ed3a1c468c7fe6ef2fc37f27d Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 5 Nov 2025 16:33:54 +0800 Subject: [PATCH 13/20] disallow sentinel key refresh for AFD --- .../AzureAppConfigurationProvider.cs | 112 +++++----- .../AzureAppConfigurationSource.cs | 5 + .../Constants/ErrorMessages.cs | 1 + .../ConfigurationClientExtensions.cs | 30 +-- .../KeyValueChange.cs | 3 - .../WatchedSetting.cs | 14 -- .../Unit/AfdTests.cs | 193 ++---------------- 7 files changed, 85 insertions(+), 273 deletions(-) delete mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedSetting.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 02740dc1f..38ea16d8c 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -33,7 +33,7 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private Uri _lastSuccessfulEndpoint; private AzureAppConfigurationOptions _options; private Dictionary _mappedData; - private Dictionary _watchedIndividualKvs = new Dictionary(); + private Dictionary _watchedIndividualKvs = new Dictionary(); private HashSet _ffKeys = new HashSet(); private Dictionary> _kvPageWatchers = new Dictionary>(); private Dictionary> _ffPageWatchers = new Dictionary>(); @@ -142,14 +142,6 @@ public AzureAppConfigurationProvider(IConfigurationClientManager configClientMan MinRefreshInterval = RefreshConstants.DefaultRefreshInterval; } - if (options.IndividualKvWatchers.Any()) - { - foreach (KeyValueWatcher kvWatcher in options.IndividualKvWatchers) - { - _watchedIndividualKvs.Add(new KeyValueIdentifier(kvWatcher.Key, kvWatcher.Label), new WatchedSetting()); - } - } - _requestTracingEnabled = !EnvironmentVariableHelper.GetBoolOrDefault(EnvironmentVariableNames.RequestTracingDisabled); if (_requestTracingEnabled) @@ -287,7 +279,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken) Dictionary> kvEtags = null; Dictionary> ffEtags = null; HashSet ffKeys = null; - Dictionary watchedIndividualKvs = null; + Dictionary watchedIndividualKvs = null; List watchedIndividualKvChanges = null; Dictionary data = null; Dictionary ffCollectionData = null; @@ -405,7 +397,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => } else { - watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); + watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); await ProcessKeyValueChangesAsync(watchedIndividualKvChanges, _mappedData, watchedIndividualKvs).ConfigureAwait(false); @@ -780,7 +772,7 @@ private async Task InitializeAsync(IEnumerable clients, Can Dictionary data = null; Dictionary> kvEtags = new Dictionary>(); Dictionary> ffEtags = new Dictionary>(); - Dictionary watchedIndividualKvs = null; + Dictionary watchedIndividualKvs = null; HashSet ffKeys = new HashSet(); await ExecuteWithFailOverPolicyAsync( @@ -838,8 +830,8 @@ await ExecuteWithFailOverPolicyAsync( private async Task> LoadSelected( ConfigurationClient client, - Dictionary> kvEtags, - Dictionary> ffEtags, + Dictionary> kvPageWatchers, + Dictionary> ffPageWatchers, IEnumerable selectors, HashSet ffKeys, CancellationToken cancellationToken) @@ -922,11 +914,11 @@ await CallWithRequestTracing(async () => if (loadOption.IsFeatureFlagSelector) { - ffEtags[loadOption] = pageWatchers; + ffPageWatchers[loadOption] = pageWatchers; } else { - kvEtags[loadOption] = pageWatchers; + kvPageWatchers[loadOption] = pageWatchers; } } else @@ -981,12 +973,14 @@ await CallWithRequestTracing(async () => return resolvedSettings; } - private async Task> LoadIndividualWatchedSettings( + private async Task> LoadIndividualWatchedSettings( ConfigurationClient client, IDictionary existingSettings, CancellationToken cancellationToken) { - var watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); + var watchedIndividualKvs = new Dictionary(_watchedIndividualKvs); + + Debug.Assert(!_options.IsAfdUsed || !_options.IndividualKvWatchers.Any()); foreach (KeyValueWatcher kvWatcher in _options.IndividualKvWatchers) { @@ -994,39 +988,33 @@ private async Task> LoadIndividua string watchedLabel = kvWatcher.Label; var watchedKeyLabel = new KeyValueIdentifier(watchedKey, watchedLabel); - Debug.Assert(_watchedIndividualKvs.ContainsKey(watchedKeyLabel)); - DateTimeOffset lastServerResponseTime = _watchedIndividualKvs[watchedKeyLabel].LastServerResponseTime; + + // Skip the loading for the key-value in case it has already been loaded + if (existingSettings.TryGetValue(watchedKey, out ConfigurationSetting loadedKv) + && watchedKeyLabel.Equals(new KeyValueIdentifier(loadedKv.Key, loadedKv.Label))) + { + // create a new instance to avoid that reference could be modified when mapping data + watchedIndividualKvs[watchedKeyLabel] = new ConfigurationSetting(loadedKv.Key, loadedKv.Value, loadedKv.Label, loadedKv.ETag); + continue; + } // Send a request to retrieve key-value since it may be either not loaded or loaded with a different label or different casing ConfigurationSetting watchedKv = null; try { - Response response = null; - await CallWithRequestTracing(async () => - { - response = await client.GetConfigurationSettingAsync(watchedKey, watchedLabel, cancellationToken).ConfigureAwait(false); - }).ConfigureAwait(false); - - using Response rawResponse = response.GetRawResponse(); - DateTimeOffset responseDate = rawResponse.GetDate(); - - watchedKv = response.Value; - watchedIndividualKvs[watchedKeyLabel].Setting = watchedKv; - watchedIndividualKvs[watchedKeyLabel].LastServerResponseTime = responseDate; + await CallWithRequestTracing(async () => watchedKv = await client.GetConfigurationSettingAsync(watchedKey, watchedLabel, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); } catch (RequestFailedException e) when (e.Status == (int)HttpStatusCode.NotFound) { - using Response rawResponse = e.GetRawResponse(); - DateTimeOffset responseDate = rawResponse.GetDate(); - watchedKv = null; - watchedIndividualKvs[watchedKeyLabel].Setting = watchedKv; - watchedIndividualKvs[watchedKeyLabel].LastServerResponseTime = responseDate; } // If the key-value was found, store it for updating the settings if (watchedKv != null && existingSettings != null) { + // create a new instance to avoid that reference could be modified when mapping data + watchedIndividualKvs[watchedKeyLabel] = new ConfigurationSetting(watchedKv.Key, watchedKv.Value, watchedKv.Label, watchedKv.ETag); + if (watchedKv.ContentType == SnapshotReferenceConstants.ContentType) { // Track snapshot reference usage for telemetry @@ -1068,6 +1056,8 @@ private async Task RefreshIndividualKvWatchers( StringBuilder logInfoBuilder, CancellationToken cancellationToken) { + Debug.Assert(!_options.IsAfdUsed || !_options.IndividualKvWatchers.Any()); + foreach (KeyValueWatcher kvWatcher in refreshableIndividualKvWatchers) { string watchedKey = kvWatcher.Key; @@ -1077,52 +1067,44 @@ private async Task RefreshIndividualKvWatchers( KeyValueChange change = default; - Debug.Assert(_watchedIndividualKvs.ContainsKey(watchedKeyLabel)); - ConfigurationSetting watchedKv = _watchedIndividualKvs[watchedKeyLabel].Setting; - DateTimeOffset lastServerResponseTime = _watchedIndividualKvs[watchedKeyLabel].LastServerResponseTime; - // // Find if there is a change associated with watcher - if (watchedKv != null) + if (_watchedIndividualKvs.TryGetValue(watchedKeyLabel, out ConfigurationSetting watchedKv)) { - await CallWithRequestTracing(async () => change = await client.GetKeyValueChange( + await CallWithRequestTracing(async () => + change = await client.GetKeyValueChange( watchedKv, makeConditionalRequest: !_options.IsAfdUsed, - lastServerResponseTime, cancellationToken).ConfigureAwait(false) ).ConfigureAwait(false); } else { - DateTimeOffset responseDate = lastServerResponseTime; // Load the key-value in case the previous load attempts had failed try { await CallWithRequestTracing(async () => - { - Response response = await client.GetConfigurationSettingAsync(watchedKey, watchedLabel, cancellationToken).ConfigureAwait(false); - using Response rawResponse = response.GetRawResponse(); - responseDate = rawResponse.GetDate(); - if (responseDate >= lastServerResponseTime) - { - watchedKv = response.Value; - } - }).ConfigureAwait(false); + watchedKv = await client.GetConfigurationSettingAsync( + watchedKey, + watchedLabel, + cancellationToken).ConfigureAwait(false) + ).ConfigureAwait(false); } catch (RequestFailedException e) when (e.Status == (int)HttpStatusCode.NotFound) { - using Response rawResponse = e.GetRawResponse(); - responseDate = rawResponse.GetDate(); watchedKv = null; } - change = new KeyValueChange() + if (watchedKv != null) { - Key = watchedKey, - Label = watchedLabel.NormalizeNull(), - ChangeType = watchedKv != null ? KeyValueChangeType.Modified : KeyValueChangeType.None, - ServerResponseTime = responseDate - }; + change = new KeyValueChange() + { + Key = watchedKv.Key, + Label = watchedKv.Label.NormalizeNull(), + Current = watchedKv, + ChangeType = KeyValueChangeType.Modified + }; + } } // Check if a change has been detected in the key-value registered for refresh @@ -1501,7 +1483,7 @@ await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Wa private async Task ProcessKeyValueChangesAsync( IEnumerable keyValueChanges, Dictionary mappedData, - Dictionary watchedIndividualKvs) + Dictionary watchedIndividualKvs) { foreach (KeyValueChange change in keyValueChanges) { @@ -1511,9 +1493,9 @@ private async Task ProcessKeyValueChangesAsync( if (change.ChangeType == KeyValueChangeType.Modified) { ConfigurationSetting setting = change.Current; + ConfigurationSetting settingCopy = new ConfigurationSetting(setting.Key, setting.Value, setting.Label, setting.ETag); - watchedIndividualKvs[changeIdentifier].Setting = setting; - watchedIndividualKvs[changeIdentifier].LastServerResponseTime = change.ServerResponseTime; + watchedIndividualKvs[changeIdentifier] = settingCopy; foreach (Func> func in _options.Mappers) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs index 4dd1148f3..e70ac47e5 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -50,6 +50,11 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) throw new InvalidOperationException(ErrorMessages.AfdCustomClientFactoryUnsupported); } + if (options.IndividualKvWatchers.Any()) + { + throw new InvalidOperationException($"{ErrorMessages.AfdWatchedSettingUnsupported} Please call {nameof(AzureAppConfigurationRefreshOptions)}.{nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} for configuration refresh."); + } + options.ClientOptions.AddPolicy(new AfdPolicy(), HttpPipelinePosition.PerRetry); } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs index 0d52aec31..0134e50ce 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs @@ -18,5 +18,6 @@ internal class ErrorMessages public const string AfdConnectionConflict = "Cannot connect to multiple Azure Front Doors."; public const string AfdLoadBalancingUnsupported = "Load balancing is not supported when connecting to Azure Front Door."; public const string AfdCustomClientFactoryUnsupported = "Custom client factory is not supported when connecting to Azure Front Door."; + public const string AfdWatchedSettingUnsupported = "Registering individual watched setting is not supported when connecting to Azure Front Door."; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index 046f4ef7c..93e2035ce 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -15,7 +15,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { internal static class ConfigurationClientExtensions { - public static async Task GetKeyValueChange(this ConfigurationClient client, ConfigurationSetting setting, bool makeConditionalRequest, DateTimeOffset lastChangeDetectedTime, CancellationToken cancellationToken) + public static async Task GetKeyValueChange(this ConfigurationClient client, ConfigurationSetting setting, bool makeConditionalRequest, CancellationToken cancellationToken) { if (setting == null) { @@ -32,8 +32,7 @@ public static async Task GetKeyValueChange(this ConfigurationCli Response response = await client.GetConfigurationSettingAsync(setting, onlyIfChanged: makeConditionalRequest, cancellationToken).ConfigureAwait(false); using Response rawResponse = response.GetRawResponse(); if (rawResponse.Status == (int)HttpStatusCode.OK && - !response.Value.ETag.Equals(setting.ETag) && - rawResponse.GetDate() >= lastChangeDetectedTime) + !response.Value.ETag.Equals(setting.ETag)) { return new KeyValueChange { @@ -41,26 +40,20 @@ public static async Task GetKeyValueChange(this ConfigurationCli Previous = setting, Current = response.Value, Key = setting.Key, - Label = setting.Label, - ServerResponseTime = rawResponse.GetDate() + Label = setting.Label }; } } catch (RequestFailedException e) when (e.Status == (int)HttpStatusCode.NotFound && setting.ETag != default) { - using Response rawResponse = e.GetRawResponse(); - if (rawResponse.GetDate() >= lastChangeDetectedTime) + return new KeyValueChange { - return new KeyValueChange - { - ChangeType = KeyValueChangeType.Deleted, - Previous = setting, - Current = null, - Key = setting.Key, - Label = setting.Label, - ServerResponseTime = rawResponse.GetDate() - }; - } + ChangeType = KeyValueChangeType.Deleted, + Previous = setting, + Current = null, + Key = setting.Key, + Label = setting.Label + }; } return new KeyValueChange @@ -69,8 +62,7 @@ public static async Task GetKeyValueChange(this ConfigurationCli Previous = setting, Current = setting, Key = setting.Key, - Label = setting.Label, - ServerResponseTime = lastChangeDetectedTime + Label = setting.Label }; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/KeyValueChange.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/KeyValueChange.cs index fa39707e1..2286016d7 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/KeyValueChange.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/KeyValueChange.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. // using Azure.Data.AppConfiguration; -using System; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { @@ -24,7 +23,5 @@ internal struct KeyValueChange public ConfigurationSetting Current { get; set; } public ConfigurationSetting Previous { get; set; } - - public DateTimeOffset ServerResponseTime { get; set; } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedSetting.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedSetting.cs deleted file mode 100644 index 872846e69..000000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedSetting.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using Azure.Data.AppConfiguration; -using System; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration -{ - internal class WatchedSetting - { - public ConfigurationSetting Setting { get; set; } - public DateTimeOffset LastServerResponseTime { get; set; } - } -} diff --git a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs index 1a0f35970..0384a6574 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs @@ -4,16 +4,13 @@ using Azure; using Azure.Core.Testing; using Azure.Data.AppConfiguration; -using Azure.Data.AppConfiguration.Tests; using Azure.Identity; -using Azure.ResourceManager.Resources.Models; using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Moq; using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -140,7 +137,7 @@ public void AfdTests_ThrowsWhenConnectMultipleAzureFrontDoor() } [Fact] - public void AfdTests_LoadbalancingIsUnsupportedWhenConnectAzureFrontDoor() + public void AfdTests_WatchedSettingIsUnsupportedWhenConnectAzureFrontDoor() { var afdEndpoint = new Uri("https://test.b01.azurefd.net"); var builder = new ConfigurationBuilder(); @@ -149,18 +146,21 @@ public void AfdTests_LoadbalancingIsUnsupportedWhenConnectAzureFrontDoor() builder.AddAzureAppConfiguration(options => { options.ConnectAzureFrontDoor(afdEndpoint); - options.LoadBalancingEnabled = true; + options.ConfigureRefresh(refreshOptions => + { + refreshOptions.Register("TestKey1", "label", true); + }); }); builder.Build(); }); Assert.NotNull(exception); Assert.IsType(exception); Assert.IsType(exception.InnerException); - Assert.Equal(ErrorMessages.AfdLoadBalancingUnsupported, exception.InnerException.Message); + Assert.Contains(ErrorMessages.AfdWatchedSettingUnsupported, exception.InnerException.Message); } [Fact] - public void AfdTests_CustomClientOptionsNotSupported() + public void AfdTests_LoadbalancingIsUnsupportedWhenConnectAzureFrontDoor() { var afdEndpoint = new Uri("https://test.b01.azurefd.net"); var builder = new ConfigurationBuilder(); @@ -169,185 +169,34 @@ public void AfdTests_CustomClientOptionsNotSupported() builder.AddAzureAppConfiguration(options => { options.ConnectAzureFrontDoor(afdEndpoint); - options.SetClientFactory(new TestClientFactory()); + options.LoadBalancingEnabled = true; }); builder.Build(); }); Assert.NotNull(exception); Assert.IsType(exception); Assert.IsType(exception.InnerException); - Assert.Equal(ErrorMessages.AfdCustomClientFactoryUnsupported, exception.InnerException.Message); + Assert.Equal(ErrorMessages.AfdLoadBalancingUnsupported, exception.InnerException.Message); } [Fact] - public async Task AfdTests_RefreshSingleWatchedSetting() + public void AfdTests_CustomClientOptionsNotSupported() { - var mockClient = new Mock(MockBehavior.Strict); - - var keyValueCollection = new List(_kvCollection); - var mockAsyncPageable = new MockAsyncPageable(keyValueCollection); - - mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(mockAsyncPageable); - - string etag1 = Guid.NewGuid().ToString(); - var setting = ConfigurationModelFactory.ConfigurationSetting( - "Sentinel", - "sentinel-value", - "label", - eTag: new ETag(etag1), - contentType: "text"); - - string etag2 = Guid.NewGuid().ToString(); - var oldSetting = ConfigurationModelFactory.ConfigurationSetting( - "Sentinel", - "old-value", - "label", - eTag: new ETag(etag2), - contentType: "text"); - - string etag3 = Guid.NewGuid().ToString(); - var newSetting = ConfigurationModelFactory.ConfigurationSetting( - "Sentinel", - "new-value", - "label", - eTag: new ETag(etag3), - contentType: "text"); - - mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(setting, new MockResponse(200, etag1, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")))); - - mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(oldSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T08:59:59+08:00")))) // stale - .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag3, DateTimeOffset.Parse("2025-10-17T09:00:03+08:00")))); // up-to-date - var afdEndpoint = new Uri("https://test.b01.azurefd.net"); - IConfigurationRefresher refresher = null; - var config = new ConfigurationBuilder() - .AddAzureAppConfiguration(options => - { - options.ConnectAzureFrontDoor(afdEndpoint); - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.Select("TestKey*", "label"); - options.ConfigureRefresh(refreshOptions => - { - refreshOptions.Register("Sentinel", "label", false) - .SetRefreshInterval(TimeSpan.FromSeconds(1)); - }); - - refresher = options.GetRefresher(); - }) - .Build(); - - Assert.Equal("sentinel-value", config["Sentinel"]); - - await Task.Delay(1500); - - await refresher.RefreshAsync(); - - Assert.Equal("sentinel-value", config["Sentinel"]); // should not refresh, because the response is out of date - - await Task.Delay(1500); - - await refresher.RefreshAsync(); - - Assert.Equal("new-value", config["Sentinel"]); - } - - [Fact] - public async Task AfdTests_WatchedSettingRefreshAll() - { - var mockClient = new Mock(MockBehavior.Strict); - - var keyValueCollection1 = new List(_kvCollection); - string page1_etag = Guid.NewGuid().ToString(); - string page2_etag = Guid.NewGuid().ToString(); - var responses = new List() - { - new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")), - new MockResponse(200, page2_etag, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")) - }; - var mockAsyncPageable1 = new MockAsyncPageable(keyValueCollection1, null, 3, responses); - - var keyValueCollection2 = new List(_kvCollection); - keyValueCollection2[3].Value = "updated-value"; - string page2_etag2 = Guid.NewGuid().ToString(); - var responses2 = new List() + var builder = new ConfigurationBuilder(); + var exception = Record.Exception(() => { - new MockResponse(200, page1_etag, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")), - new MockResponse(200, page2_etag2, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")) - }; - var mockAsyncPageable2 = new MockAsyncPageable(keyValueCollection2, null, 3, responses2); - - mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(mockAsyncPageable1) - .Returns(mockAsyncPageable2); - - string etag1 = Guid.NewGuid().ToString(); - var setting = ConfigurationModelFactory.ConfigurationSetting( - "Sentinel", - "sentinel-value", - "label", - eTag: new ETag(etag1), - contentType: "text"); - - string etag2 = Guid.NewGuid().ToString(); - var oldSetting = ConfigurationModelFactory.ConfigurationSetting( - "Sentinel", - "old-value", - "label", - eTag: new ETag(etag2), - contentType: "text"); - - string etag3 = Guid.NewGuid().ToString(); - var newSetting = ConfigurationModelFactory.ConfigurationSetting( - "Sentinel", - "new-value", - "label", - eTag: new ETag(etag3), - contentType: "text"); - - mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(setting, new MockResponse(200, etag1, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")))) - .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag3, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")))); - - mockClient.SetupSequence(c => c.GetConfigurationSettingAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(Response.FromValue(oldSetting, new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T08:59:59+08:00")))) // stale, should not refresh - .ReturnsAsync(Response.FromValue(newSetting, new MockResponse(200, etag3, DateTimeOffset.Parse("2025-10-17T09:00:01+08:00")))); - - var afdEndpoint = new Uri("https://test.b01.azurefd.net"); - IConfigurationRefresher refresher = null; - var config = new ConfigurationBuilder() - .AddAzureAppConfiguration(options => + builder.AddAzureAppConfiguration(options => { options.ConnectAzureFrontDoor(afdEndpoint); - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.Select("TestKey*", "label"); - options.ConfigureRefresh(refreshOptions => - { - refreshOptions.Register("Sentinel", "label", true) - .SetRefreshInterval(TimeSpan.FromSeconds(1)); - }); - - refresher = options.GetRefresher(); - }) - .Build(); - - Assert.Equal("sentinel-value", config["Sentinel"]); - - await Task.Delay(1500); - - await refresher.RefreshAsync(); - - Assert.Equal("TestValue4", config["TestKey4"]); // should not refresh, because sentinel is stale - Assert.Equal("sentinel-value", config["Sentinel"]); - - await Task.Delay(1500); - - await refresher.RefreshAsync(); - - Assert.Equal("updated-value", config["TestKey4"]); - Assert.Equal("new-value", config["Sentinel"]); + options.SetClientFactory(new TestClientFactory()); + }); + builder.Build(); + }); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.IsType(exception.InnerException); + Assert.Equal(ErrorMessages.AfdCustomClientFactoryUnsupported, exception.InnerException.Message); } [Fact] From a67f9c92a8828860c49c4576dbdd45b666a832d8 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 5 Nov 2025 17:54:59 +0800 Subject: [PATCH 14/20] add feature flag refresh test --- .../Unit/AfdTests.cs | 140 ++++++++++++++++++ .../Unit/FailoverTests.cs | 2 - 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs index 0384a6574..757cd27ac 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Azure; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; // Added for feature flag constants using Moq; using System; using System.Collections.Generic; @@ -278,5 +279,144 @@ public async Task AfdTests_RegisterAllRefresh() Assert.Equal("new-value", config["TestKey4"]); } + + [Fact] + public async Task AfdTests_FeatureFlagsRefresh() + { + var mockClient = new Mock(MockBehavior.Strict); + + var featureFlag = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "BetaFlag", + value: @" + { + ""id"": ""BetaFlag"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Browser"", + ""parameters"": { + ""AllowedBrowsers"": [ ""Firefox"", ""Safari"" ] + } + } + ] + } + }", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag(Guid.NewGuid().ToString())) + }; + + var staleFeatureFlag = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "BetaFlag", + value: @" + { + ""id"": ""BetaFlag"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Browser"", + ""parameters"": { + ""AllowedBrowsers"": [ ""360"" ] + } + } + ] + } + }", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag(Guid.NewGuid().ToString())) + }; + + var newFeatureFlag = new List + { + ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + "BetaFlag", + value: @" + { + ""id"": ""BetaFlag"", + ""enabled"": true, + ""conditions"": { + ""client_filters"": [ + { + ""name"": ""Browser"", + ""parameters"": { + ""AllowedBrowsers"": [ ""Chrome"", ""Edge"" ] + } + } + ] + } + }", + label: default, + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag(Guid.NewGuid().ToString())) + }; + + string etag1 = Guid.NewGuid().ToString(); + var responses = new List() + { + new MockResponse(200, etag1, DateTimeOffset.Parse("2025-10-17T09:00:00+08:00")) + }; + var mockAsyncPageable1 = new MockAsyncPageable(featureFlag, null, 10, responses); + + string etag2 = Guid.NewGuid().ToString(); + var responses2 = new List() + { + new MockResponse(200, etag2, DateTimeOffset.Parse("2025-10-17T08:59:59+08:00")) + }; + var mockAsyncPageable2 = new MockAsyncPageable(staleFeatureFlag, null, 10, responses); + + string etag3 = Guid.NewGuid().ToString(); + var responses3 = new List() + { + new MockResponse(200, etag3, DateTimeOffset.Parse("2025-10-17T09:00:02+08:00")) + }; + var mockAsyncPageable3 = new MockAsyncPageable(newFeatureFlag, null, 10, responses3); + + mockClient.SetupSequence(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(mockAsyncPageable1) // default load configuration settings + .Returns(mockAsyncPageable1) // load feature flag + .Returns(mockAsyncPageable2) // watch request, should not trigger refresh + .Returns(mockAsyncPageable3) // watch request, should trigger refresh + .Returns(mockAsyncPageable3) // default load configuration settings + .Returns(mockAsyncPageable3); // load feature flag + + var afdEndpoint = new Uri("https://test.b01.azurefd.net"); + IConfigurationRefresher refresher = null; + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ConnectAzureFrontDoor(afdEndpoint); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigurationSettingPageIterator = new MockConfigurationSettingPageIterator(); + options.UseFeatureFlags(o => o.SetRefreshInterval(TimeSpan.FromSeconds(1))); + refresher = options.GetRefresher(); + }) + .Build(); + + Assert.Equal("Browser", config["FeatureManagement:BetaFlag:EnabledFor:0:Name"]); + Assert.Equal("Firefox", config["FeatureManagement:BetaFlag:EnabledFor:0:Parameters:AllowedBrowsers:0"]); + Assert.Equal("Safari", config["FeatureManagement:BetaFlag:EnabledFor:0:Parameters:AllowedBrowsers:1"]); + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + // Still old values because page timestamp was stale + Assert.Equal("Firefox", config["FeatureManagement:BetaFlag:EnabledFor:0:Parameters:AllowedBrowsers:0"]); + Assert.Equal("Safari", config["FeatureManagement:BetaFlag:EnabledFor:0:Parameters:AllowedBrowsers:1"]); + + await Task.Delay(1500); + + await refresher.RefreshAsync(); + + Assert.Equal("Chrome", config["FeatureManagement:BetaFlag:EnabledFor:0:Parameters:AllowedBrowsers:0"]); + Assert.Equal("Edge", config["FeatureManagement:BetaFlag:EnabledFor:0:Parameters:AllowedBrowsers:1"]); + } } } diff --git a/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs index 28fe795b3..72b3a8260 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/FailoverTests.cs @@ -340,8 +340,6 @@ public void FailOverTests_GetNoDynamicClient() [Fact] public void FailOverTests_NetworkTimeout() { - // Arrange - IConfigurationRefresher refresher = null; var mockResponse = new MockResponse(200); var client1 = new ConfigurationClient(TestHelpers.CreateMockEndpointString(), From 7ae64ebc0073883ba5e2615a2391006467b70c56 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Wed, 5 Nov 2025 21:32:06 +0800 Subject: [PATCH 15/20] update --- .../AzureAppConfigurationProvider.cs | 19 +++++++++---------- .../ConfigurationClientExtensions.cs | 12 ++++++------ 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 38ea16d8c..84e014a03 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -283,7 +283,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken) List watchedIndividualKvChanges = null; Dictionary data = null; Dictionary ffCollectionData = null; - bool refreshFf = false; + bool refreshFeatureFlag = false; bool refreshAll = false; StringBuilder logInfoBuilder = new StringBuilder(); StringBuilder logDebugBuilder = new StringBuilder(); @@ -297,7 +297,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => watchedIndividualKvChanges = new List(); data = null; ffCollectionData = null; - refreshFf = false; + refreshFeatureFlag = false; refreshAll = false; logDebugBuilder.Clear(); logInfoBuilder.Clear(); @@ -344,7 +344,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => } // Get feature flag changes - refreshFf = await HavePageChange( + refreshFeatureFlag = await HavePageChange( refreshableFfWatchers.Select(watcher => new KeyValueSelector { KeyFilter = watcher.Key, @@ -356,7 +356,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => client, cancellationToken).ConfigureAwait(false); - if (refreshFf) + if (refreshFeatureFlag) { ffEtags = new Dictionary>(); ffKeys = new HashSet(); @@ -401,7 +401,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => await ProcessKeyValueChangesAsync(watchedIndividualKvChanges, _mappedData, watchedIndividualKvs).ConfigureAwait(false); - if (refreshFf) + if (refreshFeatureFlag) { // Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously foreach (string key in _ffKeys.Except(ffKeys)) @@ -430,7 +430,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => _nextCollectionRefreshTime = DateTimeOffset.UtcNow.Add(_options.KvCollectionRefreshInterval); } - if (_options.Adapters.Any(adapter => adapter.NeedsRefresh()) || watchedIndividualKvChanges.Any() || refreshAll || refreshFf) + if (_options.Adapters.Any(adapter => adapter.NeedsRefresh()) || watchedIndividualKvChanges.Any() || refreshAll || refreshFeatureFlag) { _watchedIndividualKvs = watchedIndividualKvs ?? _watchedIndividualKvs; @@ -864,8 +864,8 @@ await CallWithRequestTracing(async () => await foreach (Page page in pageableSettings.AsPages(_options.ConfigurationSettingPageIterator).ConfigureAwait(false)) { - using Response response = page.GetRawResponse(); - DateTimeOffset responseDate = response.GetDate(); + using Response rawResponse = page.GetRawResponse(); + DateTimeOffset responseDate = rawResponse.GetDate(); foreach (ConfigurationSetting setting in page.Values) { @@ -906,7 +906,7 @@ await CallWithRequestTracing(async () => // Each successful response should have 200 status code and an ETag pageWatchers.Add(new PageWatcher() { - Etag = new MatchConditions { IfNoneMatch = response.Headers.ETag }, + Etag = new MatchConditions { IfNoneMatch = rawResponse.Headers.ETag }, LastServerResponseTime = responseDate }); } @@ -1074,7 +1074,6 @@ private async Task RefreshIndividualKvWatchers( await CallWithRequestTracing(async () => change = await client.GetKeyValueChange( watchedKv, - makeConditionalRequest: !_options.IsAfdUsed, cancellationToken).ConfigureAwait(false) ).ConfigureAwait(false); } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index 93e2035ce..88cac6715 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -15,7 +15,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { internal static class ConfigurationClientExtensions { - public static async Task GetKeyValueChange(this ConfigurationClient client, ConfigurationSetting setting, bool makeConditionalRequest, CancellationToken cancellationToken) + public static async Task GetKeyValueChange(this ConfigurationClient client, ConfigurationSetting setting, CancellationToken cancellationToken) { if (setting == null) { @@ -29,7 +29,7 @@ public static async Task GetKeyValueChange(this ConfigurationCli try { - Response response = await client.GetConfigurationSettingAsync(setting, onlyIfChanged: makeConditionalRequest, cancellationToken).ConfigureAwait(false); + Response response = await client.GetConfigurationSettingAsync(setting, onlyIfChanged: true, cancellationToken).ConfigureAwait(false); using Response rawResponse = response.GetRawResponse(); if (rawResponse.Status == (int)HttpStatusCode.OK && !response.Value.ETag.Equals(setting.ETag)) @@ -99,14 +99,14 @@ public static async Task HavePageChange(this ConfigurationClient client, K await foreach (Page page in pages.ConfigureAwait(false)) { - using Response response = page.GetRawResponse(); - DateTimeOffset timestamp = response.GetDate(); + using Response rawResponse = page.GetRawResponse(); + DateTimeOffset timestamp = rawResponse.GetDate(); if (!existingPageWatcherEnumerator.MoveNext() || - (response.Status == (int)HttpStatusCode.OK && + (rawResponse.Status == (int)HttpStatusCode.OK && // if the server response time is later than last server response time, the change is considered detected timestamp >= existingPageWatcherEnumerator.Current.LastServerResponseTime && - !existingPageWatcherEnumerator.Current.Etag.IfNoneMatch.Equals(response.Headers.ETag))) + !existingPageWatcherEnumerator.Current.Etag.IfNoneMatch.Equals(rawResponse.Headers.ETag))) { return true; } From 9db6584e924db8e474e840110d766d1f9695ea3f Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Thu, 6 Nov 2025 14:50:14 +0800 Subject: [PATCH 16/20] resolve comments --- .../AzureAppConfigurationOptions.cs | 6 +- .../AzureAppConfigurationProvider.cs | 64 +++++++++---------- .../AzureAppConfigurationSource.cs | 2 +- .../Constants/ErrorMessages.cs | 1 - .../Constants/LoggingConstants.cs | 1 - .../ConfigurationClientExtensions.cs | 8 +-- .../{PageWatcher.cs => WatchedPage.cs} | 4 +- .../Unit/AfdTests.cs | 2 +- 8 files changed, 42 insertions(+), 46 deletions(-) rename src/Microsoft.Extensions.Configuration.AzureAppConfiguration/{PageWatcher.cs => WatchedPage.cs} (73%) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 16a94719e..52fdcece7 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -358,7 +358,7 @@ public AzureAppConfigurationOptions Connect(string connectionString) throw new ArgumentNullException(nameof(connectionString)); } - return Connect(new List { connectionString }); + return Connect(new string[] { connectionString }); } /// @@ -407,7 +407,7 @@ public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential creden throw new ArgumentNullException(nameof(credential)); } - return Connect(new List() { endpoint }, credential); + return Connect(new Uri[] { endpoint }, credential); } /// @@ -461,7 +461,7 @@ public AzureAppConfigurationOptions ConnectAzureFrontDoor(Uri endpoint) Credential ??= new EmptyTokenCredential(); - Endpoints = new List() { endpoint }; + Endpoints = new Uri[] { endpoint }; ConnectionStrings = null; IsAfdUsed = true; return this; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 84e014a03..01cc73c4d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -35,8 +35,8 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura private Dictionary _mappedData; private Dictionary _watchedIndividualKvs = new Dictionary(); private HashSet _ffKeys = new HashSet(); - private Dictionary> _kvPageWatchers = new Dictionary>(); - private Dictionary> _ffPageWatchers = new Dictionary>(); + private Dictionary> _watchedKvPages = new Dictionary>(); + private Dictionary> _watchedFfPages = new Dictionary>(); private RequestTracingOptions _requestTracingOptions; private Dictionary _configClientBackoffs = new Dictionary(); private DateTimeOffset _nextCollectionRefreshTime; @@ -276,8 +276,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken) // // Avoid instance state modification - Dictionary> kvEtags = null; - Dictionary> ffEtags = null; + Dictionary> kvEtags = null; + Dictionary> ffEtags = null; HashSet ffKeys = null; Dictionary watchedIndividualKvs = null; List watchedIndividualKvChanges = null; @@ -307,9 +307,9 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => { if (isRefreshDue) { - refreshAll = await HavePageChange( + refreshAll = await HaveCollectionsChanged( _options.Selectors.Where(selector => !selector.IsFeatureFlagSelector), - _kvPageWatchers, + _watchedKvPages, client, cancellationToken).ConfigureAwait(false); } @@ -330,8 +330,8 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => { // Trigger a single load-all operation if a change was detected in one or more key-values with refreshAll: true, // or if any key-value collection change was detected. - kvEtags = new Dictionary>(); - ffEtags = new Dictionary>(); + kvEtags = new Dictionary>(); + ffEtags = new Dictionary>(); ffKeys = new HashSet(); data = await LoadSelected(client, kvEtags, ffEtags, _options.Selectors, ffKeys, cancellationToken).ConfigureAwait(false); @@ -344,7 +344,7 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => } // Get feature flag changes - refreshFeatureFlag = await HavePageChange( + refreshFeatureFlag = await HaveCollectionsChanged( refreshableFfWatchers.Select(watcher => new KeyValueSelector { KeyFilter = watcher.Key, @@ -352,18 +352,18 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => TagFilters = watcher.Tags, IsFeatureFlagSelector = true }), - _ffPageWatchers, + _watchedFfPages, client, cancellationToken).ConfigureAwait(false); if (refreshFeatureFlag) { - ffEtags = new Dictionary>(); + ffEtags = new Dictionary>(); ffKeys = new HashSet(); ffCollectionData = await LoadSelected( client, - new Dictionary>(), + new Dictionary>(), ffEtags, _options.Selectors.Where(selector => selector.IsFeatureFlagSelector), ffKeys, @@ -434,9 +434,9 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) => { _watchedIndividualKvs = watchedIndividualKvs ?? _watchedIndividualKvs; - _ffPageWatchers = ffEtags ?? _ffPageWatchers; + _watchedFfPages = ffEtags ?? _watchedFfPages; - _kvPageWatchers = kvEtags ?? _kvPageWatchers; + _watchedKvPages = kvEtags ?? _watchedKvPages; _ffKeys = ffKeys ?? _ffKeys; @@ -770,8 +770,8 @@ private async Task TryInitializeAsync(IEnumerable cli private async Task InitializeAsync(IEnumerable clients, CancellationToken cancellationToken = default) { Dictionary data = null; - Dictionary> kvEtags = new Dictionary>(); - Dictionary> ffEtags = new Dictionary>(); + Dictionary> kvEtags = new Dictionary>(); + Dictionary> ffEtags = new Dictionary>(); Dictionary watchedIndividualKvs = null; HashSet ffKeys = new HashSet(); @@ -821,8 +821,8 @@ await ExecuteWithFailOverPolicyAsync( SetData(await PrepareData(mappedData, cancellationToken).ConfigureAwait(false)); _mappedData = mappedData; - _kvPageWatchers = kvEtags; - _ffPageWatchers = ffEtags; + _watchedKvPages = kvEtags; + _watchedFfPages = ffEtags; _watchedIndividualKvs = watchedIndividualKvs; _ffKeys = ffKeys; } @@ -830,8 +830,8 @@ await ExecuteWithFailOverPolicyAsync( private async Task> LoadSelected( ConfigurationClient client, - Dictionary> kvPageWatchers, - Dictionary> ffPageWatchers, + Dictionary> kvPageWatchers, + Dictionary> ffPageWatchers, IEnumerable selectors, HashSet ffKeys, CancellationToken cancellationToken) @@ -856,7 +856,7 @@ private async Task> LoadSelected( } } - var pageWatchers = new List(); + var pageWatchers = new List(); await CallWithRequestTracing(async () => { @@ -904,9 +904,9 @@ await CallWithRequestTracing(async () => // The ETag will never be null here because it's not a conditional request // Each successful response should have 200 status code and an ETag - pageWatchers.Add(new PageWatcher() + pageWatchers.Add(new WatchedPage() { - Etag = new MatchConditions { IfNoneMatch = rawResponse.Headers.ETag }, + MatchConditions = new MatchConditions { IfNoneMatch = rawResponse.Headers.ETag }, LastServerResponseTime = responseDate }); } @@ -1010,7 +1010,7 @@ private async Task> LoadInd } // If the key-value was found, store it for updating the settings - if (watchedKv != null && existingSettings != null) + if (watchedKv != null) { // create a new instance to avoid that reference could be modified when mapping data watchedIndividualKvs[watchedKeyLabel] = new ConfigurationSetting(watchedKv.Key, watchedKv.Value, watchedKv.Label, watchedKv.ETag); @@ -1447,36 +1447,34 @@ private void UpdateClientBackoffStatus(Uri endpoint, bool successful) _configClientBackoffs[endpoint] = clientBackoffStatus; } - private async Task HavePageChange( + private async Task HaveCollectionsChanged( IEnumerable selectors, - Dictionary> pageWatchers, + Dictionary> pageWatchers, ConfigurationClient client, CancellationToken cancellationToken) { - bool havePageChanged = false; + bool haveCollectionsChanged = false; foreach (KeyValueSelector selector in selectors) { - Debug.Assert(pageWatchers.ContainsKey(selector)); - - if (pageWatchers.TryGetValue(selector, out IEnumerable watchers)) + if (pageWatchers.TryGetValue(selector, out IEnumerable watchers)) { await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions, - async () => havePageChanged = await client.HavePageChange( + async () => haveCollectionsChanged = await client.HaveCollectionsChanged( selector, watchers, _options.ConfigurationSettingPageIterator, makeConditionalRequest: !_options.IsAfdUsed, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false); - if (havePageChanged) + if (haveCollectionsChanged) { return true; } } } - return havePageChanged; + return haveCollectionsChanged; } private async Task ProcessKeyValueChangesAsync( diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs index e70ac47e5..1f929feb9 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -52,7 +52,7 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) if (options.IndividualKvWatchers.Any()) { - throw new InvalidOperationException($"{ErrorMessages.AfdWatchedSettingUnsupported} Please call {nameof(AzureAppConfigurationRefreshOptions)}.{nameof(AzureAppConfigurationRefreshOptions.RegisterAll)} for configuration refresh."); + throw new InvalidOperationException($"Registering individual keys for refresh via `{nameof(AzureAppConfigurationRefreshOptions)}.{nameof(AzureAppConfigurationRefreshOptions.Register)}` is not supported when connecting to Azure Front Door. Instead, to enable configuration refresh, use `{nameof(AzureAppConfigurationRefreshOptions)}.{nameof(AzureAppConfigurationRefreshOptions.RegisterAll)}`."); } options.ClientOptions.AddPolicy(new AfdPolicy(), HttpPipelinePosition.PerRetry); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs index 0134e50ce..0d52aec31 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs @@ -18,6 +18,5 @@ internal class ErrorMessages public const string AfdConnectionConflict = "Cannot connect to multiple Azure Front Doors."; public const string AfdLoadBalancingUnsupported = "Load balancing is not supported when connecting to Azure Front Door."; public const string AfdCustomClientFactoryUnsupported = "Custom client factory is not supported when connecting to Azure Front Door."; - public const string AfdWatchedSettingUnsupported = "Registering individual watched setting is not supported when connecting to Azure Front Door."; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs index 3209c7a7f..3bdcaecd5 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/LoggingConstants.cs @@ -34,6 +34,5 @@ internal class LoggingConstants public const string RefreshSkippedNoClientAvailable = "Refresh skipped because no endpoint is accessible."; public const string RefreshFailedToGetSettingsFromEndpoint = "Failed to get configuration settings from endpoint"; public const string FailingOverToEndpoint = "Failing over to endpoint"; - public const string RefreshAborted = "Configuration refresh aborted."; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index 88cac6715..da2b78b50 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -66,7 +66,7 @@ public static async Task GetKeyValueChange(this ConfigurationCli }; } - public static async Task HavePageChange(this ConfigurationClient client, KeyValueSelector keyValueSelector, IEnumerable pageWatchers, IConfigurationSettingPageIterator pageIterator, bool makeConditionalRequest, CancellationToken cancellationToken) + public static async Task HaveCollectionsChanged(this ConfigurationClient client, KeyValueSelector keyValueSelector, IEnumerable pageWatchers, IConfigurationSettingPageIterator pageIterator, bool makeConditionalRequest, CancellationToken cancellationToken) { if (pageWatchers == null) { @@ -91,10 +91,10 @@ public static async Task HavePageChange(this ConfigurationClient client, K AsyncPageable pageable = client.GetConfigurationSettingsAsync(selector, cancellationToken); - using IEnumerator existingPageWatcherEnumerator = pageWatchers.GetEnumerator(); + using IEnumerator existingPageWatcherEnumerator = pageWatchers.GetEnumerator(); IAsyncEnumerable> pages = makeConditionalRequest - ? pageable.AsPages(pageIterator, pageWatchers.Select(p => p.Etag)) + ? pageable.AsPages(pageIterator, pageWatchers.Select(p => p.MatchConditions)) : pageable.AsPages(pageIterator); await foreach (Page page in pages.ConfigureAwait(false)) @@ -106,7 +106,7 @@ public static async Task HavePageChange(this ConfigurationClient client, K (rawResponse.Status == (int)HttpStatusCode.OK && // if the server response time is later than last server response time, the change is considered detected timestamp >= existingPageWatcherEnumerator.Current.LastServerResponseTime && - !existingPageWatcherEnumerator.Current.Etag.IfNoneMatch.Equals(rawResponse.Headers.ETag))) + !existingPageWatcherEnumerator.Current.MatchConditions.IfNoneMatch.Equals(rawResponse.Headers.ETag))) { return true; } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/PageWatcher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedPage.cs similarity index 73% rename from src/Microsoft.Extensions.Configuration.AzureAppConfiguration/PageWatcher.cs rename to src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedPage.cs index 7158ba5de..5fd14a444 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/PageWatcher.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/WatchedPage.cs @@ -6,9 +6,9 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { - internal class PageWatcher + internal class WatchedPage { - public MatchConditions Etag { get; set; } + public MatchConditions MatchConditions { get; set; } public DateTimeOffset LastServerResponseTime { get; set; } } } diff --git a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs index 757cd27ac..f19c93e9e 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/AfdTests.cs @@ -157,7 +157,7 @@ public void AfdTests_WatchedSettingIsUnsupportedWhenConnectAzureFrontDoor() Assert.NotNull(exception); Assert.IsType(exception); Assert.IsType(exception.InnerException); - Assert.Contains(ErrorMessages.AfdWatchedSettingUnsupported, exception.InnerException.Message); + Assert.Contains("Registering individual keys for refresh via `AzureAppConfigurationRefreshOptions.Register` is not supported when connecting to Azure Front Door.", exception.InnerException.Message); } [Fact] From bb5d7fff5b45e144edbb9fdb4269f8f1f57c99e8 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Fri, 7 Nov 2025 16:20:54 +0800 Subject: [PATCH 17/20] update --- .../AzureAppConfigurationProvider.cs | 4 ++-- .../AzureAppConfigurationSource.cs | 2 ++ .../Extensions/ConfigurationClientExtensions.cs | 12 +++++++++--- .../Extensions/ResponseExtensions.cs | 13 +++++++++++-- .../RequestTracingOptions.cs | 10 ++++++++++ .../TracingUtils.cs | 5 ----- 6 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index 01cc73c4d..bfcddd07e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -865,7 +865,7 @@ await CallWithRequestTracing(async () => await foreach (Page page in pageableSettings.AsPages(_options.ConfigurationSettingPageIterator).ConfigureAwait(false)) { using Response rawResponse = page.GetRawResponse(); - DateTimeOffset responseDate = rawResponse.GetDate(); + DateTimeOffset serverResponseTime = rawResponse.GetMsDate(); foreach (ConfigurationSetting setting in page.Values) { @@ -907,7 +907,7 @@ await CallWithRequestTracing(async () => pageWatchers.Add(new WatchedPage() { MatchConditions = new MatchConditions { IfNoneMatch = rawResponse.Headers.ETag }, - LastServerResponseTime = responseDate + LastServerResponseTime = serverResponseTime }); } }).ConfigureAwait(false); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs index 1f929feb9..230b99cba 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -55,6 +55,8 @@ public IConfigurationProvider Build(IConfigurationBuilder builder) throw new InvalidOperationException($"Registering individual keys for refresh via `{nameof(AzureAppConfigurationRefreshOptions)}.{nameof(AzureAppConfigurationRefreshOptions.Register)}` is not supported when connecting to Azure Front Door. Instead, to enable configuration refresh, use `{nameof(AzureAppConfigurationRefreshOptions)}.{nameof(AzureAppConfigurationRefreshOptions.RegisterAll)}`."); } + options.ReplicaDiscoveryEnabled = false; + options.ClientOptions.AddPolicy(new AfdPolicy(), HttpPipelinePosition.PerRetry); } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs index da2b78b50..28b6749c6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs @@ -66,7 +66,13 @@ public static async Task GetKeyValueChange(this ConfigurationCli }; } - public static async Task HaveCollectionsChanged(this ConfigurationClient client, KeyValueSelector keyValueSelector, IEnumerable pageWatchers, IConfigurationSettingPageIterator pageIterator, bool makeConditionalRequest, CancellationToken cancellationToken) + public static async Task HaveCollectionsChanged( + this ConfigurationClient client, + KeyValueSelector keyValueSelector, + IEnumerable pageWatchers, + IConfigurationSettingPageIterator pageIterator, + bool makeConditionalRequest, + CancellationToken cancellationToken) { if (pageWatchers == null) { @@ -100,12 +106,12 @@ public static async Task HaveCollectionsChanged(this ConfigurationClient c await foreach (Page page in pages.ConfigureAwait(false)) { using Response rawResponse = page.GetRawResponse(); - DateTimeOffset timestamp = rawResponse.GetDate(); + DateTimeOffset serverResponseTime = rawResponse.GetMsDate(); if (!existingPageWatcherEnumerator.MoveNext() || (rawResponse.Status == (int)HttpStatusCode.OK && // if the server response time is later than last server response time, the change is considered detected - timestamp >= existingPageWatcherEnumerator.Current.LastServerResponseTime && + serverResponseTime >= existingPageWatcherEnumerator.Current.LastServerResponseTime && !existingPageWatcherEnumerator.Current.MatchConditions.IfNoneMatch.Equals(rawResponse.Headers.ETag))) { return true; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs index 597adfa71..9cf8833ae 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ResponseExtensions.cs @@ -1,13 +1,22 @@ using Azure; +using Azure.Core; using System; namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions { internal static class ResponseExtensions { - public static DateTimeOffset GetDate(this Response response) + public static DateTimeOffset GetMsDate(this Response response) { - return response.Headers.Date ?? DateTimeOffset.UtcNow; + if (response.Headers.TryGetValue(HttpHeader.Names.XMsDate, out string value)) + { + if (DateTimeOffset.TryParse(value, out DateTimeOffset date)) + { + return date; + } + } + + return DateTimeOffset.UtcNow; } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs index 8914a0ee5..d6e32ddc0 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/RequestTracingOptions.cs @@ -198,6 +198,16 @@ public string CreateFeaturesString() sb.Append(RequestTracingConstants.SnapshotReferenceTag); } + if (IsAfdUsed) + { + if (sb.Length > 0) + { + sb.Append(RequestTracingConstants.Delimiter); + } + + sb.Append(RequestTracingConstants.AfdTag); + } + return sb.ToString(); } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs index b0d937233..33050b5b8 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/TracingUtils.cs @@ -211,11 +211,6 @@ private static string CreateCorrelationContextHeader(RequestType requestType, Re correlationContextTags.Add(RequestTracingConstants.PushRefreshTag); } - if (requestTracingOptions.IsAfdUsed) - { - correlationContextTags.Add(RequestTracingConstants.AfdTag); - } - var sb = new StringBuilder(); foreach (KeyValuePair kvp in correlationContextKeyValues) From 40b92f56270e4ab8776311f9414effee634138e2 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Sun, 9 Nov 2025 16:07:54 +0800 Subject: [PATCH 18/20] update error message --- .../Constants/ErrorMessages.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs index 0d52aec31..7b6e0acdd 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Constants/ErrorMessages.cs @@ -16,7 +16,7 @@ internal class ErrorMessages public const string SnapshotInvalidComposition = "{0} for the selected snapshot with name '{1}' must be 'key', found '{2}'."; public const string ConnectionConflict = "Cannot connect to both Azure App Configuration and Azure Front Door at the same time."; public const string AfdConnectionConflict = "Cannot connect to multiple Azure Front Doors."; - public const string AfdLoadBalancingUnsupported = "Load balancing is not supported when connecting to Azure Front Door."; + public const string AfdLoadBalancingUnsupported = "Load balancing is not supported when connecting to Azure Front Door. For guidance on how to take advantage of geo-replication when Azure Front Door is used, visit https://aka.ms/appconfig/geo-replication-with-afd"; public const string AfdCustomClientFactoryUnsupported = "Custom client factory is not supported when connecting to Azure Front Door."; } } From c8abbd5921ea852ebf60cdf7a70d3625dd8902a7 Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Tue, 11 Nov 2025 09:22:52 +0800 Subject: [PATCH 19/20] upgrade sdk version --- ...rosoft.Extensions.Configuration.AzureAppConfiguration.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ed0ec6e59..ad2a93ac6 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Microsoft.Extensions.Configuration.AzureAppConfiguration.csproj @@ -15,7 +15,7 @@ - + From fe11bbff2de22956d1f862582e3029af87f605ec Mon Sep 17 00:00:00 2001 From: Zhiyuan Liang Date: Tue, 11 Nov 2025 11:32:37 +0800 Subject: [PATCH 20/20] update test --- .../Unit/FeatureManagementTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs index 1e9508426..de649a0d1 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs @@ -1072,7 +1072,7 @@ public void PreservesDefaultQuery() options.UseFeatureFlags(); }).Build(); - bool performedDefaultQuery = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains("/kv?key=%2A&label=%00")); + bool performedDefaultQuery = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains("key=%2A&label=%00")); bool queriedFeatureFlags = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains(Uri.EscapeDataString(FeatureManagementConstants.FeatureFlagMarker))); Assert.True(performedDefaultQuery); @@ -1100,7 +1100,7 @@ public void QueriesFeatureFlags() }) .Build(); - bool performedDefaultQuery = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains("/kv?key=%2A&label=%00")); + bool performedDefaultQuery = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains("key=%2A&label=%00")); bool queriedFeatureFlags = mockTransport.Requests.Any(r => r.Uri.PathAndQuery.Contains(Uri.EscapeDataString(FeatureManagementConstants.FeatureFlagMarker))); Assert.True(performedDefaultQuery);